GitHub supply chain attack investigation: a walkthrough
A fictional GitHub supply chain attack investigated end to end: stolen PAT, 38 repos cloned, AWS keys printed by a workflow, branch protection removed.
TL;DR. This walkthrough uses the tool's fictional "Try a sample" incident (organization northwind-labs; people, repositories, tokens and IPs are all invented). A developer's classic PAT is replayed from a VPS in the Netherlands at 01:58 UTC. It clones 38 private repositories in about 18 minutes, pushes a workflow to a new branch that prints the AWS deploy keys double-base64'd, adds a write deploy key and removes branch protection on main. The analyzer returns Compromised with 10 findings. Below is how to read them and in what order to act.
Fictional scenario. Everything in this article comes from
scripts/make-samples.py, which generates the sample served by the tool's "Try a sample" button. IP addresses are from documentation ranges (RFC 5737), and the AWS key pair is AWS's own documentation example. Any resemblance to a real organization is accidental.
The setup
northwind-labs is a small engineering organization: an owner (c-okafor) and three developers (m-hernandez, j-lindqvist, a-nowak) who mostly work from France and Sweden during office hours. The evidence is the three files an investigator would realistically collect, each in its real export format:
| File | Source | Content |
|---|---|---|
northwind-labs-audit-log.json | Organization audit log, UI JSON export | Member changes, workflow runs, secrets, keys, branch protection |
export-northwind-labs-1789360000.json.gz | Git events export | git.clone / git.fetch / git.push with token metadata |
northwind-labs-actions-run-4412.zip | "Download log archive" of one run | Step logs of the suspicious workflow run |
The baseline covers three weeks (2026-08-24 to 2026-09-13). That matters, because the new-country and new-IP rules need history to compare against.
Step 1: the verdict
Drop the three files (or click Try a sample). The banner reads Compromised. Under Why, four findings drove it: actions-secret-exfil (critical), and token-new-country, git-clone-burst and branch-protection-removed (high). The coverage notes are empty. IPs, Git events and token metadata are all present, so none of the rules was blind.
The complete list, as the analyzer reports it:
| Severity | Rule | When (UTC, 2026-09-14 unless noted) | What |
|---|---|---|---|
| critical | actions-secret-exfil | 02:40:08 | Step script pipes AWS keys through base64 twice. Next hop: AWS |
| high | token-new-country | 01:58:12 → 03:06:30 | 43 events from NL for a token only ever seen in FR |
| high | git-clone-burst | 02:14:00 → 02:31:53 | 38 distinct repositories cloned from 203.0.113.66 |
| high | branch-protection-removed | 03:05:44 | main on payments-api |
| medium | actions-encoded-output | 02:40:08 | Long base64 blob printed in the same step |
| medium | deploy-key-added | 02:52:03 | backup-sync key on infra-terraform, write access |
| low | token-new-ip | 01:58:12 → 03:06:30 | Same 43 events, new IP |
| low | workflow-new-branch | 02:39:40 | Workflow CI runs on ci/cache-fix for the first time |
| low | actions-secret-created | 2026-09-08 | a-nowak created the AWS secrets, legitimate |
| low | webhook-created | 2026-09-03 | a-nowak created a CI webhook, legitimate |
The last two are the honest noise of any real organization: routine admin work that the rules surface because it could matter. They share no actor, token or IP with the rest, so you can set them aside.
Step 2: which credential?
Open token-new-country. Its entities are the actor m-hernandez, one hashed_token and the IP 203.0.113.66. Pivot on the token (Entities → Tokens → Show events). It is a Personal access token (classic) with scopes repo, workflow, read:org. For three weeks it was used from 198.51.100.23 (FR) with the user agent git/2.39.5 (Apple Git-154), during the day. From 01:58 UTC on 2026-09-14 it shows up from 203.0.113.66 (NL) with git/2.43.0, and later with curl/8.5.0.
Same person, same token, different machine, different country, middle of the night: the token was replayed. The workflow scope explains how the attacker could push a workflow file. In the scenario, the source is an infostealer on the developer's laptop. The GitHub logs cannot show that part, which is why the leaked token playbook insists on checking the endpoint.
Step 3: what did it take?
git-clone-burst counts 38 distinct repositories. In the Git events, the clones run from 02:14:00 to 02:31:53, one roughly every 29 seconds. That regular rate is scripted. m-hernandez normally touches four repositories (payments-api, billing-worker, ledger, web-app). The burst includes infra-terraform, secrets-rotation, kyc-service and terraform-modules, repositories they never pushed to.
Conclusion for the report: the source code and full history of 38 repositories are in the attacker's hands. The remediation item "Treat cloned code as leaked" follows from it: scan those histories for secrets and rotate what you find. The repository exfiltration article covers that scoping.
Step 4: what did it change?
Read the timeline with Findings only:
- 02:39:21
git.pushtopayments-apifrom the VPS. - 02:39:40
workflows.created_workflow_run: workflowCI, eventpush, branchci/cache-fix. This is the first run on that branch, soworkflow-new-branchfires. - 02:40:08 In run log archive
4412, step Restore build cache runsecho "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" | base64 -w0 | base64 -w0and prints a long blob. The mask did not apply because the value was encoded. Decoded twice, it is the key pair (here, AWS's documentation example).actions-secret-exfilfires as critical with the AWS next hop, andactions-encoded-outputfires on the blob. - 02:52:03
public_key.createoninfra-terraform: titlebackup-sync,read_only: false, user agentcurl/8.5.0. A write deploy key is persistence that survives the PAT's revocation. - 03:05:44
protected_branch.destroyonpayments-api/main. The rule required two approving reviews. - 03:06:30
git.pushtopayments-api, one minute after protection came off. - 07:48:10
protected_branch.createbyc-okaforfrom the office: the owner noticed and restored the rule.
Step 6 is the one that should worry you most. Something was pushed to main without review. The audit log does not contain the diff, so the commit must be reviewed in the repository (see branch protection tampering).
Step 5: remediation, in the tool's order
The Remediation tab orders the checklist by urgency:
- Preserve the evidence: re-export the audit log and Git events and keep the run log archive, before anyone deletes run 4412's logs.
- Rotate every Actions secret in reach: all secrets readable by workflows in
payments-api, not only the two AWS ones. - Rotate cloud credentials and investigate the cloud: deactivate the AWS key and search CloudTrail from 02:40 UTC. See the cloud pivot and AWS Forensics.
- Review workflow changes and runs: diff
.github/workflowsonci/cache-fix, delete the branch, and require CODEOWNERS review for workflow files. - Revoke the token and reset the account, after re-imaging the laptop.
- Treat cloned code as leaked: scan the 38 repositories' history.
- Restore branch protection and review what landed: the 03:06 push.
- Remove persistence: delete the
backup-syncdeploy key and look for anything else created from203.0.113.66.
What this sample does not show
Real incidents are messier. There is no owner takeover here, no self-hosted runner and no audit-stream removal. The attacker did not spread activity over days to stay under the thresholds either. If a rule did not fire on your data, check the coverage notes and what the audit log does not record before you conclude.