Seguridad de la cadena de suministro: SBOM, firma de artefactos y protección contra paquetes maliciosos

1. Descripción general

Una aplicación Spring Boot típica contiene solo unos pocos miles de líneas de código escritas por ti. El resto procede de cientos de librerías de terceros, herramientas de compilación, runners de CI e imágenes base de contenedores. Cada uno de estos eslabones puede ser atacado. Este es el problema de la seguridad de la cadena de suministro de software.

Este artículo cubre tres pilares prácticos: generar SBOM para saber qué estás usando, firmar artefactos para demostrar que no han sido modificados, y bloquear paquetes maliciosos desde el mismo momento de resolver dependencias. Después de leerlo, podrás incorporar estos pasos en un proyecto Maven existente y tu pipeline de CI.

2. Por qué la cadena de suministro está en el punto de mira

Los atacantes se dieron cuenta de que comprometer una librería popular es mucho más efectivo que atacar aplicaciones individuales. Solo necesitan insertar código malicioso en un paquete para alcanzar todos los proyectos que lo usan.

Los tipos de ataque más comunes son:

  • Typosquatting: publicar un paquete con un nombre casi idéntico al real, con un carácter diferente, esperando que un desarrollador se equivoque al escribirlo.
  • Dependency confusion: publicar en un registry público un paquete con el mismo nombre que uno interno, pero con una versión más alta. Una herramienta de compilación mal configurada elegirá la versión pública.
  • Compromiso de la cuenta del mantenedor: la cuenta de la persona que mantiene la librería se expone, y una nueva versión “legítima” con código malicioso se sube al registro.
  • Ataque al sistema de compilación: el código fuente es limpio, pero el runner de CI o el script de compilación se ve comprometido, por lo que el artefacto resultante está infectado.
  • Vulnerabilidades en dependencias transitivas: no declaras la librería directamente, pero sigue estando en tu classpath.

Junto con esto, muchas organizaciones y clientes empresariales están comenzando a requerir a los proveedores de software que envíen SBOM y demuestren el origen de sus compilaciones. Por lo tanto, este tema ya no es “trabajo del equipo de seguridad” sino un requisito técnico cotidiano.

3. SBOM: saber qué estás ejecutando

Un SBOM (Software Bill of Materials) es una lista completa de todos los componentes de un software, incluyendo nombre, versión, licencia, hash y relaciones de dependencia. Imagínalo como la tabla de ingredientes impresa en un envase de alimentos.

Los dos formatos más comunes son CycloneDX y SPDX. El ecosistema Java soporta bastante bien CycloneDX, así que este artículo usa ese formato.

3.1. Generar SBOM con Maven

Añade el plugin CycloneDX a tu pom.xml:

<properties>
    <!-- Reemplaza con la última versión que hayas verificado en 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>

Explicación:

  • makeAggregateBom agrupa las dependencias de todos los módulos en un proyecto multi-módulo en un único archivo.
  • Se vincula a la fase package para que SBOM siempre se genere junto con el JAR, nadie tiene que acordarse de ejecutarlo por separado.
  • includeTestScope=false excluye JUnit, Mockito, etc. del SBOM, ya que no están presentes en tiempo de ejecución.

Ejecuta la compilación:

mvn clean package

El plugin crea el archivo target/bom.json. Una parte de su contenido se ve así:

{
  "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": [ ... ]
    },
    ...
  ]
}

El campo más importante es purl (Package URL). Este es el identificador estándar que las herramientas de escaneo de vulnerabilidades usan para comparar contra la base de datos de CVE.

3.2. Qué hacer con el SBOM

El SBOM en sí no te protege de nada. Su valor está en cómo lo uses:

  • Escanear vulnerabilidades: pasa bom.json a una herramienta de escaneo de SBOM en tu CI. Cuando aparezca un nuevo CVE, puedes escanear SBOMs antiguos sin necesidad de recompilar.
  • Responder “¿nos afecta esto?”: cuando aparece una gran vulnerabilidad, buscas por purl en tu repositorio de SBOMs de todos los servicios, en lugar de preguntar a cada equipo.
  • Auditar licencias: detectar librerías con licencias que no se ajustan a la política de tu empresa.

