Three waves in one year
The timeline is worth having in one place, because the waves share one design.
- September 2025, Shai-Hulud. The first true npm worm: over 180 packages republished with a payload that stole npm, GitHub, AWS and GCP credentials and used the stolen npm tokens to infect more packages. A second wave in November reached several hundred more.
- May 2026, Mini Shai-Hulud. The same playbook against TanStack, Mistral AI, UiPath and over 160 npm and PyPI packages.
- August 2026, ChainDrop. A maintainer’s GitHub account gave the worm
keyvandcacheable, foundational caching packages, and from there it spread to over 2,200 poisoned versions across roughly 450 packages within days. Datadog’s Security Labs analysis has the full technical breakdown.
The name, by the way, is the giant sandworm from Dune, and the original worm earned it with theatrical flourishes: it published its victims’ stolen secrets in public GitHub repositories named “Shai-Hulud”, and among its first actions on an infected machine was installing TruffleHog, a legitimate open-source secret scanner, pointed at its owner.
How the worm actually spreads
Every wave runs the same loop. A poisoned package version carries a preinstall or postinstall script. Anyone installing it runs that script, which harvests whatever credentials the machine holds: npm tokens, GitHub tokens, cloud keys, CI secrets. If the loot includes a valid npm publish token, the worm publishes new versions of that maintainer’s packages with itself injected, and the loop continues on the next victim’s machine.
No vulnerability in npm itself is involved anywhere. The worm rides two features working as designed, install scripts that run arbitrary code and publish tokens that authorize releases, plus the fact that developer machines and CI runners are soaked in credentials.
What decides whether you were hit
Two questions decide it, and neither is “do I depend on keyv”.
First, did anything on your side resolve new versions during the attack window? A frozen lockfile installed with npm ci keeps serving the versions it recorded, poisoned or not, which cuts both ways: it protects you if the lockfile predates the attack, and it preserves the infection if the bad version is already recorded. A plain npm install with loose ranges, a dependabot merge, or a fresh project scaffold during the window are the events that pull in whatever was newest, and how much a range like ^4.5.0 is allowed to pull in is exactly the semantics covered in what caret and tilde really allow.
Second, did install scripts run? The payload lives in preinstall. An install with scripts disabled downloads the poison but never detonates it, which is the difference between an afternoon of cleanup and a full credential rotation.
Auditing the lockfile, concretely
The advisories for each wave publish exact package-and-version lists. Your job is to intersect them with what you actually resolved:
npm ls keyv cacheableorpnpm why keyvshows whether and where the package sits in your tree, transitive paths included.- Search the lockfile directly for the affected versions, since the lockfile records what was truly installed, not what package.json wished for.
npm auditflags the versions once advisories are live, which for recent waves happened within hours. Its blind spot is the gap between publication of the malware and publication of the advisory.- Check CI logs from the window for installs that resolved new versions, because a runner that installed and executed the payload is compromised even if a later lockfile looks clean.
If the affected versions never appear in any lockfile and no unpinned install ran in the window, you are done, and the remaining sections are prevention.
If you find a hit
Treat the machine and every credential it could see as exposed. Rotate npm tokens, GitHub tokens and SSH keys, cloud credentials, and the secrets in CI. The order is rotation first, forensics second, the same triage as after committing a .env file. Then check your GitHub account for repositories and workflow runs you did not create, since the worms use stolen GitHub tokens to persist, and check npm for versions of your own packages you did not publish.
Pin the known-good versions with your package manager’s overrides field until upstream ships cleaned releases, and only then update.
The install-script problem
One line of config removes the detonation mechanism of every wave so far.
# default npm behaviour npm install # preinstall of every package # in the tree executes
# .npmrc ignore-scripts=true # pnpm 10: default already, # allow real build steps only: # pnpm approve-builds
The cost is real but small: packages with genuine native build steps stop working until you allowlist them, and the allowlist is short in most projects. pnpm 10 made this the default and asks you to approve build scripts per package, which is the single biggest reason pnpm users came through ChainDrop lightly.
Slowing down the next wave
Worm-published versions have a short life, typically hours between publication and takedown. That makes freshness itself the risk signal, and waiting the cheapest defense there is. pnpm’s minimumReleaseAge setting refuses to resolve versions younger than a threshold you pick, so a version that is three days old and still on the registry has survived three days of the ecosystem’s scrutiny. A one-week delay would have skipped every poisoned version of all three waves without any other change.
The rest is hygiene you already know: commit the lockfile, install with npm ci in CI, and treat automated dependency-update PRs as the risk events they are rather than rubber-stamping them the hour they appear. If you sell software in the EU, knowing what that lockfile contains is also becoming a legal duty under the Cyber Resilience Act.
Publish tokens are the real target
Strip away the obfuscation and every wave is a token-theft campaign, because one maintainer’s publish token converts into thousands of downstream victims. That economics is why the registry side has been moving too: npm has been phasing out long-lived classic tokens and pushing trusted publishing, where CI proves its identity to the registry per release instead of holding a durable secret that a preinstall script can steal.
If you publish packages, that migration is the most valuable hour you can spend on this. Your token is everyone downstream’s risk, and it stops being a risk the day it stops existing.
Worm questions, practically asked
How do I check if my project pulled a compromised npm package version?
Ask the package manager, not your memory. npm ls <package> and pnpm why <package> show whether the package is in your tree and at which resolved version, including as a transitive dependency, and searching the lockfile shows the exact pinned versions. Compare those against the version lists in the published advisories. Checking package.json alone is not enough, because the malicious version usually arrives through a dependency of a dependency.
What is the Shai-Hulud worm?
The first self-replicating worm on npm, named after the sandworms in Dune. Since September 2025 its waves have compromised well over a thousand package versions by stealing maintainers’ publish tokens and using them to inject itself into more packages automatically.
Does npm audit catch supply chain attacks?
Only after an advisory exists, and the first hours of a worm outbreak are exactly the window where none does. npm audit is worth running, but it reports the past. Version pinning, install-script controls and a release-age delay protect you during the gap.
Does a lockfile protect against compromised packages?
It protects the moment of installation: npm ci installs exactly the hashes recorded earlier, so a version poisoned yesterday cannot slip into today’s build. The protection ends the moment anything refreshes the lockfile, which is why update PRs deserve more suspicion than routine builds.
Should I disable npm install scripts?
Where you can, yes. Every wave so far entered through preinstall or postinstall scripts. npm install --ignore-scripts, or ignore-scripts=true in .npmrc, closes that door, and pnpm 10 already ships with dependency scripts disabled unless you allowlist them. The cost is that packages with genuine native build steps need explicit exceptions.
What should I rotate after a compromise?
Everything a script on that machine could read: npm and GitHub tokens, SSH keys, cloud credentials, CI secrets, .env contents. Rotate first, investigate second.
Is pnpm safer than npm against supply chain attacks?
Its defaults currently are. pnpm 10 does not run dependency lifecycle scripts unless you allowlist them, and its minimumReleaseAge setting can hold back versions younger than a threshold you choose, which is exactly the window in which worm-published versions live and die. npm can be configured to similar effect, but you have to do it yourself.
What does preinstall in package.json do?
It runs an arbitrary command before the package installs, on every machine that installs it. That is why npm malware lives there.