ISSUE 2026-09-27SERIES BREC 25TYPE BRIEFgarnetgrid.com

The pipeline is the most privileged identity you own

Insights

Most pipeline security spend inspects the code passing through. The harder question is what someone can do with the pipeline's own privileges, and who may change what it does.

Ask a security team who has production access and you get a list of people. The build system rarely makes that list, even though it holds credentials to the cloud accounts, the container registry and the package namespaces, and exercises them unattended all day.

So start with the audit almost nobody has done: for each pipeline identity, write down what it can actually do in every system it touches. These roles only accumulate, because a removal breaks a build at an unpredictable moment and the person who debugs it is not the person carrying the risk.

Then deal with the review asymmetry. Application code is reviewed by people who understand it; the pipeline definition is reviewed by whoever is on the rota, and a diff that changes a trigger condition, a cache key or a token scope reads as housekeeping. It is a change to a security boundary, and it wants a named owner who understands the platform's trust model. More reviewers will not help. The right one will.

While you are in there, check whether the pipeline can bypass the controls that guard the pipeline. If the automation's token can push to a protected branch, or a required status check is defined in a file the pull request may edit, the protection is advisory. It is also the cheapest check here, and almost never run.

The trigger is the trust boundary

The most consequential decision in a pipeline is which events cause a job holding credentials to execute code that someone outside your organisation controls. Most of a pipeline threat model is downstream of that one question.

Fork pull requests are the canonical case. The safe arrangement is that a contributor's push runs with no secrets and a read-only token. The unsafe arrangement is any mechanism that hands a job the base repository's secrets while it executes the contributor's tree. On GitHub Actions that mechanism is pull_request_target, and it exists for good reasons: labelling, triage and commenting, none of which require running the submitted code. The failure is adding a checkout of the pull request head and then running something out of it. workflow_run has the same shape, and other platforms have their own version of it.

Running something out of it is broader than people assume: you do not need to reach the test suite to be compromised, only to resolve the dependency graph.

The pattern that holds is two jobs: an untrusted job with no credentials builds and uploads a result, and a trusted job consumes it. The caveat is that consumes must mean reads data, not executes code, and plenty of tooling blurs that line. A coverage-report parser is a parser fed attacker-controlled input.

A template engine in front of a shell

Pipeline definitions are templates. Expression syntax of the ${{ ... }} form is expanded into the script text before any shell sees it. That is textual substitution, not variable passing, and the difference is the entire vulnerability.

If the substituted value comes from anywhere an outsider can write, a branch name, a pull request title, a commit message, then an outsider is writing part of your build script. Quoting the expression in the YAML does not save you, because substitution happens first and the injected text can close the quote itself.

The fix is indirection, not filtering. Put the value in the step's env block and reference the environment variable inside the script, so the shell receives a variable to expand, which you quote as you already quote untrusted strings. Do not sanitise in the template; you will be writing a shell lexer and getting it wrong.

Everything a job restores is an input

Teams reason about the repository as the pipeline's input and stop there. A job's real input set also includes its cache, artefacts from earlier jobs, container and base images, anything a setup step downloads at run time and, on a persistent runner, whatever the previous job left on disk.

Caches are the interesting one, because a cache is a write path from a low-trust context into a high-trust one. If a build on an untrusted branch can write a cache entry that a trusted build later restores, it can place a compiled dependency, a populated node_modules tree or a tool binary exactly where the trusted build will pick it up and run it. Cache scoping rules differ by platform and have changed over time, so look up the current rules for the system you run rather than assuming caches are isolated per branch.

Persistent self-hosted runners are the same problem with more surface. State survives between jobs: a git config with an insteadOf rewrite, an npmrc pointing at a different registry, a shadowed binary earlier on the PATH, a process still resident, waiting for the next job's environment. The mitigation is a runner created for one job and destroyed after it, which costs you cold caches and more orchestration. If you keep persistent runners they must never see untrusted code, and that boundary needs enforcing in configuration rather than remembering.

OIDC moved the risk into the trust policy

Federating the pipeline to your cloud with OIDC instead of a stored access key is the largest single improvement available to most teams: it deletes the stealable long-lived secret rather than protecting it better.

It also relocates the whole boundary into one condition inside a trust policy, and that condition is easy to write too loosely. The token's subject claim says which repository and which ref is asking. If your policy checks the issuer and the audience but leaves the subject unconstrained, or wildcards the repository portion, then any repository on that CI provider can assume your role. Not only yours. The narrow condition tends to break when someone renames a repository, and the fastest way to unbreak a red build is a wildcard that nobody revisits.

Two things then get skipped. The assumed role still needs least privilege, because OIDC fixes credential lifetime and not blast radius, and a fifteen-minute token that can delete the account is not a win. And scope by protected ref or deployment environment, not by repository alone, or any branch can mint production credentials.

Mutable references, all the way down

A pipeline is a chain of references, and most of them point at something another party can change after you looked.

Third-party actions and shared pipeline templates referenced by tag are the obvious case, because tags move, including backwards. Pin to an immutable commit identifier, then be honest about the cost: you stop receiving upstream fixes silently, so you need automation to propose bumps and a human to read them. The pin also covers the source tree only, so if the pinned step downloads a binary at run time you have pinned the wrapper, not the payload. Base images by floating tag are the same reasoning.

The deployment end is where teams most often leave a mutable reference after tightening everything upstream. CI builds an image, tests it, pushes it, and the deploy step pulls a tag. Nothing binds the artefact that passed the tests to the artefact that runs. Deploy by digest, and have the step assert the digest it expected.

On signing, an opinion, and I will label it as one. A signature that nothing verifies is a build step that makes files larger. If you add provenance attestation, add the admission check that rejects an unattested artefact in the same piece of work. Otherwise you have bought the paperwork, none of the control, and a belief that you are covered, which is worse than knowing you are not.

Nobody is watching the pipeline

Application logs go wherever your security team looks. Pipeline logs go to the pipeline. Ask who would notice a job that ran on a branch nobody pushed, or a build step that opened a connection to a host it has never contacted before.

Masking deserves particular scepticism. It matches the literal secret in output. Base64, a JSON encoding, a value split across two lines, or a subprocess that prints it with different whitespace all defeat it. Masking stops accidents. It is not a control against anyone who is trying.

Egress from build jobs is usually unrestricted, because restricting it breaks dependency resolution and nobody wants to own the allowlist, which makes the exfiltration step of every attack above free. If you do one thing here, get pipeline logs and events somewhere a rule can evaluate them.

Do the cheap things first; none of them require a purchase. Enumerate every trigger in every pipeline and mark each job where credentials and externally-controlled code can be present in the same runtime; fix those first, because that is where real compromises come from. Then write down what each pipeline identity can do in your cloud and registries, and cut it. Then check whether the pipeline can bypass the controls that guard it, and put an owner on the definitions. After that, pin your references, make runners ephemeral wherever untrusted code runs, tighten the subject condition on your OIDC policies, and deploy by digest. Scanning stays useful, and stays last: it addresses a different risk. How much of this applies to you depends on whether you accept outside contributions, whether you own your runners, and how much your pipeline may do unattended. There is no honest general answer to that, and anyone who offers you one without asking those three questions is selling something.

Talk to us about this