- 1Execute at installpreinstall / postinstall hook → node setup.mjs → Bun payload
- 2Harvest credentialsprocess.env · ~/.npmrc · TruffleHog sweep · 169.254.169.254 · Actions secrets
- 3Exfiltratenew public repo in the victim's own GitHub account
- 4Find targetsGET /-/whoami → GET /-/v1/search?text=maintainer:<user>
- 5Republish every packagetarball + payload + hook, x.y.z → x.y.z+1, PUT /<name> (up to 100 per token)
Shai-Hulud (npm worm)
Shai-Hulud is a self-propagating npm worm that steals maintainer credentials at install time and republishes itself into the victims' other packages.
Also known as Shai-Hulud npm worm, Shai Hulud, Sha1-Hulud, Shai-Hulud 2.0, Sha1-Hulud: The Second Coming, Mini Shai-Hulud
Shai-Hulud is a self-propagating supply chain worm that has hit the npm registry in waves since September 2025. A poisoned package's install script steals npm, GitHub, and cloud credentials, then uses the victim's own publish rights to inject the worm into every package they maintain. Later waves are called Sha1-Hulud and Mini Shai-Hulud.
The worm needs no vulnerability. It needs two things npm provided by default for a decade: an install hook that runs arbitrary code on every machine that installs a package, and a publish credential sitting on the machines where packages get installed. Every infected maintainer becomes a new seed. We think the decisive fix is not another scanner or another token format. It is removing the step where a publish goes live with no human in the loop. npm 12 closed the install-hook half of the problem in July 2026. The publish half is still opt-in.
What is the Shai-Hulud npm worm?
#The Shai-Hulud npm worm is malware that spreads from package to package by republishing itself with stolen maintainer credentials. The name comes from the sandworms in Dune. The first version created a public GitHub repository named Shai-Hulud in each victim's account and wrote a workflow file called shai-hulud-workflow.yml.
The first malicious publish we observed was rxnt-authentication@0.0.3 at 17:58 UTC on September 14, 2025. The compromise that drew attention came a day later: @ctrl/tinycolor, then downloaded more than two million times a week, received two malicious versions. By September 16 the worm had reached the crowdstrike-publisher account. Our indicators list for that first wave has 526 package versions. CISA's September 23, 2025 alert put it at "over 500 packages."
GitHub's own description is precise: "a self-replicating worm that infiltrated the npm ecosystem via compromised maintainer accounts." Shai-Hulud is a supply chain attack, but the property that makes it different from the thousands of malicious packages published every month is the loop. A typosquat waits for victims. A worm recruits publishers.
How npm publishing and install scripts make a worm possible
#Two npm design decisions combine into the worm. Each was reasonable on its own.
Publishing is authorized by a bearer token. npm publish sends a single PUT to https://registry.npmjs.org/<name> containing the new manifest and the tarball as a base64 attachment. The registry checks that the token belongs to a maintainer of that package. It does not check where the tarball came from, whether it matches any source repository, or whether a human was present. Long-lived automation tokens existed precisely so CI could publish unattended.
Installing runs code. When npm installs a dependency, it executes that dependency's preinstall, install, and postinstall scripts with the installing user's privileges and full environment. The feature exists for native addons that must compile against the local Node.js headers, which is why npm also runs an implicit node-gyp rebuild for any package that ships a binding.gyp. Until npm 12, all of it ran by default for every package in the tree, including transitive ones nobody chose. Our postinstall scripts entry covers that history.
Put those together and a worm is a short program. Code that runs at install time can read NPM_TOKEN from the environment or ~/.npmrc. That token can publish. Publishing a patch release with an added install hook puts the same code on every machine that resolves the new version. Some of those machines hold publish tokens for other packages.
How does Shai-Hulud spread from one maintainer to the next?
#Shai-Hulud spreads by turning each stolen npm token into new malicious releases of every package that token can publish. Across waves the loop has five steps.
Figure 1 shows the loop as a vertical chain that returns to the top: install-time execution, credential harvesting, exfiltration to a repository in the victim's account, target discovery through the registry, and republishing every package the token can reach. Each step in detail:
- Execute at install. A lifecycle hook runs the payload. The first wave used
postinstall: node bundle.js. Later waves moved topreinstall, which runs before the package itself is installed, and download a standalone Bun runtime to execute the real stage outside Node.js. - Harvest. The payload reads every environment variable, runs a secret scanner over the home directory (the first two waves downloaded the real TruffleHog binary), queries cloud metadata endpoints such as
169.254.169.254, and on GitHub Actions runners enumerates repository secrets. - Exfiltrate. Stolen data goes into a new public repository created in the victim's own GitHub account. Wave one also posted to a
webhook.siteURL. Wave two triple-base64-encoded the files to slip past secret scanning. Mini Shai-Hulud encrypts it with AES-256-GCM and a key wrapped under RSA-OAEP-4096. - Find targets. The payload validates each npm token against
/-/whoami, then asks the registry's public search API which packages that user maintains. - Republish. For each package, it downloads the current tarball, injects the payload and hook, bumps the patch version, recomputes the integrity fields, and publishes. Downstream installs of the new version start the loop again.
Step 4 is a single unauthenticated request. The registry search API answers it for anyone:
$ curl -s "https://registry.npmjs.org/-/v1/search?text=maintainer:isaacs&size=3" \
| jq '{total, names: [.objects[].package.name]}'
{
"total": 412,
"names": ["minimatch", "brace-expansion", "yallist"]
}We ran that on October 15, 2026. One unauthenticated request returns 412 packages for a single maintainer. The worm caps each pass: the first wave's fourth payload revision raised the per-maintainer limit from 10 to 20 packages, and the November 2025 wave asked for up to 100. This is the propagation routine from the second wave, deobfuscated in our analysis:
async function El(npmToken) {
let client = new Wl(npmToken);
let user = await client["validateToken"](); // GET /-/whoami
if (user) {
let pkgs = await client["getPackagesByMaintainer"](user, 0x64); // up to 100
await Promise["all"](pkgs["map"](async (p) => {
await client["updatePackage"](p); // fetch tarball, add setup_bun.js + bun_environment.js,
})); // add preinstall, bump x.y.z -> x.y.z+1, publish
}
}The patch-version bump is deliberate. A caret range such as ^4.1.0 accepts 4.1.1 automatically, so any project that installs without a lockfile, or refreshes one, picks up the infected version without a code change.
When no npm token is on the host, the second wave went looking for one. It enumerated GitHub Actions secrets for every repository the stolen GitHub token could reach and tried any value starting with npm_. That turned one compromised developer laptop into a path to every CI pipeline that developer could administer. If it found neither a GitHub token nor a valid npm token, the November 2025 payload ran a wiper: shred over every writable file in $HOME on Linux and macOS, cipher /W on Windows.
Shai-Hulud waves, September 2025 to October 2026
#Shai-Hulud has arrived in at least seven distinct waves or offshoots between September 2025 and October 2026. They share the loop above and differ in trigger, initial access, and branding.
| Wave | First seen | Install-time trigger | How it got publish rights | Scale |
|---|---|---|---|---|
| Shai-Hulud | Sep 14, 2025 | postinstall: node bundle.js | Stolen npm tokens | 526 versions (Socket IOC list); over 500 packages (CISA) |
| Sha1-Hulud: The Second Coming | Nov 24, 2025 | preinstall: node setup_bun.js, then Bun runs bun_environment.js | Stolen npm tokens, plus npm_ values mined from GitHub Actions secrets | Hundreds of packages; tens of thousands of GitHub repositories exposed |
| SANDWORM_MODE | Feb 2026 | Typosquatted packages with MCP server injection | Attacker-owned npm aliases | At least 19 packages |
| Shai-Hulud: The Third Coming | Apr 23, 2026 | bw1.js in @bitwarden/cli 2026.4.0 | Compromised GitHub Action in the release pipeline | Part of the Checkmarx campaign |
| Mini Shai-Hulud | Apr 29, 2026 | preinstall: node setup.mjs; git optionalDependencies with a prepare hook | Stolen tokens; OIDC tokens read from runner memory | 1,069 artifacts across 507 packages (npm, PyPI) |
| Miasma and Hades | Jun 1, 2026 | preinstall, binding.gyp, PyPI *-setup.pth | Maintainer account takeovers | 448 artifacts by Jun 7, 2026 |
| ChainDrop (keyv, cacheable, tensorlake) | Aug 4, 2026 | preinstall: node setup.mjs, then Bun runs Math_Symbol.js | Stolen npm tokens and OIDC token exchange | More than 400 npm packages |
The first wave went through seven payload revisions in three days. We could count them because the worm cannot edit itself: each new SHA-256 of bundle.js was a new seeding by the operator. Revisions added Google Cloud collection, removed a repository-exists check to win races between infected hosts, and dropped a noisy git config --unset core.bare trick that left forensic traces.
The second wave, self-described as "Sha1-Hulud: The Second Coming," landed on November 24, 2025 across packages from Zapier, Postman, PostHog, AsyncAPI, and ENS Domains. It moved to preinstall, ran a 10 MB obfuscated Bun bundle in a detached background process, and searched GitHub for its own beacon phrase to recover a backup exfiltration token if victims deleted the earlier repositories. PostHog later traced its initial access to a pull_request_target workflow that leaked a bot token. The wave also spilled into Maven Central, where an automated rebuild of npm packages republished the infected posthog-node@4.18.1 as org.mvnpm:posthog-node:4.18.1.
Each wave carried a marker in the repositories it created. Wave one used the name Shai-Hulud. Wave two used "Sha1-Hulud: The Second Coming." The April 2026 Bitwarden CLI compromise used "Shai-Hulud: The Third Coming." Mini Shai-Hulud used "A Mini Shai-Hulud has Appeared" and, in the May 18 @antv burst, the reversed string niagA oG eW ereH :duluH-iahS. Searching GitHub for these strings is still the fastest way to see how many accounts were hit.
What is Mini Shai-Hulud?
#Mini Shai-Hulud is the 2026 wave of the Shai-Hulud worm, attributed to the group TeamPCP and first detected on April 29, 2026. It began with four SAP packages for the Cloud Application Programming Model and Cloud MTA build tooling, including @cap-js/sqlite@2.2.2 and mbt@1.2.48, then moved to Intercom, TanStack, Mistral AI, UiPath, OpenSearch, Guardrails AI, and the @antv visualization ecosystem.
Mini Shai-Hulud is larger than the original. Our campaign page tracks 1,069 affected artifacts across 507 unique packages on npm and PyPI. The single largest burst came late on May 18, when the hijacked account atool published 639 malicious versions across 323 packages, including echarts-for-react. npm responded on May 19 by invalidating every granular access token with write access that bypassed two-factor authentication.
Two things set Mini Shai-Hulud apart. First, it stopped needing long-lived tokens: the TanStack compromise minted its publish credential from the project's own trusted publishing setup, covered in the next section. Second, its persistence moved into developer tooling. The payload writes .claude/settings.json hooks and .vscode/tasks.json tasks that re-run it when a developer or coding agent opens the project, and it commits those files to the victim's repositories under a spoofed claude@users.noreply.github.com author.
In May 2026 TeamPCP released the worm's source as open attack tooling and co-ran a BreachForums contest paying $1,000 in Monero for the most-downloaded packages compromised with it. The result is a family, not a campaign. The Miasma and Hades waves in June 2026 reused the code on npm, PyPI, and a Go module; the August 2026 keyv and cacheable compromise and the October 2026 tensorlake compromise used a new loader and payload built on the same tradecraft. On August 26, 2026, the Australian Federal Police charged two men, aged 21 and 23, over their alleged roles in the group; the AFP estimates more than 500,000 stolen credentials across more than 1,000 organizations. The original September 2025 authors have never been publicly identified.
How Mini Shai-Hulud published through trusted publishing
#Mini Shai-Hulud showed that removing long-lived tokens does not remove the publish credential. It moves it into the CI runner. npm trusted publishing replaces a stored token with an exchange: the CI job proves its identity with a short-lived OIDC token, and npm returns a short-lived publish token scoped to one package.
The exchange is two HTTP calls, and any process on the runner that can see the job's OIDC request credentials can make them. This is the code path in the npm 12.2.0 CLI, from lib/utils/oidc.js, trimmed to the requests:
// 1. Ask GitHub Actions for an OIDC JWT addressed to the npm registry.
const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
url.searchParams.append('audience', 'npm:registry.npmjs.org')
const { value: idToken } = await (await fetch(url, {
headers: { Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` },
})).json()
// 2. Trade it for a short-lived npm token for one package.
const { token } = await npmFetch.json(
`/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`,
{ method: 'POST', '//registry.npmjs.org/:_authToken': idToken }
)TanStack's postmortem shows how an attacker reached that point without touching the publish step. On May 11, 2026, a fork's pull request triggered a pull_request_target workflow that executed the fork's build. That build saved a poisoned pnpm store into the GitHub Actions cache under the key the release workflow restores. When the real release workflow ran on main that evening, it restored the cache, and attacker binaries found the Runner.Worker process through /proc, read the OIDC token out of its memory, and published directly to the registry. 84 malicious versions across 42 @tanstack/* packages went out between 19:20 and 19:26 UTC.
Figure 2 in words: the Actions runner asks GitHub's OIDC provider for a JWT with the audience npm:registry.npmjs.org and gets back a token naming the repository, workflow, and ref. The runner POSTs that JWT to /-/npm/v1/oidc/token/exchange/package/<pkg> and receives a short-lived npm publish token. It then PUTs the package to the registry with Sigstore provenance. Any process on the runner that can read the request token or the Runner.Worker process memory can make all three calls. Staged publishing adds a gate after the PUT: a maintainer must run npm stage approve with interactive 2FA before the version can be installed, and OIDC tokens cannot approve.
TanStack's own summary of the gap is one line: "OIDC trusted-publisher binding has no per-publish review." The malicious versions also carried valid Sigstore provenance pointing at TanStack's own release workflow, because the payload generated the attestation from inside that workflow run. Provenance answered the question it was designed to answer, which pipeline produced this artifact, and the answer was correct. It cannot answer whether that pipeline was running someone else's code. This is the general lesson of Mini Shai-Hulud for GitHub Actions security: the runner is now the credential, so anything that executes on the runner, including cached dependencies and third-party actions referenced by mutable tags, is inside the publish trust boundary.
The registry keeps a record of every wave
#npm removes a malicious version's tarball and manifest but keeps its publish timestamp. A version that appears in a package document's time object but not in versions was published and later pulled. That makes the registry itself a forensic index of the worm, queryable without any vendor feed:
$ curl -s https://registry.npmjs.org/@tanstack%2freact-router | python3 -c '
import json, sys
d = json.load(sys.stdin)
for v, ts in d["time"].items():
if v not in ("created", "modified") and v not in d["versions"]:
print(v, ts)'
1.169.5 2026-05-11T19:20:42.105Z
1.169.8 2026-05-11T19:26:17.716ZBoth timestamps land within four seconds of the publish times in TanStack's postmortem, and they match the affected range in the GitHub advisory. That advisory is also CVE-2026-45321, classed as CWE-506, embedded malicious code, and published on May 12, 2026. A CVE for malware is unusual, and even this one arrived the day after TanStack had already deprecated every bad version. A scanner that waits for CVE records saw nothing during the window that mattered.
Running the same check on one or two packages from each wave, on October 15, 2026:
| Wave | Package | Removed versions | Published (UTC) |
|---|---|---|---|
| Shai-Hulud | @ctrl/tinycolor | 4.1.1, 4.1.2 | 2025-09-15 19:52:46, 20:13:43 |
| Shai-Hulud | @crowdstrike/commitlint | 8.1.1, 8.1.2 | 2025-09-16 01:14:39, 01:22:17 |
| Sha1-Hulud: The Second Coming | posthog-node | 5.11.3, 5.13.3, 4.18.1 | 2025-11-24 04:04:24, 04:09:08, 04:13:22 |
| Mini Shai-Hulud | @tanstack/react-router | 1.169.5, 1.169.8 | 2026-05-11 19:20:42, 19:26:17 |
| Mini Shai-Hulud | @antv/g2 | 5.5.8, 5.6.8 | 2026-05-19 01:56:41, 02:06:01 |
| ChainDrop | keyv | 6.0.0 | 2026-08-04 09:35:00 |
| ChainDrop | tensorlake | 0.5.144 | 2026-10-08 01:12:07 |
If any of these version strings appear in a lockfile, a cache, or an internal artifact mirror, the machine that produced it ran the worm. One of CISA's first instructions in 2025 was to check lockfiles for exactly this reason. A lockfile is not a defense against the worm, but it is the best evidence of whether you were exposed.
Does npm 12 stop Shai-Hulud?
#npm 12 stops the install-time trigger that every npm wave of Shai-Hulud has used, for packages you have not explicitly approved. It does not stop the worm from publishing, and it does nothing about execution paths outside npm install. npm 12 became generally available on July 8, 2026, with allowScripts off, --allow-git=none, and --allow-remote=none by default. pnpm had already turned dependency lifecycle scripts off by default in version 10.
We tested how npm 12.2.0 handles the worm's patch-bump pattern against a local registry. A package with a preinstall hook is blocked on first install:
$ npm install evil-demo@^1.0.0
added 1 package in 749ms
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts evil-demo@1.0.1 (preinstall: node setup.mjs)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.npm approve-scripts evil-demo records the approval pinned to the exact version, "allowScripts": {"evil-demo@1.0.1": true}. We then published 1.0.2 with the same hook, as the worm would, and ran npm update. npm installed 1.0.2 and blocked its script. A pinned approval does not transfer to a version the worm published. With --no-allow-scripts-pin, the approval is recorded by name, "allowScripts": {"evil-demo": true}, and the same update ran the hook. Keep the default. A name-only approval is a standing grant to whoever next obtains publish rights for that package.
The limits matter as much as the default:
- Packages that genuinely need install scripts, such as
sharporbetter-sqlite3, still need approval on every upgrade. If upgrades are approved by reflex, pinning buys nothing. - The keyv and cacheable payload committed
.claude/settings.jsonSessionStart hooks and.vscode/tasks.jsonfolder-open tasks to source repositories. Those run when someone opens the project, with nonpm install. - The Hades wave shipped Python wheels with a
*-setup.pthfile, which runs at Python startup. The compromisedguardrails-ai@0.10.1ran its payload on import. npm settings do not reach PyPI. - Two GitHub Actions compromised in May 2026,
actions-cool/issues-helperandactions-cool/maintain-one-comment, were disabled by GitHub and then re-enabled on September 16, 2026 with their tags still pointing at the payload. Every workflow that referenced them by tag ran it again. Only a commit SHA pin from before May 18 avoided that. - Older npm versions still run scripts by default. In July 2026 the npm team said it hoped to backport v12 to Node.js 24 and 26, a decision that belongs to the Node.js release team. Check which npm your CI image actually runs.
A dependency cooldown covers part of the remaining gap from the consumer side. pnpm 11 defaults to a 24-hour minimum release age. Recent waves were detected in minutes, not days: Socket's AI scanner flagged the TanStack versions within six minutes of publication, the keyv and cacheable versions in an average of five minutes and 18 seconds, and tensorlake@0.5.144 in about 11 minutes. TanStack had deprecated all 84 versions by 21:03 UTC, under two hours after the first publish. A one-day delay would have skipped all of them. The cost is that a one-day delay also applies to the security fix you are waiting for, so cooldowns need an override path for advisories.
What actually breaks the loop
#The loop has one step that no consumer setting can reach: the publish. Every wave, regardless of trigger, ends with a valid credential publishing a new version that the registry makes installable immediately. Rotating tokens removes the credentials already stolen. Trusted publishing removes the stored token but, as TanStack showed, not the ability of code on the runner to mint a new one. The control that breaks the loop is a human with a second factor between the publish and the version going live.
npm now has that control, as an option. Staged publishing entered public preview on May 20, 2026: npm stage publish puts a version in a queue, and only npm stage approve with interactive two-factor authentication makes it installable. OIDC tokens cannot approve. Since September 18, 2026, npm offers stage-only tokens that cannot publish directly, and since October 2, 2026, staged publishing can create new packages. npm plans to remove direct publishing from 2FA-bypass tokens around January 2027. We think staged publishing should be the default for every trusted publisher configuration, not a per-package opt-in. A worm that can republish 100 packages per token is not stopped by a defense that maintainers must enable 100 times.
Until then, incident response has an ordering trap. The August 2026 keyv payload installed a dead-man's switch: a LaunchAgent or systemd user service that polls GitHub with the stolen token every 60 seconds and runs an attacker-supplied command the moment the token stops working. The October tensorlake payload carries the string IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner. On a host hit by a recent wave, first remove ~/.config/gh-token-monitor, the com.user.gh-token-monitor LaunchAgent or matching systemd unit, and the .claude and .vscode hooks. Then, from a clean machine, rotate every credential the host could reach, not only npm tokens.
The September 2025 worm was one operator's code. A year later the source is public, the techniques have reached PyPI, Packagist, Maven Central, Go modules, and GitHub Actions, and anyone with one stolen token can start a new wave. The registries can make each wave smaller. Only making publishes wait for a human makes the next one stop at the first maintainer.
