open atlas
↑ Back to track
CI/CD pipelines CICD · 05 · 04

Pipeline security and poisoned builds

The CI runner holds every secret and can tamper with the artifact, so threat-model it. Poisoned Pipeline Execution runs attacker code with your privileges — classically via pull_request_target running a fork PR. Isolate untrusted builds, default-deny the token, use OIDC.

CICD Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The PR came from an account nobody recognized — a first-time “contributor” fixing a typo in the README, plus one unrelated change to the test setup. The maintainer skimmed it, saw it was tiny, and left it for CI to run. Forty seconds later the workflow had checked out the fork’s branch, run the build, and a prepare script in the changed setup file had read the environment, base64-encoded the cloud deploy key out of secrets, and POSTed it to an attacker’s endpoint. No merge ever happened. The maintainer didn’t approve a deploy; they didn’t approve anything. The workflow used pull_request_target — which runs in the base repo’s context, with the repo’s secrets and a write token — and then it checked out and ran the attacker’s code. The runner did exactly what it was told. This lesson is about treating the pipeline itself as an attack surface: the runner holds every secret you give it and can tamper with the artifact you ship, so compromising the build is often easier — and worse — than attacking prod directly.

Why the runner is a high-value target

Before you read this section: what would happen if the code running inside your build job right now were hostile? Think through the answer, then continue.

A CI runner is not “just a build box.” For the duration of a job it holds every secret you handed the pipeline — deploy keys, registry credentials, cloud tokens, signing keys — and it has two things an attacker wants: outbound network access (to exfiltrate) and write access to the artifacts you’re about to ship (to tamper). That second capability is the SolarWinds-class threat: an attacker who controls the build doesn’t need to break into production at all. They modify the artifact as it’s built, your own pipeline signs and ships it, and every customer pulls a backdoored binary that passed all your checks. SLSA models exactly this — threats against the build process and the provenance of what it emits, not just the source.

So the pipeline is a system that must be threat-modeled like any other. The governing question for every job is: if the code running in this job were hostile, what could it reach? The answer is “every secret and credential in scope, plus the artifact.” That framing is what makes the rest of the lesson’s defenses obvious.

Poisoned Pipeline Execution: the core failure mode

Poisoned Pipeline Execution (PPE) is the umbrella name for the central attack: an adversary gets your CI to run their code with the pipeline’s privileges. They don’t need to compromise a runner or steal credentials up front — they convince the pipeline to execute attacker-controlled code in a privileged context, and the secrets fall out for free.

On GitHub, the canonical PPE vector is a trigger choice. Compare the two pull-request triggers:

# SAFE DEFAULT for fork PRs: low-privilege, no secrets, read-only token.
on: pull_request

# DANGEROUS when it runs PR code: base-repo context, secrets + write token.
on: pull_request_target

pull_request runs in a deliberately restricted context for fork PRs: no secrets, a read-only GITHUB_TOKEN. That is the correct place to build and test untrusted code, because even if the PR ships a malicious postinstall or a poisoned test, there’s nothing valuable in scope to steal. pull_request_target is the opposite: it exists for workflows that need to comment, label, or triage, so it runs in the base repo’s context — with your secrets and a write-scoped token. The trigger itself is not the bug. The bug is the combination: a pull_request_target workflow that then checks out the PR’s head and executes it.

# ANTI-PATTERN — do not do this. pull_request_target + checkout of untrusted head.
on: pull_request_target
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # attacker-controlled code
      - run: npm ci && npm test                             # runs it with secrets in scope

The moment that build step runs, attacker-controlled code is executing in a context that has your secrets. A fork PR from anyone on the internet can read secrets.* and exfiltrate it. No merge, no approval, no review of the code that actually ran — npm ci alone executes the PR’s lifecycle scripts. The defense is structural: never check out and run untrusted PR code in a job that has secrets. Keep secret-bearing steps physically separate from untrusted-code steps, require manual approval before a first-time contributor’s workflow runs at all, and treat the PR head as hostile input the way you’d treat a request body.

Why this works

Why is pull_request_target running the PR’s code uniquely dangerous, when pull_request running the exact same code is fine? Because the two triggers run in different contexts, by design. pull_request deliberately runs fork PRs in a restricted sandbox: no secrets exposed, a read-only token — the safe default precisely because the code is untrusted input. pull_request_target exists for a different need: workflows that must comment on or label a PR need write access and sometimes secrets, so it runs in the BASE repo’s context with both. That’s fine as long as it never executes the PR’s code — it’s meant to act on metadata. The vulnerability is born the instant such a workflow checks out the head ref and runs it: now attacker-controlled code is executing inside a context that holds your secrets and a write token. The trigger isn’t the flaw; combining a privileged context with execution of untrusted code is. Same code, two triggers, opposite blast radius.

Least-privilege GITHUB_TOKEN

