SM

Command Palette

Search for a command to run...

Blog

The Fix Shipped 27 Days Early: What I Learned Researching the keyv Shai-Hulud npm Worm

Syed Moinuddin12 min read
SecurityDev ToolsBackendWorkflow
The Fix Shipped 27 Days Early: What I Learned Researching the keyv Shai-Hulud npm Worm

npm 12 turned dependency install scripts off on July 8. On August 4 a hijacked keyv release used a preinstall hook to spread to hundreds of packages. Here is the 10-minute check, the cleanup order, and the two lines of .npmrc that close the gap.

Here is the whole argument up front. On July 8, 2026, npm 12 shipped with dependency install scripts turned off by default S1S2. Twenty-seven days later, on August 4, a hijacked keyv release used exactly that feature, a preinstall hook, to steal credentials and republish itself into hundreds of other packages S3. The fix existed before the attack. It did not help anyone on a bundled npm, because the Node.js Release WG decided not to land npm 12 on Node 22, 24 or 26 S4. If you run Node today, install scripts from your dependencies still run unless you turn them off yourself. This post is the 10-minute check worth running now, plus the two lines of config that close most of the gap.

I started this expecting to write a "here is the latest worm" recap. What changed my mind was the npm 12 timeline. The new defaults shipped in July, so I expected the advice to be "update and you are covered". Then I read the Node.js Release WG thread and found that no current Node line bundles npm 12. That gap, between a security default being released and being the thing on your machine, is the useful part of this story.

Timeline from June to November 2026: npm v12 changes announced June 9, npm 12.0.0 ships with install scripts off July 8, keyv 6.0.0 published with a preinstall hook August 4 at 09:35 UTC, 27 days later, Morphisec counts 1,280+ packages August 20, Node 22, 24 and 26 still ship without npm 12 on October 2, and Node 27 alpha opens October 28.

FIG. 01 · The fix shipped 27 days before the worm · npm 12 on Jul 8, keyv@6.0.0 on Aug 4, Node 27 alpha on Oct 28 S2S3S4

What actually happened on August 4?

