Segurança da cadeia de suprimento de software: SBOM, assinatura de artefatos e combate a pacotes maliciosos
1. Visão geral
Uma aplicação Spring Boot típica tem apenas alguns milhares de linhas de código que você escreve. O restante vem de centenas de bibliotecas de terceiros, da ferramenta de build, do runner de CI e da imagem base do container. Cada elo da cadeia pode ser atacado. Este é o problema da segurança da cadeia de suprimento de software.
O artigo passa por três pilares práticos: gerar SBOM para saber o que você está usando, assinar artefatos para provar que os artefatos não foram alterados, e bloquear pacotes maliciosos desde o momento da resolução de dependências. Após ler, você pode adicionar essas etapas a um projeto Maven existente e pipeline de CI.
2. Por que a cadeia de suprimento está recebendo atenção
Os invasores perceberam que atacar uma biblioteca popular é muito mais eficiente do que atacar aplicações individuais. Basta injetar código malicioso em um pacote e podem atingir todos os projetos que usam esse pacote.
Tipos de ataque comuns:
- Typosquatting: publicar um pacote com nome semelhante ao pacote legítimo, por exemplo faltando um caractere, e esperar que o programador digite errado.
- Dependency confusion: publicar um pacote com o mesmo nome que um pacote interno em um registry público, mas com versão mais alta. Uma ferramenta de build configurada incorretamente escolherá a versão pública.
- Sequestro do mantedor: a conta do mantenedor da biblioteca é comprometida e uma versão “legítima” contendo código malicioso é publicada.
- Ataque ao sistema de build: o código-fonte está limpo, mas o runner de CI ou script de build é comprometido, então o artefato de saída fica contaminado.
- Vulnerabilidades em dependências transitivas: você não declara a biblioteca diretamente, mas ela ainda está no classpath.
Em paralelo, muitas organizações e clientes empresariais começaram a exigir que fornecedores de software entreguem SBOM e provem a origem do build. Portanto, esse tópico deixou de ser “responsabilidade do time de segurança” e se tornou um requisito técnico do dia a dia.
3. SBOM: saiba o que você está executando
SBOM (Software Bill of Materials) é uma lista completa dos componentes em um software, incluindo nome, versão, licença, hash e relações de dependência. Pense nela como a lista de ingredientes impressa em uma embalagem de alimento.
Os dois formatos mais comuns são CycloneDX e SPDX. O ecossistema Java oferece bom suporte para CycloneDX, então este artigo usa esse formato.
3.1. Gerar SBOM com Maven
Adicione o plugin CycloneDX ao pom.xml:
<properties>
<!-- Substitua pela versão mais recente que você verificou no Maven Central -->
<cyclonedx.version>REPLACE_WITH_LATEST</cyclonedx.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<version>${cyclonedx.version}</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>makeAggregateBom</goal>
</goals>
</execution>
</executions>
<configuration>
<outputFormat>json</outputFormat>
<includeTestScope>false</includeTestScope>
</configuration>
</plugin>
</plugins>
</build>
Explicação:
makeAggregateBomagrega as dependências de todos os módulos em um projeto multi-módulo em um único arquivo.- Anexar à fase
packagepara que o SBOM seja sempre gerado junto com o arquivo JAR, sem necessidade de executar separadamente. includeTestScope=falseexclui JUnit, Mockito… do SBOM, pois eles não estão presentes durante a execução.
Execute o build:
mvn clean package
O plugin cria o arquivo target/bom.json. Uma parte do conteúdo fica assim:
{
"bomFormat": "CycloneDX",
"components": [
{
"group": "com.fasterxml.jackson.core",
"name": "jackson-databind",
"version": "...",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@...",
"hashes": [ { "alg": "SHA-256", "content": "..." } ],
"licenses": [ ... ]
},
...
]
}
O campo mais importante é purl (Package URL). Este é o identificador padrão que as ferramentas de verificação de vulnerabilidades usam para cruzar com o banco de dados CVE.
3.2. Para que usar SBOM
O SBOM por si só não oferece proteção. Seu valor reside em como você o usa:
- Verificar vulnerabilidades: forneça
bom.jsona uma ferramenta de verificação de SBOM em seu CI. Quando surge um novo CVE, você pode verificar novamente o SBOM antigo sem reconstruir. - Responder à pergunta “Nós somos afetados?”: quando uma grande vulnerabilidade surge, você procura pelo
purlno repositório de SBOM de todos os serviços, em vez de perguntar a cada time. - Verificar licenças: detectar bibliotecas com licenças que não estão em conformidade com a política da empresa.
Spring Boot 3.x nas versões recentes também possui um endpoint de actuador sbom. Quando o arquivo SBOM é empacotado na aplicação (usando spring-boot-starter-parent com o plugin CycloneDX), você pode ativar este endpoint:
management.endpoints.web.exposure.include=health,sbom
Chame GET /actuator/sbom para ver o SBOM da própria instância em execução. Isto é útil quando você precisa saber exatamente qual versão está em execução na produção.
Nota: O SBOM lista completamente as bibliotecas e versões, então invasores podem usá-lo para descobrir vulnerabilidades. Não exponha o endpoint
sbompara a Internet. Restrinja-o à rede interna ou coloque-o atrás de autenticação.
4. Assinar artefatos: prove que os artefatos não foram alterados
SBOM indica o que o artefato contém. Uma assinatura digital indica de onde o artefato veio e se não foi alterado desde a assinatura.
4.1. Checksum: necessário mas não suficiente
O nível mais simples é verificar o hash. A classe abaixo calcula SHA-256 de um arquivo e compara com o valor esperado:
package dev.nonla.supplychain;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public final class ArtifactVerifier {
private ArtifactVerifier() {
}
public static String sha256(Path file) throws IOException {
MessageDigest digest = newDigest();
try (InputStream in = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
public static void verify(Path file, String expectedSha256) throws IOException {
String actual = sha256(file);
boolean matches = MessageDigest.isEqual(
actual.getBytes(StandardCharsets.US_ASCII),
expectedSha256.toLowerCase().getBytes(StandardCharsets.US_ASCII));
if (!matches) {
throw new SecurityException(
"Checksum mismatch for " + file.getFileName() + ": " + actual);
}
}
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
Pontos dignos de nota:
- O arquivo é lido em blocos de 8 KB, então artefatos grandes não encherão a memória.
HexFormat(disponível desde Java 17) converte bytes para string hex sem necessidade de bibliotecas externas.MessageDigest.isEqualcompara em tempo constante. Aqui isso não é super crítico, mas é uma boa prática ao comparar valores relacionados à segurança.
Teste para o comportamento principal, usando JUnit 5 e AssertJ:
package dev.nonla.supplychain;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ArtifactVerifierTest {
private static final String HELLO_SHA256 =
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
@TempDir
Path tempDir;
@Test
void givenUntouchedArtifact_whenVerify_thenPasses() throws Exception {
Path jar = Files.writeString(tempDir.resolve("order-service.jar"), "hello");
assertThat(ArtifactVerifier.sha256(jar)).isEqualTo(HELLO_SHA256);
assertThatCode(() -> ArtifactVerifier.verify(jar, HELLO_SHA256))
.doesNotThrowAnyException();
}
@Test
void givenTamperedArtifact_whenVerify_thenThrows() throws Exception {
Path jar = Files.writeString(tempDir.resolve("order-service.jar"), "hello!");
assertThatThrownBy(() -> ArtifactVerifier.verify(jar, HELLO_SHA256))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("order-service.jar");
}
}
Basta adicionar um caractere ! e o hash é completamente diferente, e verify lança SecurityException.
O problema é: se um invasor conseguir substituir o artefato, geralmente também consegue substituir o arquivo .sha256 ao lado. Checksum apenas garante a integridade, não prova a origem. Para provar a origem, você precisa de uma assinatura digital.
4.2. Assinar com Sigstore cosign
A maneira tradicional é assinar com GPG. Isso funciona, mas gerenciar chaves de longo prazo é muito trabalhoso: você precisa armazenar, girar e revogar quando comprometidas. Sigstore oferece uma abordagem diferente chamada keyless signing (assinatura sem chave). Ao assinar, a ferramenta cosign obtém uma identidade OIDC (por exemplo, a identidade do workflow de CI), solicita um certificado de curta duração, assina e depois registra em um log de transparência público.
Assinar um arquivo JAR em seu CI:
cosign sign-blob target/order-service.jar \
--bundle order-service.jar.sigstore.json \
--yes
O arquivo .sigstore.json contém a assinatura, o certificado e a prova registrada no log de transparência. Quem recebe verifica assim:
cosign verify-blob target/order-service.jar \
--bundle order-service.jar.sigstore.json \
--certificate-identity-regexp "^https://github.com/acme/order-service/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
O resultado quando válido:
Verified OK
O importante são os dois parâmetros --certificate-identity* e --certificate-oidc-issuer. Se os omitir, você apenas sabe “alguém assinou”, não se o signatário é seu pipeline. Com imagens de container, o processo é semelhante usando cosign sign e cosign verify no digest da imagem.
4.3. Integrar no pipeline
Um job mínimo do GitHub Actions que assina JAR junto com SBOM:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # necessário para cosign obter o token OIDC
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: sigstore/cosign-installer@v3
- run: mvn -B clean package
- run: |
cosign sign-blob target/order-service.jar --bundle order-service.jar.sigstore.json --yes
cosign sign-blob target/bom.json --bundle bom.json.sigstore.json --yes
A permissão id-token: write permite que o workflow obtenha o token OIDC. Você não armazena nenhuma chave privada em secrets. Assinar também bom.json ajuda a garantir que o SBOM realmente vem junto com esse artefato, não foi encaixotado de outro lugar.
5. Bloquear pacotes maliciosos desde o início
Assinar artefatos protege sua saída. Esta seção protege a entrada, ou seja, as dependências que você puxa.
5.1. Resolver apenas através de um repositório controlado
Configure ~/.m2/settings.xml (ou o settings usado em CI) para que todas as solicitações passem por um repositório interno e falhem imediatamente se o checksum não corresponder:
<settings>
<mirrors>
<mirror>
<id>internal</id>
<mirrorOf>*</mirrorOf>
<url>https://repo.acme.internal/maven-all/</url>
</mirror>
</mirrors>
<profiles>
<profile>
<id>strict</id>
<repositories>
<repository>
<id>internal</id>
<url>https://repo.acme.internal/maven-all/</url>
<releases>
<checksumPolicy>fail</checksumPolicy>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>strict</activeProfile>
</activeProfiles>
</settings>
mirrorOf=*intercepta todos os repositórios, inclusive repositórios que umpom.xmlde terceiros declara, devem passar pelo proxy interno.checksumPolicy=failfaz o build falhar em vez de apenas avisar quando o checksum está incorreto.- Desabilitar snapshots de fontes externas, pois snapshots podem mudar de conteúdo sem alterar a versão.
Para evitar dependency confusion, configure o proxy de repositório para que groupIds internos (por exemplo com.acme.*) sejam obtidos apenas do repositório interno, nunca de fontes públicas. Maven Central exige verificação de propriedade de namespace ao publicar, então o risco é menor que alguns outros registros, mas você ainda não deve contar completamente com isso.
5.2. Reforçar políticas com Maven Enforcer
O plugin Enforcer transforma políticas em falhas de build:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>${enforcer.version}</version>
<executions>
<execution>
<id>supply-chain-rules</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<excludes>
<exclude>log4j:log4j</exclude>
<exclude>commons-collections:commons-collections:[,3.2.2)</exclude>
</excludes>
</bannedDependencies>
<requireUpperBoundDeps/>
<requireReleaseDeps>
<onlyWhenRelease>false</onlyWhenRelease>
</requireReleaseDeps>
</rules>
</configuration>
</execution>
</executions>
</plugin>
bannedDependenciesbloqueia bibliotecas que já não recebem suporte ou intervalos de versão com vulnerabilidades conhecidas. Esta regra se aplica também a dependências transitivas.requireUpperBoundDepsrelata um erro quando Maven silenciosamente escolhe uma versão inferior à versão exigida por outra biblioteca.requireReleaseDepsproíbe SNAPSHOT nas dependências.
Ao violar, o build falha com uma mensagem como:
[ERROR] Rule 0: org.apache.maven.enforcer.rules.dependency.BannedDependencies failed with message:
com.acme:order-service:jar:1.0.0
log4j:log4j:jar:1.2.17 <--- banned via the exclude/include list
5.3. Pequenos hábitos, grande impacto
- Fixar versões específicas. Não use intervalos de versão como
[1.0,)ouLATEST, pois builds hoje e amanhã podem ser diferentes. - Revisar ao adicionar novas dependências: verificar nome exato, groupId, número de usuários, histórico de lançamentos. Uma biblioteca obscura com nome semelhante a uma biblioteca famosa é um sinal de typosquatting.
- Automatizar atualizações com um bot que cria pull requests, mas deixe o CI executar testes completos e verificar SBOM antes de fazer merge.
- Reduzir dependências: o que não está no classpath não pode atacá-lo.
6. Erros comuns e quando não exagerar
Nota: Gerar SBOM mas ninguém ler é quase inútil. Vincule o SBOM a uma etapa de verificação automática e tenha uma pessoa ou time responsável por lidar com os resultados.
Alguns erros comuns:
- Assinar artefatos mas ninguém verificar. Assinaturas só têm valor quando a etapa de deploy (ou admission controller no Kubernetes) rejeita artefatos sem assinatura válida.
- Verificar assinatura sem vincular identidade. Como mencionado na seção 4.2, “ter assinatura” é bem diferente de “ter assinatura do meu pipeline”.
- SBOM gerado a partir do código-fonte em vez do artefato final. Se a etapa de empacotamento adicionar ou remover bibliotecas, o SBOM ficará impreciso. Gere o SBOM na mesma etapa de build que cria o artefato para deploy.
- Lista de bloqueio muito longa e não atualizada. Nesse caso, o time tentará contornar o Enforcer e a política perde a eficácia.
Quando não investir pesadamente:
- Protótipos, scripts internos executados uma vez, projetos de aprendizado. Fixar versão e manter um mirror de repositório é suficiente. Configurar keyless signing junto com políticas de admission custará mais do que o valor que agregará.
- Time sem processo de tratamento de vulnerabilidades. Centenas de alertas de CVE chegando de uma vez sem alguém para triá-los apenas causa “alert fatigue”. Comece com SBOM e verificação apenas de severidade alta, depois expanda gradualmente.
- Ambiente completamente air-gapped. Keyless signing depende de serviço OIDC e log de transparência externos. Nesse ambiente, assinar com chaves auto-gerenciadas (GPG ou cosign com par de chaves) é mais apropriado.
7. Conclusão
A segurança da cadeia de suprimento de software gira em torno de três perguntas: o que estou usando, este artefato é realmente meu e o que estou puxando é confiável? SBOM responde à primeira pergunta, assinatura digital com Sigstore responde à segunda, e um repositório controlado junto com Maven Enforcer ajuda a responder a terceira.
Você não precisa aplicar tudo em um dia. Um caminho razoável é: fixar versões e usar um mirror interno, depois gerar SBOM em cada build, adicionar Enforcer, e finalmente assinar artefatos com verificação obrigatória na etapa de deploy. Cada etapa reduz o risco claramente e pode ser automatizada em seu pipeline existente.