What is poisoned pipeline execution, and how does direct PPE differ from indirect PPE?
answer
- the build reads the branch under test
- run happens before the merge
- who can edit what the job runs
- the definition versus what it invokes
- direct, indirect, public
basics
~20 sPoisoned pipeline execution means getting CI to run attacker-supplied code by changing something the build reads. Direct PPE changes the pipeline definition itself; indirect PPE changes a file the pipeline invokes, such as a build or test script.
solid answer
~50 sPoisoned pipeline execution, or PPE, is the class of attacks where somebody who cannot merge to a protected branch still gets code running inside your build, because the build reads files from the branch under test. In direct PPE the attacker edits the pipeline definition itself on a branch that triggers a run, so a job they wrote executes with the runner's identity and secrets. In indirect PPE the definition is untouched — it still just says `build` or `test` — but the file that verb resolves to is editable: a build script, a task-runner file, lint or test configuration that loads plugins, a hook that runs during dependency restore. A third variant, public PPE, is the same execution reached by an outside contributor through a fork pull request. The root cause is that source is an input to a privileged build, and reviewing a merge is not reviewing what the build executes.
go deeper
Be able to state the term and the split: direct means editing the pipeline definition, indirect means editing something the pipeline runs. Remember that the run happens before the merge, so review is not the control.
Explain why a job is a privileged environment — the token, the credentials, the cache — and be able to name concrete indirect inputs: build scripts, task-runner files, plugin-loading lint or test configuration, restore-time hooks.
Show you can find these in a real repository: which triggers build unreviewed refs, which of those jobs hold credentials, and how you split privileged work out. Be clear that signing and provenance are detective, not a fix for PPE.
Own the position that build systems are production infrastructure with their own access model, and be ready to argue the delivery cost of treating CI-affecting paths as protected code across an estate rather than repo by repo.
### The idea in one line Poisoned pipeline execution (PPE) is getting your CI system to run code an attacker wrote, by changing something the build reads — without ever merging that change into the branch you protect. It is one of the named risks in the OWASP catalogue of CI/CD security risks, and it is the most common way a build environment is compromised from the inside. ### Why the build is worth attacking at all A CI job is not a sandbox that turns text into a binary. It usually holds, all at once: - a token that can write to the repository or its releases, - registry or cloud credentials, increasingly a short-lived identity federated to the runner, - the authority used to sign artifacts or to issue provenance attestations, - a filesystem or dependency cache that later builds will reuse. Anything that executes inside the job inherits all of that for the length of the job. That is why somebody who cannot approve a merge is still dangerous: they do not need to change what ships, only to be present on the machine that builds what ships. ### The three variants **Direct PPE.** The attacker can edit the pipeline definition itself. On a repository where any contributor with write access can push a feature branch, and the CI system builds every branch, the definition that runs is the definition on the branch under test. Adding a step that prints the environment, curls a payload, or opens a reverse shell needs no approval, because the run happens before any review. Branch protection on the default branch is irrelevant here — nothing was merged. **Indirect PPE.** The attacker cannot change the pipeline definition, or does not need to. The definition still invokes one verb: run the build, run the quality gate. What that verb resolves to lives in the repository. A build script written in a real programming language, a task-runner file, a linter or test-framework configuration that loads plugins from the tree, a properties file that injects a task into dependency restore — every one of those is executed by the job, and every one of them reads in review as configuration rather than as code. This is the variant that survives a mature review culture, because the diff does not look like a program. **Public PPE.** The same execution, reached by somebody with no repository access at all: an outside contributor whose pull request causes a build. Which trigger events do that, and what they hand the fork's code, is a subject of its own; for the taxonomy, the point is only that the attacker position changes from insider to anonymous while the mechanism stays the same. ### Why review misses it Two asymmetries do the work. First, the diff looks harmless: a build-properties file, a task added to a runner, a plugin name in a lint config. Reviewers who would read a hundred lines of application logic carefully will wave through six lines of what they read as settings. Second, the ordering: review gates the merge, but the run that matters happened when the branch was pushed. By the time anyone reads the diff, the job has already finished with the credential. ### What a successful PPE gets the attacker Exfiltrating whatever secrets are in the job environment is the obvious one, but rarely the worst. With the job's write token the attacker can push to the repository. Executing between the compile step and the publish step lets them tamper with the artifact after it was reviewed as source — signing and provenance make that detectable to a downstream consumer, they do not prevent it from happening. Poisoning a shared cache means a later, entirely trusted build picks the payload up. On a runner that is not destroyed after each job, whatever the poisoned job left behind is waiting for the next one. ### The shape of the fix All of it follows from one principle: a job that untrusted content can reach must not hold anything worth stealing. In practice that means builds of arbitrary refs run with no secrets and a read-only token; credentials live in a separate job that only a protected ref plus an approval can reach; runners are ephemeral; and the files that the build executes are treated as code for the purposes of required review. Note the direction of these controls — they are preventive and they live in the trigger and permission model. Attestation and signature verification are detective, and they protect the consumer, not the build. ### What PPE is not It is not a compromised upstream package pulled in from outside the repository — that is dependency chain abuse, a different input with a different owner. It is not a vulnerability in a build tool. PPE is your own repository's content being the payload, and your own pipeline being the thing that runs it.
- Why does requiring review on the pipeline definition fail to stop indirect PPE?Because the definition is not where the executable content is. It invokes a verb — build, lint, test — and the file that verb resolves to lives in the repository under a different path with no special review requirement. Protecting the pipeline file while leaving build scripts, task-runner files and plugin-loading lint configuration unprotected just moves the attacker one path over.
- What does an attacker actually gain from executing inside a build job?The job's identity and everything in its environment: the repository write token, registry or cloud credentials, and any signing or attestation authority. Beyond theft, they can tamper with the artifact between build and publish, poison a shared cache so a later trusted build ingests the payload, and, if the runner is not ephemeral, leave something behind for the next job.
- How is PPE different from a malicious upstream dependency?Direction of the input. A malicious dependency enters from outside your repository through resolution, and the controls are pinning, provenance and allowlisting. PPE is content that is already in your repository, on a branch nobody merged, executing because the pipeline builds that branch. The controls are trigger scope, job permissions and required review on executable paths.
Protecting the default branch is locking the front door of the warehouse. PPE is noticing that the loading bay lets anyone drive a truck in for inspection, and the inspection bay has the keys to everything.
saying these in an interview costs you the question
- Thinks PPE requires merging to the default branch first
- Believes branch protection on main also protects CI
- Treats build scripts and lint config as non-executable settings
- Confuses PPE with pulling in a malicious upstream package
- Assumes only public repositories are exposed to it