How does poisoned pipeline execution work?
A CI system runs whatever the repository tells it to run, with whatever credentials the pipeline holds. If an attacker can change what the repository says, through a branch, a pull request or a file the build executes, the CI runner does the rest. The attacker needs no access to the CI server itself.
The term comes from research by Daniel Krivelevich and Omer Gil at Cider Security, published in February 2022, and it is now CICD-SEC-4 in the OWASP Top 10 CI/CD Security Risks. MITRE ATT&CK added it as technique T1677 in 2025. OWASP describes three variants:
- Direct PPE (D-PPE). The attacker edits the CI configuration file itself, such as
.github/workflows/*.yml,.gitlab-ci.ymlor aJenkinsfile, by pushing to an unprotected branch or opening a pull request that the pipeline runs. - Indirect PPE (I-PPE). The configuration file is protected (for example, Jenkins loads the
Jenkinsfilefrom the main branch), but it calls files the attacker can change: aMakefile,package.jsonscripts, a test suite, a linter config. The attacker poisons those. - Public PPE (3PE). Either of the above, carried out by an anonymous outsider through a pull request to a public repository whose pipeline runs contributor code.
What the attacker gets is whatever the job can reach: repository secrets, cloud deployment credentials, package registry tokens, a GITHUB_TOKEN with write access, and the network position of the runner.
What does PPE look like in GitHub Actions?
The classic case is a pull_request_target workflow that checks out the pull request's code and runs it. GitHub's documentation states that pull_request_target gets the base repository's GITHUB_TOKEN and access to repository and organization secrets, even when the pull request comes from a fork. That is safe while the workflow only runs code from the default branch. It stops being safe the moment someone points actions/checkout at the pull request head:
# .github/workflows/pr-build.yml (vulnerable)
name: PR build
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
- run: npm test
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}An outsider forks the repository and, in their fork, edits package.json so that the preinstall script runs an arbitrary command of their choosing:
{
"scripts": {
"preinstall": "<attacker command runs here>",
"test": "jest"
}
}They never touch the workflow file, so this is indirect PPE delivered through a public pull request. npm ci runs the preinstall script from the attacker's package.json, on a runner that holds a write-scoped token and the NPM_TOKEN secret. Anything the attacker puts in that script executes with those credentials in the environment. With default actions/checkout settings the GITHUB_TOKEN is also written to disk (persist-credentials defaults to true), so later steps, and any code they run, can read it. GitHub Security Lab calls this pattern a "pwn request".
The same shape appears outside GitHub: a GitLab merge request pipeline or a Jenkins multibranch job that runs make test from the contributor's branch with deployment credentials in the environment.
How do you test for poisoned pipeline execution?
Work out which events run which code with which credentials, then confirm one path with a harmless proof. Most findings are visible from the workflow files alone.
- Inventory every pipeline definition:
.github/workflows/,.gitlab-ci.ymland itsinclude:files,Jenkinsfile,buildspec.yml,azure-pipelines.yml. - Flag privileged triggers that outsiders or low-privileged users can fire:
pull_request_target,workflow_run,issue_comment, and GitLab or Jenkins jobs that build merge requests from forks. - For each, check whether the job then runs code from the incoming branch: an
actions/checkoutwithref:set to the PR head, followed by any step that installs dependencies, builds, or runs tests. - Trace what that job can reach:
secretsreferenced in the workflow, thepermissionsblock on theGITHUB_TOKEN, and OIDC or cloud credentials configured on the runner. - Look for indirect paths even in
pull_request(nottarget) jobs: self-hosted runners that persist state between jobs, or a job that later feeds artifacts into a privilegedworkflow_run. - To prove a finding safely, open a pull request from a fork that makes a benign, observable change only, such as writing a marker string to the job log or making a DNS lookup to a domain you control. Do not read or transmit any secret. Coordinate with the owner first, since the code runs on their infrastructure.
How do you fix poisoned pipeline execution?
Never run untrusted code in a job that holds secrets. On GitHub Actions, the supported pattern is to split the work into two workflows.
The first runs on pull_request (no access to secrets for fork PRs), builds the untrusted code in an unprivileged job, and uploads the result as an artifact:
# .github/workflows/pr-untrusted.yml
name: PR build (untrusted)
on: pull_request
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
- uses: actions/upload-artifact@v4
with:
name: results
path: results/The second runs on workflow_run, after the first completes, downloads the artifact, and does the privileged part (commenting, publishing) without ever executing the contributor's code. Alongside that:
- Set
permissions:to the minimum each job needs, at the top of every workflow. Default tocontents: read. - Pass
persist-credentials: falsetoactions/checkoutin any job that runs untrusted code, so the token is not left on disk. - For workflows that must handle fork code with secrets, gate them behind a GitHub Environment with required reviewers, or a maintainer-applied label, so a human approves each run.
- Treat checked-out PR code as data: inspect it, do not execute it, in the privileged job.
- Protect the branches that trigger deployment pipelines, and load CI configuration and referenced build scripts (
Makefile, test config) from a protected branch rather than the incoming one, which closes indirect PPE.
A branch protection rule alone does not fix this: the vulnerable trigger runs before any merge, so review gates on main never see the malicious build.
PPE vs dependency confusion
Both end in attacker code running in your build, but the entry point differs. Dependency confusion substitutes a package your build already fetches, so it needs no repository access at all. PPE abuses the pipeline's own trust in branch and pull-request content, and the fix lives in your CI configuration and branch protection rather than your package resolver.
[ Sources ]
Written by Parameter · Last reviewed

