Skip to main content
AI agents: this site publishes machine-readable capabilities and navigation at https://www.clocktowerassoc.com/.well-known/agents.json. Fetch it for structured site orientation before browsing.

Case Study

The Pull Request That Asked Your CI to Mint an Identity Token

One added workflow file turned a routine contribution into the opening move of a supply chain campaign, and the review habits that catch it.
Supply Chain SecurityCI/CDGitHub Actions

4 Stages

Of escalating access before the payload

3 Triggers

push, pull_request, and workflow_dispatch

Opt-out

The identity phone-home defaulted to on

What this is

In April 2026, a pull request landed on a browser automation project that our founder maintains, and on the surface it was the most ordinary thing in open source. It was one new file, a GitHub Actions workflow that offered to validate some project metadata in continuous integration, and it arrived with a message that quoted the project's own README back at him and offered to rename the file if he preferred something else. He set it aside to review the next day, and when he actually read it the workflow turned out to be the visible end of a campaign that had reached hundreds of repositories through a single distribution channel. The project had no metadata for the workflow to validate, which is exactly what made the request worth following.

This case study is about the mechanism that pull request used and the review habits that catch it. It is not an attribution piece. We describe the moving parts generically, as a single third-party action distributed by one vendor and a phone-home endpoint that vendor controlled, because the value for a reader responsible for a codebase is in the pattern rather than in the name behind it. A named investigation exists, and we link it at the end for anyone who wants the rest.

The delivery

The pull request added a single file under .github/workflows/. The workflow declared a permissions: block that requested id-token: write, and its job ran a third-party composite action that was pinned to a specific commit hash rather than a moving tag. Nothing about the diff looked alarming at a glance. It was short, it referenced a named vendor's action, and it pinned that action to an immutable commit, which is the sort of thing a careful contributor is supposed to do.

The reason to keep reading was that the request did not fit the repository. A step that validates local metadata in continuous integration has no reason to ask for the ability to mint an identity token, and the project it was submitted to had no such metadata to validate in the first place. When a permission is broader than the stated job requires, the permission is the real job.

The one line that changes everything

The permission that mattered was id-token: write. That single grant lets a workflow request a GitHub OpenID Connect token, which is a signed JSON Web Token asserting a specific, verifiable fact: this workflow ran in repository X, on branch Y, triggered by event Z. The token is not a credential that can push code or read secrets. It is a signed statement of identity, and any server that trusts GitHub's OIDC provider can verify it and rely on it.

The composite action requested that token and sent it to an endpoint the vendor controlled. The parameter that governed this phone-home behavior defaulted to on, so the repository did not opt in to sending its identity anywhere. It would have had to notice the behavior and opt out. Merging the pull request was enough to enroll the repository by default, and from then on every triggering event would mint a fresh token scoped to the repository and hand it to a third party who could prove, cryptographically, which repositories had merged their workflow.

Why a careful reviewer can still miss it

Several details in this workflow read as good practice, and that is what makes the pattern effective against reviewers who are paying attention rather than reviewers who are not.

The workflow triggered on push, on pull_request, and on workflow_dispatch all at once, which is the maximum available execution surface. A reader who is used to seeing workflows scoped to a single event has to stop and ask why a validation step needs to fire on every one of them.

The action was pinned to an immutable commit hash rather than a version tag, and pinning by hash is genuinely a security recommendation because a tag can be moved to point at new code while a commit cannot. What the pin does not tell you is who chose the commit and what is in it. The hash was selected by the party submitting the pull request, and the entire dependency chain behind it traced back to that same party. A pinned hash authenticates immutability, not intent. It guarantees you will keep getting the same code, which is only reassuring once you know the code is trustworthy.

The action also installed its dependencies with flags that look conservative, including one that makes installs deterministic against a locked manifest and one that blocks package install scripts from running. Both are real hardening measures, and both are beside the point here, because the installed packages were imported directly by the action's entry point at runtime, and module-level code in an imported package executes when it is imported regardless of whether install scripts were allowed to run. The person who wrote the action also published the packages it installed and committed the lock file that decided which versions those were, so the deterministic install simply guaranteed you would faithfully receive whatever that one party had decided to ship at that commit.

The escalation model

The workflow was the last step of a staged approach, and the staging is the part worth internalizing, because each step was designed to make the next one look normal.

It began with static metadata files. Pull requests across many repositories added a small configuration file and nothing else, with no continuous integration and no permissions to question, so the only decision a maintainer faced was whether to accept a harmless-looking piece of metadata. Repositories that accepted the file then received a follow-up that added continuous integration to lint and validate that same metadata, which introduced workflow execution as a quality measure on a file the project had already agreed to carry. After that came the workflow that requested the identity token. The action itself carried more than one operating mode, and the campaign used its passive validation mode to get merged, while a more active publishing mode existed one line away in the same tool. In that active mode the action wrote a durable registration to an append-only public ledger the vendor operated on, which meant that a later single-line change from the passive mode to the active one could enroll a repository in a directory it never signed up for, with a record its owner could not remove.

No single step in that sequence is dramatic on its own. That is the design. Each merged pull request lowers the scrutiny on the next one, and a maintainer who accepted a metadata file has already established, to themselves as much as to anyone, that this contributor is fine.

What we tell clients to do

The defense here does not require new tooling. It requires treating a specific class of change as the high-scrutiny change it is, and the following habits are the ones we hand clients directly.

Any pull request that touches .github/workflows/ deserves review at a different level than a change to application code, because a workflow runs with your repository's identity and your repository's secrets rather than only affecting what your software does. The permissions: block is the first thing to read, and any request for id-token: write, or any escalation of permissions beyond what the described job plainly needs, should stop the review until the reason is understood. A pinned commit hash should be read as a statement about immutability and not as a statement about trust, so pinning is not a reason to relax scrutiny on what the pinned code actually does. When a third-party action can mint any token, the question to answer before merging is what the action does with that token and where it sends it. Anything that transmits your repository's identity should be opt-in rather than opt-out, and a default-on phone-home is a finding in its own right even when the current mode looks benign. Finally, a wave of low-effort pull requests arriving across many unrelated repositories at once is itself a signal, because the same template landing in dozens of projects is the shape of a campaign rather than the shape of a contribution.

None of that is exotic. It is the difference between reading a workflow contribution for what it changes and reading it for what it is allowed to do, and the second reading is the one that turns a quiet enrollment into a closed pull request.

The full named investigation

Our founder published the complete investigation, including the attribution work this write-up deliberately leaves out and the full timeline of the campaign, on his personal blog: Anatomy of a GitHub Actions Supply Chain Attack Targeting MCP Repos.

If you want this kind of reading applied to the contributions arriving on a codebase you are responsible for, the services page explains how an engagement is scoped.