Lab: Firma y Verificación de Imágenes Container con Cosign en GitHub Actions

Descripción General

Cada imagen de contenedor que produce tu pipeline de CI/CD debe estar firmada criptográficamente antes de llegar a cualquier entorno. Las imágenes sin firmar son un punto ciego — no tienes prueba de que provengan de tu pipeline, no tienes garantía de que no hayan sido manipuladas en tránsito, y no tienes ningún mecanismo de política para bloquear despliegues no autorizados.

En este laboratorio práctico:

  • Firmarás una imagen de contenedor localmente usando un par de claves de Cosign.
  • Configurarás la firma keyless en GitHub Actions usando la infraestructura Fulcio y Rekor de Sigstore.
  • Verificarás firmas localmente con comprobaciones de certificados basadas en identidad.
  • Aplicarás la verificación de firmas en tiempo de admisión en Kubernetes usando Kyverno.
  • Adjuntarás y verificarás una attestation de SBOM con Cosign y Syft.

Al final tendrás un workflow completo de GitHub Actions que construye, publica, firma y certifica cada imagen — y una política de Kubernetes que rechaza cualquier imagen sin firmar.

Requisitos Previos

Antes de comenzar, asegúrate de tener lo siguiente listo:

  • Cuenta de GitHub con permisos para crear repositorios y habilitar GitHub Actions.
  • Cuenta de registro de contenedores — este laboratorio usa GitHub Container Registry (GHCR), pero Docker Hub también funciona.
  • Docker instalado y ejecutándose localmente.
  • Cosign CLI instalado localmente:
# macOS (Homebrew)
brew install cosign

# Or install from source with Go
go install github.com/sigstore/cosign/v2/cmd/cosign@latest

# Verify installation
cosign version
  • kubectl y Helm instalados (para el ejercicio de Kyverno).
  • Syft instalado (para el ejercicio de SBOM):
brew install syft

También necesitarás una aplicación simple para containerizar. Aquí tienes una aplicación Go mínima y su Dockerfile que usaremos a lo largo del laboratorio.

main.go

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello from a signed container!")
	})
	http.ListenAndServe(":8080", nil)
}

Dockerfile

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY main.go .
RUN go build -o server main.go

FROM alpine:3.19
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Configuración del Entorno

Comienza creando un repositorio de prueba y publicando el código de la aplicación.

Paso 1 — Crear el repositorio

# Create a new directory and initialize a Git repo
mkdir cosign-lab && cd cosign-lab
git init

# Create the Go application and Dockerfile from the prerequisites above
# Then push to GitHub
git add .
git commit -m "Initial commit: simple Go app"
gh repo create cosign-lab --public --source=. --push

Paso 2 — Crear el workflow sin firma

Antes de agregar la firma, crea un workflow base que solo construya y publique la imagen. Esto te da algo con qué comparar más adelante.

Crea .github/workflows/build.yml:

name: Build and Push (Unsigned)

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

Haz commit y push de este workflow. Crea un tag para activarlo:

git add .
git commit -m "Add unsigned build workflow"
git push origin main
git tag v0.1.0
git push origin v0.1.0

Una vez que el workflow se complete, tu imagen estará en GHCR — pero no lleva ninguna firma criptográfica. Cualquier persona con acceso de escritura al registro podría reemplazarla, y nada aguas abajo lo detectaría.

Ejercicio 1: Firma Local con Par de Claves

Antes de pasar a la firma keyless en CI, es útil entender los fundamentos firmando una imagen localmente con un par de claves explícito.

Paso 1 — Generar un par de claves de Cosign

cosign generate-key-pair

Esto crea dos archivos en tu directorio actual:

  • cosign.key — la clave privada (cifrada con una contraseña que elijas).
  • cosign.pub — la clave pública que distribuyes a los verificadores.

Paso 2 — Construir, publicar y firmar la imagen

# Build the image
docker build -t ghcr.io/<your-username>/cosign-lab:v1 .

# Push to GHCR
docker push ghcr.io/<your-username>/cosign-lab:v1

# Sign the image with your private key
cosign sign --key cosign.key ghcr.io/<your-username>/cosign-lab:v1

Reemplaza <your-username> con tu nombre de usuario de GitHub. Cosign te pedirá la contraseña que estableciste durante la generación de claves.