Phoenix Security's timeline is the most precise one I found S3. Between 09:02 and 09:04 UTC, the attacker planted IDE hooks on keyv's main branch through a hijacked maintainer account. At 09:35, keyv@6.0.0 was published with a new setup.mjs file and "preinstall": "node setup.mjs" in package.json S3S5. Four minutes later the payload had been copied into 19 @keyv/* workspace packages. At 12:10, the latest tags were partly rolled back to clean releases S3.

The dropper does not carry the malware itself. It downloads the legitimate Bun runtime (v1.3.13) and uses it to run a 728 KB obfuscated file called Math_Symbol.js S3S5. That file collects secrets, uploads them encrypted to public GitHub repositories whose description contains "Shai-Hulud: Here We Go Again" (Aikido counted about 1,300 of them), and then uses any npm token it found to inject itself into other packages, bump their versions and publish them S5S3. That last step is why this is a worm and not a single bad release.

Five-step attack chain on August 4, 2026: IDE hooks planted on keyv main at 09:02 UTC, keyv 6.0.0 published with a preinstall hook at 09:35, setup.mjs downloads Bun 1.3.13 and runs a 728 KB payload, credentials are harvested to public GitHub repos, and stolen npm tokens republish other packages, looping back to step two.

FIG. 02 · Five steps, and the fifth restarts the first · every stolen publish token becomes another 09:35 S3

Sygnia notes this is a return of the Shai-Hulud family, whose first wave hit npm in September 2025 S6. Morphisec's Brad LaPorte calls it the fourth wave, and makes the point that stuck with me most: the release was properly signed. In his words, "the signature was honest. The code was not." S7

Why would I have keyv if I never installed it?

Because you rarely install it directly. The compromised list includes flat-cache@6.1.24 and file-entry-cache@11.1.6, and Phoenix Security says those reached projects transitively through ESLint S3. If that is your path, the hook runs wherever you install dev dependencies, including CI, which is also where publish tokens and cloud credentials tend to live.

Dependency chain from your app to eslint, then a dashed link to file-entry-cache (11.1.6 compromised, 11.1.5 safe) and flat-cache (6.1.24 compromised, 6.1.23 safe). keyv alone has about 127 million weekly downloads.

FIG. 03 · You never typed keyv · the bad versions sit two hops below a devDependency S3S5

I could not confirm which ESLint versions resolve to the bad versions, so treat the chain as a possibility to check, not a fact about your repo. Here is the check, using the full package list from Kodem Security's runbook S8:

# Run in every repo and in every CI image you build from
npm ls keyv flat-cache file-entry-cache cacheable-request cacheable \
  @cacheable/utils cache-manager @cacheable/net @cacheable/node-cache \
  @cacheable/memory ecto --all

The bad versions to look for, per Singapore's CSA and Aikido: keyv 6.0.0, flat-cache 6.1.24, file-entry-cache 11.1.6, cacheable-request 13.0.20, cacheable 2.5.1, cache-manager 7.2.10, @cacheable/memory 2.2.1, @cacheable/node-cache 3.1.2, @cacheable/utils 2.5.1, @cacheable/net 2.1.1 and ecto 5.0.1 S9S5. Kodem lists the safe pins as keyv 5.6.0, flat-cache 6.1.23, file-entry-cache 11.1.5, cacheable-request 13.0.19, cache-manager 7.2.9 and ecto 5.0.0 S8.

How big was it, really?

It depends who you ask, and when. Sygnia says 400+ packages, Aikido 444 packages across 1,381 versions, Phoenix 868 packages across the same 1,381 versions, and Morphisec more than 1,280 packages by August 20 S6S5S3S7. Sonatype tracks 2,225 affected versions, and CSA says over 1,300 S10S9. The number most of them agree on is reach: over 2 billion monthly installs S5S9.

Bar chart of reported counts by source. Packages: Sygnia 400+, Aikido 444, Phoenix 868, Morphisec 1,280+. Versions: CSA Singapore 1,300+, Aikido 1,381, Phoenix 1,381, Sonatype 2,225. Most sources agree on over 2 billion monthly installs.

FIG. 04 · Every source counts it differently · packages and versions reported between Aug 4 and Aug 20 S3S5S6S7S10S9

I am not going to pick a winner. The counts measure different things at different times, and Kodem's advice is the right frame: treat the blast radius as "several hundred packages and rising rather than a settled number" S8. Your own lockfile is the only count that matters to you.

What does it take, and what does it leave behind?

The payload goes after npm tokens, every kind of GitHub token (classic PATs, OAuth, App and OIDC), AWS credentials from files, environment variables, instance metadata and Secrets Manager, plus GCP, Azure, Kubernetes service account tokens, Vault, Stripe and Slack S5S3. It also scans about 200 file patterns for SSH keys, .env files, Terraform state and Docker configs S5S7.

Two panels. It takes: npm tokens, GitHub tokens (classic PAT, OAuth, App, OIDC), AWS credentials, GCP and Azure credentials, Kubernetes service account tokens, Vault tokens, Stripe and Slack API tokens, and about 200 file patterns such as SSH keys and .env files. It leaves behind: a .claude/settings.json SessionStart hook, a .vscode/tasks.json folder-open task, a gh-token-monitor service, and bun-dl folders.

FIG. 05 · It takes the keys, then moves into your editor · removing the package does not remove the persistence S5S8

The part most recaps skip is persistence. The worm commits to up to 50 branches per repository and adds hooks to .claude/settings.json (a SessionStart hook) and .vscode/tasks.json (a task that runs on folder open) S5S8. It also installs a token watcher called gh-token-monitor as a shell script, systemd service or macOS plist S8. So a clean lockfile is not a clean machine. Hunt for the leftovers:

# Payload files and the downloaded runtime
find . -name setup.mjs -path '*node_modules*' 2>/dev/null
find . -name 'Math_Symbol.js' 2>/dev/null
find ~ -maxdepth 4 -type d -name 'bun-dl-*' 2>/dev/null
 
# Editor and agent hooks added on or after the attack
find . -path '*/.vscode/tasks.json' -newermt '2026-08-04'
find . -path '*/.claude/settings.json' -newermt '2026-08-04'
 
# The token watcher, on Linux or macOS
find ~ -maxdepth 6 -name 'gh-token-monitor*' 2>/dev/null
find ~ -maxdepth 6 -name 'com.user.gh-token-monitor.plist' 2>/dev/null

