Un pipeline CI/CD tiene acceso al código, a los secretos y al registro de imágenes, así que es uno de los objetivos favoritos de un atacante: basta una acción de terceros comprometida o un token con demasiados permisos para publicar una imagen maliciosa. En este tutorial endurecerás paso a paso un workflow de GitHub Actions que construye una imagen Docker: permisos mínimos, gestión de secretos, detección de secretos filtrados con Gitleaks, acciones fijadas por SHA, escaneo de vulnerabilidades con Trivy, SBOM y firma de la imagen con Cosign. Las pruebas locales se hacen desde Ubuntu 24.04.

Requisitos previos

  • Un repositorio en GitHub con un Dockerfile en la raíz y permisos de administrador sobre él.
  • Una máquina con Ubuntu 24.04 LTS (tu estación de trabajo o un VPS de CubePath) con Docker instalado y un usuario no root con sudo, para ejecutar las herramientas en local.
  • La CLI de GitHub (gh) autenticada, opcional pero útil para gestionar secretos.
  • Las imágenes se publicarán en GitHub Container Registry (ghcr.io). Si usas otro registro, solo cambian el nombre de la imagen y el inicio de sesión.

Paso 1: Reducir los permisos del GITHUB_TOKEN

Cada ejecución recibe un GITHUB_TOKEN automático. Si no declaras permisos, su alcance depende de la configuración del repositorio y puede incluir escritura en todo. Declara permisos de solo lectura a nivel de workflow y amplíalos únicamente en el job que los necesita.

Crea el archivo .github/workflows/build.yml con esta cabecera:

name: build

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

Más adelante el job que publica la imagen añadirá packages: write (para subir a ghcr.io) e id-token: write (para la firma sin claves de Cosign). Ningún otro job los tendrá.

Además, en Settings > Actions > General > Workflow permissions del repositorio marca Read repository contents and packages permissions, para que cualquier workflow que olvide el bloque permissions arranque con permisos de lectura.

Paso 2: Guardar los secretos fuera del código

Los secretos nunca van en el repositorio ni en el YAML del workflow. Guárdalos como secretos de GitHub, idealmente asociados a un entorno (environment) para que solo los jobs de ese entorno puedan leerlos:

gh secret set DEPLOY_SSH_KEY --env production < ~/.ssh/deploy_production
gh secret list --env production
NAME            UPDATED
DEPLOY_SSH_KEY  less than a minute ago

En el workflow, pasa los secretos a los comandos mediante variables de entorno del paso, no interpolándolos directamente en el script. Así evitas que un valor con caracteres especiales rompa el comando o acabe en el log:

      - name: Desplegar
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        run: ./scripts/deploy.sh

GitHub enmascara los secretos en los logs, pero solo si aparecen tal cual: un secreto codificado en base64 o recortado se imprime en claro. No hagas echo de secretos ni actives set -x en pasos que los usan. Si tu proveedor de nube lo admite, sustituye las claves de larga duración por credenciales temporales mediante OIDC (id-token: write), que caducan al terminar el job.

Paso 3: Detectar secretos filtrados con Gitleaks

Gitleaks busca claves, tokens y contraseñas en el historial de Git. Ejecútalo primero en local con su imagen oficial para revisar el historial completo del repositorio:

cd ~/tu_repositorio
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest git /repo --redact -v

Si no hay hallazgos verás:

INF 128 commits scanned.
INF scanned ~2481930 bytes (2.48 MB) in 1.2s
INF no leaks found

Si encuentra algo, el comando termina con código 1 y muestra el archivo, el commit y la regla. Un secreto que ya está en el historial se considera comprometido: rótalo en el servicio correspondiente. Borrarlo del último commit no sirve, sigue en el historial y en los clones existentes.

Para que el pipeline lo compruebe en cada push, añade este job al workflow. Necesita el historial completo (fetch-depth: 0):

jobs:
  secretos:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Gitleaks
        run: docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:v8.24.2 git /repo --redact -v

En CI fija una versión concreta de la imagen en lugar de latest, así un cambio de reglas no rompe el pipeline sin aviso. Consulta la última versión en la página de releases del proyecto gitleaks/gitleaks.

Paso 4: Fijar las acciones de terceros por SHA

Una referencia como uses: org/accion@v2 apunta a una etiqueta de Git que el mantenedor (o un atacante con acceso a su cuenta) puede mover a otro commit. Es lo que ocurrió en marzo de 2025 con tj-actions/changed-files: se reescribieron sus etiquetas y miles de pipelines volcaron sus secretos en los logs. La referencia a un SHA completo de commit es inmutable.

Obtén el commit al que apunta una versión:

