RC RANDOM CHAOS

Nobody has to break into GitHub

GitHub incidents are feature abuse under valid credentials. The break is at the execution boundary, and standing scope is the real exposure.

· 9 min read
Nobody has to break into GitHub

GitHub is rarely broken into. It is operated. The attacker signs in, or holds a token, or authorizes an app, and then uses the same features every engineer uses. A personal access token. A GitHub Actions workflow. An OAuth authorization. A pull request from a fork. Each of these is a supported function of the platform. Each one carries an identity the platform already trusts. When an attacker controls one, the platform sees legitimate identity performing permitted actions.

This is the position you have to start from. The threat on GitHub is not perimeter failure. It is feature abuse under valid credentials. The control that would stop an attacker is the same control that lets a developer merge code, run CI, and publish a package. That control is applied at one point, authorization, and then the resulting access is trusted for as long as its scope lasts. Nothing in that trust decision is re-checked while the access is in use.

The specifics of any single incident, the number of accounts touched, the dwell time, the exact path taken, are not confirmed here and are not the point. The mechanism is the point, because the mechanism is stable across incidents. If the platform grants execution context to code and tokens that carry legitimate identity, that context is available to anyone who obtains the identity. No exploit is required. No vulnerability is required. The feature working as designed is the access path.

What is observable is straightforward. Attacker-supplied input executed with legitimate credentials. A workflow defined to trigger on an external contribution ran on the repository’s own runner and had access to the repository’s own secrets. A token issued for one purpose retained write access to everything the account could reach. An authorized app continued to act inside its granted scope. In each case the action completed and the platform recorded a valid identity performing it.

None of this looks like an intrusion in the logs. Code ran. Tokens read secrets. Commits landed. Packages published. Every one of those events carried an identity the platform had already validated. There is no anomalous login to point at when the login itself was legitimate. There is no malformed request to block when the request was exactly the request the feature accepts. The failure is not that something unexpected happened. The failure is that something fully expected happened under attacker control.

State the boundary precisely. The break is not in GitHub’s authentication. Authentication worked. The credential was valid. The break is at the execution boundary, where a valid credential is treated as sufficient proof to run code, read secrets, or write to a repository. Whether that credential was in the right hands is a question the platform does not ask at execution time. That is the observable gap, and it is the same gap whether the credential was a leaked token, a granted app, or a workflow trigger reachable from outside the trust boundary.

Identity is the boundary, and the platform treats a valid credential as the entire proof of identity. GitHub confirms who you are at authorization. After that, the token, the workflow context, or the app grant is trusted for the life of its scope. There is no continuous re-validation of whether the holder is still the party who was authorized, or whether the action being performed matches any legitimate intent. Validation happens once. Trust persists. That persistence is the condition the attacker relies on.

Automation removes the last check that a human might have provided. A workflow executes whatever it is handed, at machine speed, without judgment about the source. A token does not know it was leaked. A CI job does not know the pull request that triggered it was hostile. Scope granted broadly stays broad until someone explicitly revokes it, and broad scope is the default path of least resistance when engineers configure access under deadline. The platform enforces the scope faithfully. It does not enforce that the scope was ever appropriate.

This is why the same class of incident repeats. It is not a specific bug that gets patched. It is the trust model. A credential is accepted as identity, identity is accepted as authorization, and authorization is accepted as intent. Collapse those three into one and every feature that runs on trusted identity becomes an attacker capability the moment the identity is obtained. If a system allows an action within granted scope, that action will occur, and the platform will attribute it to a valid user, because from the platform’s position that is exactly what happened.

The mechanism resolves to a single decision made once and never revisited. At authorization, GitHub binds a credential to a scope. From that moment the credential is the identity. Nothing downstream re-establishes the link between the credential and the party authorized to hold it. The execution boundary receives a valid credential and a requested action inside scope, and it has exactly one question available to it: is this credential valid, and is this action in scope. Both answers are yes. The action runs. The holder is never evaluated. That is the whole of the mechanism. Everything else is the shape it takes across features.

Follow the token. A personal access token is issued with write access across what the account reaches. That access does not narrow when the task narrows. A token created to publish one package can write to every repository the account can write to, because the scope was bound to the account, not to the task. When that token is copied into a log, a CI variable, a dotfile, or a compromised laptop, the copy carries the full scope. The platform enforces the scope precisely. It has no mechanism to enforce that the copy is in authorized hands, because it has no concept of the token being copied. A bearer credential treats possession as authorization. Whoever holds it is, to the platform, the party it was issued to.