Phoenix also suggests checking proxy or build logs for Bun downloads and the Bun/1.3.13 user agent, and for traffic to npm-cache[.]com S3.

Why didn't npm 12 stop it?

It would have, for anyone running it. npm 12 stopped dependency preinstall, install and postinstall scripts from running unless you allow them, and also blocks Git and remote tarball dependencies by default S1S2. But npm usually arrives bundled with Node, and the Release WG decided not to land npm 12 on Node 26, 24 or 22 S4. The earliest bundled path is Node 27, whose alpha opens October 28, 2026, and even that depends on an npm 12 pull request landing in Node's main branch, which was still being worked out at the time I checked S11.

Table of Node.js versions. Node 22, 24 and 26 bundle npm older than 12, so dependency install scripts run by default and you must check npm --version and opt in. Node 27 plans to bundle npm 12 with scripts off unless allowed, alpha from October 28, 2026 and release April 22, 2027.

FIG. 06 · No Node release you run today turns this on for you · npm 12 is opt-in until Node 27 S4S11

So you opt in. You do not need npm 12 for the most important line. ignore-scripts works on older npm too, and npm added min-release-age (in days) in 11.10.0 and a granular allowScripts list in 11.16.0 S12. Craig Harmon's write-up has the equivalent setting for every package manager S12:

Package managerRelease cooldownUnitTurn off dependency scripts
npm 11.10+min-release-age=1 in .npmrcdaysignore-scripts=true, or allowScripts on 11.16+
pnpm 10.16+minimumReleaseAge: 1440minutesallowBuilds mapping
Yarn 4.10+npmMinimalAgeGate: 1440minutesenableScripts: false
Bun 1.3+minimumReleaseAge = 86400secondsignoreScripts = true

For npm, the whole change is this:

# .npmrc, committed to the repo
ignore-scripts=true
min-release-age=1

Why a one-day cooldown helps here: the latest tags were rolled back about two and a half hours after the bad publish S3, and Harmon notes compromised releases "are usually flagged and removed from the registry within hours of publication" S12. Two caveats. Packages that need a native build (an esbuild or sharp, say) will break under ignore-scripts, and older npm versions ignore min-release-age silently, so check npm --version on every machine that installs S13. And the strongest control was already available: npm ci installs exactly what your lockfile pins, so a lockfile committed before August 4 never resolved keyv@6.0.0 at all S13S6.

In what order should I clean up?

This is where the sources disagree, and it matters. Sygnia, Sonatype and Phoenix all say to remove persistence before revoking or rotating credentials, because some variants watch token validity and act destructively when a token is revoked S6S10S3. Kodem says the opposite for npm tokens: freeze publishing and revoke every npm automation token first, and calls the watcher's threat string, IfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClients, an extortion message "with no technical merit" S8.

Five-step response: find with npm ls, contain by isolating the host and freezing publishing, hunt hooks and the monitor service, rotate npm, GitHub, cloud, Kubernetes, Vault and SSH credentials, then rebuild. Sygnia, Sonatype and Phoenix say remove persistence before revoking; Kodem says revoke npm tokens first.

FIG. 07 · Hunt first, then rotate · the sources split on npm tokens, not on rebuilding infected hosts S3S6S10S8

My read: these are compatible if you separate the token from the host. Revoke npm publish tokens from a clean machine immediately, because that is what stops the spread. On any host that actually installed a bad version, hunt the watcher and hooks first, then rotate GitHub, cloud, Kubernetes, Vault and SSH credentials. Then do what CSA and Sonatype both recommend: treat that host as compromised and rebuild it rather than clean it S9S10.

What should I do this afternoon?

If you have not checked yet:

  • Run the npm ls check on every repo and CI image S8
  • Search for setup.mjs, bun-dl-* and new .claude or .vscode hooks S3S8

Whether or not you were hit:

  • Commit an .npmrc with ignore-scripts=true and min-release-age=1 S12
  • Use npm ci in CI so the lockfile decides, not the registry S6S13
  • Check npm --version everywhere; your Node version decides your defaults S4S13
  • Rotate on the assumption of exposure, not proof of it, as Morphisec puts it S7