git ls-remote https://github.com/docker/setup-buildx-action refs/tags/v3.10.0

La salida tiene el SHA de 40 caracteres seguido de la etiqueta:

<sha_de_40_caracteres>	refs/tags/v3.10.0

Y úsalo en el workflow, con la versión como comentario para saber qué es:

      - uses: docker/setup-buildx-action@<sha_de_40_caracteres> # v3.10.0

Si la etiqueta es anotada, git ls-remote devuelve además una línea terminada en ^{}: ese es el SHA del commit que debes usar. Como mínimo, fija por SHA todas las acciones que no sean de GitHub (actions/*) o de un proveedor que ya uses y en el que confíes.

Para no quedarte con versiones antiguas, activa Dependabot para las acciones. Crea .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Dependabot abrirá pull requests que actualizan el SHA y el comentario de versión, y tú revisas el cambio antes de fusionarlo.

Paso 5: Escanear dependencias e imagen con Trivy

Trivy detecta vulnerabilidades conocidas en dependencias (package-lock.json, requirements.txt, go.sum...) y en los paquetes del sistema de una imagen. Instálalo en Ubuntu 24.04 desde su repositorio oficial:

sudo apt update
sudo apt install -y wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /etc/apt/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update
sudo apt install -y trivy
trivy --version

Escanea el código del repositorio (dependencias declaradas y archivos de configuración):

trivy fs --scanners vuln,misconfig --severity HIGH,CRITICAL .

Y una imagen construida en local:

docker build -t miapp:local .
trivy image --severity HIGH,CRITICAL --ignore-unfixed miapp:local

La salida es una tabla por componente con el CVE, la versión instalada y la versión que lo corrige:

miapp:local (debian 12.11)
Total: 1 (HIGH: 1, CRITICAL: 0)

Debajo del resumen, Trivy muestra una tabla con cada paquete afectado, el CVE, la versión instalada y la versión que lo corrige.

--ignore-unfixed oculta las vulnerabilidades que aún no tienen parche, que no puedes corregir actualizando. En el pipeline añadirás --exit-code 1 para que un hallazgo HIGH o CRITICAL detenga el job antes de publicar la imagen. Si aceptas un riesgo concreto, documéntalo en un archivo .trivyignore en la raíz con un CVE por línea y un comentario que explique por qué.

Paso 6: Generar el SBOM de la imagen

Un SBOM (Software Bill of Materials) es el inventario de todo lo que contiene la imagen. Cuando se publique un CVE nuevo podrás saber en minutos qué imágenes están afectadas sin volver a escanearlas todas. Trivy lo genera en formato CycloneDX:

trivy image --format cyclonedx --output sbom.cdx.json miapp:local

Comprueba que el archivo contiene componentes:

jq '.components | length' sbom.cdx.json
142

En el pipeline lo adjuntarás a la imagen como atestación firmada (paso 7), así viaja con ella en el registro.

Paso 7: Firmar la imagen con Cosign

Firmar la imagen permite comprobar, antes de desplegarla, que la construyó tu pipeline y no alguien con acceso al registro. Cosign admite firma keyless: en GitHub Actions usa el token OIDC del job para obtener un certificado de corta duración de Sigstore, así que no hay clave privada que guardar ni rotar. La firma queda registrada en el log público de transparencia Rekor.

Este es el job completo de construcción, escaneo, publicación y firma. Añádelo debajo del job secretos en .github/workflows/build.yml:

  imagen:
    needs: secretos
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write
      id-token: write
    env:
      IMAGE: ghcr.io/tu_usuario/tu_repositorio
    steps:
      - uses: actions/checkout@v4

      - name: Instalar Trivy
        run: |
          wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /etc/apt/keyrings/trivy.gpg > /dev/null
          echo "deb [signed-by=/etc/apt/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
          sudo apt-get update
          sudo apt-get install -y trivy

      - name: Escanear dependencias
        run: trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .

      - name: Construir imagen
        run: docker build -t "$IMAGE:$GITHUB_SHA" .

      - name: Escanear imagen
        run: trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$IMAGE:$GITHUB_SHA"

      - name: Generar SBOM
        run: trivy image --format cyclonedx --output sbom.cdx.json "$IMAGE:$GITHUB_SHA"

      - name: Publicar imagen
        if: github.event_name == 'push'
        id: push
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
          docker push "$IMAGE:$GITHUB_SHA"
          DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE:$GITHUB_SHA")
          echo "ref=$DIGEST" >> "$GITHUB_OUTPUT"

      - uses: sigstore/cosign-installer@v3
        if: github.event_name == 'push'

      - name: Firmar imagen y adjuntar SBOM
        if: github.event_name == 'push'
        env:
          REF: ${{ steps.push.outputs.ref }}
        run: |
          cosign sign --yes "$REF"
          cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "$REF"

Sustituye tu_usuario/tu_repositorio por tu ruta en minúsculas: ghcr.io rechaza nombres con mayúsculas. Fíjate en tres detalles:

  • Se firma el digest (imagen@sha256:...), no la etiqueta. Una etiqueta se puede reasignar; el digest identifica el contenido exacto.
  • En los pull requests la imagen se construye y se escanea, pero no se publica ni se firma, y ese job no necesita secretos.
  • Si Trivy encuentra algo, el job falla antes de docker push y la imagen vulnerable nunca llega al registro.

Tras un push a main, verifica la firma desde tu máquina. Instala Cosign descargando el binario de la última release de sigstore/cosign en GitHub y ejecuta:

cosign verify ghcr.io/tu_usuario/tu_repositorio@sha256:DIGEST \
  --certificate-identity-regexp '^https://github.com/tu_usuario/tu_repositorio/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com
Verification for ghcr.io/tu_usuario/tu_repositorio@sha256:9f2c... --
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 certificates

La opción --certificate-identity-regexp es la parte importante: comprueba que la firma la hizo un workflow de tu repositorio y no cualquier otro de GitHub. Si la imagen es privada, inicia sesión antes con docker login ghcr.io.

Paso 8: Proteger la rama principal y el entorno de producción

Los controles anteriores sirven de poco si alguien puede saltárselos con un push directo a main o modificando el workflow. En Settings > Rules > Rulesets crea un ruleset para la rama main con:

  • Require a pull request before merging, con al menos una aprobación.
  • Require status checks to pass, marcando los jobs secretos e imagen.
  • Block force pushes.

Para los despliegues, crea un entorno production en Settings > Environments, limita Deployment branches a main y añade Required reviewers. Los jobs con environment: production esperarán la aprobación de una persona antes de ejecutarse y de acceder a los secretos de ese entorno.

Por último, crea un archivo .github/CODEOWNERS para que cualquier cambio en los workflows necesite la revisión de tu equipo de plataforma:

/.github/ @tu_usuario

Activa Require review from Code Owners en el ruleset para que se aplique.

Paso 9: Comprobar el pipeline completo

Abre un pull request con un cambio trivial y revisa la pestaña Actions: deben ejecutarse secretos e imagen, y en imagen los pasos de publicación y firma aparecen como omitidos. Después fusiona el pull request y confirma que:

  1. El job imagen termina en verde y aparece el paquete en la pestaña Packages del repositorio.
  2. cosign verify (paso 7) valida la firma del digest publicado.
  3. La atestación con el SBOM se puede verificar:
cosign verify-attestation --type cyclonedx \
  --certificate-identity-regexp '^https://github.com/tu_usuario/tu_repositorio/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/tu_usuario/tu_repositorio@sha256:DIGEST > /dev/null && echo "SBOM verificado"
SBOM verificado

Para probar que el control funciona, cambia temporalmente la imagen base del Dockerfile por una versión antigua (por ejemplo FROM python:3.8) en una rama: el paso Escanear imagen debe fallar y la imagen no debe publicarse.

Solución de problemas

denied: installation not allowed to Write organization package: el paquete ya existe en ghcr.io y no está vinculado al repositorio. En la configuración del paquete, en Manage Actions access, añade el repositorio con rol Write.

cosign sign pide abrir un navegador o no obtiene un token de identidad: falta id-token: write en los permisos del job, o el workflow se ejecuta desde un fork, donde GitHub no emite token OIDC.

Trivy falla con TOOMANYREQUESTS al descargar la base de datos: el registro que aloja la base de vulnerabilidades limita las descargas anónimas. Guarda en caché el directorio ~/.cache/trivy con actions/cache para no descargarla en cada ejecución.

Gitleaks marca un falso positivo: añade al commit la línea con el comentario gitleaks:allow o crea un archivo .gitleaksignore con la huella (fingerprint) que muestra el hallazgo. No desactives la regla completa.

Conclusión

Tu pipeline ahora trabaja con permisos mínimos, no expone secretos, depende solo de acciones fijadas a commits concretos, bloquea imágenes con vulnerabilidades conocidas y publica imágenes firmadas con su SBOM adjunto. Como siguientes pasos, puedes exigir la verificación de la firma en el lado del despliegue (por ejemplo con una política de admisión en Kubernetes como Kyverno o el policy-controller de Sigstore), programar un escaneo semanal de las imágenes ya publicadas con trivy image para detectar CVE nuevos, y habilitar Secret scanning y Push protection en la configuración de seguridad del repositorio para bloquear secretos antes de que lleguen al historial.