Descripción General
GitHub Actions se ha convertido en la plataforma de CI/CD más ampliamente adoptada tanto para software de código abierto como comercial. Esa popularidad la convierte en la superficie de ataque número uno en el panorama CI/CD. Los workflows mal configurados filtran secretos de forma rutinaria, otorgan permisos excesivos e incorporan código de terceros que puede ser manipulado silenciosamente.
En este lab práctico, endurecerás un workflow de GitHub Actions deliberadamente inseguro utilizando las tres técnicas de mayor impacto disponibles actualmente:
- Permisos mínimos — restringir el
GITHUB_TOKENúnicamente a los scopes que cada job realmente necesita. - SHA pinning — referenciar cada action de terceros por su SHA de commit inmutable en lugar de un tag mutable.
- Protección de secretos — delimitar los secretos a environments con puertas de aprobación y prevenir fugas a través de pull requests basados en forks.
Al finalizar el lab, tendrás una plantilla de workflow de nivel producción que puedes incorporar en cualquier repositorio.
Requisitos Previos
- Una cuenta de GitHub con permisos para crear repositorios.
- Familiaridad básica con la sintaxis YAML de GitHub Actions (triggers, jobs, steps).
- El CLI
ghinstalado (opcional pero útil para consultar SHAs de actions).
Configuración del Entorno
Crear un Repositorio de Prueba
Crea un nuevo repositorio público en GitHub llamado gha-hardening-lab. Puedes hacerlo a través de la interfaz o con el CLI:
gh repo create gha-hardening-lab --public --clone
cd gha-hardening-lab
Inicializa un proyecto Node.js mínimo para que el workflow tenga algo que construir:
npm init -y
cat <<'EOF' > index.js
console.log("Hello from the hardening lab");
EOF
git add -A && git commit -m "Initial commit" && git push
El Workflow Inicial (Inseguro)
Crea el archivo .github/workflows/build.yml con el siguiente contenido. Este workflow es intencionalmente inseguro — no tiene bloque de permissions, usa tags mutables y expone secretos de forma demasiado amplia:
# .github/workflows/build.yml — Punto de partida INSEGURO
name: Build
on:
push:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm test
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .
Haz commit y push de este archivo. Se ejecutará correctamente, pero tiene al menos cinco problemas de seguridad que corregirás en los ejercicios siguientes.
Ejercicio 1: Permisos Mínimos
El Problema con los Permisos por Defecto
Cuando un workflow no declara un bloque permissions, el GITHUB_TOKEN recibe los permisos por defecto del repositorio. Para la mayoría de los repositorios, esto significa acceso de lectura y escritura a cada scope — contents, packages, issues, pull requests, deployments y más. Si un atacante compromete cualquier step en ese workflow, hereda todos esos permisos.
El principio de mínimo privilegio exige que otorgues únicamente los permisos que cada job realmente requiere, y nada más.
Paso 1 — Establecer un Valor por Defecto Restrictivo a Nivel Superior
Añade una clave permissions a nivel superior inmediatamente después del bloque on:. Esto establece el valor por defecto para cada job en el workflow:
permissions:
contents: read
Si deseas comenzar con el valor por defecto más restrictivo posible y luego otorgar permisos por job, puedes usar un mapa vacío:
permissions: {}
Paso 2 — Añadir Permisos por Job
Cada job puede sobrescribir el valor por defecto a nivel de workflow. Otorga únicamente lo que el job necesita:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # check out code
actions: read # read workflow metadata
steps:
- uses: actions/checkout@v4
# ...
Si un segundo job necesita subir un asset de release, le otorgarías contents: write únicamente a ese job — nunca a nivel de workflow.
Antes y Después
Antes (inseguro):
name: Build
on:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
Después (endurecido):
name: Build
on:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@v4
- run: npm install
Verificar los Permisos Efectivos
Después de que el workflow se ejecute, abre el job en la pestaña Actions. Haz clic en el ícono de engranaje en la parte superior derecha del log del job y selecciona “Set up job”. Expande esa sección para ver los permisos exactos del GITHUB_TOKEN que fueron otorgados. Confirma que solo aparecen contents: read y actions: read.
También puedes consultar los permisos programáticamente dentro de un step:
- name: Print token permissions
run: |
curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }} \
| jq '.permissions'
Ejercicio 2: Fijar Actions por SHA
Por Qué los Tags Son Peligrosos
Cuando escribes uses: actions/checkout@v4, estás referenciando un tag de Git. Los tags son mutables — el mantenedor de la action (o un atacante que comprometa su cuenta) puede eliminar y recrear el tag apuntando a un código completamente diferente. Tu workflow entonces ejecutaría silenciosamente el nuevo código en su siguiente ejecución. El SHA pinning elimina este riesgo porque un SHA de commit es inmutable.
Paso 1 — Encontrar el SHA de una Action
Usa el CLI gh para resolver un tag a su SHA de commit:
# Resolver actions/checkout@v4 a un SHA de commit
gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha'
Si el tag es anotado (la mayoría lo son), el comando anterior devuelve el SHA del objeto tag. Necesitas desreferenciarlo al commit:
TAG_SHA=$(gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha')
gh api repos/actions/checkout/git/tags/$TAG_SHA --jq '.object.sha'
Alternativamente, visita el repositorio de la action en GitHub, haz clic en el tag y copia el SHA de commit completo desde la URL o el encabezado del commit.
Paso 2 — Fijar las Actions Comunes
Reemplaza cada tag mutable con el SHA completo de 40 caracteres. Siempre añade un comentario al final con la versión para facilitar la lectura:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: build-output
path: dist/
Paso 3 — Automatizar las Actualizaciones de SHA con Dependabot
Fijar por SHA significa que ya no recibirás actualizaciones automáticas basadas en tags. Dependabot resuelve esto abriendo pull requests cada vez que una action fijada publica una nueva versión.
Crea el archivo .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"
Después de hacer push de este archivo, Dependabot escaneará tus workflows semanalmente y abrirá PRs para actualizar los SHAs fijados. Cada PR muestra el diff del código de la action, dándote la oportunidad de revisar antes de hacer merge.
Si prefieres Renovate en lugar de Dependabot, añade un archivo renovate.json en la raíz del repositorio:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"github-actions": {
"enabled": true
}
}
Ejercicio 3: Protección de Secretos
Repository Secrets vs. Environment Secrets
GitHub ofrece dos niveles de almacenamiento de secretos:
- Repository secrets — disponibles para cada workflow y cada job en el repositorio. Convenientes pero excesivamente amplios.
- Environment secrets — disponibles únicamente para jobs que declaren explícitamente
environment: <nombre>. Este es el enfoque recomendado para credenciales sensibles.
Paso 1 — Crear un Environment con Reglas de Protección
En tu repositorio, navega a Settings → Environments y crea un environment llamado production. Habilita las siguientes reglas de protección:
- Required reviewers — añade al menos un miembro del equipo que debe aprobar los deployments.
- Wait timer — opcionalmente añade un retraso (por ejemplo, 5 minutos) para dar tiempo a los revisores.
- Deployment branches — restringe únicamente a
main.
Ahora añade tu DEPLOY_TOKEN como secreto dentro de este environment, no a nivel de repositorio.
Paso 2 — Referenciar el Environment en Tu Workflow
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Deploy
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
La declaración environment: production significa que este job se pausará y esperará a que un revisor lo apruebe antes de que cualquier step se ejecute. El secreto DEPLOY_TOKEN solo está disponible dentro de este environment — no puede ser accedido por otros jobs o workflows que no declaren este environment.
Paso 3 — Entender el Comportamiento con Forks
Los secretos no están disponibles para workflows disparados por eventos pull_request desde forks. Esta es una frontera de seguridad crítica. Si creas un workflow que depende de secretos durante las verificaciones de PR, fallará para contribuidores externos:
# Este step fallará para PRs basados en forks porque DEPLOY_TOKEN está vacío
- name: Authenticated API call
run: |
curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/health
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Esto es por diseño — evita que forks maliciosos exfiltren tus secretos.
Paso 4 — El Peligro de pull_request_target
El trigger pull_request_target se ejecuta en el contexto del repositorio base, lo que significa que sí tiene acceso a los secretos. Esto es extremadamente peligroso si también haces checkout del código head del PR:
# PELIGROSO — NO HAGAS ESTO
on:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }} # Checks out UNTRUSTED code
- run: npm install # Executes attacker-controlled code with access to secrets
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Un atacante puede modificar package.json para incluir un script postinstall que exfiltre DEPLOY_TOKEN. Nunca combines pull_request_target con un checkout del head del PR a menos que hayas validado y aislado explícitamente el código.
Alternativa segura: Usa el trigger estándar pull_request para workflows de build y test. Reserva pull_request_target únicamente para workflows de etiquetado o comentarios que nunca ejecuten código del PR.
Resumen de Mejores Prácticas
- Almacena secretos sensibles en environments, no a nivel de repositorio.
- Añade required reviewers y restricciones de branch a cada environment que contenga credenciales de producción.
- Usa el trigger
pull_requestpara CI. Evitapull_request_targeta menos que comprendas completamente las implicaciones de confianza. - Diseña workflows de forma que los jobs que necesitan secretos estén separados de los jobs que ejecutan código no confiable.
Ejercicio 4: Hardening Adicional
Prevenir Ejecuciones Duplicadas con Concurrency
Sin una política de concurrency, hacer push de múltiples commits en rápida sucesión genera múltiples ejecuciones del workflow que desperdician recursos y pueden causar condiciones de carrera durante el deployment. Añade un bloque concurrency a nivel de workflow:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Esto cancela cualquier ejecución en progreso para el mismo workflow y branch cuando se hace push de un nuevo commit.
Establecer Límites de Timeout
Un job colgado puede consumir minutos de runner indefinidamente. Siempre establece un timeout explícito:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
Elige un valor que dé a tu build suficiente margen pero prevenga procesos descontrolados. Para la mayoría de builds de Node.js o Go, 10 a 20 minutos es generoso.
Restringir los Triggers del Workflow
Evita triggers sin restricciones que se disparen en cada branch:
# Demasiado amplio — se ejecuta en cada push a cada branch
on:
push:
En su lugar, delimita los triggers a los branches que importan:
on:
push:
branches: [main]
pull_request:
branches: [main]
Esto reduce las ejecuciones innecesarias y limita la superficie de ataque para ataques de inyección basados en branches.
Ejecución Condicional para Steps Sensibles
Usa condiciones if: para prevenir que steps sensibles se ejecuten en contextos donde no deberían:
- name: Deploy to production
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Esto asegura que el step de deploy solo se ejecute en pushes a main, nunca en pull requests u otros branches, incluso si el job en sí fue disparado.
El Workflow Final Endurecido
A continuación se muestra el workflow endurecido completo junto al original. Cada mejora de seguridad está anotada con un comentario.
Original (Inseguro)
name: Build
on:
push:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm test
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .
Endurecido
name: Build
# HARDENED: Scoped triggers — only main branch, safe PR trigger
on:
push:
branches: [main]
pull_request:
branches: [main]
# HARDENED: Restrictive default permissions for all jobs
permissions:
contents: read
# HARDENED: Cancel duplicate runs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
# HARDENED: Explicit timeout
timeout-minutes: 15
# HARDENED: Per-job permissions (least privilege)
permissions:
contents: read
actions: read
steps:
# HARDENED: All actions pinned by SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
- run: npm install
- run: npm test
# HARDENED: No secrets exposed in the build/test job
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: build-output
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
# HARDENED: Only runs on push to main
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# HARDENED: Secrets gated behind environment with required reviewers
environment: production
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Deploy
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Rompiéndolo (Fallo Intencional)
Para consolidar tu comprensión, rompe deliberadamente el workflow endurecido y observa las consecuencias.
Prueba 1 — Eliminar el Bloque de Permissions
Elimina la clave permissions: a nivel superior y los permisos por job. Haz push y ejecuta el workflow. Seguirá teniendo éxito, pero si inspeccionas el step de setup del job, verás que el token ahora tiene acceso de lectura y escritura a cada scope. Un step comprometido podría hacer push de código, eliminar branches o modificar releases.
Prueba 2 — Usar una Action Sin Fijar
Cambia una action de vuelta a una referencia por tag:
- uses: actions/checkout@v4
El workflow seguirá ejecutándose. Pero si el tag v4 alguna vez se mueve a un commit malicioso, tu workflow ejecutará ese código sin advertencia. No hay rastro de auditoría — el tag simplemente resuelve a un SHA diferente. Fija de nuevo al SHA después de esta prueba.
Prueba 3 — Acceder a Secretos de Producción desde un PR
Crea un feature branch y abre un pull request. El job deploy no se ejecutará debido a la condición if:. Incluso si eliminas la condición, el secreto DEPLOY_TOKEN del environment está protegido por el environment production, que restringe el deployment al branch main y requiere aprobación de un revisor. El valor del secreto estará vacío en el contexto del PR.
Este es exactamente el comportamiento que deseas — los secretos nunca están disponibles en contextos no confiables.
Limpieza
Cuando hayas terminado el lab, elimina el repositorio de prueba para evitar acumular desorden en tu cuenta:
gh repo delete gha-hardening-lab --yes
Si usaste un fork de un proyecto existente, puedes restablecerlo en su lugar:
git checkout main
git reset --hard origin/main
git push --force
Conclusiones Clave
- Siempre declara un bloque
permissions. Establece un valor por defecto restrictivo a nivel de workflow y otorga scopes adicionales por job solo según sea necesario. - Fija cada action de terceros por su SHA completo. Los tags son mutables y pueden ser redirigidos silenciosamente a código malicioso.
- Usa Dependabot o Renovate para mantener los SHAs fijados actualizados automáticamente.
- Almacena secretos sensibles en environments con required reviewers y restricciones de branch — nunca a nivel de repositorio.
- Usa
pull_request, nopull_request_target, para workflows que construyan o prueben código de PRs. El triggerpull_request_targetotorga acceso a secretos a código potencialmente no confiable. - Añade
concurrency,timeout-minutesy triggers delimitados por branch para reducir el desperdicio de recursos y reducir la superficie de ataque.
Próximos Pasos
Continúa construyendo tu conocimiento de seguridad CI/CD con estas guías relacionadas:
- Modelos de Ejecución CI/CD y Suposiciones de Confianza — Comprende cómo diferentes plataformas de CI/CD modelan la confianza y dónde se rompen las fronteras.
- Separación de Responsabilidades y Mínimo Privilegio en Pipelines CI/CD — Diseña pipelines donde ningún actor o credencial individual tenga más acceso del necesario.