Network worm
Code Red, July 2001. The edge is a bug.
Package worm
npm, 2025 to 2026. The edge is a credential.
A worm is self-replicating malware. An npm worm steals a maintainer's publish credentials at install time and republishes itself into their other packages.
Also known as computer worm, npm worm, package worm, supply chain worm, self-replicating malware
A worm is malware that copies itself to new systems without human help, using each infected system to find and infect the next. Network worms exploit software bugs. An npm worm, or package worm, exploits trust instead: it steals a maintainer's publish credential at install time and republishes itself into every package that credential controls.
A package worm needs no vulnerability. It needs two conditions that npm's defaults allowed for a decade: installing a package runs that package's code, and the machines that install packages often hold credentials that can publish them. CERT/CC described the resulting loop in March 2016. npm kept both defaults, and Shai-Hulud, the first widely observed self-replicating npm worm, began its first wave on September 14, 2025. We think the install-time fixes that followed are necessary and late. The loop only stops reliably when no publish can go live without a human.
A computer worm is a self-contained program that spreads between machines on its own, with no one opening a file. A virus, by contrast, rides inside a host file and spreads when someone runs it. A trojan waits to be installed. Worm describes how the code travels, not what it does: once it lands, a worm can carry any payload that other malware carries.
Every worm has three parts. A target finder decides where to go next. A propagation vector gets code running on the target. A payload does whatever the author wanted. How fast a worm spreads depends almost entirely on the first two.
The Morris worm, released on November 2, 1988, hit an estimated 6,000 of the roughly 60,000 computers then on the internet (LLNL). It used three vectors: sendmail's debug mode, a buffer overflow in fingerd, and accounts it could log into with guessed passwords. From a cracked account it read .rhosts and /etc/hosts.equiv and moved to every host that trusted that account (Page, 1988). That third vector is the ancestor of every package worm. It exploited no bug, only a trust relationship.
The network worms that followed optimized for speed. Code Red infected more than 359,000 hosts in under 14 hours on July 19, 2001, by probing random IP addresses for unpatched IIS servers (CAIDA). Slammer, in January 2003, doubled every 8.5 seconds and infected more than 90 percent of vulnerable hosts within 10 minutes (CAIDA). Both were bounded by one number: how many machines still ran the vulnerable code. Patching drives that number toward zero.
An npm worm spreads by turning each stolen publish credential into new malicious versions of every package that credential can publish. Three properties of the registry make that possible. Each was designed for a good reason.
Installing runs code. npm executes a dependency's preinstall, install, and postinstall scripts with the installing user's privileges and full environment. The feature exists so native addons can compile on the target machine. Our entry on install scripts covers the history.
Publishing is authorized by a credential, not a person. A valid npm token, or a short-lived one minted through OIDC, is enough to make a new version installable. The registry does not check whether a human was present.
Version ranges float. A caret range such as ^1.2.3 accepts 1.2.4. Under semantic versioning, a new patch version reaches dependents whenever the range is resolved fresh: a new install, a lockfile refresh, or an npx run.
Network worm
Code Red, July 2001. The edge is a bug.
Package worm
npm, 2025 to 2026. The edge is a credential.
Figure 1 compares two loops. The network worm loop, using Code Red as the example, has four steps: pick a random IP address, exploit the IIS buffer overflow, copy the worm into memory on the new host, and have that host start scanning. Code Red reached more than 359,000 hosts in under 14 hours that way. The package worm loop has five steps: an install runs a lifecycle hook such as preinstall, the hook reads a publish credential, it asks the registry which packages that credential can publish, it injects the hook into each one, bumps the patch version, and publishes, and downstream installs pull the new version because ^1.2.3 accepts 1.2.4. The step that differs in kind is the second. The network worm needs a bug. The package worm needs a credential.
Target discovery, the step that slowed early network worms, costs an npm worm one HTTP request. The registry answers it for anyone. These are the two calls a worm makes after it finds a token. We ran them on October 31, 2026:
# 1. Whose token is this? Without a valid token:
$ curl -s https://registry.npmjs.org/-/whoami
{"error":"Unauthorized"}
# With a valid token, the same call returns the username.
# 2. Which packages does that user maintain? No authentication needed.
$ curl -s "https://registry.npmjs.org/-/v1/search?text=maintainer:<user>&size=3" | jq .total
549The 549 is one prolific maintainer we picked. Each infected maintainer is a multiplier, not one new host. On May 19, 2026, Mini Shai-Hulud used one hijacked npm account to publish 639 malicious versions across 323 packages between about 01:56 and 02:56 UTC. A network worm gains one host per successful exploit. A package worm can gain hundreds of publish targets per stolen token.
The trade-off runs the other way on speed. A network worm reaches its next host in milliseconds. A package worm waits for someone to install the new version, which can take minutes on a busy CI fleet and weeks for a quiet library. That delay is the defender's window. Most of the controls below work by widening it or by acting inside it.
On March 26, 2016, CERT/CC published VU#319816, crediting Sam Saccone and David Ross. It listed three properties of npm: floating semver ranges, logins that persist until the user logs out, and a central registry. Then it drew the conclusion: "When these three aspects of npm are combined, it provides the capability for a self-replicating worm." The example workflow in the note matches what Shai-Hulud did nine years later, step for step: run on install, walk the user's packages, and publish a "bugfix" version bump.
npm had answered the day before, in a blog post on March 25, 2016: "On balance, it's npm's belief that the utility of having installation scripts is greater than the risk of worms." The workaround it offered, npm config set ignore-scripts true, was correct, but it was opt-in, and the default stayed on for ten more years.
On July 12, 2018, a maintainer account takeover published eslint-scope@3.7.2 with a postinstall script that sent the contents of each installer's .npmrc to the attacker (ESLint postmortem). That was half a worm. It harvested publish tokens automatically but did not use them to republish itself. npm responded by revoking every access token created before 12:30 UTC that day.
We think the 2016 trade-off was the design mistake that made everything after it possible. Default-on lifecycle scripts gave every package in a dependency tree, including transitive ones nobody chose, code execution on every machine that installed it. pnpm turned dependency scripts off by default in version 10 in January 2025. npm did the same in npm 12 on July 8, 2026. In between, npm revoked all classic tokens on December 9, 2025, and made staged publishing generally available on May 22, 2026. Staged publishing holds a new version in a queue until a maintainer approves it with a 2FA challenge.
Figure 2 as a table:
| Date | Event |
|---|---|
| 2016-03-26 | CERT/CC VU#319816 describes a self-replicating npm worm. npm keeps install scripts on by default. |
| 2018-07-12 | eslint-scope@3.7.2 steals .npmrc tokens at install time. It does not republish itself. |
| 2025-01-07 | pnpm 10.0.0 stops running dependency lifecycle scripts by default. |
| 2025-09-14 | Shai-Hulud, the first widely observed self-replicating npm worm, begins publishing. |
| 2025-12-09 | npm permanently revokes classic tokens. |
| 2026-05-19 | Mini Shai-Hulud publishes 639 versions across 323 packages from one account in about an hour. |
| 2026-05-22 | npm staged publishing becomes generally available as an opt-in. |
| 2026-07-08 | npm 12 ships with dependency install scripts off by default. |
A malicious package is a worm only if it closes the loop without the attacker: it runs on a victim's machine, obtains the ability to publish, and uses that ability to publish itself. Most supply chain attacks fail at least one of those tests.
The distinction sets the scope of incident response. A malicious package's blast radius is the set of machines that installed it. A worm's blast radius also includes every package those machines could publish and every credential they held. Calling something a worm when it is not wastes a response team's day. Missing a worm leaves its next generation running.
| Case | Runs on the victim | Takes a publish credential | Republishes itself | Verdict |
|---|---|---|---|---|
| TEA Protocol spam, 2024 to 2025 | No install trigger | No | No | Spam, not a worm |
| eslint-scope, July 2018 | Yes, postinstall | Yes, from .npmrc | No | Credential stealer |
| GlassWorm on Open VSX, 2025 to 2026 | Yes, in the editor | Yes | Not automatically | Operator-driven |
| CanisterWorm, March 2026 | Yes | Yes | Script exists, not run on install | Worm-capable |
| Shai-Hulud, September 2025 onward | Yes, install hook | Yes | Yes | Worm |
| SANDWORM_MODE, February 2026 | Yes, on import | Yes | Yes, via npm, GitHub, and SSH | Worm |
The TEA Protocol spam shows why the first test matters. Up to 150,000 packages listed each other as dependencies and bundled a publish script, but nothing ran that script on install. Our analysis put it plainly: "Dependency resolution is not code execution." GlassWorm spread through Open VSX with stolen publisher tokens, but the registry's maintainers said it was "not self-replicating in the traditional sense." CanisterWorm shipped a deploy.js that resolves a token's owner through /-/whoami, lists that owner's packages, bumps patch versions, and republishes, but npm install never runs it. Wiring that script to an install hook would close the loop. SANDWORM_MODE closes the loop three ways: it republishes with stolen npm tokens, adds itself as a dependency through the GitHub API, and falls back to repositories it can reach over SSH.
The npm install hook is the most convenient trigger, not the only one. Recent waves have swapped it out while keeping the rest of the loop.
Python startup. The Hades wave shipped 37 malicious wheels across 19 PyPI packages, each with a *-setup.pth file. Python's site module executes any .pth line that starts with import at interpreter startup. The payload runs the next time any Python process starts, with no import and no install script.
Editors and coding agents. The keyv and cacheable compromise in August 2026 wrote .claude/settings.json and .vscode/tasks.json hooks into source repositories. Those hooks run the loader when a developer or an AI coding agent opens the project. No package manager is involved.
CI pipelines. SANDWORM_MODE adds pull_request_target workflows that serialize every repository secret with ${{ toJSON(secrets) }}, then edits release configuration so later publishes carry the worm.
Trusted publishing. npm trusted publishing removes stored tokens by having the CI job exchange a short-lived OIDC token for a publish credential. It moves the credential into the CI runner, and code running in the runner can make the same exchange. In the TanStack compromise on May 11, 2026, malware restored from a poisoned build cache read the OIDC token out of the release job's runner memory and published 84 malicious versions across 42 packages. TanStack's postmortem names the gap in one line: "OIDC trusted-publisher binding has no per-publish review." The keyv payload later republished other packages "through npm OIDC trusted publishing."
Every variant ends the same way. A valid credential publishes a version, and the registry makes that version installable immediately.
Staged publishing is the only control that acts on the one step every worm variant shares: a new version going live. The other controls each cut a different part of the loop and leave the rest. None is sufficient alone.
Writing down what one infected host contributes to the next generation makes the trade-offs concrete. This is a model for reasoning about controls, not a measurement:
new infected hosts per infected host ≈ X × C × P × G × I
X chance the payload executes on a host that installs it
C chance that host holds a usable publish credential
P packages that credential can publish
G 1 if a publish goes live with no human, else ≈ 0
I installs of each new version, by hosts that will run it, before takedownA network worm has a similar product. Its P is every vulnerable address on the internet, and its I is instant. Slammer's 8.5-second doubling time is what the product looks like with no human anywhere in it. A package worm's I depends on people and pipelines installing things. That is why package worms move in hours rather than seconds, and why detection speed matters.
| Factor | Control | What it does | What it misses |
|---|---|---|---|
| X | Install scripts off by default (npm 12, pnpm 10) | Skips lifecycle scripts and implicit node-gyp builds for unapproved packages | .pth files, editor and agent hooks, import-time payloads, and scripts approved by reflex on every upgrade |
| C | Trusted publishing, no tokens on laptops | Removes long-lived npm tokens from developer machines and CI secrets | Code in the CI runner can still mint a credential, as in TanStack and keyv |
| P | Granular tokens scoped to named packages | Caps how many packages one stolen token reaches | Maintainers with broad scopes; one account reached 323 packages on May 19, 2026 |
| G | Staged publishing with 2FA approval | Holds a publish in a queue until a human approves it with 2FA | Opt-in per package; a phished or rubber-stamping approver still approves |
| I | Dependency cooldowns | Consumers skip versions younger than a set age | Delays security fixes too; not standardized across ecosystems |
| I | Detection and install-time blocking | Flags and blocks a malicious version after it is published | The minutes between publish and detection |
Verdict: consumers should keep npm 12's script default, add a dependency cooldown with an override path for security fixes, and block known malware at install. Maintainers should turn on staged publishing for every package they publish, with the trusted publisher limited to staging so the release job cannot publish directly. It is the only control on the list that still works when the worm is already running inside that job.
Detection shrinks I from the consumer side. In the keyv and cacheable compromise, Socket flagged affected artifacts an average of 5 minutes and 18 seconds after publication. Socket Firewall Free (sfw npm install) filters the package manager's network requests and refuses to fetch a blocked package at any depth of the tree. It blocks only confirmed malware, meaning versions verified by human review, and only warns on AI-detected candidates. A fresh worm version can therefore be flagged minutes before it is blocked, and a cooldown covers exactly that gap.
Provenance does not appear in the table because it does not cut any factor. keyv@6.0.0 passed provenance checks because the release workflow built source that was already trojanized. Our write-up put the lesson in one line: "provenance attests build integrity, not source integrity."
Network worms were contained by patching, which shrinks the population of vulnerable hosts. A package worm has no bug to patch. Its vulnerable population is every package whose credential can publish without a human present. As of October 2026, that includes every npm package that a token or CI job can publish without staged approval. Until that population shrinks by default, each wave stops only when someone notices it.
Manifest confusion is a mismatch between an npm package's registry manifest and the package.json in its tarball, two copies that tools trust inconsistently.
A software bill of materials (SBOM) is a machine-readable inventory of the components, versions, and dependency relationships inside a piece of software.
Software composition analysis (SCA) inventories the third-party code in an application and checks each component for vulnerabilities, licenses, and malware.