actions/checkout parece uno de los pasos más inocentes de un workflow de GitHub Actions:
- uses: actions/checkout@v7
Clona el repositorio y seguimos adelante. Pero detrás de esa comodidad existe una decisión de seguridad importante: por defecto, checkout persiste credenciales para que comandos Git posteriores puedan autenticarse sin configuración adicional.
Eso es cómodo para workflows que hacen git push, crean tags o actualizan ramas. También significa que una credencial de CI queda disponible en disco durante el job.
La pregunta correcta no es “¿actions/checkout es inseguro?”. La pregunta correcta es:
¿necesita este job conservar una credencial Git después del checkout?
Si la respuesta es no, la opción más segura suele ser explícita:
- uses: actions/checkout@v7
with:
persist-credentials: false
Pero hay un matiz importante: la historia cambió en actions/checkout@v6. Muchos artículos y avisos de seguridad anteriores hablan de credenciales almacenadas directamente en .git/config. Desde v6, checkout movió esas credenciales a un archivo separado bajo $RUNNER_TEMP. Eso reduce el riesgo de empaquetarlas accidentalmente junto con el repositorio, pero no elimina el principio de mínimo privilegio ni hace innecesario persist-credentials: false cuando no hay operaciones Git autenticadas.
Qué hacía checkout históricamente
Hasta la generación anterior de actions/checkout, la autenticación persistida quedaba asociada al repositorio local de Git. La propia documentación de checkout explicaba que el token se persistía en la configuración Git para permitir comandos autenticados posteriores, y que el paso de post-job intentaba retirarlo al finalizar.
El problema práctico era fácil de entender:
checkoutclonaba el repositorio.- una credencial quedaba accesible desde el entorno Git local;
- otro paso del workflow empaquetaba archivos, inspeccionaba
.git, ejecutaba código no confiable o publicaba artefactos; - el secreto podía terminar en un lugar donde nunca debió estar.
Herramientas como zizmor llaman a esta familia de hallazgos artipacked: credenciales persistidas localmente que pueden terminar expuestas mediante artefactos u otros pasos del pipeline.
La recomendación histórica era directa:
- uses: actions/checkout@v4
with:
persist-credentials: false
Y sigue siendo una recomendación válida cuando el job sólo necesita leer el repositorio.
El cambio importante de checkout@v6
A partir de actions/checkout@v6, GitHub cambió el mecanismo: las credenciales persistidas pasan a un archivo separado bajo $RUNNER_TEMP en vez de almacenarse directamente dentro de .git/config.
Eso es una mejora real.
Si un workflow sube el directorio del repositorio como artefacto, ya no debería arrastrar automáticamente el secreto sólo porque incluyó .git. Por eso, versiones recientes de zizmor reducen la severidad del hallazgo cuando detectan checkout@v6 o superior.
Pero la mejora no significa “problema resuelto”. Mientras el job esté ejecutándose, la credencial todavía existe en el runner y puede ser utilizada por comandos Git autenticados. Si un paso posterior está comprometido, ejecuta código controlado por un atacante o tiene acceso excesivo al filesystem del runner, sigue existiendo una superficie de exposición.
La regla útil es:
si no necesitas autenticación Git persistente, no la conserves.
El patrón seguro para jobs de sólo lectura
Para compilar, ejecutar tests, hacer lint, generar documentación o analizar código, normalmente no necesitas git push.
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- run: npm ci
- run: npm test
Aquí combinamos dos defensas distintas:
persist-credentials: falseevita mantener autenticación Git para pasos posteriores;permissions: contents: readlimita lo que elGITHUB_TOKENpodría hacer aunque llegara a utilizarse.
No son sustitutos. Conviene usar ambos.
Por qué persist-credentials: false puede romper un workflow
El problema aparece cuando el job sí escribe en el repositorio:
- uses: actions/checkout@v7
with:
persist-credentials: false
- run: |
git add .
git commit -m "update generated files"
git push
El git push puede fallar porque Git ya no tiene una credencial configurada.
Ese fallo lleva a una tentación peligrosa: volver a activar credenciales persistentes para todo el job aunque sólo un único paso necesite autenticación.
Hay una opción mejor: entregar credenciales justo donde hacen falta.
Patrón 1: usar gh auth setup-git
Yann Pellegrini propone un enfoque práctico: mantener persist-credentials: false y, sólo en el paso que necesita escritura, dejar que GitHub CLI configure Git temporalmente.
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Commit and push
env:
GH_TOKEN: ${{ github.token }}
run: |
gh auth setup-git
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Update generated files"
git push
La ventaja conceptual es importante: el checkout no deja autenticación preparada “por si acaso”. La autenticación aparece en el punto donde existe una necesidad real.
En runners self-hosted o imágenes de contenedor personalizadas hay que asegurarse de que gh esté instalado.
Patrón 2: separar lectura y escritura en jobs distintos
Una defensa aún mejor para pipelines complejos consiste en separar responsabilidades.
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- run: ./build.sh
publish:
needs: build
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Publish changes
env:
GH_TOKEN: ${{ github.token }}
run: |
gh auth setup-git
./publish.sh
Así, la mayor parte del pipeline vive con permisos de lectura. Sólo el job que realmente publica obtiene contents: write.
Esto también reduce el impacto de una dependencia comprometida en fases anteriores.
Lo que no conviene hacer: meter el token en la URL y olvidarlo
Existe un workaround clásico:
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/OWNER/REPO"
git push
Funciona, pero tiene un problema: la URL autenticada puede terminar almacenada en configuración local. Puedes restaurarla después, pero si el script falla antes de la limpieza, la credencial puede permanecer.
Yann muestra precisamente este riesgo y considera más seguro el enfoque con gh auth setup-git.
Si aun así necesitas manipular URLs autenticadas, usa limpieza garantizada con trap, evita imprimir la URL y entiende dónde queda almacenada la credencial.
permissions importa tanto como persist-credentials
Supongamos que una credencial se filtra accidentalmente. El impacto depende de lo que esa credencial puede hacer.
Esto:
permissions:
contents: write
issues: write
pull-requests: write
actions: write
crea una superficie mucho mayor que:
permissions:
contents: read
Por eso la seguridad del GITHUB_TOKEN debe pensarse en dos dimensiones:
- exposición: ¿dónde y durante cuánto tiempo existe la credencial?;
- capacidad: ¿qué puede hacer si alguien la obtiene?
Reducir sólo una de las dos deja trabajo pendiente.
CWE-522 como marco mental
MITRE define CWE-522: Insufficiently Protected Credentials como la situación en la que un producto transmite o almacena credenciales de autenticación mediante un mecanismo susceptible de recuperación o interceptación no autorizada.
No significa que cada uso de persist-credentials: true sea automáticamente una vulnerabilidad CWE-522. El mapeo depende del contexto. Pero CWE-522 sirve como una buena pregunta de diseño:
¿estoy almacenando una credencial en un lugar o durante un tiempo mayor de lo necesario?
En CI/CD, donde ejecutamos herramientas de terceros, scripts, compiladores, package managers y acciones externas, esa pregunta merece ser explícita.
El caso especial de código no confiable
Hay escenarios donde el riesgo es mucho mayor que un simple artifact accidental: workflows que procesan pull requests de forks o que ejecutan contenido controlado por contribuidores externos.
actions/checkout@v7 añadió otra protección relevante: por defecto rechaza hacer checkout de código de un fork en contextos privilegiados como pull_request_target o workflow_run, salvo que se active explícitamente allow-unsafe-pr-checkout: true.
La razón es la misma: no mezclar código no confiable con credenciales y permisos privilegiados.
Un workflow que tiene un token con escritura y luego ejecuta código proveniente de un PR externo puede convertir una pequeña comodidad en una ruta de compromiso del repositorio.
Una política sencilla que escala
En lugar de discutir cada warning individualmente, puedes adoptar esta política:
# Default para todos los checkouts
- uses: actions/checkout@v7
with:
persist-credentials: false
Y permitir excepciones sólo cuando exista una razón documentada:
- uses: actions/checkout@v7
with:
# Este job publica tags y necesita Git autenticado.
persist-credentials: true
Aún mejor: mantener false y autenticar únicamente el paso de escritura.
La idea no es convertir cada workflow en una fortaleza imposible de mantener. Es hacer que la presencia de una credencial sea una decisión consciente y localizada, no un efecto secundario silencioso del checkout.
Checklist práctico
Cuando revises un workflow, pregunta:
- ¿el job necesita realmente
git push, tags o fetch autenticado? - si no, ¿usa
persist-credentials: false? - ¿el
GITHUB_TOKENtiene sólo los permisos necesarios? - ¿hay artefactos que incluyen
.git,$RUNNER_TEMPu otros directorios demasiado amplios? - ¿se ejecuta código proveniente de PRs o fuentes no confiables?
- ¿las acciones de terceros están fijadas a una versión o SHA apropiado?
- ¿puedes mover la escritura a un job separado?
- ¿puedes usar
gh auth setup-gitsólo en el paso que necesita autenticación? - ¿un runner self-hosted limpia adecuadamente su estado entre jobs?
La lección más importante
actions/checkout no es “el problema”. El problema es persistir autoridad más tiempo y en más lugares de los necesarios.
checkout@v6+ mejoró el almacenamiento de credenciales al moverlas fuera de .git/config y hacia $RUNNER_TEMP. checkout@v7 añadió defensas adicionales para escenarios de forks privilegiados. Son mejoras importantes.
Pero la disciplina sigue siendo la misma:
credenciales mínimas, permisos mínimos y duración mínima.
Si tu workflow sólo lee código, persist-credentials: false debería ser el punto de partida. Si necesita escribir, haz esa necesidad explícita, limita permissions y autentica lo más cerca posible del comando que realmente requiere acceso.