Attack paths
Every GitHub attack has two halves: how the attacker gets in, and what they do once they're there. This page lists both, with the setting or practice that stops each step, and ties them back to the IRL incidents where they were used.
The source control part (the first three objectives and most entry vectors) follows François Proulx's attack tree for Boost Security, built on the SLSA source integrity threats. The CI/CD, exposure, persistence and GHES paths are drawn from the incidents.
How they get in
Every technique further down starts from one of these.
Insider
Member with write access
Someone who can already push to the repository. Their reach is exactly what branch protection and rulesets allow; without them, write access means pushing straight to the default branch.
Defense
- Put the strongest branch protection or ruleset you can on every branch that ships (see Submit malicious code below).
- Protect release tags with a ruleset, since pushing a tag only needs write access by default.
Repository admin or org owner
Admins can bypass or switch off every control that protects the repository, then turn it back on afterwards. Most of the techniques below get easier or become trivial from here.
Defense
- Keep the number of org owners and repo admins as small as you can.
- Manage org and repo settings as code (for example with the GitHub Terraform provider) or through an internal GitHub App, so changes go through review instead of a person's admin rights.
- Stream the audit log somewhere admins can't edit, and alert on protection rules being changed or deleted.
External, via a stolen identity
Leaked token
A personal access token, npm token, or workflow token found in a dotfile, a CI manifest, a public artifact, a log, or a machine infected by a stealer. A classic PAT with the repo scope is the most valuable find because it works everywhere the owner has access.
Defense
- Move to fine-grained PATs with the fewest permissions and a short expiry, and require org owner approval for them.
- Restrict or block classic PATs at the org and enterprise level.
- Turn on secret scanning with push protection so tokens are caught before they land in a repo.
- Set persist-credentials: false on checkout, and never upload the workspace as an artifact.
Phished or hijacked account
Password reuse, phishing, a malicious OAuth consent screen, device-code phishing, or a stolen browser session. A session cookie skips 2FA entirely, and an OAuth grant keeps working after the password is changed.
Defense
- Require 2FA, and push admins and maintainers toward security keys or passkeys. GitHub can't enforce which factor people pick.
- Put the org behind SAML or OIDC SSO, or use Enterprise Managed Users so the IdP owns the account.
- Turn on OAuth app access restrictions so a user can't grant an app access to org data on their own.
Compromised GitHub App or OAuth integration
A vendor's integration gets breached, or its private key leaks, and every org that installed it is exposed at whatever permission level it was granted.
Defense
- Install as few apps as possible, and only from vendors you trust with the permissions they ask for.
- Review installed apps and OAuth grants regularly and remove the ones nobody uses.
- Scope installations to specific repositories rather than all repositories.
Stolen SSH or deploy key
A user's SSH key or a deploy key with write access, taken from a laptop or a server. Git over SSH doesn't prompt for 2FA.
Defense
- Keep keys in an SSH agent with a passphrase, or in hardware or a secure enclave (Secretive on macOS, smart cards, FIDO keys).
- Require signed commits, so a stolen transport key alone isn't enough to get code merged.
- Use an SSH certificate authority at the org or enterprise level so keys expire.
External, via the developer endpoint
Compromised developer tool or extension
A backdoored IDE extension, CLI or other local tool runs on a developer's machine and takes whatever that machine can reach: tokens, SSH keys, browser sessions and cloned code. Extensions update themselves, so one bad release reaches every install within minutes.
Defense
- Allow-list IDE extensions centrally and block the rest.
- Hold new extension and tool versions for a few days before they install (a cooldown).
- Keep long-lived tokens off laptops; prefer short-lived, fine-grained ones.
- Put the organization behind an IP allow list so tokens taken off the machine fail elsewhere.
- Work on sensitive repositories in Codespaces or dev containers instead of on the laptop.
External, via the pipeline
Untrusted input reaches a privileged workflow
A fork pull request, branch name, issue title or comment is run or interpolated by a workflow that has secrets or a write token. pull_request_target, workflow_run and issue_comment triggers are the usual suspects, along with non-ephemeral self-hosted runners that public PRs can reach.
Defense
- Don't check out or build PR code in pull_request_target or workflow_run jobs.
- Pass untrusted values through env variables instead of ${{ }} expressions in run steps.
- Set the default GITHUB_TOKEN to read-only and grant write per job.
- Keep self-hosted runners away from public repos, and make them ephemeral.
- Lint workflows with zizmor or a similar tool in CI.
Compromised Action or package
A third-party Action or npm/PyPI package you depend on is backdoored upstream. It runs with whatever your workflow or developer machine has in scope.
Defense
- Pin third-party Actions to a full commit SHA, not a tag.
- Review new dependencies in PRs and hold new package versions for a few days before they're allowed in (a cooldown).
- Disable install scripts where you can (npm ignore-scripts).
Prompt injection into an AI agent
An agent holding a GitHub token reads attacker-written text (an issue, a PR description, a file) and follows it, using the token for things its user never asked for.
Defense
- Give agents a token scoped to the single repository they're working on.
- Don't let an agent read public input and write to private repos in the same session.
- Require a human to approve anything an agent pushes or posts.
External, via the platform
Vulnerability in GitHub Enterprise Server
A bug in a self-hosted GHES instance: an authentication bypass, or a way to turn a low-privilege role or plain push access into root on the appliance.
Defense
- Patch GHES quickly; several of these were critical and exploitable before login.
- Keep the Management Console and admin SSH off the general network.
Submit malicious code
Get attacker-controlled code onto a branch that gets built and shipped.
Push directly, without review
Attack
There's no branch protection on the target branch, or it doesn't require a review.
Defense
Require a pull request with approvals, status checks and signed commits, and don't let admins bypass it. Leave force pushes and deletions off.
Enabled by:T2Contents: WriteApprove your own PR from a second account
Attack
The attacker talks an owner into granting write access to one or more extra accounts, then opens a PR from one and approves it from another before anyone notices.
Defense
Tie every membership to one person through SSO or Enterprise Managed Users.
Have a workflow approve the PR
Attack
Orgs created before 2022 let GITHUB_TOKEN approve pull requests by default. The attacker adds a workflow to their PR that approves it as soon as it opens.
Defense
Turn off "Allow GitHub Actions to create and approve pull requests" in the org Actions settings.
Push more commits after approval
Attack
The attacker gets a clean change approved, then pushes the bad code to the same PR and keeps the old approval.
Defense
Dismiss stale pull request approvals when new commits are pushed.
Approve someone else's PR, then push to it
Attack
The attacker approves a colleague's good PR, pushes their own bad commit on top, and the colleague's approval or their own covers it.
Defense
Require approval of the most recent reviewable push.
Hide it in a change nobody can review
No setting fully fixes thisAttack
The bad code is buried in a PR touching thousands of lines, or arrives as a new dependency the attacker publishes and controls.
Defense
Keep PRs small, require CODEOWNERS review on lockfiles and manifests, and check every new dependency before it's added.
Use a trusted colleague's account
No setting fully fixes thisAttack
Same as a sock puppet, but with a real senior engineer's account. Reviewers question their changes less.
Defense
The entry-vector controls above (SSO, strong 2FA, short-lived tokens). There's no single fix.
Trick or outlast the reviewer
No setting fully fixes thisAttack
The change uses a bug class the reviewer doesn't know, is written to be confusing, or goes to the reviewer known to approve quickly.
Defense
Require two reviewers on sensitive paths and run SAST, knowing a careful attacker will test against it first. Audit who approves unusually fast.
Enabled by:T2Pull requests: WriteBypass or switch off the rules
Attack
An admin merges past branch protection, or turns it off for a few minutes and back on.
Defense
Fewer admins, settings as code, and audit log alerts on protection changes.
Delete code or cover tracks
Remove the malicious commit, the branch it came from, or the workflow runs that show what happened.
Force push or delete a protected branch
Attack
With a weak rule, or as an admin who loosens it for a moment, the attacker rewrites history on the branch.
Defense
Never enable Allow force pushes or Allow deletions on protected branches. Both are off by default.
Delete branches and workflow runs
Attack
After using a stolen token, the attacker deletes the branches and workflow runs they created so the logs are gone.
Defense
Stream the audit log out of GitHub. Workflow runs can be deleted from the repo, but the audit log entries for them stay put.
Point a release tag at a bad commit
Ship code that never went through review by moving the tag a build or a downstream user trusts.
Push a release tag to an unreviewed commit
Attack
The release pipeline builds on tags like v1.2.3. Pushing a tag only needs write access, so the attacker tags a commit on an unprotected branch, or an old commit with a known bug.
Defense
Add a tag ruleset that restricts who can create, update or delete release tags. The older tag protection rules have been folded into rulesets.
Enabled by:T2Contents: Write
Steal CI secrets and tokens
Turn one foothold into credentials for package registries, cloud accounts and other repos.
Add a workflow that dumps secrets
Attack
With a token that has the workflow scope, the attacker commits a workflow that sends ${{ toJSON(secrets) }} or named secrets to their own server.
Defense
Protect .github/workflows with CODEOWNERS and a ruleset, keep workflow scope out of PATs, and move secrets into environments with required reviewers.
Enabled by:T1Workflows: WriteRun PR code in a privileged workflow
Attack
A pull_request_target or workflow_run job checks out the attacker's PR, or interpolates its title into a shell step, and the attacker's code runs with secrets in scope.
Defense
Treat anything from a PR as untrusted, pass it through env variables, and keep secrets out of those jobs.
Read secrets from the runner
Attack
A backdoored Action or a process on a reused runner reads secrets out of runner memory or from .git/config, and prints them to logs or uploads them as artifacts.
Defense
Pin Actions by SHA, use ephemeral runners, set persist-credentials: false, and don't upload the checkout directory as an artifact.
Enabled by:T2Contents: WriteSteal a session and read secrets from a CI vendor
Attack
Malware on an engineer's laptop takes a 2FA-backed session cookie and uses it to pull customer secrets out of a CI service.
Defense
Use OIDC federation instead of long-lived cloud keys stored in CI, and rotate secrets on a schedule.
Seen in:CircleCI session-cookie theft
Poison the build or published package
Change what gets released without touching the reviewed source.
Poison a shared Actions cache
Attack
A PR workflow writes a cache entry that the release workflow later restores, so attacker-controlled files end up in the release build.
Defense
Don't share caches between untrusted PR jobs and release jobs, and don't restore caches in release workflows at all.
Trigger the publish workflow from a tampered branch
Attack
With a write token, the attacker creates a branch with a modified CI script and starts the publish workflow against it through workflow_dispatch.
Defense
Lock publish workflows to protected branches or tags with deployment environments, and publish with OIDC trusted publishing instead of a stored registry token.
Seen in:Nx "s1ngularity"Backdoor a persistent self-hosted runner
Attack
Code from a fork PR runs on a self-hosted runner that isn't wiped afterwards, and stays there for the next release job.
Defense
Use ephemeral self-hosted runners, and don't attach them to public repositories.
Publish a malicious version with a stolen token
Attack
A stolen npm or PyPI token is used to publish a new version with an install script that steals the next victim's tokens.
Defense
Publish only from CI with trusted publishing, behind an environment with required reviewers, and turn on 2FA for the registry account. Provenance shows which commit was built, not that anyone approved it, so the branch and tags the workflow builds from need protecting too.
Enabled by:T1Packages: WriteFixed by:Adopt artifact attestations for build provenance on published binari…Cloud credentials migrated from static keys to OIDC federation, scop…Publish packages with trusted publishing (OIDC) instead of long-live…Put production secrets in environments with required reviewersWrite and rehearse a credential-rotation runbook before you need itSeen in:Toptal repo-visibility flip and wiperShai-Hulud self-propagating worm (1.0 & 2.0)TeamPCP tag force-overwrite campaignMegalodon: mass backdooring of CI workflowsChainDrop: a hijacked maintainer account publishes through trusted publishingGlassWorm: editor extensions that steal GitHub tokens and force-push
Read or expose private code
Copy private repositories out, or make them public.
Clone private repos with a stolen token
Attack
A leaked PAT or OAuth token with repo read is enough to clone everything its owner can see.
Defense
Short-lived, fine-grained tokens with an IP allow list, and alerting on unusual clone volume.
Enabled by:T3Contents: ReadFlip private repos to public
Attack
An owner or admin token is used to change visibility on many repos at once.
Defense
Restrict repository visibility changes to org owners, keep owner accounts few, and alert on visibility changes in the audit log.
Get an agent to copy private code into public
Attack
A public issue tells an AI agent to read a private repo and post what it finds somewhere public.
Defense
Scope agent tokens to one repository and require approval before an agent writes anywhere.
Keep access
Make sure revoking the first stolen credential doesn't end it.
Add a guest collaborator or new app
Attack
The attacker adds an outside collaborator, a deploy key, or an OAuth grant that survives a password reset.
Defense
Only let org owners invite outside collaborators, and alert on new collaborators, deploy keys and app installs.
Register the attacker's machine as a runner
Attack
The infected host registers itself as a self-hosted runner and plants a workflow that runs commands on it when triggered.
Defense
Limit who can register runners, use runner groups, and review the runner list regularly.
Leave a dormant workflow behind
Attack
The attacker replaces a workflow with one that only runs on workflow_dispatch. Nothing happens until they trigger it through the API, long after the push that planted it has scrolled out of view.
Defense
Require code owner review for .github/workflows, and alert on new or changed workflows and on workflow_dispatch runs nobody expected.
Enabled by:T1Workflows: Write
Take over the GHES appliance
Go beyond GitHub's permission model to the server it runs on.
Forge a login
Attack
A SAML signature bug lets an unauthenticated attacker sign in as any user, including a site admin.
Defense
Patch GHES, and prefer encrypted assertions where the IdP supports them.
Seen in:CVE-2024-4985: SAML auth bypassEscalate from a GHES role to root
Attack
A Management Console editor, an enterprise admin editing pre-receive hooks, or even a user with push access abuses a bug to run commands as root.
Defense
Patch GHES, and restrict Management Console and pre-receive hook access to as few people as possible.
Escalations
How one permission becomes more. Each step starts from a permission (or from code already running somewhere it should not) and ends with access the attacker did not have before. These are the steps to break first: remove the starting permission, or add the control that stops the step.
Change a workflow, get its secrets
Commit a workflow, or change an existing one, that runs on push and sends the repository's secrets out. A workflow can also ask for any token permission, including id-token: write.
Gains Every secret the repository's workflows can read, and cloud access through OIDC.
Start a privileged publish job by hand
Run the publish workflow with workflow_dispatch against a branch whose build script was changed.
Gains Whatever the publish job holds: package registry tokens and the right to publish.
Flip private repositories to public
Change repository visibility, one repository at a time or in bulk with an owner token.
Gains Private source code exposed to everyone.
Register a self-hosted runner
Create a runner registration token and register a machine the attacker controls.
Gains Every job routed to that runner, with the secrets those jobs receive, and a foothold that outlives the stolen token.
Add a collaborator or guest that outlives the attack
Add an outside collaborator, a guest, a deploy key or an App grant under the attacker's control.
Gains Access that survives password resets and token revocation.
Add yourself to a team with repository access
From: The Team maintainer role on a team with repository access
A team maintainer adds an account they control to their team.
Gains Every repository role the team holds, often Write or Admin on production repositories.
Harvest leaked secrets from scanning alerts
List the secret scanning alerts. Each one includes the leaked value, and many are still valid.
Gains Live credentials found in code and history: cloud keys, tokens and passwords, often for systems outside GitHub.
Join any organization as an enterprise owner
From: The Enterprise owner role
An enterprise owner can join any organization owned by the enterprise as a member or as an organization owner.
Gains Organization owner in every organization of the enterprise, even ones they were never added to.
Claim the old name of a renamed Action
From: An Action or reusable workflow whose owner renamed or deleted their account or repository
Register the freed owner or repository name and publish code at the same path. Workflows that still use the old owner/repo@ref fetch the new owner's code.
Gains Code running in every workflow that still uses the old path, with its secrets and GITHUB_TOKEN.
Take the workflow token from a runner
From: Code already running in a workflow job: a compromised Action, dependency or pull request
Read GITHUB_TOKEN from runner memory or from .git/config, where actions/checkout leaves it unless told not to.
Gains The job's token, often with write access to the repository.
Poison a cache the release build restores
From: A pull request from a fork whose workflow can write to the shared Actions cache
Write a cache entry under a key the release workflow later restores.
Gains Attacker files inside the release build, and with them the release job's credentials.
Persist on a non-ephemeral self-hosted runner
From: A pull request from a fork that runs on a self-hosted runner that is not wiped between jobs
Leave a process or file behind that the next job, often a release, picks up.
Gains The next jobs on that runner, with their secrets and tokens.
Incidents by path
Each incident, newest first, with the way in and what the attacker went after.