Paso 3 — Verificar la firma

cosign verify --key cosign.pub ghcr.io/<your-username>/cosign-lab:v1

Deberías ver una salida similar a:

Verification for ghcr.io/<your-username>/cosign-lab:v1 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key

[{"critical":{"identity":{"docker-reference":"ghcr.io/<your-username>/cosign-lab"},"image":{"docker-manifest-digest":"sha256:abc123..."},"type":"cosign container image signature"},"optional":null}]

¿Dónde se almacena la firma?

Cosign almacena las firmas como artefactos OCI en el mismo registro, junto a la imagen. Para una imagen etiquetada como sha256:abc123, Cosign publica la firma en un tag derivado de ese digest — sha256-abc123.sig. Esto significa:

  • No se necesita infraestructura separada para almacenar firmas.
  • Las firmas viajan con la imagen cuando replicas registros.
  • Los controles de acceso del registro se aplican a las firmas de la misma manera que a las imágenes.

La firma con par de claves funciona, pero introduce una carga de gestión de claves: debes proteger la clave privada, rotarla periódicamente y distribuir la clave pública a cada verificador. En el siguiente ejercicio, eliminamos esa carga por completo con la firma keyless.

Ejercicio 2: Firma Keyless en GitHub Actions

La firma keyless elimina la necesidad de generar, almacenar o rotar claves de firma. En su lugar, se basa en certificados de corta duración emitidos por Fulcio y registrados en el log de transparencia Rekor.

Cómo funciona la firma keyless

  1. Token OIDC — GitHub Actions genera un token de identidad OIDC que prueba la identidad del workflow (repositorio, archivo de workflow, ref, y más).
  2. Certificado Fulcio — Cosign envía ese token OIDC a Fulcio, que emite un certificado X.509 de firma de corta duración vinculado a la identidad del workflow.
  3. Firma — Cosign firma el digest de la imagen con la clave privada efímera que corresponde al certificado Fulcio.
  4. Log de transparencia Rekor — La firma y el certificado se registran en Rekor para que cualquiera pueda auditar cuándo y por quién se firmó una imagen.
  5. Eliminación de la clave — La clave privada efímera se descarta inmediatamente. La verificación usa el certificado y la entrada de Rekor, no una clave pública de larga duración.

Paso 1 — Crear el workflow de firma

Crea .github/workflows/sign.yml:

name: Build, Push, and Sign

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-sign:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write   # Required for keyless signing via OIDC

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Sign the image
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

Detalles clave de este workflow

  • id-token: write — este permiso permite al runner solicitar un token OIDC a GitHub, que Fulcio usa para emitir el certificado de firma.
  • packages: write — necesario para publicar la imagen y su firma en GHCR.
  • cosign sign --yes — el flag --yes confirma el modo no interactivo (sin solicitud de consentimiento para keyless). La ausencia del flag --key significa que Cosign usa automáticamente la firma keyless.
  • Firmamos por digest (@sha256:...) en lugar de por tag para asegurar que firmamos exactamente la imagen que acabamos de construir.

Paso 2 — Push y activar el workflow

git add .github/workflows/sign.yml
git commit -m "Add keyless signing workflow"
git push origin main
git tag v1.0.0
git push origin v1.0.0

Paso 3 — Revisar los logs de Actions

En el paso «Sign the image» verás una salida similar a:

Generating ephemeral keys...
Retrieving signed certificate...

        The sigstore community wants to hear from you! Connect with us at
        https://links.sigstore.dev/slack-invite

Successfully verified SCT...
tlog entry created with index: 45678901
Pushing signature to: ghcr.io/<your-username>/cosign-lab:sha256-a1b2c3d4.sig

La imagen ahora está firmada con un certificado que la vincula criptográficamente a la identidad de tu workflow de GitHub Actions. La firma y el certificado quedan registrados permanentemente en el log de transparencia Rekor.

Ejercicio 3: Verificación de Firmas Localmente

Verificar una imagen firmada con keyless requiere dos datos: la identidad del certificado (quién firmó) y el emisor OIDC (quién respaldó esa identidad).

Paso 1 — Verificar la imagen firmada

cosign verify \
  --certificate-identity "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/v1.0.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Salida exitosa:

