GitHub Actions bajo ataque: cómo auditar y blindar tus workflows antes de que exfiltren tus credenciales
Un workflow puede ejecutar código ajeno, recibir un token con permisos de escritura y acceder a credenciales de despliegue. Esa combinación convierte un fallo de configuración en una vía de exfiltración.
El caso de workflows maliciosos descrito por The Hacker News sirve de recordatorio: los archivos de .github/workflows/ merecen la misma revisión que cualquier cambio con acceso a producción. La auditoría empieza por una pregunta concreta: ¿puede código que no controlas ejecutarse en un job con credenciales?
1. Localiza los cruces entre código no confiable y credenciales
Revisa los archivos .yml y .yaml de .github/workflows/. Para cada job, anota qué evento lo dispara, qué código descarga o ejecuta, qué secrets recibe y qué permisos tiene su GITHUB_TOKEN. Una búsqueda inicial ayuda a localizar usos, aunque no sustituye la lectura del contexto:
rg -n 'secrets\.|pull_request_target|repository_dispatch|workflow_dispatch|write-all|id-token: write' .github/workflows
rg -n 'uses: .*@' .github/workflows
Prioriza los jobs donde coinciden un trigger accesible a terceros, un checkout o script controlable desde fuera y algún permiso sensible. Un uses: actions/checkout no es peligroso por sí mismo; importa qué referencia descarga y qué puede hacer después ese job.
PRs desde forks: distingue pull_request de pull_request_target
En un pull_request procedente de un fork, GitHub no entrega por defecto los secrets del repositorio al workflow y limita el GITHUB_TOKEN. Eso no hace seguro ejecutar cualquier cosa, pero evita la exposición directa que supondría pasar credenciales al código del PR.
El caso que exige especial cuidado es pull_request_target: el workflow se ejecuta en el contexto de la rama base y puede tener acceso a secrets y permisos de escritura. Si en ese contexto haces checkout del código del PR y ejecutas sus scripts, le das al atacante una ruta hacia esas credenciales.
Mantén los tests del PR en un job sin secrets ni permisos de escritura:
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
ci-pr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
Reserva el despliegue para código revisado en una rama protegida. Si necesitas pull_request_target para comentar o etiquetar PRs, evita ejecutar código del PR en ese job y concede únicamente el permiso necesario para esa operación.
Acciones externas: fija lo que ejecutas
Una referencia como someuser/some-action@main cambia con cada actualización de la rama. Una etiqueta como @v1 también puede moverse. En una acción externa con acceso a credenciales, eso permite que cambie el código ejecutado sin modificar tu workflow.
- uses: someuser/some-action@<sha-completo-del-commit>
Fijar el SHA evita cambios silenciosos, pero no convierte una acción maliciosa en segura. Revisa qué hace antes de incorporarla y actualiza el SHA mediante PRs que puedas inspeccionar. Hacer un fork solo compensa si tu equipo va a mantenerlo y revisar sus cambios; de lo contrario, añade otra dependencia difícil de seguir.
checkout, scripts y permisos de escritura
Este job concentra demasiada capacidad en un mismo sitio:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions: write-all
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: ./scripts/deploy.sh
env:
CLOUD_TOKEN: ${{ secrets.CLOUD_TOKEN }}
El riesgo depende del trigger y de la referencia descargada. Si ese código procede de un PR no confiable y el job tiene acceso efectivo a CLOUD_TOKEN, un cambio en scripts/deploy.sh puede enviarlo fuera. write-all amplía además lo que podría hacer con GITHUB_TOKEN.
Separa build y deploy. El primero puede ejecutar el PR sin credenciales sensibles; el segundo debe operar sobre una referencia revisada y protegida. Comprueba también si actions/checkout persiste credenciales en el checkout: cuando pasos posteriores ejecuten código no confiable, usa persist-credentials: false y evita conceder al job permisos que no necesita.
2. Da a cada job solo la identidad que necesita
Reduce los permisos de GITHUB_TOKEN
Declara los permisos explícitamente. Si un job solo lee el repositorio, no necesita pull-requests: write ni contents: write:
permissions:
contents: read
Cuando un job no necesita el token, puedes dejar sus permisos vacíos:
permissions: {}
Ajusta los permisos por job cuando build, publicación y despliegue necesiten capacidades distintas. Evita write-all: obliga a revisar qué operación concreta requiere escritura y limita el alcance de una acción o script comprometido.
Sustituye claves persistentes de cloud por OIDC
Con OIDC, GitHub solicita una identidad para el job y el proveedor cloud la intercambia por credenciales temporales. Así evitas guardar una clave de larga duración en secrets.*.
El permiso en GitHub es necesario, pero no basta:
permissions:
contents: read
id-token: write
En el proveedor cloud debes configurar la relación de confianza para aceptar únicamente los jobs previstos. Restringe los claims disponibles, por ejemplo a un repositorio y a la rama main o al environment production, según cómo hayas definido el despliegue. Verifica la condición exacta contra el valor real del claim: al usar un environment, el sub de GitHub puede identificar ese environment en lugar de la rama. Si la política es demasiado amplia, cualquier job que la cumpla podrá pedir credenciales temporales.
OIDC reduce la exposición de claves persistentes; no protege un job autorizado que ejecuta código malicioso. Por eso la revisión del código y los límites de confianza siguen siendo necesarios.
Protege el environment de producción
Asocia el despliegue a production y configura en GitHub revisores obligatorios y restricciones de ramas para ese environment. El job esperará la aprobación antes de acceder a sus secrets y ejecutar los pasos protegidos:
jobs:
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy-prod.sh
Ese ejemplo presupone que ya has configurado la federación OIDC y que el script obtiene las credenciales temporales mediante la integración de tu proveedor cloud. No añadas id-token: write a jobs que no vayan a usarla.
La aprobación humana tampoco sustituye la protección de main. Exige revisión antes del merge y evita que un cambio en el propio script de despliegue llegue a producción sin pasar por ella.
3. Otros patrones que merecen una búsqueda
Expresiones controlables dentro de run
Una expresión de GitHub interpolada directamente en un comando de shell puede convertir texto de un usuario en código ejecutable:
- name: Run user command
run: ${{ github.event.inputs.command }}
No ejecutes inputs como comandos. Si necesitas elegir una operación, compara el valor con una lista cerrada de opciones y ejecuta el comando correspondiente sin interpolar el texto recibido en el shell.
Dispatches con acceso a despliegue
workflow_dispatch y repository_dispatch no equivalen a un PR abierto: su invocación requiere permisos. Aun así, revisa quién puede activarlos y si los valores del evento terminan controlando scripts, referencias o destinos de despliegue. Un token con capacidad para enviar un repository_dispatch puede ser suficiente para activar un job privilegiado si el workflow no limita lo que acepta.
Una credencial compartida por varios entornos
Si staging y production usan el mismo CLOUD_TOKEN, comprometer un job de pruebas puede dar acceso a producción. Separa identidades y políticas por entorno, preferiblemente con OIDC. Si aún dependes de secrets, asígnalos al environment correspondiente y no al repositorio entero.
4. Alertas mínimas que no añaden ruido a cada build
La alerta más útil sobre workflows empieza antes de ejecutar nada: protege sus cambios como código sensible. Añade una regla en CODEOWNERS y exige revisión de code owners en la protección de la rama. La regla por sí sola no obliga a aprobar el PR; esa exigencia se configura en GitHub.
/.github/workflows/ @tu-organizacion/equipo-seguridad
Revisa también quién puede saltarse la protección de la rama. Una notificación enviada por un workflow después de un push puede dar visibilidad, pero no impide que un cambio malicioso ya esté en main.
Para detectar exfiltración, no confíes solo en el enmascaramiento de secrets en los logs: un script puede enviar credenciales por red o incluirlas en artefactos. Evita imprimir variables sensibles, restringe qué archivos se suben como artefactos y consulta los registros de auditoría de GitHub y del proveedor cloud para investigar cambios de workflows, aprobaciones de despliegue y emisión de credenciales. En el cloud, empieza alertando por uso de la identidad de despliegue fuera del repositorio o entorno autorizados; define umbrales de volumen solo cuando tengas una línea base, para no convertir cada despliegue normal en una alarma.
Por dónde empezar
Abre .github/workflows/ y localiza primero los jobs que usan secrets, id-token: write o permisos de escritura. Para cada uno, comprueba su trigger y si ejecuta código que pueda venir de un PR o de una acción externa. Corrige ese cruce antes de añadir más alertas: una revisión obligatoria sobre cambios en workflows ayuda, pero no compensa un job privilegiado que ejecuta código no confiable.