Even when a job legitimately runs your own code, the automatically-provided GITHUB_TOKEN is a credential worth scoping down. By default a workflow’s token can be broad; an attacker who achieves code execution in a job with a write-scoped token can do more than read — they can push commits, cut releases, or move tags, turning a build compromise into a source compromise. The fix is default-deny: set top-level permissions to the minimum, then grant only what each job needs, per job.

# Default-deny at the top; grant the minimum, per job.
permissions: {}                 # nothing by default
jobs:
  build:
    permissions:
      contents: read            # this job only reads the repo
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build

Scope tokens per job, not per workflow: the job that runs untrusted code should have permissions: {} and no secrets, while a separate publish job gets exactly the write scope it needs and nothing more. A write token in a job that runs untrusted code is a self-inflicted PPE.

OIDC instead of long-lived secrets

The deeper fix for “the runner holds stealable secrets” is to stop storing the stealable secret at all. A long-lived cloud key sitting in CI secrets is broad, rarely rotated, and a single exfiltration hands it over for as long as it lives. OIDC removes the standing secret: the runner requests a short-lived, signed identity token that encodes which repo, branch, and environment it’s running as; the cloud provider verifies that token against a trust policy and exchanges it for temporary credentials scoped to that workload.

# OIDC: no standing cloud key in secrets. The runner mints a short-lived identity,
# the cloud provider exchanges it for temporary credentials.
permissions:
  id-token: write              # allow the runner to request the OIDC token
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/deploy-prod
          aws-region: us-east-1

There’s a real trade: OIDC needs trust-policy setup on the cloud side (which role trusts which repo and branch), versus pasting one key into a secret. But the blast radius of a stolen credential goes from “a broad, long-lived key” to “a token that expires in minutes and only works for one workload” — often near zero. The standing secret an attacker would exfiltrate simply doesn’t exist anymore.

Untrusted dependency execution at build time

PPE has a quieter cousin that doesn’t need a malicious maintainer or a misconfigured trigger: a malicious dependency. When the build installs packages, their install and build lifecycle scripts (preinstall, postinstall, build hooks) run on the runner with the job’s privileges — the same blast radius as any other code in the job. A compromised transitive dependency can read whatever secrets the job holds and exfiltrate them, all during a routine npm ci.

The mitigations stack: run installs with --ignore-scripts where you can so lifecycle hooks don’t fire automatically; use ephemeral, isolated runners so a compromised build can’t persist or pivot; apply egress filtering so a build that has no business calling an unknown host simply can’t; and — the cheapest, most effective control — don’t give the build job secrets it doesn’t need. A build that compiles code does not need your deploy key in scope. Move the deploy key to the deploy job, which doesn’t run untrusted dependency code.

Pick the best fit

You must build and test untrusted fork PRs in CI — including running their dependencies and test suite — without handing an attacker your secrets. How do you structure it?

Quiz

Why is the CI runner a prime attack target even though it isn't a production server?

Quiz

A fork PR from an unknown account exfiltrated the repo's cloud deploy key with no merge and no approval. Which pattern caused it, and what's the structural fix?

Recall before you leave
  1. 01
    Why must the CI pipeline itself be threat-modeled, and what is Poisoned Pipeline Execution? Use the pull_request vs pull_request_target distinction.
  2. 02
    Walk through the defenses: untrusted-build isolation, least-privilege GITHUB_TOKEN, OIDC, and untrusted-dependency mitigations.
Recap

The CI runner must be threat-modeled because, for the length of a job, it holds every secret you give the pipeline and has write access to the artifact you ship — so compromising the build lets an attacker steal credentials or tamper with what ships (the SolarWinds class, modeled by SLSA) without ever touching production. The core failure mode is Poisoned Pipeline Execution: getting CI to run the attacker’s code with the pipeline’s privileges. On GitHub the canonical vector is the trigger — pull_request runs fork PRs in a restricted context with no secrets and a read-only token (the safe place for untrusted code), while pull_request_target runs in the base-repo context with secrets and a write token for acting on metadata; the bug is a pull_request_target workflow that checks out and runs the PR’s head, executing attacker code where secrets live, so any fork PR can read secrets.* and exfiltrate — even npm ci runs the PR’s lifecycle scripts. The defenses are structural: isolate the untrusted build under pull_request with no secrets and keep privileged steps in a separate job; default-deny the GITHUB_TOKEN with permissions: {} and grant the minimum per job so a compromised job can’t push commits or cut releases; replace long-lived cloud keys with OIDC, where the runner mints a short-lived workload-scoped identity exchanged for temporary credentials, dropping the stolen-secret blast radius to near zero; and contain untrusted dependency execution at build time with —ignore-scripts, ephemeral isolated runners, egress filtering, and not granting the build job secrets it doesn’t need. Now when you review a workflow that uses pull_request_target, the first question to ask is: does this job ever check out and execute the PR’s code? If yes, it’s a PPE waiting to happen.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.