Follow the workflow. A workflow configured to trigger on an external contribution runs attacker-authored input on the repository’s own runner, inside the repository’s own execution context, with reach to the repository’s own secrets. The trigger is the boundary crossing. Input from outside the trust boundary is handed an execution context that lives inside it. The runner does not distinguish a maintainer’s commit from a fork’s pull request. Both arrive as work to execute. Secrets present in that context are readable by whatever code the context runs. The attacker does not extract the secret by breaking the runner. The runner hands the secret to code the attacker supplied, because supplying code to the runner is the feature operating as designed.

Follow the grant. An authorized app holds a scope granted by a user or organization and continues to act inside that scope with no further consent. The grant is a standing authorization. It does not expire on inactivity, it does not re-prompt on sensitive action, and it does not distinguish the app operating as intended from the app operating under attacker control. Whoever controls the app’s credential controls everything inside the grant. The consent that established the grant is treated as consent for every action the grant permits, indefinitely, until a human revokes it. Three different features, one decision: valid credential, in scope, execute.

The pattern is the standing credential. Wherever a system issues a durable proof of identity and then accepts that proof as sufficient to act, the interval between issuance and revocation is fully available to anyone who obtains the proof. The size of that interval is the size of the exposure. On GitHub the interval is the life of the token, the life of the grant, the persistence of the workflow trigger. None of these close on their own. They close when a human revokes them, and revocation requires first knowing the credential is compromised, which the logs do not show, because every action taken with it was valid. A long-lived cloud access key exhibits the identical mechanism: a bearer credential, scope bound once at issuance, no rebinding to the holder at time of use. The instance changes. The mechanism does not.

Broad scope turns the interval into a blast radius. A credential scoped to one task and one repository, on compromise, exposes one task and one repository. A credential scoped to the account, on compromise, exposes everything the account reaches. The mechanism is identical in both cases. The only difference is the scope granted at issuance. Default-broad scope is the common configuration, because narrow scope requires deliberate effort under deadline and broad scope requires none. The platform enforces whatever scope it was handed. It does not enforce that the scope was ever necessary. The decision that determines the blast radius is made once, early, by whoever was fastest, and it persists unreviewed.

Automation removes the interval where compromise might have surfaced. A standing credential inside an automated pipeline is used continuously, at machine speed, with no human in the path to notice that the action does not match intent. The token does not know it leaked. The workflow does not know the input was hostile. The app does not know its operator changed. Every check a person might have applied is absent by design, because the purpose of the automation was to remove the person. The same automation that scales legitimate work scales abuse of the same credential at the same rate. The platform attributes both to a valid identity, because from its position both are a valid identity acting in scope.

Treat every credential as compromised from the moment it is issued, and build so that assumption is survivable. Authorization once is not identity forever. The credential must be short-lived, so the interval closes without human action. The scope must be minimal, so the blast radius on compromise is the task, not the account. Access must be re-validated at use, not trusted for the life of the scope. These are not hardening suggestions. They are the conditions under which the mechanism described stops producing the outcome described. Change nothing and the outcome repeats, because the model has not changed.

Untrusted input does not get a trusted execution context. A workflow reachable from outside the trust boundary must not run with access to secrets that live inside it. A trigger from a fork is external input. Handing it the repository’s runner and the repository’s secrets is handing execution to an unauthenticated party. Isolate the context, strip the secrets, or do not run it. If the pipeline cannot enforce that separation, the pipeline is the exposure. Standing grants are standing liabilities under the same rule. Every authorized app and every long-lived token is an authorization that persists without further consent. Inventory them. Expire them. Re-consent on sensitive action. A grant no one reviews is a grant no one controls.

GitHub will not ask whether the credential is in the right hands. That question is not part of the model. It confirms identity once and enforces scope faithfully after that, which means the entire burden of keeping the credential yours falls on you. If a system allows an action within granted scope, that action will occur, and it will be attributed to you, and the logs will agree it was you. A control that is not enforced at the point of use is not a control. Reduce the scope, shorten the life, validate at use, and operate as though the credential is already gone. Everything past that is trusting a system that was never designed to tell you apart from someone holding your token.

Share

Keep Reading

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.