The worm leaves marks in three places: the tarball, the machine and GitHub.
In a package tarball
- A patch-version bump of a real package whose
package.jsongained an install script. - Wave 1: a minified
bundle.jsof about 3.6 MB, run bypostinstall. - Wave 2:
setup_bun.jsand an obfuscatedbun_environment.jsof about 10 MB, run bypreinstall. vendor
On a machine that installed one
- A TruffleHog run you didn't start, or a Bun runtime you didn't install.
- Outbound requests to
webhook.site(wave 1). - The machine registered as a GitHub self-hosted runner (wave 2).
In GitHub
- A new public repository named
Shai-Hulud(wave 1), or with a random name and the description “Sha1-Hulud: The Second Coming.” (wave 2). - Former private repositories, now public with a
-migrationsuffix (wave 1). - A
shai-huludbranch, ashai-hulud-workflow.yml, adiscussion.yamlor a runner namedSHA1HULUD. - Versions of your own packages that you did not publish.
One stolen token, and a loop that makes more.
The worm used no vulnerability. Every step is a feature working as designed: npm runs a package's install scripts, a token publishes whatever its owner can publish, and GitHub hosts whatever a token asks it to.
What made it a worm is the last step. It looks for an npm token among the stolen secrets and uses it to infect that victim's packages, so every new install is a chance to start the loop again. The fine print in each box says which wave did what.
- Lifecycle scriptA command a package asks npm to run at install time, such as preinstall or postinstall. It runs whether or not your code ever imports the package.
- npm tokenA credential that lets a tool publish to npm as you. Before December 2025, a classic token could publish every package its owner maintained.
- Self-hosted runnerA machine registered with GitHub to run a repository's Actions workflows. Whoever can trigger a workflow can run commands on it.
Wave 1 · 14 Sep 2025 · Wave 2 · 24 Nov 2025
It starts with one stolen npm token.
Before December 2025 a single npm token could publish every package its owner maintained, often without a second factor. Whoever holds one can ship a new version of any of them.
How the attacker got wave 1's first token is not publicly established; Unit 42 cites phishing that spoofed npm's login page. Wave 2's first tokens came out of CI: a
pull_request_targetworkflow that ran an attacker's pull request leaked PostHog's secrets, and Datadog traces the start to a malicious commit inasyncapi/cli's CI.12- credential
- npm publish token
- can publish
- every package its owner can
- wave 1 seed
- not established
- wave 2 seed
- CI secrets, pull_request_target
It ships a patch release of a real package.
The attacker downloads the package's real tarball, adds the payload and an install script entry to
package.json, bumps the patch number and publishes it under the maintainer's name.Anyone whose dependency is declared with a
^range picks up the new patch automatically the next time a dependency is resolved fresh.19- base
- the real tarball
- adds
- payload + install script
- version
- patch bump
- reaches
- ^ ranges, on a fresh resolve
npm installruns it. Nothing has to import it.npm runs a package's lifecycle scripts during install, on a developer laptop or a CI runner alike, with every credential that environment holds. Wave 1 used
postinstallto start a minifiedbundle.jsof about 3.6 MB, and skipped Windows.13Wave 2 moved to
preinstall, which runs before the install finishes and even if it later fails. Itssetup_bun.jsinstalls the Bun runtime and runs an obfuscatedbun_environment.jsof about 10 MB there, outside Node-focused monitoring.19- wave 1
- postinstall → bundle.js
- wave 2
- preinstall → Bun → payload
- needs import
- no
- runs in
- laptops and CI
It collects every secret it can find.
It reads environment variables and local credential files such as
.npmrcand the gcloud config, queries cloud instance-metadata services and cloud secret managers, and runs TruffleHog, a legitimate open-source secret scanner, over the disk. ReversingLabs notes TruffleHog detects more than 800 types of secret.16Developer and CI machines tend to hold npm, GitHub and cloud credentials together. One install could hand over all three.
- env
- environment variables
- files
- .npmrc, gcloud config
- cloud
- metadata, secret managers
- scanner
- TruffleHog, 800+ types
It publishes your secrets with your own GitHub token.
Using the victim's GitHub token, it creates a public repository and commits the stolen data as encoded JSON. In wave 1 the repository was named
Shai-Hulud; it also sent data to awebhook.siteURL and republished private repositories as public ones with a-migrationsuffix.2In wave 2 the name was a random 18 characters and the description read “Sha1-Hulud: The Second Coming.” Datadog counted more than 14,000 such repositories; Wiz and Unit 42, more than 25,000.18
- channel
- public GitHub repo
- made with
- the victim's GitHub token
- wave 1
- Shai-Hulud, webhook.site
- wave 2
- random name, self-described
It leaves a backdoor in GitHub.
Wave 1 pushed a
shai-hulud-workflow.ymlGitHub Actions workflow that dumps a repository's secrets.13Wave 2 registered the infected machine as a self-hosted runner named
SHA1HULUD, and added adiscussion.yamlworkflow with a command injection: anyone who posts a GitHub Discussion can run commands on that machine. Rotating the stolen secrets does not remove it.18- wave 1
- shai-hulud-workflow.yml
- wave 2 runner
- SHA1HULUD
- wave 2 trigger
- any Discussion post
- survives
- token rotation
Your npm token publishes the next infected versions.
If an npm token is among the secrets, the worm checks that it works, lists the packages it can publish and repeats step 2 on each: up to 20 per maintainer in wave 1, up to 100 in wave 2.13
This is what made Shai-Hulud a worm rather than one bad release. One infected install on a maintainer's laptop turns the packages they maintain into new ways in.
- checks
- the stolen token works
- lists
- packages it can publish
- wave 1 fan-out
- up to 20
- wave 2 fan-out
- up to 100
Every new install can start it again.
Anyone who installs a newly infected package re-enters at step 3. Wave 2 also jumped between victims: with no GitHub token of its own, it searched GitHub for other victims' exfiltration repositories and used the tokens it found there, so one victim's secrets turned up in repositories owned by unrelated people.19
And wave 2 punished failure. If it could neither steal nor spread, it tried to delete or overwrite the user's home directory.20
- loop
- new installers re-enter at step 3
- wave 2
- reuses other victims' tokens
- fallback
- tries to wipe home directory
The numbered markers on the diagram match the steps.
By combining self-replication with the capability to steal multiple types of secrets (and not just npm tokens), this worm could have enabled an endless stream of attacks.
Two waves, ten weeks apart, and a registry that changed in between.
- 500+
packages in wave 1, per GitHub and CISA.
- 796
packages in wave 2, per Datadog, deduplicated across seven vendors.
- 100
packages at most republished per stolen token in wave 2, up from 20 in wave 1.
- 2h
the life of an npm login session since 9 Dec 2025.
The first afternoon
Socket's hour-by-hour count for 14 September: six packages at 17:58 UTC, a small burst at 18:35, more than 25 between 20:29 and 20:45, about 17 more between 21:01 and 21:03. @ctrl/tinycolor, with about 2 million weekly downloads, followed on the evening of the 15th.14
The second wave ran earlier, hid better and punished failure.
| Aspect | Wave 1 · Sep 2025 | Wave 2 · Nov 2025 |
|---|---|---|
| First token | Not established (Unit 42: phishing) | CI secrets, via pull_request_target |
| Install hook | postinstall | preinstall (runs even if the install fails) |
| Runtime | Node, ~3.6 MB bundle.js | Installs Bun, ~10 MB bun_environment.js |
| Exfiltration | Repo named Shai-Hulud, plus webhook.site | Random-named repos; other victims' tokens |
| Backdoor | shai-hulud-workflow.yml | Self-hosted runner SHA1HULUD, Discussion trigger |
| Fan-out per token | Up to 20 packages | Up to 100 packages |
| Destructive | None reported | Tries to wipe the home directory |
Wave 2 started in CI. On 18 November an attacker opened a pull request against PostHog that edited a workflow script. The workflow ran on pull_request_target, which runs with the base repository's secrets, and on 23 November between 15:43 and 15:45 UTC the modified run sent out all of PostHog's GitHub secrets. The first infected posthog-node versions were published about twelve hours later; PostHog found and removed its packages by 09:30 UTC.12
Aikido detected the wave at 03:16:26 UTC on 24 November, starting with go-template and 36 AsyncAPI packages; Datadog puts the last affected publish around 18:00 that day. Aikido reads the timing as aimed at the window before npm's classic-token deadline. vendor interpretation17
Wiz found @postman/tunnel-agent in about 27% of the cloud and code environments it scanned, and new exfiltration repositories appearing at about 1,000 every 30 minutes.18
Every count is a snapshot. Quote it with its source.
There is no single true number for either wave. Each vendor counted at a different moment, by a different method: Aikido's 492 packages is a first-day count, and Datadog's 796 deduplicates the lists of seven vendors. For repositories, Aikido counts those with exposed secrets, the others count malicious repositories. Read the spread below as the worm growing while people counted.19
npm's answer was to make a stolen token worth less.
- Classic tokensUnlimited life → all revoked 9 Dec 2025
- New write tokens30-day default → 7 days, 90-day maximum
- npm login2-hour session, 2FA enforced on publish
- Trusted publishingGenerally available since 31 Jul 2025
GitHub removed more than 500 packages after wave 1, blocked uploads that matched the worm's indicators and published a plan on 22 September: 2FA for local publishing, short-lived granular tokens, an end to classic tokens and TOTP in favour of FIDO, and more trusted-publishing providers.1
It shipped in stages. On 29 September GitHub announced that from mid-October new write tokens would default to 7 days with a 90-day maximum, and new TOTP setups would stop; on 5 November classic tokens could no longer be created and write tokens enforced 2FA by default; on 9 December every classic token was revoked. The plan's “7-day lifetime” became a 7-day default.6
Trusted publishing replaces a stored CI token with a short-lived identity token that GitHub's announcement said “cannot be exfiltrated or reused”. The TanStack compromise of May 2026 later read one out of a runner's memory (see related entries). By July 2026 GitHub listed further layers: npm v12 turns install scripts off by default, staged publishing adds an approval, and Dependabot waits three days before proposing a new release.9
Long-lived tokens are a primary vector for supply chain attacks
Take away the token, or take away the install script.
What would have broken the loop, mapped to the steps above. npm has since made several of these the default.
| Control | Effect here | Why |
|---|---|---|
| Trusted publishing, no stored npm token | Breaks step 7 | The worm spreads with an npm token it finds on the machine. A maintainer who publishes only from CI through OIDC has none to find. inference from the chain A short-lived token can still be stolen from inside the job that holds it, as TanStack found in 2026. |
| Install scripts off, or allow-listed | Breaks step 3 | Both waves ran from a lifecycle script. ignore-scripts, or an allow-list of packages permitted to run scripts, removes that path; npm v12 now blocks dependency lifecycle scripts unless allowed.11 |
| No pull_request_target that runs PR code with secrets | Breaks step 1, wave 2 | PostHog's secrets left through a workflow that ran an attacker's pull request with the base repository's privileges. |
| Rotate everything, from a clean machine | Contains it | Rotation doesn't undo the theft, but it ends reuse. Wave 2 reused leaked tokens across victims for days, and its runner backdoor survives rotation: remove it too. |
| Short-lived, 2FA-gated write tokens | Shrinks step 7 | npm's defaults since late 2025: 7-day write tokens, 2-hour login sessions, 2FA on publish. A stolen token works for less time. |
| Minimum release age | Protects step 2 | Most infections came within hours of a publish, so a cooldown before adopting new versions would have skipped them. inference from the timelines pnpm's minimumReleaseAge defaults to one day since v11; npm has min-release-age. Check your package manager enforces it.10 |
| Committed lockfile, frozen installs | Protects step 2 | A bumped patch reaches a ^ range only when dependencies are resolved fresh. The lockfile is also where you look first. |
| Watch repository creation and runner registration | Finds steps 5, 6 | Public GitHub repositories and workflows were both the drop site and the backdoor. A new public repository or an unknown self-hosted runner is the tell. |
| Phishing-resistant MFA | Hardening | CISA's recommendation after wave 1, alongside removing unneeded GitHub apps, auditing webhooks, branch protection and secret-scanning alerts. |
Sources
Primary sources are the registry operator, advisory databases, government, the registry itself and an affected maintainer. Secondary sources are the discoverers' and researchers' own analyses.
Primary · registry operator, advisories and government
- 1GitHubOur plan for a more secure npm supply chain
Dating to 14 September, 500+ packages removed, the IOC upload block, the hardening plan.
- 2CISAWidespread supply chain compromise impacting npm ecosystem
Over 500 packages, the Shai-Hulud repository, the 16 September cutoff, recommendations.
- 3GitHub Advisory DBGHSA-qjqf-7j6f-82c4 · GHSA-2f8v-v92m-4q75 · GHSA-5jj9-3vg7-7785 · GHSA-wrhq-7m36-c43f
Per-package malware advisories: versions and publish times.
- 4npm registryregistry.npmjs.org/rxnt-authentication and the other example packages
Exact publish times.
- 5GitHub changelogStrengthening npm security
7-day default and 90-day maximum for write tokens; no new TOTP setups.
- 6GitHub changelogClassic token creation disabled and granular token changes
No new classic tokens; 2FA by default on write tokens.
- 7GitHub changelognpm classic tokens revoked, session-based auth
Every classic token revoked; 2-hour login sessions.
- 8GitHub changelognpm trusted publishing with OIDC is generally available
Trusted publishing; “cannot be exfiltrated or reused”.
- 9GitHubDisrupting supply chain attacks on npm and GitHub Actions
Later layers: npm v12 install scripts off, staged publishing, Dependabot cooldown. It does not name Shai-Hulud.
- 10pnpmDependency resolution settings
minimumReleaseAge, one day by default since v11.
- 11npmnpm config
ignore-scripts, min-release-age.
Primary · affected maintainer
- 12PostHogShai-Hulud attack post-mortem
The pull_request_target root cause, the 18–24 November timeline, eight packages, removal in about 5.5 hours.
Secondary · discoverers and researchers
- 13StepSecurity@ctrl/tinycolor and 40+ npm packages compromised
Wave 1 mechanism: bundle.js, postinstall, TruffleHog, the workflow, 20-package fan-out.
- 14SocketOngoing supply chain attack targets CrowdStrike npm packages
14 September bursts, 526 packages.
- 15WizShai-Hulud npm supply chain attack
Wave 1 early counts; dates the attack to 15 September.
- 16ReversingLabsShai-Hulud worm
Patient zero, the Dune name, -migration repositories, TruffleHog's 800+ secret types.
- 17AikidoShai-Hulud strikes again
First wave-2 detection, 492 packages, 26.3k repositories, the deadline reading.
- 18WizShai-Hulud 2.0
Wave 2 timeline, ~800 packages, 25,000+ repositories, secret counts, the runner, cross-victim reuse.
- 19Datadog Security LabsShai-Hulud 2.0 npm worm
Wave 2 stages, 796 packages, 100-package fan-out, the destructive fallback, the deduplicated package list.
- 20Palo Alto Networks Unit 42npm supply chain attack
Both waves, ~350 users, the phishing claim, the destructive fallback, guidance.
Written from sources checked on 2026-10-01. Counts differ between sources because they are snapshots taken at different times by different methods; each is attributed. How wave 1's first token was stolen, and why wave 2 was timed as it was, are vendor claims and described only as such. Pull quotes are verbatim from GitHub.