FAQ

  1. Which keyv version was compromised?

    keyv 6.0.0, published on August 4, 2026 at 09:35 UTC with a malicious preinstall hook. Kodem Security lists keyv 5.6.0 as the safe pin.

  2. How do I check if my project pulled in a compromised package?

    Run npm ls with the full list of affected packages (keyv, flat-cache, file-entry-cache, cacheable-request, cacheable, cache-manager, the @cacheable scoped packages and ecto) and the --all flag, in every repo and CI image, then compare against the bad versions listed in the CSA and Aikido advisories.

  3. Why could I have keyv without installing it?

    It usually arrives as a transitive dependency. Phoenix Security reports that compromised flat-cache and file-entry-cache versions reached projects through ESLint, so check the whole dependency tree rather than package.json.

  4. Would npm 12 have stopped this attack?

    npm 12 turns dependency preinstall, install and postinstall scripts off by default, which blocks this delivery method. But Node 22, 24 and 26 do not bundle npm 12, so most installs still run scripts unless you opt out.

  5. What should I put in .npmrc?

    ignore-scripts=true stops dependency install scripts on any npm version, and min-release-age=1 (npm 11.10 or later) refuses versions published less than a day ago. Packages that need native builds may need an allowlist.

  6. What does the worm leave behind after the package is removed?

    A .claude/settings.json SessionStart hook, a .vscode/tasks.json task that runs on folder open, bun-dl folders, and a gh-token-monitor token watcher installed as a script, systemd service or macOS plist.

  7. Should I revoke tokens first or remove persistence first?

    Sources disagree. Sygnia, Sonatype and Phoenix say remove persistence before revoking. Kodem says revoke npm tokens first. A workable split is to revoke npm publish tokens from a clean machine right away, hunt persistence on infected hosts, then rotate everything else.

  8. How many packages were affected?

    Counts vary by source and date: Sygnia reported 400+, Aikido 444, Phoenix 868 and Morphisec more than 1,280 packages, with Sonatype tracking 2,225 versions. Most sources agree on over 2 billion monthly installs.

Sources

  1. S1GitHub Changelog, "Upcoming breaking changes for npm v12," June 9, 2026, github.blog
  2. S2Socket, "npm v12 Ships With Install Scripts Off by Default, Begins Deprecating 2FA-Bypass Tokens," July 8, 2026, socket.dev
  3. S3Phoenix Security, "Mini Shai-Hulud keyv/cacheable npm Compromise: Self-Propagating Worm Steals CI, Cloud, and Developer Credentials," August 4, 2026, phoenix.security
  4. S4Node.js Release WG, "npm 12 request for Node.js 27 Alpha phase," GitHub issue 1183, 2026, github.com
  5. S5Aikido Security, "Keyv and friends compromised in npm supply chain attack," August 4, 2026, aikido.dev
  6. S6Sygnia, "Shai-Hulud Returns: When Software Trust Becomes the Attack Surface," August 5, 2026, sygnia.co
  7. S7Brad LaPorte (Morphisec), "Inside the fourth wave of the Shai-Hulud npm worm," SC Media, August 20, 2026, scworld.com
  8. S8Kodem Security, "keyv supply chain attack: Shai-Hulud npm worm affected versions, IOCs and first-hour response runbook," August 4, 2026, kodemsecurity.com
  9. S9Cyber Security Agency of Singapore, "Ongoing npm Supply Chain Attack Affecting Keyv and Related Packages (Shai-Hulud Worm)," August 6, 2026, csa.gov.sg
  10. S10Sonatype, "Mini Shai-Hulud npm Attack: Over 2,200 Components Compromised," August 5, 2026, sonatype.com
  11. S11npm/cli, "Request for npm 12 (re-)submission to Node.js main branch," GitHub issue 9997, 2026, github.com
  12. S12Craig Harmon, "Locking down dependency installs across npm, pnpm, yarn, and bun," May 29, 2026, craigory.dev
  13. S13Belchamber, "npm 12 install scripts: allowScripts and min-release-age," September 26, 2026, tools.belchamber.us

Written by

Syed Moinuddin

Full Stack Engineer.

Notes on AI tooling, agentic systems, and building things that survive contact with production.

Command Palette

Search for a command to run...