Skip to main content

The npm worms of 2026

· 10 min read
Vulkro
Security research

In 2025 a self-replicating worm showed the npm ecosystem what one stolen publish token could do. We covered that year in supply-chain attacks are a code problem. This post is about what came after: 2026 brought several more worms, and each wave changed the trick it used to run code during npm install.

The pattern held. A malicious version runs code at install time, harvests whatever credentials the machine holds, and uses any npm token it finds to publish poisoned versions of other packages. What changed was where the code hid and how the payload ran.

Below is what the primary write-ups and advisories say, the defences that slow these worms down, and where Vulkro fits. We are careful about that last part, because a scanner that reads code after it is installed is not a substitute for not running it.

April and May: the Shai-Hulud follow-ons​

Unit 42's running npm threat landscape page (updated 15 July 2026) tracks the spring waves. Its timeline:

  • 22 April. A campaign whose payload carried the string "Shai-Hulud: The Third Coming", tied to a malicious @bitwarden/cli@2026.4.0 that Unit 42 says was live for about an hour and a half that evening.
  • 29 April. A wave it calls Mini Shai-Hulud, aimed at the SAP developer ecosystem through four npm packages. These added a preinstall hook, node setup.mjs.
  • 11 May. Unit 42 reports 84 malicious artifacts across 42 @tanstack/* packages within six minutes, and 373 malicious versions across 169 npm packages by the end of the day. The entry point was a pull_request_target workflow, a poisoned GitHub Actions cache and an OIDC token pulled from runner memory.
  • 19 May. 639 malicious versions across 323 packages in about an hour, with a preinstall of bun run index.js.

Unit 42 attributes the April and May waves to a group called TeamPCP. It adds that once the worm's source was released on 12 May, the activity could no longer be pinned to that group alone. Two technical details recur. The payload downloads the Bun JavaScript runtime during install and runs through it rather than through Node. And according to Unit 42, the 11 May payload carried a destructive fallback if the stolen GitHub token was revoked.

June: a worm with no install script​

On 5 June 2026 the OSV database published MAL-2026-5209 (also GHSA-mjhg-2cvf-w2p4) for @vapi-ai/server-sdk, covering versions 0.11.1, 0.11.2, 1.2.1 and 1.2.2. The record describes the Miasma malware as a self-propagating worm that abuses weaponised binding.gyp files, so that it runs during installation and evades tools that inspect only lifecycle scripts. It steals credentials and OIDC tokens and spreads by compromising other packages those accounts manage.

The trick is in npm's own documentation. The scripts page explains that when a package has a binding.gyp at its root and no install or preinstall script of its own, npm defaults the install step to node-gyp rebuild. A gyp file can run commands while it is processed. So the package's scripts block can be empty, and code still runs at install time.

That matters for any defence that reads package.json scripts, including ours. More on that below.

August: keyv and the cacheable family​

On 4 August 2026 the OSV record MAL-2026-11524 (also GHSA-3p9h-f68w-m6fx) flagged keyv@6.0.0, and the scoped @keyv/* packages received their own malicious-code records the same day. According to the advisory:

  • The trojanized package.json adds a preinstall script that runs setup.mjs before any project code.
  • setup.mjs downloads a standalone Bun runtime and uses it to run an obfuscated second stage of about 728 KB.
  • The stealer collects cloud metadata credentials, provider keys, Vault and Kubernetes tokens, GitHub Actions OIDC tokens and secrets, and npm tokens, then sweeps for other secrets and private keys.
  • The stolen npm identity is used to republish other packages it can reach.
  • The release carried valid provenance attestations, because the attacker pushed it through the maintainer's own GitHub Actions pipeline.

That last point deserves attention. Provenance tells you which pipeline built a package. It does not tell you whether that pipeline was in friendly hands that day.

How far it spread depends on who you ask, and the counts were moving while people wrote them. The advisory links it to a wave of more than 400 packages. A JFrog Security Research post from 4 August says 400+ packages across 1,700+ versions. A Cloudsmith write-up the same day tracked roughly 444 packages and about 2,236 malicious versions. Treat every figure as a snapshot.

What slows a worm down​

No single setting stops all of these. Each one below breaks a specific link in the chain.

Do not run install scripts by default. Unit 42 recommends ignore-scripts=true in .npmrc. npm's config reference says that with it set, npm does not run scripts specified in package.json files. Packages that need a native build can then be rebuilt on purpose, one at a time, once you have looked at them. The June wave is a reason to check that your own install path really skips the implicit node-gyp step too, rather than assuming it.

# .npmrc
ignore-scripts=true

Install from the lockfile, exactly. npm ci exits with an error if package.json and the lockfile disagree, and never writes to either. Combined with exact pins, that keeps a fresh poisoned version from floating in through a ^ range. Unit 42 also suggests a cooldown that refuses versions published in the last 24 to 72 hours: most of these waves were found and pulled within that window.

{
"dependencies": {
"keyv": "^5.3.0"
}
}

A caret range like this one accepts the next minor release, whatever it contains. A committed lockfile installed with npm ci accepts only the version you already reviewed.

Make stolen tokens worth less. Every one of these worms spread by reusing a token it found on disk or in the environment. Keep long-lived publish tokens off developer machines and CI runners that install third-party code. Scope what remains to the packages it needs and give it an expiry.

Use trusted publishing, and lock it down. npm's trusted publishers feature publishes from CI with short-lived OIDC credentials instead of a stored token. The same page recommends that once it works, you disallow token-based publishing for the package. Two cautions from this year apply. Unit 42 identified a trusted-publisher binding that trusted a whole repository rather than one workflow on a protected branch, and keyv showed that a hijacked pipeline publishes with perfectly valid provenance. Bind trust to a specific workflow, protect the branch it runs from, and treat pull_request_target workflows that touch caches or secrets as high risk.

Rotate from a clean machine. Both OSV records say the same thing: treat a machine that installed an affected version as compromised, and rotate its credentials from a different computer.

Where Vulkro fits​

Vulkro reads what is in your project. Its malicious package behaviour checks look at what is installed there:

  • Install scripts that fetch or run code. For each installed npm package, the check reads the preinstall, install and postinstall command and the script file that command runs. It flags a script that reads a credential store or a named token and also makes a network call, one that pipes a download into a shell, and one that decodes and executes a payload.
  • setup.py with network or exec at import. In vendored Python packages, module-level code in setup.py runs during pip install, so a network call or exec there is install-time code.
  • Executable .pth files. Python's site documentation says lines in a .pth file that start with import are executed, and that happens at interpreter startup. A .pth file in site-packages whose import line decodes, execs, shells out or fetches is flagged.
  • Private-looking names resolved from the public registry. When a lockfile shows a package fetched from the public registry under a scope your .npmrc maps to a private registry, or under a name that looks internal, the dependency-confusion finding is graded High with that evidence attached.

vulkro inspect groups these by capability for a person to read, and the same checks run as part of vulkro scan.

Now the limits, plainly. A preinstall hook that downloads code and runs it is exactly the kind of behaviour the first check exists for. But the check grades what the hook command and the file it names actually contain. A loader that only downloads a runtime and hands everything else to a second file can fall below the bar, because downloading a prebuilt binary at install time is common and usually legitimate. We have not run these checks against the samples from these waves, and we do not claim they would have flagged any particular one.

The June technique also sits outside the first check by design: it reads package.json scripts, and a binding.gyp payload has none. And because these checks read packages already on disk, they help most when the install ran with scripts disabled. Install with ignore-scripts, scan what landed, then rebuild only the packages that need it.

As with all of Vulkro's malicious-code checks, an empty result means no known shape was found. It never means the code is safe.

Start with what is installed​

Run vulkro inspect on a project after a scripts-off install, and vulkro scan for the lockfiles, the dependency advisories and your own workflows. Both run locally and send no code anywhere. The inspect reference and the install-hook rules have the details. To run it on your own code, start here.

Sources​