Verification for ghcr.io/<your-username>/cosign-lab:v1.0.0 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The code-signing certificate was verified using trusted certificate authority
  - The signatures were verified against the specified public key
  - The signature was verified against a valid Fulcio certificate

[{"critical":{"identity":{"docker-reference":"ghcr.io/<your-username>/cosign-lab"},"image":{"docker-manifest-digest":"sha256:a1b2c3d4..."},"type":"cosign container image signature"},"optional":{...}}]

Paso 2 — Verificar con una identidad incorrecta (se espera fallo)

cosign verify \
  --certificate-identity "https://github.com/attacker/malicious-repo/.github/workflows/build.yml@refs/tags/v1.0.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Salida:

Error: no matching signatures:
none of the expected identities matched what was in the certificate

Esto confirma que las firmas están vinculadas a la identidad. Incluso si alguien logra publicar una firma, no pasará la verificación a menos que haya sido firmada por el workflow exacto que especifiques.

Paso 3 — Verificar una imagen sin firmar (se espera fallo)

cosign verify \
  --certificate-identity "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/v0.1.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v0.1.0

Salida:

Error: no matching signatures
no signatures found for image

La imagen v0.1.0 fue construida con el workflow sin firma de la sección de Configuración del Entorno, por lo que no existe ninguna firma.

Comprendiendo los campos del certificado

Cuando verificas una firma keyless, Cosign comprueba varios campos incrustados en el certificado Fulcio:

  • Emisor (certificate-oidc-issuer) — el proveedor OIDC que autenticó al firmante. Para GitHub Actions siempre es https://token.actions.githubusercontent.com.
  • Sujeto / Identidad (certificate-identity) — la referencia completa del workflow incluyendo el repositorio, la ruta del archivo de workflow y la referencia Git. Esto vincula la firma a un workflow específico en un commit o tag específico.
  • Extensiones de GitHub Workflow — el certificado también contiene extensiones OID personalizadas para el repositorio, el SHA del workflow, el evento desencadenante y el entorno del runner. Estas permiten políticas de verificación detalladas.

También puedes usar coincidencia por regex para políticas más flexibles:

cosign verify \
  --certificate-identity-regexp "https://github.com/<your-username>/cosign-lab/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Esto es útil cuando quieres aceptar firmas de cualquier workflow en un repositorio, o de cualquier tag.

Ejercicio 4: Verificación en Kubernetes con Kyverno

La verificación local es útil para depuración, pero los clústeres de producción necesitan aplicación automatizada. Kyverno es un controlador de admisión de Kubernetes que puede verificar firmas de Cosign en cada solicitud de admisión de pods.

Paso 1 — Instalar Kyverno

helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno -n kyverno --create-namespace

Espera a que los pods de Kyverno estén listos:

kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=kyverno -n kyverno --timeout=120s

Paso 2 — Crear la política de verificación de imágenes

Guarda lo siguiente como require-signed-images.yml:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-cosign-signature
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: verify-cosign-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/<your-username>/cosign-lab:*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: "https://rekor.sigstore.dev"

Aplica la política:

kubectl apply -f require-signed-images.yml

Paso 3 — Probar con una imagen firmada (debería tener éxito)

kubectl run signed-app \
  --image=ghcr.io/<your-username>/cosign-lab:v1.0.0 \
  --restart=Never

Salida esperada:

pod/signed-app created

Paso 4 — Probar con una imagen sin firmar (debería fallar)

kubectl run unsigned-app \
  --image=ghcr.io/<your-username>/cosign-lab:v0.1.0 \
  --restart=Never

Salida esperada:

Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
resource Pod/default/unsigned-app was blocked due to the following policies:

require-cosign-signature:
  verify-cosign-signature: |
    image verification failed for ghcr.io/<your-username>/cosign-lab:v0.1.0:
    no matching signatures found

Este es exactamente el bucle de aplicación que deseas: solo las imágenes firmadas por tu workflow confiable de GitHub Actions pueden entrar al clúster.

Ejercicio 5: Adjuntar un SBOM

Una firma demuestra quién construyó la imagen. Una attestation de SBOM demuestra qué contiene. Combinar ambas te da una cadena de confianza completa: identidad, integridad y transparencia del contenido.

Paso 1 — Generar el SBOM con Syft

syft ghcr.io/<your-username>/cosign-lab:v1.0.0 -o spdx-json > sbom.spdx.json

