Vivian Voss

npm Is on Fire

security node freebsd architecture

Wire Fire ❯❯❯❯ Episode 01

npm, the open registry that sits behind nearly every JavaScript project on the public internet, has been under permanent attack for years. Every modern application pulls in dozens of packages, each of which pulls in more, silently and on every install. In 2025 alone, security vendors logged 454,648 malicious npm packages; Unit 42's parallel telemetry puts more than 99 per cent of all observed open-source malware on npm. This week's worm waves are not the disease. They are the latest symptom.

Six weeks, four waves, one open-source worm. This is the post about what happened, what it means for anyone whose production runs JavaScript, and what an architecture that did not have this problem in the first place looks like.

The Breach, in Four Acts

31 March 2026. State-sponsored operators push a backdoored release of axios, versions 1.14.1 and 0.30.4, into the npm registry. The payload is a remote-access trojan delivered via a malicious dependency, plain-crypto-js@4.2.1, executed at install time through the postinstall lifecycle hook. The poisoned versions are live for roughly three hours and, crucially, tagged latest; on a package with roughly one hundred million weekly downloads, three hours of latest is enough. Microsoft's threat-intelligence team attributes the operation to Sapphire Sleet (DPRK); Mandiant tracks the same cluster as UNC1069.

29 April 2026. Mini Shai-Hulud, a stripped-down variant of the previous autumn's Shai-Hulud worm, surfaces against four SAP-related npm packages. The wave is small enough to read as a smoke test. With hindsight, it was.

11 May 2026, 19:20 to 19:26 UTC. A six-minute window in which, per Wiz's incident telemetry, 84 poisoned versions are published across 42 packages in the TanStack JavaScript ecosystem. Within 48 hours, the wave widens to roughly 172 packages and 403 versions across npm and PyPI, with an estimated 518 million cumulative downloads; the blast radius reaches @uipath, @mistralai/mistralai, OpenSearch, Guardrails AI, and a long tail of smaller projects. The flagship casualty, @tanstack/react-router, ships roughly twelve million weekly downloads of its own.

12 May 2026. The malware-research aggregator vx-underground reports that the fully weaponised Shai-Hulud worm source code is now publicly available. The attack kit is, as of that morning, off the shelf. This is the watershed; everything before was a campaign, everything after is the weather.

Six Weeks, Four Waves, One Open-Source Worm toolkit off the shelf 31 Mar axios 1.14.1 + 0.30.4 backdoored 3 h, tagged latest ~100M weekly 29 Apr Mini Shai-Hulud hits 4 SAP-related npm packages 11 May 19:20–26 84 versions / 42 pkgs TanStack → 172/403 in 48 h, ~518M dl 12 May Shai-Hulud source code goes public Wave 4 is the watershed. Until then the kit was custom; from 12 May it is downloadable.

The Scope

Some numbers, because the scale is the argument. axios alone ships roughly one hundred million weekly downloads. The flagship TanStack package, @tanstack/react-router, ships roughly twelve million on its own. Cumulative downloads of the compromised cohort across the May wave: an estimated 518 million. 172 packages, 403 versions, two registries (npm and PyPI), inside 48 hours.

Set against the steady-state numbers from 2025, the campaign is not an aberration. 454,648 malicious npm packages observed in 2025 alone, per Cybernews and Unit 42's parallel telemetry. More than 99 per cent of all open-source malware now targets npm. The reason is not that JavaScript developers are more wicked than their colleagues in other ecosystems. The reason is that npm is the easiest registry on the public internet to attack and one of the largest by usage. The two properties are not independent.

Where Open-Source Malware Lives in 2025 npm > 99% — 454,648 malicious packages PyPI + RubyGems + Crates.io + Maven + others < 1% combined The largest registry is not the only one. It is the easiest.

The Mechanism

The dependency chain is the attack surface, and the worm walks it. Compromise one maintainer account anywhere in the chain (by phishing, by a leaked CI token, by an over-permissioned bot pull request), and the worm does the rest: it harvests the credentials of every package that account can publish to, publishes poisoned versions under those stolen identities, and uses each new compromise to reach the next. The 11 May wave automated this across hundreds of projects in seconds.

The 11 May automation, per StepSecurity's reconstruction, chained three weaknesses in GitHub's build service. First, a pull_request_target trigger configured without input sanitisation, exploitable through a so-called "Pwn Request": a malicious pull request runs with the privileges of the target repository, not the forking one. Second, GitHub Actions cache poisoning: a malicious cache entry is keyed such that a later run on the legitimate branch reads attacker-controlled code as if it were trusted build artefact. Third, OIDC token extraction: the workflow's short-lived OIDC token, meant to authenticate to cloud providers, is used to publish to npm before it expires. The payload then runs inside any machine that does npm install, via the standard lifecycle hooks. The destructive variants observed on the day include rm -rf ~/ on the install host once the harvested tokens have been revoked by their owners.

