Anyone who can change code that a pipeline executes can make that pipeline run their commands with the pipeline's identity, secrets and network reach. OWASP calls this Poisoned Pipeline Execution (PPE) and lists it as CICD-SEC-4 in the Top 10 CI/CD Security Risks. This guide explains the three forms OWASP defines, the controls that stop untrusted code reaching privileged jobs on GitHub Actions, GitLab CI/CD and Jenkins, how to detect and measure exposure, and where PPE maps to NIST, CIS, ISO and MITRE ATT&CK. It is written for platform engineers, AppSec teams and the people who own CI/CD service identities. It goes deeper than the Top 10 CI/CD Security Risks overview and describes the attack at concept level only.
Key takeaways
- PPE needs repository access, not CI access. OWASP defines it as an attacker with access to source control, but not to the build environment, manipulating the build by injecting commands into the pipeline configuration or the files it relies on.
- There are three forms. Direct PPE edits the CI configuration file, Indirect PPE edits files the configuration calls (Makefiles, scripts, tests, linter configs), and Public PPE (3PE) comes from anonymous contributors to public repositories.
- Unreviewed code is the trigger condition. Pipelines that run on pull requests or pushes to arbitrary branches execute code nobody has approved, so they must run on isolated nodes with no secrets.
- Privileged triggers are the most dangerous pattern. On GitHub,
pull_request_targetandworkflow_runrun with write access and secrets; checking out pull request code inside them, or interpolating untrusted text into scripts, hands that privilege to the contributor. - The blast radius is an identity problem. What a poisoned job can do equals what its tokens, cloud roles and runner host can do, which is why PPE controls go together with OWASP CI/CD Top 10 CICD-SEC-5 Insufficient PBAC Guide controls.
What it is and who it applies to
OWASP's definition is specific: PPE is the ability of an attacker with access to source control systems, and without access to the build environment, to manipulate the build process by injecting malicious code or commands into the build pipeline configuration, so that the code runs as part of the build. The OWASP page then describes how, once running inside the pipeline, the attacker can carry out a wide range of operations "within the context of the pipeline's identity". That phrase is the core of the risk: the attacker borrows a trusted non-human identity without ever stealing its credential directly.
It applies to any organisation where a CI system executes code taken from a repository, which is almost every organisation that builds software. The exposure is highest for:
- Open source maintainers: public repositories accept pull requests from anyone, so 3PE is reachable by anonymous internet users.
- Organisations with shared CI instances: OWASP notes that when a vulnerable public repository's pipeline runs on the same CI instance as private projects, private secrets can be exposed too.
- Large engineering organisations: many people hold write access to many repositories, so a single compromised developer account or token is enough for Direct or Indirect PPE.
- Teams using self-hosted runners or agents: persistent hosts keep state between jobs and often sit on internal networks.
PPE is distinct from its neighbours. OWASP CI/CD Top 10 CICD-SEC-1 Insufficient Flow Control Mechanisms Guide covers pushing code to production without review; PPE is about code that executes before any review happens. OWASP CI/CD Top 10 CICD-SEC-3 Dependency Chain Abuse Guide covers malicious packages pulled from outside; PPE comes through the repository itself.
Structure and key requirements
The OWASP entry has three parts that matter to defenders: the three PPE variants, the impact, and six recommendations.
The three variants
| Variant | What the attacker changes | Precondition | Why common defences miss it |
|---|---|---|---|
| Direct PPE (D-PPE) | The CI configuration file itself, through a push to an unprotected branch or a pull request from a branch or fork | The pipeline is triggered by push or PR events and reads its definition from the changed branch | Teams protect main but let feature branches trigger pipelines with secrets |
| Indirect PPE (I-PPE) | Files the configuration calls: a Makefile, a script, test code, or linter and scanner configuration that loads external code | The configuration is protected (separate branch, separate repository or defined in the CI tool) but still executes repository content | Protecting the pipeline file feels complete; the build still runs contributor-controlled files |
| Public PPE (3PE) | Either of the above, submitted as a pull request to a public repository | The public project's CI runs unreviewed contributions automatically | Contributors are anonymous, so access reviews and account security do not help |
Two related patterns deserve the same treatment even though OWASP does not name them as separate variants. Script injection: GitHub's documentation warns that values in the github context ending in body, head_ref, label, message, ref or title, along with branch names and email addresses, are attacker-controllable and can be interpreted as code if written straight into a run script. Infrastructure-as-code plan steps: OWASP's references include research showing that commands such as terraform plan can execute code from a provider, so a "read-only" plan job on a pull request is not inherently safe.
The impact OWASP lists
- Secrets: any secret available to the CI job, whether injected as environment variables or stored in the CI system, typically cloud, artefact registry and SCM credentials.
- Node assets: files on the node's file system and credentials to a cloud environment reachable through the underlying host.
- Artefact integrity: the ability to ship code and artefacts further down the pipeline disguised as legitimate build output.
- Lateral movement: access to other hosts and assets on the job node's network.
The six OWASP recommendations, in practical terms
- Run pipelines that execute unreviewed code on isolated nodes with no secrets and no access to sensitive environments.
- Evaluate whether external contributors on public repositories need to trigger pipelines at all; avoid running pipelines from forks where possible and add manual approval.
- For sensitive pipelines, give every branch that can trigger the pipeline a matching branch protection rule in the SCM.
- Review every CI configuration change before the pipeline runs, or load the configuration from a separate protected branch.
- Remove SCM repository permissions that users do not need.
- Give each pipeline only the credentials it needs, with minimum privileges.
Identity and NHI requirements
PPE turns a contributor identity into a pipeline identity. Defenders should treat every credential a job can reach as part of the PPE attack surface:
| Pipeline identity | How PPE reaches it | Control |
|---|---|---|
Built-in job token (GitHub GITHUB_TOKEN, GitLab CI_JOB_TOKEN) | Available to every step; actions/checkout persists it to disk by default | Default permissions to read-only, grant write per job; set persist-credentials: false where later steps run untrusted code |
| Stored secrets and CI variables | Loaded into the environment of any job that references them | Environment-scoped or protected variables that untrusted triggers cannot read; required reviewers on deployment environments |
| Cloud roles via OIDC federation | A job that can request an OIDC token can assume any role whose trust policy matches its claims | Pin trust to sub values such as repo:org/repo:environment:Production or a specific job_workflow_ref, never to a whole organisation or any branch |
| Package publishing tokens | Left in CI variables after migration to trusted publishing | Revoke legacy tokens when moving to OIDC-based publishing |
| Runner host identity | Instance metadata service, mounted credentials, Docker socket on a persistent self-hosted runner | Ephemeral runners for untrusted jobs; block metadata access from job containers |
| Developer accounts and personal access tokens | The entry point for Direct and Indirect PPE in private repositories | Phishing-resistant MFA, fine-grained tokens, least-privilege repository roles |
GitHub documents that the GITHUB_TOKEN is a GitHub App installation token limited to the workflow's repository and expiring when the job finishes (at most 6 hours on GitHub-hosted runners). That short lifetime limits replay but does nothing to stop misuse during the run, which is exactly what PPE exploits. The OWASP CI/CD Top 10 CICD-SEC-2 Inadequate Identity and Access Management Guide covers the human and machine accounts that feed Direct PPE, and the CI/CD Pipeline Identity Security Guide covers pipeline credentials end to end. See also the Workload Identity Federation Guide.
Implementation steps
Roll out in this order. Each phase reduces exposure on its own, and the early phases can run in report-only mode before enforcement.
- Inventory triggers and secrets (week 1 to 2). List every pipeline, what triggers it (push, pull request, fork pull request, schedule, comment,
pull_request_target,workflow_run), which runner it uses and which secrets, environments and cloud roles it can reach. OpenSSF Scorecard's Dangerous-Workflow check (rated Critical) and Token-Permissions check (rated High) can scan GitHub repositories for the most common patterns at scale. - Remove secrets from untrusted triggers (week 2 to 4). Builds and tests for pull requests run with a read-only job token and no secrets. On GitHub, use the split the GitHub Security Lab recommends: an unprivileged
pull_requestworkflow builds and uploads results as artefacts, and a separate privilegedworkflow_runworkflow consumes the artefacts without checking out or executing pull request code, treating the artefacts as untrusted data. - Gate fork and first-time contributor runs. Require maintainer approval for fork pull request workflows (GitHub repository, organisation or enterprise setting; runs awaiting approval for more than 30 days expire). On GitLab, fork merge request pipelines run in the fork with the fork's variables; a parent project member can run them in the parent project, which uses the parent's variables after a warning. Disable that with the
ci_allow_fork_pipelines_to_run_in_parent_projectproject setting if you do not need it. - Protect the pipeline definition. Add
.github/workflows/,.gitlab-ci.yml,Jenkinsfile, and the scripts and Makefiles they call, to CODEOWNERS with required review. For Jenkins, load the pipeline definition from a protected branch or a separate repository. - Align branch protection with triggers. Every branch that can trigger a pipeline with secrets needs a branch protection or ruleset entry. GitLab enforces this natively: merge request pipelines reach protected variables and runners only when both source and target branches are protected, the user has push or merge access to the target, and both branches are in the same project.
- Fix script injection. Pass untrusted context values through an intermediate environment variable rather than inline
${{ }}expressions inrunblocks, and prefer actions that take inputs as arguments. - Isolate execution. Use ephemeral runners for anything that runs unreviewed code. GitHub states self-hosted runners should almost never be used for public repositories, and offers just-in-time runners that perform at most one job. On Jenkins, set the built-in node's executors to 0 so builds run on agents, and keep Agent to Controller Access Control enabled (always on since Jenkins 2.326).
- Narrow identities. Set workflow default token permissions to read, pin OIDC trust policies to environment or workflow claims, and revoke static credentials that federation replaced.
Testing: before enforcing, run Scorecard or an equivalent linter in report mode, raise test pull requests from a fork in a sandbox repository, and confirm the fork run receives no secrets and only a read-only token. Rollback: keep workflow and ruleset changes in version control so a broken release pipeline can be restored from the previous commit; approval gates and fork settings can be relaxed per repository without undoing the organisation default.
Choosing the right pattern
| Situation | Preferred approach | Trade-off |
|---|---|---|
| Public repository, external contributors | Unprivileged PR builds on hosted ephemeral runners, approval for first-time contributors, privileged work only after merge | Slower feedback for contributors; maintainers spend time approving runs |
| PR needs to post a comment or label | Split workflow (pull_request then workflow_run) that never executes PR code | More workflow files to maintain |
| Private repository, trusted staff only | No secrets on PR builds, protected branches for deployment, CODEOWNERS on pipeline files | Integration tests needing real credentials move to post-merge or to approved environments |
Label-gated pull_request_target | Use only as a temporary measure | GitHub Security Lab notes a race: new commits can be pushed after the label is applied |
| Large organisation | Organisation-level rulesets, central reusable workflows, runner groups by sensitivity, policy scanning in CI | Needs a platform team and exception process |
Evidence and assessment
Assessors and internal auditors should ask for evidence, not policy statements. Useful evidence for CICD-SEC-4:
- Trigger inventory: an export listing every workflow or job, its triggers and the secrets and environments it references.
- Platform settings: screenshots or API output for fork workflow approval, default workflow permissions, GitLab fork pipeline settings, Jenkins built-in node executor count.
- Scanner output: Scorecard Dangerous-Workflow results, or equivalent, with remediation tickets for findings.
- Ownership rules: CODEOWNERS entries covering pipeline files and the scripts they invoke, and branch rulesets that require that review.
- Test records: results of a fork pull request test showing no secrets were exposed.
Detection and metrics
Monitor for: changes to pipeline definition files and called scripts outside normal review; workflow runs from forks or first-time contributors; new pull_request_target or workflow_run usage; outbound connections from runners to unexpected destinations; OIDC token exchanges or cloud API calls from pipeline roles outside expected repositories and branches; and secret use (registry publishes, cloud logins) that does not correlate with a protected-branch run. GitHub's audit log and the cloud provider's own logs (for example federated role assumption records) are the primary sources.
| Metric | Target |
|---|---|
| Workflows that run untrusted code with access to secrets or a write token | Zero |
| Repositories with default job token permissions set to read | 100 percent |
| Pipeline definition files covered by CODEOWNERS and required review | 100 percent of repositories with deployment secrets |
| Branches that can trigger secret-bearing pipelines without a protection rule | Zero |
| Self-hosted runners serving public repositories or mixed-trust workloads | Zero |
| Median time to fix a Dangerous-Workflow finding | Within the critical vulnerability SLA |
Mapping to other frameworks
| Framework | Item | Relevance to PPE |
|---|---|---|
| NIST SP 800-53 Rev. 5 | CM-3 Configuration Change Control; CM-5 Access Restrictions for Change | Review and restrict changes to pipeline definitions and called scripts |
| NIST SP 800-53 Rev. 5 | AC-6 Least Privilege; SC-39 Process Isolation | Minimal job credentials; isolated nodes for unreviewed code |
| NIST SP 800-53 Rev. 5 | SA-15 Development Process, Standards, and Tools; SI-7 Software, Firmware, and Information Integrity | Secure build process; prevent tampered artefacts shipping |
| NIST CSF 2.0 | PR.PS-06; PR.AA-05; DE.CM-09 | Secure development practices; least-privilege permissions; monitoring runtime environments |
| CIS Controls v8.1 | Control 16 Application Software Security; Control 6 Access Control Management | Secure development pipeline and least-privilege access |
| ISO/IEC 27001:2022 Annex A | A.8.25 Secure development life cycle; A.8.32 Change management; A.8.31 Separation of development, test and production environments | Controlled change to build logic; separation of untrusted builds from production credentials |
| MITRE ATT&CK v20 | T1195.002 Compromise Software Supply Chain; T1059.004 Unix Shell; T1528 Steal Application Access Token; T1552.005 Cloud Instance Metadata API | Tampered builds, commands executed in jobs, theft of job tokens and host credentials |
| MITRE D3FEND | D3-EI Execution Isolation; D3-OTF Outbound Traffic Filtering | Isolated runners; limiting where a poisoned job can send data |
The SLSA and Build Provenance Guide covers how build isolation and provenance help consumers detect output from a poisoned build.
Common pitfalls
- Protecting
mainbut not the triggers. Feature branches or pull requests still run a pipeline with secrets. The CI/CD pipeline exploitation case showed a researcher with leaked repository credentials editing a Bitbucket pipeline and pushing it, so the next run deployed their SSH key to the server. - Privileged triggers plus untrusted input. In the Ultralytics PyPI compromise, a
pull_request_targetworkflow inserted a pull request branch name into a shell script; the resulting execution is believed to have poisoned the shared Actions cache later restored by the release build. The flaw had been fixed once and then reintroduced, which is why scanning must be continuous. - Trusting the platform to withhold secrets. Travis CI CVE-2021-41077 passed public projects' secrets to fork pull request builds for about a week in September 2021. Defence in depth means fork builds should not need secrets at all, so a platform bug exposes nothing.
- Stopping at the configuration file. Moving the Jenkinsfile to a protected branch while the build still runs
make, test suites or linters from the contributor's branch leaves Indirect PPE open. - Label or comment gates treated as permanent. Approval by label has a time-of-check to time-of-use gap; old pull requests also keep the workflow version they were opened with, so close or rebase them after a fix.
- Persistent self-hosted runners for untrusted jobs. State, caches and host credentials survive between jobs, turning one poisoned run into a foothold for later privileged ones. The CI/CD Pipeline Compromise Playbook covers response when this happens.
Practitioner checklist
- Inventory every pipeline trigger and the secrets, environments and cloud roles each one reaches.
- Ensure pull request and fork builds run with no secrets and a read-only job token on ephemeral runners.
- Require approval for fork and first-time contributor workflow runs; disable GitLab fork pipelines in the parent project unless needed.
- Replace
pull_request_targetwithpull_requestplusworkflow_runwhere privileged follow-up is needed, and never check out PR code in privileged jobs. - Put pipeline files and the scripts, Makefiles, test and linter configuration they call under CODEOWNERS with required review.
- Give every branch that can trigger a secret-bearing pipeline a protection rule.
- Pass untrusted context values through environment variables, never inline into scripts.
- Set Jenkins built-in node executors to 0 and keep agents separate by trust level.
- Pin OIDC trust policies to environment or workflow claims and revoke static keys they replaced.
- Run Scorecard Dangerous-Workflow (or equivalent) on every repository continuously and track findings to closure.
Standards and references
- OWASP: CICD-SEC-4 Poisoned Pipeline Execution (PPE)
- OWASP: Top 10 CI/CD Security Risks
- GitHub Docs: Secure use reference for GitHub Actions
- GitHub Security Lab: Keeping your GitHub Actions and workflows secure, preventing pwn requests
- GitLab Docs: Merge request pipelines
- Jenkins: Controller Isolation
- OpenSSF: Scorecard checks
- CISA: CISA and NSA Release Joint Guidance on Defending CI/CD Environments
Related NHI Mgmt Group resources: Top 10 CI/CD Security Risks · GitHub and GitHub Actions Security Guide · CI/CD Security Guide · OWASP CI/CD Top 10 CICD-SEC-5 Insufficient PBAC Guide · CI/CD Pipeline Identity Security Guide