Esto escanea las capas de la imagen y produce un documento JSON en formato SPDX que lista cada paquete, biblioteca y dependencia dentro de la imagen.

Paso 2 — Adjuntar el SBOM como attestation

cosign attest \
  --predicate sbom.spdx.json \
  --type spdxjson \
  --yes \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Al igual que la firma keyless, esto usa identidad basada en OIDC cuando se ejecuta en GitHub Actions o solicita autenticación interactiva cuando se ejecuta localmente. La attestation se almacena como un artefacto OCI junto a la imagen.

Paso 3 — Verificar la attestation

cosign verify-attestation \
  --type spdxjson \
  --certificate-identity "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/v1.0.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

La verificación exitosa confirma que el SBOM fue generado y adjuntado por tu workflow confiable, y que no ha sido manipulado desde entonces.

El Pipeline Completo de Firma

Aquí está el workflow final que combina todo: construir, publicar, firmar, generar un SBOM y certificarlo. Guárdalo como .github/workflows/sign-and-attest.yml:

name: Build, Sign, and Attest

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-sign-attest:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Install Syft
        uses: anchore/sbom-action/download-syft@v0

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Sign the image
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

      - name: Generate SBOM
        run: |
          syft ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} \
            -o spdx-json > sbom.spdx.json

      - name: Attest SBOM
        run: |
          cosign attest --yes \
            --predicate sbom.spdx.json \
            --type spdxjson \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

      - name: Verify signature
        run: |
          cosign verify \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

      - name: Verify SBOM attestation
        run: |
          cosign verify-attestation \
            --type spdxjson \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

Este workflow te da una cadena de confianza completa para cada release etiquetado: la imagen está firmada, su contenido está documentado en un SBOM, y el SBOM está certificado criptográficamente — todo sin gestionar una sola clave de larga duración.

Limpieza

Cuando hayas terminado con el laboratorio, limpia los recursos que creaste.

Eliminar imágenes de prueba de GHCR

Navega a https://github.com/<your-username>?tab=packages y elimina el paquete cosign-lab, o usa el GitHub CLI:

# List package versions
gh api user/packages/container/cosign-lab/versions | jq '.[].id'

# Delete each version
gh api --method DELETE user/packages/container/cosign-lab/versions/<version-id>

Eliminar Kyverno

kubectl delete clusterpolicy require-cosign-signature
helm uninstall kyverno -n kyverno
kubectl delete namespace kyverno

Eliminar el repositorio de prueba

gh repo delete <your-username>/cosign-lab --yes

Eliminar archivos locales

cd .. && rm -rf cosign-lab
rm -f cosign.key cosign.pub

Conclusiones Clave

  • La firma keyless elimina la gestión de claves. Al usar tokens de identidad OIDC de GitHub Actions y certificados Fulcio de corta duración, evitas la carga operativa de generar, almacenar, rotar y distribuir claves de firma.
  • Las firmas están vinculadas a la identidad, no a la clave. La verificación comprueba quién firmó la imagen (qué workflow, en qué repositorio, en qué ref) en lugar de qué clave se usó. Esto hace que las políticas sean más intuitivas y auditables.
  • El log de transparencia Rekor proporciona un rastro de auditoría a prueba de manipulaciones. Cada firma se registra públicamente, por lo que puedes demostrar cuándo se firmó una imagen y detectar cualquier intento de antedatar o eliminar firmas.
  • Los controladores de admisión aplican políticas de firma en tiempo de despliegue. Kyverno (o alternativas como Connaisseur o Sigstore Policy Controller) asegura que las imágenes sin firmar o firmadas incorrectamente nunca se ejecuten en tu clúster.
  • Las attestations de SBOM extienden la cadena de confianza. La firma demuestra quién construyó la imagen; adjuntar un SBOM firmado demuestra qué contiene. Juntos, proporcionan procedencia completa desde el código fuente hasta el tiempo de ejecución.
  • Firma por digest, no por tag. Los tags son mutables — alguien puede mover un tag a una imagen diferente. Los digests son direcciones de contenido inmutables, por lo que firmar por digest garantiza que firmaste exactamente la imagen que construiste.

Próximos Pasos

Continúa construyendo tu conocimiento en seguridad de la cadena de suministro con estas guías relacionadas: