New npm compromise: The keyv Worm Published Releases That Had No Commit Behind Them
Published on gastropod.io - 08-05-2026
The keyv Worm Published Releases That Had No Commit Behind Them
Tags: vulnerability intelligence, software supply chain security, npm, credential-theft, ci-cd-security, worm, provenance, sbom
Researchers are still counting the packages poisoned in the npm compromise that started around 09:00 UTC on August 4. Every vendor tracking it published a different number. The Hacker News reported SafeDep's registry-backed tally at 1,684 poisoned versions across 420 package names tied to nine organizations, with a separate count higher by package name and lower by version. Microsoft Threat Intelligence put it at more than 400 packages across unrelated publishers. DevOps.com reported a combined 2 billion monthly installs in scope. Each firm sampled a different minute of a campaign that moved between publishing organizations every two to seven minutes.
The first confirmed malicious release was keyv@6.0.0. It added node setup.mjs as a preinstall command and shipped setup.mjs and Math_Symbol.js alongside unchanged library code. Stage one checks for Bun, pulls version 1.3.13 from the runtime's official GitHub releases if it is missing, and runs a 727KB compiled bundle. That bundle collects GitHub, npm, AWS, Kubernetes, Vault, database, and SSH material, reads GitHub Actions runner memory, and then uses whatever npm publishing access it recovered to do the same thing to the next publisher. Microsoft's analysis describes the propagation routine plainly: download the target package's latest tarball, insert the malware and loader, replace the lifecycle scripts, increment the patch version, publish.
Microsoft noted that many malicious versions had no corresponding source commit, pull request, tag, or release. The attacker modified and published tarballs directly. The registry artifact and the source repository did not match. npm install never compares the two so you end up installing what the registry serves, not what the repo supposedly contains.
Provenance verification does not catch this either. The poisoned keyv release carried valid OIDC and SLSA attestations because it went through the project's real GitHub Actions release workflow. The attestation described the build process but does not about whether the input to that build was safe. This is similar to another article I wrote where attackers hijacked the AsyncAPI release pipeline in July. This campaign includes a targeted path for republishing through workflows configured as npm trusted publishers. Packages coming out of that route inherit legitimate provenance by construction.
A blocklist by package name does not work in this situation. The attacker did not touch every package in the affected namespaces: the published @keyv/* adapters and the keyv 5.x line stayed clean. Meanwhile the latest tag kept changing. Hours after the removal of the poisoned keyv and cacheable releases, latest still resolved to a malicious version for most other affected names. Upgrading to latest on August 4 could have moved you from a clean version to a compromised one. Blocking by name flags releases that are fine and misses versions that are not.
What actually scopes this incident is version-level and digest-level data: the exact release your lockfile resolved, whether the machine that installed it ran lifecycle scripts, and which builds and workstations pulled it inside the publication window. Microsoft goes further, telling teams to confirm the compromised hashes are absent from package caches and artifact stores, and to rebuild shared base images and golden runners so future jobs do not inherit poisoned caches, but that is a question about your own storage, not about npm.
A second execution path will survive a dependency cleanup. The worm injects .claude/settings.json, .claude/setup.mjs, .vscode/tasks.json, and .vscode/setup.mjs into repositories it can write to. The VS Code task runs on folder open and the Claude Code hook runs at session start. Workspace trust prompts gate both, so they do not fire unconditionally, but a developer who clicks through the prompt on a checked-out repo executes the payload with no npm install involved. Removing the bad dependency version does not remove that. The payload also installs a token-revocation watcher: SafeDep advised responders to remove it before rotating credentials, because revocation is what triggers its destructive handler.
If you run npm 12, the client blocks unapproved dependency lifecycle scripts by default. Earlier clients and other install paths do not.
How a product like gastropod could help in this situation:
Gastropod is an artifact registry that assigns identity at intake. Every artifact resolves to a PURL, version, ecosystem, source, and sha256 digest. The store is content-addressed, so a poisoned tarball and its clean predecessor are separate objects with separate identities. That is the difference between "is keyv affected" and "did keyv@6.0.0, digest abc123, enter this org, and where is it now."
The per-pull audit log in gastropod attributes every pull to a principal, with the client, source IP, and timestamp. For a campaign with a bounded publication window on a single day, you scope with a query instead of a survey: which principals pulled an affected version between 09:00 UTC and the takedown, and which repositories and runners were they operating on. The blast-radius graph gives you the transitive dependents, so you know which of your own services carried the poisoned version even when nobody pulled it directly. Gastropod reconstructs a CycloneDX SBOM on demand for each consumer, which makes the "compare lockfiles and resolved versions against the affected list" step something you run rather than something you organize.
Block rules apply at intake. OSV advisories correlate to the artifact, so once you mark a version bad the registry refuses it at intake. This of course does not prevent the original maintainer-account compromise but it will tell you when it matters exactly which digests are in your caches and exactly who pulled them.
If your answer to "which build runners pulled an affected version yesterday" involves grep and guessing, you are missing the pull history. Point a package client at Gastropod and see what yours looks like.