Ask a security team to name the most privileged system they own and you will hear about the domain controller, the jump box, maybe the hypervisor. Almost nobody says the build pipeline. Yet the pipeline can deploy to production, it holds the credentials to every environment, and it executes code written by anyone who can open a pull request. It is the only system in most estates that combines all three properties, and it is usually governed by a YAML file nobody has reviewed since it was copied from a blog post.
That gap is not theoretical. In March 2025 an attacker repointed every version tag of a widely used GitHub Action, from v1 through v45.0.7, at a single malicious commit, and every build that ran it dumped its secrets into the logs. More than 23,000 repositories referenced that action. The teams who had pinned to an immutable commit SHA were unaffected. The teams who had written @v35 because it looked tidier were not.
Why the pipeline is a different security problem
Two properties make CI/CD unlike the systems your controls were designed around.
Concentration. The pipeline holds, or can mint, the credentials for every environment it deploys to. Compromise it and you do not inherit a repository, you inherit the estate. This is the same argument as blast radius in cloud IAM, which I have written about separately, except the pipeline is usually excluded from the identity programme entirely because it is considered developer tooling rather than infrastructure.
Execution of untrusted code. A build runner exists to fetch code from the internet and run it. A pull request from a fork, a new transitive dependency, a third-party action, a build plugin: all of it executes with the runner's identity and whatever access that identity carries. Most organisations would never allow an unreviewed binary to run on a production server, then allow exactly that to happen on a runner that can deploy to one.
Put those together and you get the failure pattern behind essentially every pipeline incident: attacker code executes inside a runner, reads whatever secrets the workflow has access to, exfiltrates them, and then uses them to publish a package, deploy an image, or move into the cloud account. The tooling changes, the chain does not.
The technical detail
Kill the static credential first
If a pipeline holds a long-lived cloud access key in its secret store, nothing else you do matters much. That key is a bearer token with no expiry, sitting in a system that runs untrusted code, readable by any workflow that can reference it, and reproducible in any log line that echoes the environment.
The replacement is workload identity federation. The CI platform issues a short-lived OIDC token describing the workflow, the cloud trusts that issuer, and the workflow exchanges the token for temporary credentials scoped to a role. Nothing durable is stored anywhere. On AWS that is an IAM OIDC identity provider plus a role with a trust policy; on Azure a federated credential on an app registration; on GCP a workload identity pool.
The detail that matters, and the one most often wrong, is the subject condition on the trust policy. A trust policy that accepts any workflow from the whole organisation gives every repository the ability to assume the deploy role. Constrain it to the repository, and to the specific branch or environment.
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub":
"repo:my-org/my-repo:environment:production"
}
}
Using environment:production rather than a wildcard on ref means the role can only be assumed by a job running against the protected environment, which is also where you attach required reviewers. That single condition turns the deploy role from something any branch can use into something that requires a human approval to reach.
The fork pull request problem
The most reliable way to get code execution in someone else's pipeline is to open a pull request. Platforms know this, which is why pull requests from forks normally run without access to secrets. The exposure comes from the trigger that deliberately reverses this.
In GitHub Actions, pull_request_target runs in the context of the base repository, with access to secrets, specifically so that workflows can label or comment on external contributions. If such a workflow then checks out the pull request head and runs anything from it, a build script, a lint config, a dependency install, the attacker's code executes with your secrets in scope. This is not an edge case. The compromise chain behind the March 2025 action incident reportedly began months earlier with exactly this pattern being used to steal a maintainer's personal access token.
The rule is simple to state and worth auditing for directly: a workflow may have secrets, or it may check out untrusted code, but never both. If you need to build a fork's code, do it in a job with no secrets and read-only permissions, and post results from a separate, privileged job that only consumes the output.
pull_request_target and then check whether the workflow checks out github.event.pull_request.head.sha. That combination is the single highest-yield finding in a pipeline review, and it takes about ten minutes across an estate.
Pin actions to commit SHAs, not tags
A Git tag is a mutable pointer. Anyone who controls the upstream repository can move v4 to different code at any time, and consumers referencing that tag silently execute the new version on the next run. A commit SHA cannot be repointed. This is precisely the mechanism that made the March 2025 incident so broad: the attacker moved every tag to one malicious commit, and organisations pinned to SHAs were not affected.
So actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683, not actions/checkout@v4. The obvious objection is maintenance, and the answer is that Dependabot raises pull requests for pinned SHAs, so updates become a reviewed change rather than an invisible one. GitHub has since added the ability to enforce SHA pinning through the allowed actions policy, which is worth turning on at organisation level rather than relying on every team to remember.
Pinning is a structural control that stops the attacker getting in through a moved tag. It does nothing once code is already executing, which is why it belongs alongside runner isolation and egress control rather than instead of them.
Runner isolation, and the self-hosted trap
Hosted runners are ephemeral by default: fresh machine per job, destroyed afterwards. Self-hosted runners frequently are not. They persist between jobs, retain caches and credentials on disk, and are often shared across teams to save cost. That combination turns a single compromised build into persistence, and gives an attacker access to whatever the next team's job brings with it.
If you run self-hosted, the non-negotiables are one job per runner instance with the instance destroyed afterwards, no shared filesystem or Docker cache between jobs, no self-hosted runners attached to public repositories, and an egress allow-list so a compromised job cannot post your secrets to an arbitrary endpoint. Egress control is the one most often skipped and the one that most reliably converts a total compromise into a contained one.
Secrets, logs, and the retention problem
Platforms mask known secret values in log output, but masking is string matching. Base64 the value, split it across variables, or transform it and the mask does not apply. The March 2025 attack worked exactly this way, printing encoded credentials into logs that were public on many repositories.
The controls that hold up are structural: set permissions explicitly to read-only at workflow level and grant write scopes only on the specific job that needs them; prefer OIDC so there is no durable secret to print; scope secrets to environments rather than the repository so a test job cannot reference a production value; keep log retention short; and run secret scanning with push protection so a leaked credential is caught at commit time rather than in an incident.
Artefact integrity, which is where this is heading
Everything above protects the process. The last layer protects the output. Signing build artefacts and generating provenance attestation lets the deploy step verify that an image was built by the pipeline you expect, from the commit you expect, rather than uploaded by someone with registry credentials. Paired with an SBOM, it also answers the question every incident raises within an hour: where else is this dependency running.
This matters more each year because the regulatory direction is the same across jurisdictions, and because provenance verification at deploy is the only control on the list that catches an artefact substituted after the build succeeded.
| Boundary | Primary control | What it stops |
|---|---|---|
| Source | Branch protection, CODEOWNERS, signed commits | Unreviewed change reaching the build |
| Trigger | No secrets on untrusted-code workflows | Fork pull request stealing credentials |
| Dependencies | SHA pinning, allowed-actions policy | Moved tag executing new code |
| Runner | Ephemeral instances, egress allow-list | Persistence and exfiltration |
| Credentials | OIDC federation, scoped subject condition | Long-lived key reuse |
| Artefact | Signing, SBOM, provenance verification | Substituted image at deploy |
Delivering it: who, what, when, deliverables, lineage
Who. This lands between platform engineering, who own the pipelines, and security, who usually do not have access to them. Neither can deliver it alone, and the common failure is a security team issuing findings against YAML they cannot change. Get a named platform engineer accountable for the workflow estate before you start.
What. An inventory of pipelines and their identities first. Not repositories, identities: what can each pipeline deploy to, and with what credential. Most organisations cannot answer that on day one, and the answer usually reveals two or three pipelines with far more access than anyone intended.
When. Sequence by irreversibility. Static cloud keys and secret-bearing fork triggers come first because both hand an external party your credentials. SHA pinning next, because it is cheap and broad. Runner isolation and egress after that, because it needs infrastructure work. Signing and provenance last, because it is the layer that assumes the rest already holds.
Deliverables. A pipeline identity register, a workflow hardening standard teams can be measured against, the OIDC trust policies with scoped subject conditions, an organisation-level allowed-actions policy with SHA pinning enforced, and a runner build standard. Those are artefacts a client keeps, which is the difference between a review and a programme.
Lineage. This work descends directly from cloud IAM. The pipeline is a non-human identity with production access, and every argument about blast radius and least privilege applies to it unchanged. It also runs upstream of software supply chain assurance: signing and SBOM only mean something if the pipeline producing them is itself trustworthy.
The honest summary
Pipeline security is not difficult in the way cryptography is difficult. Every control here is a configuration change someone could make this week. What makes it hard is ownership: the pipeline sits in the gap between the team that runs it and the team responsible for what it can reach, and gaps of that shape are where estates accumulate the sort of privilege nobody would have granted deliberately.
If you do nothing else after reading this, do two things. Grep for secret-bearing workflows that check out untrusted code, and list every long-lived cloud credential in a CI secret store. Both take an afternoon. Between them they cover the two failure modes that turn a build system into an incident.
If you want a second opinion on a pipeline estate or help running the remediation, that is the kind of work I do through Cyber Spartans.