How the Worm Walks the Chain 1 ■ Compromise one maintainer phishing • leaked CI token • bot PR 2 ■ Harvest credentials npm tokens, GitHub PATs, OIDC artefacts 3 ■ Publish poisoned versions under the stolen identity, tagged latest 4 ■ Walk to the next maintainer and repeat — the chain is the surface The 11 May automation three weaknesses chained pull_request_target Pwn Request: runs with target repo privileges Actions cache poisoning attacker-controlled artefact read as trusted on next run OIDC token extraction short-lived token publishes to npm before it expires

The Exposure

If you have installed axios versions 1.14.1 or 0.30.4 since 31 March, or any package matching @tanstack/*, @uipath/*, @mistralai/mistralai or any of the SAP-related npm namespaces in the same window, the assumption is that the host on which the install ran is compromised.

The minimum response, in order:

  1. Rotate every credential reachable from the affected host: npm tokens, GitHub Personal Access Tokens, SSH keys, cloud-provider credentials, anything stored in the local keychain
  2. Downgrade or pin the affected packages to known-good versions; treat the lockfile as canonical evidence of what actually got installed
  3. Audit transitive dependencies; the worm is interested in the chain, not the headline package
  4. On FreeBSD, cross-check against the VuXML advisory feed, which the Ports security team maintains as a structured XML record of known vulnerabilities in the ports tree

For everyone else, the structural hygiene that the year after this article should be normal:

npm config set ignore-scripts true

The line above turns off lifecycle scripts for all npm install operations. Many packages will stop working; that is the point. A package whose install requires running arbitrary code on the operator's machine is, in the security model of any other domain, an unsigned binary one is being asked to execute on trust. If it breaks, you know which packages have been smuggling install-time code under the dependency wrapper.

Then: pin transitive dependencies (every layer, not just the top), isolate untrusted installs in FreeBSD jails or equivalent containers, separate the operator's workstation from the build host, and treat any new dependency the way a sensible person treats a parcel that arrived without an invoice.

The Pattern

npm was designed for trust by default. Every install runs arbitrary code with the permissions of the user invoking it. The average modern project pulls in over a thousand contributors no one in the project has ever met, through a transitive graph that npm itself does not surface honestly. The ecosystem has had left-pad in 2016, event-stream in 2018, ua-parser-js in 2021, the September 2025 wave of eighteen packages with 2.6 billion weekly downloads, and now the fully open-sourced worm of May 2026. Five years of public incidents, one public worm, and the architecture remains, rather notably, unchanged.

The architecture is the product. One phished maintainer, one over-permissioned token, one approved bot pull request: each is enough; the mistake travels at machine speed; and there is no brake in the pipeline. There never was one. The defenders' wrappers (Dependabot, Snyk, Renovate, GitHub Advanced Security, the entire supply-chain monitoring SaaS economy) sit downstream of the registry and run on the engineer's pull-request schedule, which is, on a busy week, slower than the worm.

The FreeBSD project's Ports collection offers a quietly contrasting design. Each port is maintained by a named human committer, reviewed before commit, signed at distribution, and tracked in VuXML for advisories. Updates land slowly, on purpose. Boring on purpose. The cost of slowness is a freshness lag of days or weeks; the cost of npm's speed is the last six weeks of this article. Capsicum, jails and per-build rctl limits provide the runtime budget the registry chose not to provide. None of this was invented in 2026. The Ports tree has worked this way since the late 1990s.

Two Registries, Two Trust Properties npm (status quo) trust by default, arbitrary install-time code ~1,000 transitive contributors per project automated publish via OIDC tokens no human review at commit advisories scattered across vendor blogs optimised for velocity FreeBSD Ports (since ~1995) named maintainer per port human review before commit signed distfiles, checksum-verified slow updates, on purpose VuXML: single canonical advisory feed optimised for trust A trade-off, not a hierarchy. Choose the one whose failure mode you can afford.

For any environment that takes its own security seriously, npm is simply not fit for purpose. That is not a moral statement about JavaScript; it is an architectural statement about a registry whose threat model was never updated for the world in which the threat actors became patient. The choice is between accepting the freshness premium and the failure mode that goes with it, or moving the production stack to a registry whose architecture already disagrees with the worm.

This week's wave was a campaign. The fire underneath has been the weather for years, and the forecast does not change. Forty-five poisoned versions in six minutes; one published worm; an open registry that has no brake. The architectural answer is older than the problem. Today it is the weather.