El Modelo de Amenazas Que Nadie Dibuja

La mayoría de los equipos piensan en ataques a la cadena de suministro como "alguien envenena una dependencia." Ese es un vector, pero la superficie de ataque completa es mucho más amplia:

Mapa de Superficie de Ataque:
┌─────────────────────────────────────────────────┐
│ 1. Código Fuente │
│ - Cuenta de developer comprometida │
│ - PR malicioso desde fork (typosquatting CI) │
│ - Commits sin firmar (impersonación) │
│ │
│ 2. Dependencias │
│ - Paquetes typosquatting │
│ - Cuenta de maintainer comprometida │
│ - Confusión de dependencias (privado/público)│
│ - Scripts postinstall maliciose es │
│ │
│ 3. Pipeline de Build │
│ - Archivos de CI workflow modificados │
│ - Entorno de build comprometido │
│ - Pase es de build inyectados │
│ - Secrets exfiltrados durante el build │
│ │
│ 4. Almacenamiento de Artefactos │
│ - Artefactos reemplazados en el registry │
│ - Credenciales del registry comprometidas │
│ - Man-in-the-middle en descarga de artefactos│
│ │
│ 5. Deployment │
│ - Scripts de deploy modificados │
│ - Credenciales de deploy comprometidas │
│ - Inyección de dependencias en runtime │
└─────────────────────────────────────────────────┘

SLSA, Sigstore, y SBOMs cada uno aborda diferentes partes de esta superficie. Las siguientes secciones recorren cada uno con detalles de implementación reales.

Sigstore: Firma Keyless Que Realmente Funciona

El modelo viejo de firma de código requería manejar claves de larga vida, lo cual creaba sus propios problemas de seguridad (almacenamiento, rotación, revocación de claves). Sigstore reemplaza esto con claves efímeras atadas a identidad, usando los mismos tokens OIDC que tu CI ya tiene.

Así funciona la firma keyless por debajo:

# 1. Instalar cosign
go install github.com/sigstore/cosign/v2/cmd/cosign@latest

# 2. Firmar una imagen de container (keyless, en CI)
# El flag --yes salta la confirmación interactiva
cosign sign --yes ghcr.io/myorg/myapp:v1.2.3

# Qué pasa detrás de escena:
# a) cosign pide un token OIDC al proveedor de CI
# (GitHub Actions: ACTIONS_ID_TOKEN_REQUEST_URL)
# b) Manda el token OIDC al CA Fulcio de Sigstore
# c) Fulcio verifica el token, emite un certificado de vida corta
# (vinculado a la identidad del workflow, válido ~10 minutos)
# d) cosign genera un par de claves efímero
# e) Firma el digest de la imagen con la clave privada efímera
# f) Registra la firma + certificado en el log de transparencia Rekor
# g) Descarta la clave privada (nunca se almacena en ningún lado)

La verificación es donde se pone poderoso:

# Verificar una imagen firmada, chequeando claims de identidad
cosign verify \
 --certificate-identity "https://github.com/myorg/myapp/.github/workflows/release.yml@refs/tags/v1.2.3" \
 --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
 ghcr.io/myorg/myapp:v1.2.3

# Esto chequea:
# 1. La firma es válida
# 2. El certificado de firma fue emitido por Fulcio
# 3. La identidad del certificado matchea el workflow esperado
# 4. El evento de firma existe en el log de transparencia Rekor
# 5. El certificado era válido al momento de la firma

Este es un workflow de GitHub Actions para firmar imágenes de container:

name: Build and Sign
on:
 push:
 tags: ['v*']

permissions:
 contents: read
 packages: write
 id-token: write # Requerido para firma keyless

jobs:
 build-sign:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4

 - uses: sigstore/cosign-installer@v3

 - name: Login a GHCR
 uses: docker/login-action@v3
 with:
 registry: ghcr.io
 username: ${{ github.actor }}
 password: ${{ secrets.GITHUB_TOKEN }}

 - name: Build y push
 id: build
 uses: docker/build-push-action@v5
 with:
 push: true
 tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}

 - name: Firmar imagen
 run: |
 cosign sign --yes \
 ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}

 - name: Adjuntar SBOM
 run: |
 syft ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \
 -o cyclonedx-json > sbom.cdx.json
 cosign attach sbom \
 --sbom sbom.cdx.json \
 ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}

SLSA: Provenance Que Significa Algo

SLSA (se pronuncia "salsa") te da un framework para pensar sobre integridad de builds. El concepto clave es provenance, un registro verificable de cómo se construyó un artefacto, incluyendo:

  • Qué código fuente se usó
  • Qué sistema de build lo produjo
  • Cuáles fueron los pase es de build
  • Cuáles fueron los inputs y outputs

El generador SLSA para GitHub Actions lo hace casi llave en mano:

# .github/workflows/slsa-build.yml
name: SLSA Build
on:
 push:
 tags: ['v*']

permissions:
 contents: read
 id-token: write
 actions: read