Spring Boot 3.x en versiones recientes incluso tiene un endpoint de actuador sbom. Cuando el archivo SBOM se incluye en la aplicación (usando spring-boot-starter-parent junto con el plugin CycloneDX), puedes activar este endpoint:

management.endpoints.web.exposure.include=health,sbom

Llama a GET /actuator/sbom para ver el SBOM de la instancia que se está ejecutando. Esto es útil cuando necesitas saber exactamente qué versión se está ejecutando en producción.

Nota: El SBOM lista todas las librerías y versiones, por lo que los atacantes pueden usarlo para explorar vulnerabilidades. No expongas el endpoint sbom a Internet. Limítalo a la red interna o colócalo detrás de autenticación.

4. Firmar artefactos: demostrar que no han sido modificados

El SBOM muestra qué contiene un artefacto. La firma digital muestra de dónde viene y que no ha sido modificado desde que se firmó.

4.1. Checksum: necesario pero no suficiente

El nivel más simple es verificar el hash. Esta clase calcula SHA-256 de un archivo y lo compara con un 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);
        }
    }
}

Puntos dignos de mención:

  • El archivo se lee en bloques de 8 KB, por lo que los artefactos grandes no agotan la memoria.
  • HexFormat (disponible desde Java 17) convierte bytes a string hexadecimal sin necesidad de librerías externas.
  • MessageDigest.isEqual compara en tiempo constante. Aquí no es crítico, pero es una buena práctica cuando se comparan valores sensibles de seguridad.

Test para el comportamiento principal, usando JUnit 5 y 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");
    }
}

Solo un carácter ! adicional cambia completamente el hash, y verify lanza una SecurityException.

El problema es: si un atacante puede reemplazar el artefacto, probablemente también puede reemplazar el archivo .sha256 que está junto a él. El checksum solo garantiza integridad, no demuestra el origen. Para demostrar el origen, necesitas una firma digital.

4.2. Firmar con Sigstore cosign

La forma tradicional es firmar con GPG. Funciona, pero gestionar claves de larga duración es tedioso: almacenarlas, rotarlas, revocarlas cuando se exponen. Sigstore propone un enfoque diferente llamado keyless signing. Al firmar, la herramienta cosign obtiene tu identidad OIDC (por ejemplo, la identidad de tu workflow de CI), solicita un certificado de corta duración, firma, y registra la firma en un log de transparencia público.

Firmar un archivo JAR en CI:

cosign sign-blob target/order-service.jar \
  --bundle order-service.jar.sigstore.json \
  --yes

El archivo .sigstore.json contiene la firma, el certificado y la prueba de que se registró en el log de transparencia. Para verificar, el receptor ejecuta:

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"

Si es válido, el resultado es:

Verified OK

Lo importante son los dos parámetros --certificate-identity* y --certificate-oidc-issuer. Sin ellos, solo sabes “alguien firmó esto”, no “el pipeline de mi empresa firmó esto”. Para imágenes de contenedor, el proceso es similar usando cosign sign y cosign verify en el digest de la imagen.

4.3. Integrar en el pipeline

Un job mínimo de GitHub Actions que firma el JAR junto con el SBOM:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # necesario para que cosign obtenga el 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

El permiso id-token: write permite al workflow obtener el token OIDC. No almacenas ninguna clave privada en secrets. Firmar también bom.json asegura al receptor que el SBOM realmente va con ese artefacto, no que fue mezclado de otra fuente.

5. Bloquear paquetes maliciosos desde el principio

Firmar artefactos protege tu salida. Esta sección protege tu entrada, es decir, las dependencias que descargas.

5.1. Resolver solo a través de un repositorio controlado

Configura ~/.m2/settings.xml (o los settings usados en CI) para que todas las peticiones pasen por un repositorio interno, y falla si el checksum no coincide:

<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 los repositorios, incluso aquellos declarados por un pom.xml de terceros, forzando que todo pase por el proxy interno.
  • checksumPolicy=fail detiene la compilación si el checksum no coincide, en lugar de solo mostrar una advertencia.
  • Deshabilita snapshots de fuentes externas, porque los snapshots pueden cambiar de contenido sin cambiar la versión.

