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:

  • makeAggregateBom agrega as dependências de todos os módulos em um projeto multi-módulo em um único arquivo.
  • Anexar à fase package para que o SBOM seja sempre gerado junto com o arquivo JAR, sem necessidade de executar separadamente.
  • includeTestScope=false exclui 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.json a 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 purl no 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 sbom para 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.isEqual compara 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 um pom.xml de terceiros declara, devem passar pelo proxy interno.
  • checksumPolicy=fail faz 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>
  • bannedDependencies bloqueia 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.
  • requireUpperBoundDeps relata um erro quando Maven silenciosamente escolhe uma versão inferior à versão exigida por outra biblioteca.
  • requireReleaseDeps proí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,) ou LATEST, 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.

securitysupply-chainsbommavendevsecops