jobs:
 build:
 runs-on: ubuntu-latest
 outputs:
 digest: ${{ steps.hash.outputs.digest }}
 steps:
 - uses: actions/checkout@v4

 - name: Build
 run: npm ci && npm run build

 - name: Empaquetar
 run: tar -czf dist.tar.gz dist/

 - name: Generar hash
 id: hash
 run: |
 DIGEST=$(sha256sum dist.tar.gz | cut -d' ' -f1)
 echo "digest=sha256:${DIGEST}" >> "$GITHUB_OUTPUT"

 - uses: actions/upload-artifact@v4
 with:
 name: build-artifact
 path: dist.tar.gz

 provenance:
 needs: build
 permissions:
 actions: read
 id-token: write
 contents: write
 uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.10.0
 with:
 base64-subjects: |
 ${{ needs.build.outputs.digest }}

El documento de provenance se ve así (simplificado):

{
 "_type": "https://in-toto.io/Statement/v1",
 "subject": [{
 "name": "dist.tar.gz",
 "digest": { "sha256": "a1b2c3d4..." }
 }],
 "predicateType": "https://slsa.dev/provenance/v1",
 "predicate": {
 "buildDefinition": {
 "buildType": "https://github.com/slsa-framework/slsa-github-generator/generic@v1",
 "externalParameters": {
 "source": {
 "uri": "git+https://github.com/myorg/myapp@refs/tags/v1.2.3",
 "digest": { "sha1": "abc123..." }
 }
 }
 },
 "runDetails": {
 "builder": {
 "id": "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v1.10.0"
 },
 "metadata": {
 "invocationId": "https://github.com/myorg/myapp/actions/runs/12345678"
 }
 }
 }
}

SBOMs: Sabé Qué Estás Despachando

Un SBOM (Software Bill of Materials) es un inventario de cada componente en tu software. Hay dos formatos dominantes: CycloneDX y SPDX. CycloneDX tiende a ser la opción preferida porque su tooling es más maduro para dependencias de aplicaciones.

# Generar SBOM desde una imagen de container
syft ghcr.io/myorg/myapp:v1.2.3 -o cyclonedx-json > sbom.cdx.json

# Generar SBOM desde source (proyecto npm)
cdxgen -t node -o sbom.cdx.json .

# Generar SBOM incluyendo paquetes del SO en container
syft ghcr.io/myorg/myapp:v1.2.3 \
 --scope all-layers \
 -o cyclonedx-json > sbom-full.cdx.json

La diferencia entre un lockfile y un SBOM es importante. En un proyecto representativo de Node.js:

package-lock.json: 847 dependencias
SBOM (solo source): 847 dependencias (igual al lockfile)
SBOM (container): 1.243 dependencias (incluye libc, openssl,
 zlib, libcurl, ca-certificates,
 runtime de node, etc.)

Esas 396 dependencias extra son superficie de ataque real. Cuando sale una vulnerabilidad en libwebp o openssl, tu lockfile no te dice nada, pero tu SBOM de container sí.

npm Provenance: Un Flag Para Gobernarlos a Todos

Desde npm 9.5, es posible publicar paquetes con provenance criptográfico en un solo paso:

npm publish --provenance

Eso es todo. Detrás de escena, esto:

  1. Genera un predicado de provenance SLSA vinculando el paquete al run de GitHub Actions
  2. Lo firma con firma keyless de Sigstore
  3. Sube el provenance firmado a npm junto con el paquete
  4. npm muestra un badge "published with provenance"

Tu package.json no necesita nada especial. El único requisito es publicar desde un workflow de GitHub Actions con permiso id-token: write.

# .github/workflows/publish.yml
name: Publish
on:
 release:
 types: [published]

permissions:
 contents: read
 id-token: write

jobs:
 publish:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4
 - uses: actions/setup-node@v4
 with:
 node-version: '22'
 registry-url: 'https://registry.npmjs.org'
 - run: npm ci
 - run: npm publish --provenance
 env:
 NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Los consumidores pueden verificar provenance:

npm audit signatures
# Chequea que todos los paquetes instalados tengan firmas Sigstore válidas

Lo Que Realmente Importa: Priorización Pragmática

Basado en experiencia de implementación en múltiples organizaciones, este es un ranking pragmático de qué entrega más valor de seguridad por hora de esfuerzo de ingeniería:

  1. npm provenance (30 min de setup, valor masivo), Un flag, attestation criptográfica. Hacer esto primero.
  2. Firma de imágenes de container con cosign (2 horas), Firma keyless en CI, verificación en deploy.
  3. Generación y escaneo de SBOMs (4 horas), Conocer las dependencias, atrapá vulns en todo el stack.
  4. SLSA Level 2 provenance (1 día), Provenance autenticado desde un servicio de build hosted.
  5. SLSA Level 3 (1-2 semanas), Entornos de build endurecidos, aislados. Acá es donde se pone caro en tiempo de ingeniería.
  6. Builds reproducibles (esfuerzo continuo), El santo grial. Prácticamente difícil para la mayoría de los proyectos.

En un caso documentado, después de implementar los items 1-4, el tiempo de detección para un compromiso similar del pipeline bajó de 11 horas a unos 4 minutos, porque el mismatch de identidad de firma se atraparía en el momento del deploy.

La seguridad de la cadena de suministro no se trata de alcanzar algún nivel SLSA perfecto. Se trata de hacer caro atacar un pipeline y barato detectar cuando alguien lo intenta. Comenzar con la fruta al alcance de la mano, medir la brecha de detección, y subir desde ahí.