Why supply-chain attacks dominate the JavaScript ecosystem
npm install pulls 1,200 packages from hundreds of strangers and runs their code. The frontend ecosystem is the densest, most-trusting dependency graph in software, and that density is the attack surface.
Why it exists
You clone a small side project, run npm install (or pnpm install), and watch a wall of progress bars scroll past. When it stops, your node_modules directory holds 1,200 packages, written by hundreds of strangers, most of whom you’ve never heard of. You didn’t read any of the code. Your editor’s auto-save will, in a few minutes, run hooks from at least a dozen of those packages with the same permissions you have. None of this struck you as weird, because this is just how JavaScript projects work.
That picture is the threat model. The JavaScript ecosystem is the densest dependency graph in mainstream software — the standard library is famously thin, the package manager is famously easy, and the culture rewards micro-packages. A single click-to-install can transitively pull in code from people who didn’t even know your project existed. That density is the surface attackers go after, and they go after it because it works.
A few real incidents map this out. In November 2018 a maintainer of the widely-used event-stream package — by his own admission, burned out and no longer interested — accepted an offer of help from a stranger calling themselves right9ctrl and handed over publish rights. The new “maintainer” added a dependency, flatmap-stream, with an encrypted payload that activated only inside the Copay Bitcoin wallet build and tried to siphon out wallets holding more than ~100 BTC. The malicious version sat in the registry for about 2.5 months before anyone noticed. In October 2021 the ua-parser-js package — pulled in by Google, Facebook, Amazon and friends, ~8 million downloads a week — had three versions published with a cryptominer and credential stealer after the maintainer’s npm account was hijacked. The malicious versions were live for roughly four hours, which was plenty. In March 2022 the maintainer of node-ipc — used in over a million weekly installs as a transitive dependency — shipped a payload that wiped files on machines geolocated to Russia or Belarus as protest against the invasion of Ukraine. Same surface, different motive.
The unifying shape across all three is that nothing in the supply chain noticed. Trusted package, hijacked (or self-sabotaging) maintainer, automatic upgrade — and your build is now running their code.
Why it matters now
The pace has accelerated, not slowed. In September 2025 a worm called Shai-Hulud appeared on npm. It’s the first widely-documented self-replicating worm in a public package registry: when a developer with a compromised npm token installs an infected package, the worm uses the token to enumerate every other package that developer maintains and publishes a poisoned version of each. It also scans the machine for tokens (npm, GitHub, AWS, GCP) using TruffleHog and exfiltrates them. By the time the first wave was being characterized, it had hit ~180 packages; a second wave dubbed “Shai-Hulud 2.0” in November 2025 moved its execution to pre-install (so even npm install --ignore-scripts for a related lifecycle hook isn’t enough on its own) and is reported to have generated tens of thousands of malicious GitHub repos as a side-effect of credential exfiltration. I’m naming this carefully because it’s recent: the high-level shape (npm worm, token-driven self-propagation, secret harvesting) is well-sourced; specific package counts and victim totals are still moving as researchers publish.
The Python and Ruby ecosystems have had similar incidents — typo-squats, malicious sdists, hijacked PyPI accounts — but at lower density, mostly because the dependency graphs are shallower. Go is a useful contrast: its standard library is broad enough that idiomatic projects vendor far fewer dependencies, and there is no postinstall hook running arbitrary code on your machine. That isn’t a security feature on purpose so much as a culture difference, and the culture difference is the security delta.
If you ship JavaScript in 2026, supply-chain risk is the most likely way your project gets owned. Not your code. Theirs.
The short answer
supply-chain attack = trusted package + hijacked maintainer/token + automatic upgrade
A supply-chain attack doesn’t break your code; it breaks the path by which someone else’s code becomes your code. The attacker takes over a package you (or a package you depend on, or a package that package depends on) already trust, publishes a new version, and waits for the world’s ^1.2.3 semver ranges and CI rebuilds to pull it down and run it.
How it works
The mechanism rests on four properties of the npm ecosystem that are individually defensible and collectively a disaster.
1. The graph is enormous. A modern frontend project’s node_modules has hundreds to low thousands of packages. Most of those are transitive — pulled in by something you pulled in, often two or three levels deep. The reasons are real: a culture of small composable packages (is-odd, left-pad, is-number), a thin standard library, and the fact that the registry makes it free to publish a 12-line module. The cost is that “I trust this package” really means “I trust this package’s author plus the closure of every author of every package they depend on, recursively.”
2. The default is to upgrade automatically. package.json ranges like ^1.2.3 mean “any compatible 1.x.” The first time a fresh CI runner resolves them, it gets the latest matching version on the registry. Lockfiles pin what you installed, but a brand-new clone with no lockfile, or a pnpm update, or a Dependabot PR, will happily pick up the version published 30 seconds ago.
3. Lifecycle scripts run arbitrary code at install time. Packages can declare preinstall, install, postinstall (and others) scripts that execute on the developer’s machine and in CI, with the developer’s permissions. This is how node-gyp builds native bindings; it is also how malware ships. The Shai-Hulud worm used these hooks; the 2.0 variant moved earlier in the lifecycle to dodge --ignore-scripts.
4. Maintainer accounts are the keys. Publish access on npm is, ultimately, gated by a token or an account. Tokens leak from CI logs, from compromised laptops, from phishing pages that look like npmjs.com. Accounts get phished — that’s how ua-parser-js fell. Or accounts get given away, as event-stream was. There is no human review step between “maintainer pushes a version” and “your pnpm install runs it.” The registry is a publish-and-go medium.
Stack those four together and the attack writes itself: phish a maintainer, push a new minor version, wait for the world’s auto-upgrade to spread it. With a worm like Shai-Hulud you don’t even need to phish individual maintainers — one compromised victim becomes the next attacker.
Defenses, with the seams
There is no clean fix. There are several partial fixes, each with a known weakness.
- Lockfiles (
package-lock.json,pnpm-lock.yaml,yarn.lock). Pin every transitive dependency to an exact version + integrity hash. This stops silent upgrades — once you’ve installed a version, your build will keep getting that exact tarball. The seam: lockfiles don’t help against a malicious version you haven’t installed yet. The first developer or CI job to resolve a fresh range still gets whatever’s latest. - Disable lifecycle scripts.
npm install --ignore-scripts, or pnpm’sonlyBuiltDependenciesallow-list, prevent untrusted packages from running code at install time. This repo’spnpm-workspace.yamlrestricts builds toesbuildandsharpfor exactly that reason. The seam: lots of packages legitimately need build steps, the allow-list has to be maintained, and Shai-Hulud 2.0 specifically moved to a pre-install lifecycle hook to widen what it could catch. - Release-age delays. pnpm’s
minimumReleaseAgesetting refuses to install any version published more recently than a configured window. The bet is that yanked malicious releases are usually identified within days of going up, so a delay buys you free immunity to most acute attacks. The CLAUDE.md for this repo names this explicitly: it setsminimumReleaseAge: 10080— 7 days — as a deliberate defense against Shai-Hulud-style worms. The seam: it’s a bet, not a guarantee. The 2.5-month dwell time ofevent-streamwould have laughed at a 7-day window. - Provenance and signing. npm provenance attestations and Sigstore let publishers attach a verifiable claim that “this tarball was built from this commit on this CI runner.” This makes some classes of attack harder — a stolen npm token alone can’t forge a CI provenance — and gives auditors something to grep. The seam: it’s only as good as the runner’s secrets hygiene and the consumer’s willingness to check. Most installs don’t.
- Vendoring and minimal dependencies. The Go ecosystem’s culture is openly different: a broad standard library, fewer-but-larger dependencies, vendored source by default. You can run a JavaScript project that way too — pin everything, audit additions, prefer one big well-known package to a thicket of small ones. The seam: you’re swimming against the river. Most JS tooling assumes the dense graph.
The honest line is that none of this makes you safe. It makes you less likely to be patient zero — and, if you’re patient one or two, it shrinks the time window during which the bad version reaches your build. The structural fix would be a culture shift toward fewer, larger, more-vetted dependencies, and that is not happening. The dense graph is what the ecosystem is.
So you compose the partial fixes — lockfiles, script restrictions, release-age delays, provenance checks where you can get them — and you accept that the next worm is, at this point, a “when,” not an “if.”
Famous related terms
- Typosquatting —
typosquat = malicious package + name that looks like a real package—lod4shinstead oflodash. Cheaper than hijacking, relies entirely on a developer’s typo or autocomplete miss. - Dependency confusion —
dep confusion = public package + same name as your private one + higher version number— Alex Birsan’s 2021 trick that smuggled code into Apple, Microsoft and others by exploiting registry-resolution order. postinstallscript —postinstall = arbitrary command + runs on every npm install— the load-bearing footgun. Most malicious npm packages do their work here.- Lockfile —
lockfile = exact-version pins + integrity hashes for every transitive dependency. Reproducibility tool that doubles as a partial defense. - npm provenance —
provenance = signed attestation that this tarball came from this commit + this CI runner, backed by Sigstore’s transparency log. Defends against pure token theft, not against a maintainer who builds the malicious version themselves. - Shai-Hulud —
Shai-Hulud = npm worm + stolen maintainer token + auto-republish of every other package the victim owns. The 2025 incident that motivated this whole post.
Going deeper
- npm Inc.’s post-mortem on the event-stream incident.
- CISA’s advisory on the ua-parser-js compromise.
- Snyk’s write-up on the node-ipc protestware and the wider open-source-trust questions it raised.
- Palo Alto Unit 42’s running analysis of Shai-Hulud and Datadog Security Labs’ Shai-Hulud 2.0 analysis — the most current sources at time of writing; numbers are still moving.
- Alex Birsan’s Dependency Confusion — the canonical write-up of the 2021 attack class.
What I’m confident about: the four-incident timeline above (event-stream 2018, ua-parser-js 2021, node-ipc 2022, Shai-Hulud 2025), the structural mechanism (transitive graph + auto-upgrade + install scripts + maintainer-account trust), and the partial-fix nature of every defense listed. What I’m less confident about: the precise scale of Shai-Hulud at any given moment — researchers were still publishing updated counts during the November 2025 wave, and any specific number I’d quote here would be stale by the time you read it. Treat the package counts as “hundreds to tens of thousands depending on which wave and which definition,” and follow the linked write-ups for current figures.