Poisoned Pipeline Execution (PPE)
An attacker who can influence what the pipeline runs gets to run code with the pipeline's privileges, without ever touching production.
If this is new to you
Pipelines are unusual among the systems you run, because untrusted input gets to decide which commands execute. Think about what a pull request actually is: a stranger proposing changes to files in your repository. If your pipeline reads its instructions from the branch being tested, then proposing a change *is* running code on your build machine.
That is the whole idea behind poisoned pipeline execution. The attacker never touches production. They do not need to. They run code where your build credentials live, and take it from there.
What to do about it
Trace every command your pipeline runs back to where its text comes from. Anything read out of the pull request branch — the workflow file, a build script, a test fixture, a config file, a lint plugin — is attacker-controlled. This is the part people get wrong: they protect the workflow file and leave the shell script it calls wide open.
Check your trigger types carefully. Most CI platforms distinguish between triggers that expose secrets to forked contributions and triggers that do not, and that distinction is the entire control. Reading the documentation properly here is worth an afternoon.
Run untrusted contributions in a job with no secrets and no production reach, and require a maintainer action before anything privileged happens.
Going deeper
Pin third-party actions and container images by digest rather than by tag. A tag is a mutable pointer that somebody else controls, so "we reviewed that action" means "we reviewed whatever it contained on the day we looked".
Then think about caches, which are the persistence mechanism people forget. If a build anyone can trigger writes to a cache that a privileged build later reads, poisoning that cache outlives the build that planted it.
Words used on this pagedefinitions
- PPE
- Poisoned Pipeline Execution. Getting your code to run inside somebody's CI system by influencing what that system executes.
- Direct vs indirect PPE
- Direct is when the pipeline definition itself comes from the attacker's branch. Indirect is when the definition is safe but the script it calls is not.
- Digest pinning
- Referencing an image or action by its content hash rather than a tag, so the thing you reviewed is the thing that runs.
Why this matters
Pipelines are unusual in that untrusted input — a pull request from outside — can determine which commands execute. If the pipeline definition, a build script, or a test fixture comes from the branch under test, then proposing a change is equivalent to executing code on the build host.
What it looks like in practice
If any of these sound familiar, that is the point — most of them are things somebody did for a sensible reason on a busy afternoon.
- A workflow triggered by pull requests that reads its own definition from the pull request's branch (direct PPE).
- A pipeline whose steps call a script in the repository, where the script — not the pipeline file — is what an attacker edits (indirect PPE).
- Triggers that expose secrets to forked pull requests.
- A test suite that executes fixtures, plugins or config from the branch being tested, with credentials in the environment.
- Build caches writable by untrusted builds and read by trusted ones.
Check your own pipeline
Questions you can actually answer, rather than a maturity score. If you cannot answer one of them, that is itself the finding — and a more useful one than a number.
Can a pull request change what the pipeline executes?
Trace every command back to where its text comes from. Anything read out of the PR branch is attacker-controlled.
Are secrets present in builds triggered by untrusted contributors?
Check the trigger type specifically. The distinction between triggers that do and do not expose secrets to forks is the entire control.
Does anything untrusted write to a cache that a trusted build reads?
Poisoning a cache is persistence: it survives the build that planted it.
How to fix it
- Keep pipeline definitions out of reach of the branch under test, and pin them to the base branch.
- Run untrusted contributions in a job with no secrets and no production reach, and require a maintainer action before anything privileged runs.
- Pin third-party actions and images by digest, not by tag — a tag is a mutable pointer someone else controls.
- Scope caches so a build that anyone can trigger cannot write what a privileged build will read.
Did this get you to an answer?
No text box on purpose — please do not paste production logs anywhere
Risk taxonomy from OWASP Top 10 CI/CD Security Risks (Daniel Krivelevich and Omer Gil, September 2022, CC BY-SA 4.0). Explanations, checks, fixes and definitions written for this site.