On 12 April 2022 GitHub began investigating an attacker who used stolen OAuth user tokens issued to two third-party integrators - Heroku and Travis CI - to list organisations and clone private repositories from dozens of organisations including npm. GitHub said it did not believe the tokens were obtained by compromising GitHub and that it does not store them in usable form, and its security chief said the attacker's behaviour suggested mining the cloned repositories for secrets that could be used to pivot into other infrastructure. What was the real blast radius decision, and what would you change?
Show the full answer Hide the answer
The situation
A code-hosting platform issues OAuth tokens so that continuous-integration and deployment products can act on a user's behalf. Those tokens are long-lived, carry the user's own reach across every organisation and repository they can see, and are usable from anywhere. The integrators holding them are separate companies with separate security postures.
GitHub's own systems were not the entry point. The tokens were the entry point, and they were being held by somebody else.
What the attacker actually got
Read access to private source code. That sounds like the least damaging category of access, and the reported behaviour explains why it is not: the attacker selectively listed organisations, cloned repositories of interest, and - per GitHub's security chief - appeared to be looking for credentials inside them to reach other systems.
Source code is a credential store in practice. Cloud keys in a test fixture, a database password in an old configuration file, a webhook secret in a script, a token in a commit from 2019 that was removed in 2020 but is still in history. So read-only on code is, with high probability, write access to something else - and that is the mechanism that turns a repository disclosure into an infrastructure incident.
Why the token model made it possible
- Scope coarseness. A user-to-server token scoped to "repo" covers every repository that user can reach, across every organisation. The integrator needed a handful; it received all of them.
- No expiry. A token that does not expire is a permanent liability from the moment it is issued, and its security becomes the weakest of every system that stores it.
- No locality. The token worked from any address. Nothing tied it to the integrator's infrastructure.
- The grant was made once, by an individual. A developer authorising a convenient integration silently extended organisational access to a third party, which is an organisational decision made at an individual's keyboard.
What it cost
Dozens of organisations' private code disclosed, an unbounded secret-rotation exercise for every one of them, and the permanent uncertainty of not knowing which secrets were extracted. Also, two integrators' customers having to assume compromise of unrelated systems.
What to change
- Prefer installation-scoped application identities over user tokens - GitHub Apps rather than OAuth apps, with permissions per repository and short-lived installation tokens. The integrator then holds something that expires and covers only what was granted.
- Require organisational approval for third-party access, so the grant is an organisational decision rather than an individual one, and review the list on a schedule.
- Use fine-grained tokens with an expiry and a repository allowlist everywhere a token is still needed.
- Assume code contains secrets and act on it: secret scanning with push protection, plus the assumption in incident planning that any code disclosure implies credential rotation.
- Alert on anomalous enumeration - an integrator token listing hundreds of organisations is not what that integrator does, and enumeration before cloning was the detectable step.
Where copying the response would be a mistake
GitHub could notify affected organisations because it had the audit trail to reconstruct what each token did. A smaller platform usually cannot, and the lesson is not "build GitHub's forensics" but "issue credentials whose reach you can state". If you cannot answer "what could this token do" in one sentence, the scope is wrong - and no amount of monitoring fixes a token that is allowed to do everything.
Common weak answers
- "Ban third-party integrations." Teams route around it with personal access tokens, which are invisible to organisational review and therefore worse than the grant you removed.
- "Rotate the tokens and move on." Rotation without scope reduction re-grants the same reach with a new value, and leaves the secrets inside the cloned code untouched.
- "Our repositories do not contain secrets." Almost always false once history is included, and the claim is testable in an afternoon with secret scanning over full history.
- "The integrator was breached, so this is their incident." The reach was decided by your scope model at issuance. Their posture set the probability; yours set the consequence.