A team stores no credentials in its repository. The cloud role is assumed through OIDC federation, the registry token lives in a managed secret store, and the build never prints it. Over one weekend an attacker publishes a malicious version of the team's package, and the audit trail shows the push came from the team's own pipeline. Trace how, and name the structural fix.
Show the full answer Hide the answer
The trigger
An outside contributor opened a pull request from a fork. The repository's workflow runs on a trigger that executes in the base repository's context so that it can post review comments, which means it holds the base repository's identity and its secrets. The workflow then checks out the pull request's head and runs the project's own test script.
That script came from the fork. The attacker changed one line in it. The job obediently ran attacker-authored code with the pipeline's publish credentials attached.
The postmortem found a second path as well: a build secret had been passed with a build argument, so it was baked into an image layer and readable by anyone who could pull the image or read the shared build cache. The secret was never logged, and it was never private either.
Why the secret store did not help
Workload identity federation removes the stored key; it does not narrow who can make the pipeline act. The token minted here was short-lived, correctly scoped and issued to exactly the right workflow, because the workflow really was the team's workflow, running on the team's trigger, on the team's default branch configuration. Every control behaved as designed.
The privilege came from the trigger, not from the code. That is the part teams get wrong, because the threat model usually asks "where is the secret stored" rather than "whose code can run while it is available".
Why detection lagged
A publish from the pipeline is the expected state of the world, so nothing looked anomalous. The package had roughly 30,000 weekly installs, and a registry serves a new version to resolvers immediately, so distribution of the malicious build was effectively complete within 15 minutes of the push and the failure window ran for 60 hours before anyone was back at a keyboard.
The signal that would have caught it is narrow and cheap: alert on any publish event with no matching approved release run, keyed on the run identifier rather than on the actor.
The structural fix versus the tempting local fix
The tempting fix is to rotate the registry token and move on. That closes this instance and leaves the mechanism intact.
The structural fixes, in order of value:
- Fork pull requests run on a trigger with no secrets and a read-only token. Anything needing credentials runs in a separate job from a trusted ref, after a human approves that specific run.
- Never check out untrusted code into a job that holds credentials. If a privileged job must see the contribution, it reads it as data, not as something it executes.
- Publish credentials live in their own protected environment gated on a signed tag and a reviewer, so no ordinary pipeline run can publish at all.
- Deliver build secrets through a mount that does not persist into a layer, never through a build argument, and treat the build cache as a readable artefact.
Decision rule: a job may hold credentials, or it may run code the pull request author controls. Never both. Everything above is an application of that one sentence.
When not to pay the full cost
On a private repository with no outside contributors, the fork-trigger class does not exist, and adding a human approval to every credentialled job costs about 20 minutes of waiting per release and prevents nothing. The control that still earns its place there is the fourth one, because a leaked layer is a leak regardless of who opened the pull request.
Common weak answers
- "Require signed commits." The attacker's commit can be signed. Signing proves authorship, and authorship was never in doubt.
- "Scan the repository for secrets." There were no secrets in the repository. The scan passes and the pipeline is still exploitable.