La mayoría de los ficheros de workflow referencian actions como actions/checkout@v4. Parece una versión, pero es un tag de git, y quien controle el repositorio puede apuntar un tag a cualquier commit que quiera, incluido uno que ayer no existía. Tu pipeline ejecuta entonces el código nuevo con tus secretos, en el siguiente push, sin que cambie una sola línea en tu repositorio.
La solución no es glamurosa y no necesita una herramienta nueva. Es una tarde de fijar versiones, un pequeño fichero de configuración y un par de líneas de permisos. Este es el orden en que lo haríamos.
Por qué las etiquetas son el punto débil
Una referencia por tag se resuelve en tiempo de ejecución. Si un atacante consigue acceso de escritura al repositorio de una action, o a la cuenta de un maintainer, puede hacer force-push de un tag a un commit malicioso y todos los workflows que usen ese tag lo recogen. Esto es justo lo que pasó en el compromiso de tj-actions/changed-files en marzo de 2025, donde versiones retagueadas volcaron secretos de CI en los logs de build.
Un SHA de commit completo de 40 caracteres es distinto. Nombra un árbol exacto de ficheros. Nadie puede hacer que apunte a otro sitio. Los SHAs cortos y los nombres de branch no cuentan: solo el hash completo es inmutable.
Fija cada action de terceros
Primero averigua qué estás usando. Esto lista cada línea uses: que no sea ya un SHA completo, ignorando las actions locales:
grep -rn "uses:" .github/workflows \
| grep -v "./" \
| grep -Ev "@[0-9a-f]{40}"
Puedes resolver una etiqueta a mano con git ls-remote, pero una herramienta es más rápida y menos propensa a errores. La herramienta open source pinact reescribe las etiquetas a SHAs y deja la versión como comentario al final, para que las personas sigan pudiendo leerlo:
pinact run
# before
- uses: actions/setup-node@v4
# after
- uses: actions/setup-node@<full-40-char-sha> # v4.1.0
Dos casos límite. Los pasos basados en Docker que usan docker:// deben fijarse por digest de imagen, no por tag. Y los workflows reutilizables llamados desde otros repositorios necesitan el mismo tratamiento que las actions.
Deja que un bot mantenga los pins al día
La objeción habitual al pinning es que dejas de recibir correcciones. No es así, si Dependabot vigila el ecosistema github-actions. Entiende los pines por SHA con comentarios de versión y abre una pull request que actualiza ambos.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
Agrupar deja el trabajo en una pull request a la semana en lugar de una docena. Revísala como cualquier otro cambio de dependencias: lee el diff de la action cuando el salto sea grande o el publisher sea pequeño.
Recorta el GITHUB_TOKEN
Fijar limita qué código se ejecuta. Los permisos limitan lo que ese código puede hacer si algo sale mal de todos modos. Pon la organización o el repositorio en solo lectura por defecto, declara los permisos al principio de cada workflow y amplíalos solo en el job que lo necesite:
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
Un permissions: {} vacío también es válido, para jobs que solo hacen lint o tests. Ya que estás, busca claves cloud de larga duración en los secretos y sustitúyelas por federación OIDC donde tu proveedor lo soporte.
La trampa de pull_request_target
pull_request_target se ejecuta en el contexto del repositorio base, con acceso a secretos y a un token con permisos de escritura. Existe para que los workflows puedan etiquetar o comentar pull requests de forks. La trampa es hacer checkout del código del fork y ejecutarlo:
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install && npm test # runs attacker code with your secrets
Lo mismo ocurre al interpolar campos no confiables, como el título de una pull request, directamente en un script de run:. Pásalos mejor por una variable de entorno, para que el shell los trate como datos.
Compruébalo en 10 minutos
- Ejecuta el grep de arriba y cuenta las referencias sin fijar.
- Ejecuta
zizmor .github/workflows. Señala actions sin fijar, permisos excesivos, triggers arriesgados e inyección de plantillas. - Ejecuta
actionlintpara detectar los errores de sintaxis que a veces introduce fijar versiones. - Revisa el ajuste del repositorio de permisos de workflow por defecto y ponlo en solo lectura.
- Añade el fichero de Dependabot y haz merge de la primera actualización agrupada.
Esa es la tarde. Hecho esto, cada cambio en lo que corre en tu pipeline llega como una pull request revisada, no como un retag silencioso.
Dónde encaja en la línea
En la torre (en desarrollo):