marco@blog.luyckx.dev:~$ cat shai-hulud-npm-worm.md
Shai-Hulud: when a supply chain attack starts replicating
$ git log --follow shai-hulud-npm-worm.md 1 edition
First published.
$ grep '^#\+' shai-hulud-npm-worm.md 5 headings

The worm known as Shai-Hulud, which surfaced in the npm ecosystem in September 2025, was different in one important way. It did not just steal credentials. Where it found a usable npm publishing token, it used that token to publish itself again, from the account of the person it had just compromised.
That single property changes the shape of the problem.
The loop
Strip away the detail and the mechanism is short enough to describe in four steps:
- A malicious version of a package is published to the registry.
- Someone installs it. A lifecycle script runs automatically during installation.
- That script searches the machine for secrets: npm, GitHub and cloud credentials it can discover.
- If it finds a usable npm publishing token, it authenticates as that developer and publishes infected versions of the packages that token is allowed to publish. Back to step one.
The word for this is a worm, and the reason it matters is arithmetic. A conventional compromise reaches the users of one package. A self-replicating one can reach the users of every package that each newly stolen token is authorised to publish, and so on outward.
Step four is conditional, and that condition is the whole ballgame. Propagation required the malicious code to run somewhere that happened to have a valid publishing token within reach. A developer laptop mid-release, or a CI runner holding a publish credential, is such a place. Most machines that merely install dependencies are not.
Why installing is enough
The step that surprises people outside the ecosystem is the second one.
At the time of the incident, installing a dependency was not a passive copy. npm packages can
define lifecycle scripts (preinstall and postinstall among them) and these executed
automatically as part of installation. The package did not have to be imported. Nothing had to call
it. Adding it to the dependency tree was sufficient for its code to run, with whatever privileges
the install had.
That was never a vulnerability. It was a documented feature, and plenty of legitimate packages relied on it to compile native extensions or fetch platform-specific binaries.
This default has since changed. npm v12 became the latest release in July 2026, and its
allowScripts setting defaults to off: dependency lifecycle scripts and implicit node-gyp builds
no longer run unless you explicitly allow them. The behaviour had been available behind warnings
since npm 11.16.0.
So the specific door this worm walked through is closed by default on current npm. That is worth saying plainly, and worth not over-reading. Plenty of projects are pinned to older npm, other ecosystems have their own install-time execution, and “closed by default” is not the same as “closed everywhere”.
Where the credentials went
Two channels, not one.
Harvested credentials were sent to an endpoint controlled by the attacker, and uploaded to a
public repository named Shai-Hulud created in the victim’s own GitHub account.
The second channel is the memorable one, and it is genuinely awkward to respond to: the hosting account belongs to the victim, so takedown is not simply a matter of seizing attacker infrastructure. But the first channel existed too, and any description that mentions only the public repositories understates what the operation actually ran.
What actually helps
None of this is exotic, which is rather the point.
- Do not let dependency scripts run by default. On npm v12 this is now the default. On older
npm,
npm ci --ignore-scriptsgets you there, with the caveat that some packages genuinely need their scripts to build, so expect to maintain a short, deliberate allowlist rather than a blanket ban. - Pin, and use a lockfile. A floating version range is a standing invitation to install whatever was published five minutes ago. A lockfile turns “latest” into a decision someone made.
- Give publishing tokens the narrowest scope that works, and prefer short-lived credentials. A token that can publish one package is a smaller problem than one that can publish forty.
- Treat a compromised token as a compromised registry account. If a build machine is implicated, rotating the token is the beginning of the response, not the end of it. Look at what was published in the window.
- Watch for unexpected publishes. A maintainer noticing “I did not release that” remains one of the faster detection paths available.
The uncomfortable bit
We talk about dependencies as if they were libraries. For a long time they were closer to build-time code execution with a friendly interface, and in many setups they still are.
Shai-Hulud is interesting less as an incident than as a demonstration. It showed that the npm dependency graph is dense enough, and install-time execution permissive enough, for something to propagate through it largely on its own. The ecosystem has since changed the default that made step two automatic, which is a real improvement. The underlying shape has not gone anywhere: code you did not write, running at a moment you did not think of.
The name is borrowed from Dune, where Shai-Hulud is the sandworm: something enormous moving underneath the surface, noticed mostly by what it disturbs. As metaphors chosen by attackers go, it is annoyingly apt.