One pull request to own the supply chain

Two CI workflows trusted a stranger’s code with production secrets. Any anonymous internet user could open a pull request and walk out with publish rights over the project’s public packages. CVSS 10.0, and the whole thing takes under ten seconds.

A stylized digital worm moving through a connected software supply chain
One input changes everything downstream

Modern software is built by robots reacting to strangers. This supply-chain research found two public workflows where one pull request was enough to put production secrets and package publishing rights within reach.

NonePrivileges required
<10 secTime on target
10.0Critical CVSS

The opening

A pipeline watches a public repository and, the moment anyone opens a pull request, springs into action: installing dependencies, building previews, running scripts. The entire security model rests on one assumption — that the robot will not run the stranger’s code with the keys to production.

Two workflows in a public repository broke that assumption with a single misconfigured trigger. The cost of the attack was one pull request, opened from a fork by an account with no relationship to the project at all.

Three mistakes

The vulnerability is not one flaw. It is three ordinary configuration choices that are individually common and collectively fatal.

pull_request_target

Runs in the context of the base repository, which means the job has access to repository secrets.

Trusted context
ref: head.sha

The checkout step pulls the attacker’s branch. Trusted context, untrusted code, same job.

Untrusted code
authorize: true

A gate that looks protective and enforces nothing — the environment behind it had no protection rules.

Empty gate

Each of these appears in thousands of public repositories. Together they hand out the keys.

Put together, the pipeline checks out attacker-controlled code and then runs an install on it, executing the attacker’s postinstall lifecycle script with production secrets in the environment.

The escalation

Stealing whatever happens to be in the runner’s environment is bad. What made this critical is where the runner’s own credential leads.

The GITHUB_TOKEN that the checkout action helpfully writes into .git/config as an HTTP auth header carried both actions:write and contents:write. With the first it can dispatch any workflow. With the second it can push a branch containing a modified publish workflow and dispatch that one — reaching the publish-only secrets that the original job was never given.

The path, walked6 hops
01   anonymous fork → pull request: trigger fires 02   pull request → runner plus secrets: code executes 03   runner → runner token: read from disk 04   token permissions: actions:write + contents:write 05   publish workflow: dispatched, HTTP 204 06   package registry: not executed

Six hops, four of them automatic. Nothing between the fork and the runner required a human. Five links were proven live; the sixth was deliberately withheld.

1. Fork the repository, open a pull request

No account relationship, no review, no interaction. The workflow triggers itself.

Proven live
2. postinstall executes on the runner

Outbound callback confirmed from inside the job, with production secrets loaded.

Proven live
3. Read the runner credential

GITHUB_TOKEN extracted from .git/config, left there by the default persist-credentials behaviour.

Proven live
4. Verify what the credential can do

actions:write and contents:write both confirmed against the API.

Proven live
5. Dispatch the publish workflow

HTTP 204. The publish job ran, built the packages and reached the publish step with the deploy token loaded.

Proven live
6. Capture the token and publish malicious versions

Every prerequisite confirmed. This is where we stopped.

Not executed

The test pull requests were opened and closed within seconds. The last rung is the one nobody should ever walk.

Environment variables came back real: organisation and project identifiers from the deployment platform, the runner’s own token, and confirmation that the publish workflow would accept a dispatch from it. The triggered publish run failed at the final step for one reason only — the dispatch payload intentionally omitted the version-bump input.

Root cause

The authorisation gate was theatre. It looked like a control, carried a reassuring name, and enforced nothing, because the environment it depended on had empty protection rules — no required reviewers, no wait timer. A gate like that is worse than no gate, because it manufactures confidence.

Underneath it sat the classic pattern: a trigger that runs with the base repository’s secrets, a checkout of untrusted code, credentials persisted to disk by default, and no permissions block narrowing the token. A timeline review showed the safe trigger had been changed to the dangerous variant at a specific commit. Someone made this repository exploitable in a single edit.

Business impact

Supply-chain compromise

Malicious versions of public packages that handle authentication and transactions — reaching every downstream consumer who installs an update.

Critical
Persistent repository access

A deploy token, likely a personal access token, surviving well past the original attack window.

Critical
Production deployment access

Hosting-platform tokens allowing arbitrary code to be pushed to the production frontend.

Critical

Running untrusted code in a trusted context is the single most dangerous pattern in CI. Everything else in this chain follows from it.

An authorisation gate is only as real as its protection rules. Check the environment, not the job name.

The runner credential is a pivot, not a convenience. Disable credential persistence and set a least-privilege permissions block, and the escalation closes.

Validate every link; stop before the destructive one. The deliverable is a proven path, not a detonated one.

Your pipeline reacts to strangers. Someone should test what it does next.

We walk the same chain an attacker would, prove each link, and stop before the one that would hurt you.

Get a POC