The keyv/cacheable npm Attack: How to Check If You Were Hit
On 4 August 2026 the npm maintainer account behind keyv and cacheable was hijacked, and eleven packages were republished with a malicious preinstall hook that steals credentials and spreads itself. keyv alone sees roughly 127 million downloads a week. Within hours the worm had reached more than 400 further packages belonging to unrelated maintainers.
This post is the checking procedure, not the news. We ran it on our own tree today, found we were clear, and hit two false signals on the way that will waste your afternoon if nobody warns you about them.
What was published
The compromised versions are specific. If none of these appears in your lockfile, you did not install the malicious code:
keyv6.0.0flat-cache6.1.24file-entry-cache11.1.6cacheable2.5.1 ·cacheable-request13.0.20@cacheable/memory2.2.1 ·@cacheable/node-cache3.1.2 ·@cacheable/utils2.5.1 ·@cacheable/net2.1.1cache-manager7.2.10ecto5.0.1
The payload arrives as a setup.mjs that runs from a preinstall hook, downloads the Bun runtime, and executes a second file that harvests npm tokens, GitHub tokens, AWS credentials, Kubernetes secrets and Vault tokens from the machine, encrypts them, and sends them out — then republishes itself into packages belonging to any maintainer whose token it just stole. Wiz identifies it as a descendant of the Shai-Hulud family.
One detail deserves emphasis. The attacker pushed to the repository's main branch and cut a release, so the poisoned versions were published with valid provenance, signed by GitHub Actions. Provenance proves a package was built by the pipeline it claims. It does not prove the commit was written by someone who should have been writing it.
Check your tree — the commands
1. Are the compromised versions installed?
Ask the lockfile, not package.json. Ranges lie; the lockfile is what gets installed:
npm ls keyv flat-cache file-entry-cache cacheable cacheable-request cache-manager ecto
Do this in every repository, and remember that transitive copies are the common case — keyv and flat-cache arrive under ESLint and build tooling far more often than anyone adds them directly. Ours came in that way and sat at 4.5.4, 4.0.1 and 8.0.0: below the malicious releases, which are 6.0.0, 6.1.24 and 11.1.6.
2. Did you ever fetch a bad version, even briefly?
This is the question that decides whether you rotate credentials, and version numbers today cannot answer it — you might have installed a poisoned version this morning and rolled back this afternoon. Your npm cache remembers:
grep -rhoE '(keyv|flat-cache|file-entry-cache|cacheable|cache-manager|ecto)/-/[a-z@.-]+-[0-9.]+\.tgz' ~/.npm/_cacache/index-v5 | sort -u
That prints every version of those packages this machine has ever downloaded. Compare against the list above. Confirm the cache exists first (du -sh ~/.npm/_cacache) — on an empty or absent cache the grep returns nothing, which looks exactly like a clean result and means the opposite. Ours came back 2.6 GB with only keyv-4.5.4, flat-cache-4.0.1 and file-entry-cache-8.0.0.
Run it on CI runners and build agents too, if they persist a cache. They install far more often than laptops do, and they hold better credentials.
3. Is the dropper on disk?
find . -path '*/node_modules/*' \( -name 'setup.mjs' -o -name 'Math_Symbol.js' \)
Expect a false positive here. regenerate-unicode-properties — a legitimate package pulled in by Babel and friends — ships General_Category/Math_Symbol.js, because “Math Symbol” is a real Unicode general category. The genuine file is about a kilobyte and starts with const set = require('regenerate')(0x2B, 0x7C, ...). Open the file before you panic; check the path, not just the name.
4. What in your tree can run code at install time?
This is the one worth keeping. preinstall, install and postinstall hooks are the mechanism the whole attack depends on:
node -e "const l=require('./package-lock.json');for(const [k,v] of Object.entries(l.packages||{})) if(v.hasInstallScript) console.log(k.replace(/.*node_modules\//,''), v.version)"
It reads the lockfile, so it works before npm install has ever run — which matters in CI, where you want the answer before the install, not after. On a healthy project the list is short and almost never changes. Ours prints four lines: @swc/core, core-js, and fsevents twice. Anything new on it deserves to be read before your next install.
5. The audit, last rather than first
npm audit is worth running, but understand what it can tell you: it reports advisories that already exist. On the day of a compromise, the advisory may be hours away. A clean audit on the morning of an attack means nothing at all.
Do you need to rotate credentials?
The rule is simple and it hinges on step 2, not step 1. If a compromised version was ever fetched onto a machine, treat every credential reachable from that machine as exposed — npm tokens, GitHub tokens and SSH keys, cloud credentials, kubeconfigs, Vault tokens, and anything in an .env the process could read. The payload runs before any of your tooling does and with your user's full filesystem access.
If the cache shows the bad version was never downloaded, you were not exposed and rotation is not required. We could state that positively rather than hopefully, which is the entire value of checking the cache instead of only the current lockfile.
One thing not to skip either way: if you rotate an npm token, also check your published packages for versions you did not cut. Propagation is the point of this malware.
The control that catches the next one
Not being hit was a matter of timing for us — our pinned versions predated the attack. Timing is not a control, so we added one the same day.
The instinct is to write a blocklist of known-bad versions. Do not bother: a blocklist is always a day late, and it is precisely the list you cannot write in the hours that matter. Build an inventory instead.
The set of dependencies allowed to execute code during installation is small, changes rarely, and is easy to enumerate. Freeze it, and fail the build on any addition. A package that never had an install hook and suddenly grows one is the highest-signal event in a lockfile diff — and by default nothing is watching for it. Ours is a ~100-line script wired into CI ahead of the test suite; the approved list currently holds two entries, both compiling native bindings.
Then verify the check by breaking it on purpose. Add a preinstall to an installed package and confirm the build fails naming it, then revert. A supply-chain guard that has never been shown to fire is a guard you are hoping about.
The trap inside that check
When we ran ours the first time it flagged fsevents, the macOS file watcher that arrives under Vite and Playwright. The lockfile marks it hasInstallScript, and the registry manifest does list install: node-gyp rebuild.
The published tarball contains no install script at all. We confirmed it by unpacking npm pack fsevents@2.3.3 and comparing with the installed copy: identical, and neither has the hook. npm derives the lockfile flag from the registry packument, which can carry script fields the tarball does not.
That distinction matters more than it first appears. “Runs code at install time” and “metadata claims it runs code at install time” look identical in every tool that reports on them, and if you fold them together you lose track of which dependencies actually execute — which is the only list you wanted. Keep them apart, and require review for additions to both.
Four habits that would have made this a non-event
- Commit your lockfile and install with
npm ci.npm installmay resolve a new version at the worst possible moment;npm ciinstalls exactly what was reviewed. - Use
--ignore-scriptswhere you can. Most production installs need no lifecycle scripts at all. When a few packages do, allow those specifically rather than leaving the door open for every dependency in the tree. - Give CI a scoped, short-lived token. The payload harvests whatever it finds. A runner holding a long-lived admin npm token turns one bad install into a compromised package of your own.
- Delay adopting brand-new versions. A cooldown of a few days on automatic dependency updates costs nothing and would have excluded every version in this incident.
None of these is exotic and none of them requires a vendor. The uncomfortable part of this incident is not that the packages were popular — it is that apreinstall hook has always been able to do this, on every machine that runs an install, and most projects have never once looked at which of their dependencies use one.