Para prevenir dependency confusion, configura el proxy de repositorio de modo que los groupIds internos (por ejemplo com.acme.*) solo se obtengan de repositorios internos, nunca de fuentes públicas. Maven Central requiere verificación de propiedad del namespace al publicar, por lo que el riesgo es más bajo que en otros registros, pero aún así no deberías confiar completamente en eso.

5.2. Forzar políticas con Maven Enforcer

El plugin Enforcer convierte las políticas en errores de compilación:

<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 bloquea librerías sin soporte o rangos de versiones con vulnerabilidades conocidas. Esta regla aplica incluso a dependencias transitivas.
  • requireUpperBoundDeps reporta un error cuando Maven silenciosamente elige una versión más baja que la que otro artefacto requiere.
  • requireReleaseDeps prohíbe SNAPSHOT en dependencias.

Si se viola, la compilación falla con un mensaje 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. Pequeños hábitos, gran impacto

  • Fija versiones específicas. No uses rangos como [1.0,) o LATEST, porque la compilación hoy y la de mañana pueden ser diferentes.
  • Revisa cuando agregues dependencias nuevas: verifica el nombre exacto, groupId, número de usuarios, historial de lanzamientos. Una librería desconocida con un nombre muy similar a uno popular es una señal de typosquatting.
  • Automatiza actualizaciones con un bot que cree pull requests, pero deja que CI ejecute pruebas completas y escanee el SBOM antes de mergear.
  • Reduce dependencias: lo que no está en el classpath no puede atacarte.

6. Errores comunes y cuándo no excederse

Nota: Generar un SBOM que nadie lee es casi inútil. Vincula el SBOM a un paso de escaneo automatizado y asigna a una persona o equipo la responsabilidad de procesar los resultados.

Algunos errores frecuentes:

  • Firmar artefactos pero no verificarlos. Las firmas solo tienen valor si el paso de despliegue (o un admission controller en Kubernetes) rechaza artefactos sin una firma válida.
  • Verificar firmas sin atar la identidad. Como se mencionó en la sección 4.2, “tiene firma” es muy diferente de “tiene firma de mi pipeline”.
  • Generar SBOM del código fuente en lugar del artefacto final. Si el paso de empaquetamiento agrega o elimina librerías, el SBOM será inexacto. Genera SBOM en el mismo paso que creas el artefacto a desplegar.
  • Lista de prohibidas demasiado larga sin actualizaciones. Entonces el equipo buscará formas de saltarse Enforcer, y la política pierde efectividad.

Cuándo no invertir mucho esfuerzo:

  • Prototipos, scripts internos de un solo uso, proyectos educativos. Fijar versiones y mantener un mirror de repositorio es suficiente. Configurar keyless signing con políticas de admission tomará más tiempo del que vale.
  • Equipo sin proceso de manejo de vulnerabilidades. Cientos de alertas CVE llegando a la vez sin nadie para triagearlos solo causa “alert fatigue”. Comienza con SBOM y escaneo a nivel de severidad alto, luego expande gradualmente.
  • Entorno completamente air-gapped. Keyless signing depende de servicios OIDC y logs de transparencia externos. Aquí, firmar con claves autogestionadas (GPG o cosign con key pair) es más apropiado.

7. Conclusión

La seguridad de la cadena de suministro gira en torno a tres preguntas: qué estoy usando, ¿es este artefacto realmente mío?, y ¿puedo confiar en lo que estoy descargando? SBOM responde la primera, las firmas digitales con Sigstore responden la segunda, y un repositorio controlado junto con Maven Enforcer ayudan a responder la tercera.

No necesitas aplicar todo en un día. Una progresión sensata sería: fijar versiones y usar un mirror interno, luego generar SBOM en cada compilación, agregar Enforcer, y finalmente firmar artefactos con verificación obligatoria en despliegue. Cada paso reduce el riesgo de forma clara, y todos pueden automatizarse en el pipeline que ya tienes.

securitysupply-chainsbommavendevsecops