The keyv npm Attack: Signed, Verified, and Malicious
The keyv and cacheable npm packages shipped a credential stealer with valid provenance. What it did, how to check if you're hit, and what to fix first.

You didn't install anything suspicious. You ran npm install, the lockfile resolved, every signature checked out, and a credential stealer started running before your app did.
That's the part of the August 4 keyv compromise worth sitting with. The malicious tarballs carried valid npm provenance, signed by GitHub Actions. Every cryptographic check passed, because the attacker didn't break the signing chain. They got the signing chain to sign their code for them.
If you installed in the affected window, assume compromise
The stealer targets npm tokens, GitHub tokens, AWS and GCP and Azure credentials, Vault tokens, Kubernetes configs, and SSH keys. If any of the packages below landed on a dev machine or a CI runner on August 4, treat those credentials as burned. There's also a persistence trick that changes the order you should do things in, covered further down.
What actually shipped
Eleven packages went out malicious on August 4, starting with keyv@6.0.0 at 09:35 UTC and ending with ecto@5.0.1 at 10:28 UTC. In between: cacheable, cacheable-request, flat-cache, file-entry-cache, cache-manager, and the @cacheable/* scoped packages.
None of those are packages you chose. They're packages your packages chose. flat-cache alone pulls around 580 million monthly downloads, mostly because ESLint's caching sits on top of it. If you have a JavaScript project with a linter, you have this dependency tree.
Socket's scanner flagged the first malicious publish 5 minutes and 18 seconds after it hit the registry, which is genuinely fast and still wasn't fast enough. CI runs on a schedule. Plenty of pipelines pulled a poisoned version inside that window.
The counts vary by tracker, on purpose
You'll see different numbers depending on who's counting. Aikido tracked 444 packages across 1,381 versions by August 5. Wiz said over 400 distinct packages. Socket counted 2,236 affected package versions. They're measuring slightly different things (initial compromise versus worm spread, packages versus versions), and the totals moved while the campaign was live. The eleven initial packages are the part everyone agrees on.
Why provenance didn't help
npm provenance is supposed to answer one question: was this tarball built from the source it claims, by the CI system it claims? For keyv@6.0.0, the answer was yes. GitHub Actions really was the trusted publisher. The workflow really did build the artifact. The attestation is real.
The attacker just committed the malware to the repo first.
Provenance proves a build is faithful to its source. It says nothing about whether the source is trustworthy. Those are different claims, and the industry has been quietly treating the first as if it delivers the second.
The lesson isn't that provenance is useless. It's that provenance moves the target from the registry to the repository, and a lot of repositories are defended like they aren't part of the build chain.
The payload chain
The manifest change was one line:
{
"scripts": {
"preinstall": "node setup.mjs"
}
}preinstall runs before anything else, with your shell's privileges, without you importing a single line of the package. The actual library code was untouched, which is why nothing looked broken.
From there it went in stages. setup.mjs (about 30 KB, lightly obfuscated) detected the platform, then downloaded a standalone Bun runtime, version 1.3.13, with no checksum verification. Stage two was Math_Symbol.js, a 728 KB Bun bundle that did the credential sweep.
Using Bun is a clever piece of tradecraft. It sidesteps whatever Node hardening you've got, it ships as a single static binary, and a Bun/1.3.13 user-agent in your egress logs looks like a developer trying something new rather than an intrusion. If you're already running Bun in production, that signal is worth even less to you. Worth noting given how good the Bun and Hono stack has gotten: the runtime being popular is exactly what makes it useful cover.
The part that makes it a worm
A stealer takes your credentials once. This one used them to keep going.
With a stolen npm token, the payload queried registry.npmjs.org/-/npm/v1/tokens to find out what else that token could publish, then injected itself into those packages and republished them with a bumped patch version. Your compromised laptop became a publisher.
With a stolen GitHub App token, it committed to as many as 50 branches per repository, dropping payloads into two files:
.claude/settings.json + .claude/setup.mjs
.vscode/tasks.json + .vscode/setup.mjsThose are auto-execution hooks. The VS Code one fires when you open the folder. The Claude Code one fires on session start. So the infection stops depending on npm install at all. Cloning the repo and opening your editor is enough.
The commits were authored under the name "claude" and carried verified GitHub signatures attributed to github-actions[bot]. That's not a forged signature, it's a real one from a compromised identity. Same trick as the provenance abuse, one layer up.
Check your agent and editor config, not just node_modules
Most remediation guides tell you to purge node_modules and reinstall. That misses .claude/settings.json and .vscode/tasks.json entirely, because they're committed files in your repo. Run git log --all -- .claude .vscode on anything that touched an infected machine.
For command and control, the attacker put the address in an Ethereum smart contract at 0xE1f2395ee43e45A1556EC6438a88c31B83493103. The payload reads the current C2 endpoint from the blockchain instead of hardcoding one. You can't take that down, and you can't sinkhole it. Exfiltrated data went to roughly 1,300 public GitHub repositories carrying the description "Shai-Hulud: Here We Go Again", with npm-cache[.]com as a fallback.
Are you affected?
Start here:
npm ls keyv flat-cache file-entry-cache cacheable-request cacheable \
@cacheable/utils cache-manager @cacheable/net \
@cacheable/node-cache @cacheable/memory ecto --allThen check for the payload files and the persistence hooks:
find . -name "Math_Symbol.js" -o -name "setup.mjs" | grep -v "^./src"
ls ~/.local/bin/gh-token-monitor.sh ~/.config/gh-token-monitor/ 2>/dev/null
ls /tmp/bun-dl-* 2>/dev/nullSnyk published hashes if you want to confirm a file rather than guess from the name:
setup.mjs sha256 54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668
Math_Symbol.js sha256 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bccDo the cleanup in the right order
This is the detail most writeups bury, and getting it backwards makes things worse.
The malware installs something called gh-token-monitor. It watches the stolen GitHub token, and when that token stops working, it runs a handler fetched from the attacker. Revoking your credentials is the trigger. Your incident response is the signal that tells them you noticed.
So the order is:
- Take the host off the network. Don't rotate anything yet.
- Remove the dead-man's switch. Delete
~/.local/bin/gh-token-monitor.shand~/.config/gh-token-monitor/, plus the LaunchAgent (com.user.gh-token-monitor.plist) or systemd unit (gh-token-monitor.service) that starts it. - Now rotate. npm tokens, GitHub tokens (personal and app), cloud keys, Vault, Kubernetes, SSH, database strings.
- Audit what was published. Check your npm account for versions you didn't publish, and your repos for commits you didn't write.
- Rebuild the machine. A credential stealer with root-level persistence isn't something you clean off.
Quick check
Why was removing gh-token-monitor the step that has to come before rotating credentials?
What to change once the fire's out
Pin to known-clean versions with an override rather than hoping the range resolves somewhere safe:
{
"overrides": {
"keyv": "5.6.0",
"flat-cache": "6.1.23"
}
}And reinstall with lifecycle scripts off:
npm install --ignore-scripts--ignore-scripts is the one change that would have stopped this attack cold on every machine that already had it turned on. It breaks a handful of packages with native builds, so it isn't free. Set it in .npmrc for CI at minimum, where nothing should be compiling anything you didn't plan for:
ignore-scripts=trueTwo more worth doing this week. Scope your CI tokens to the narrowest publish rights that work, because this worm's entire spread mechanism was tokens that could publish more than the one package the job needed. And put a delay between a version hitting the registry and your builds picking it up. Socket caught this in five minutes, but a cooldown window of even a few hours turns "we were breached" into "we read about it."
The uncomfortable takeaway
We spent years building the machinery to answer "is this package what it says it is?" That machinery worked perfectly on August 4 and provided no protection whatsoever.
The registry was honest. The signatures were valid. The build was reproducible. The source was hostile, and none of the tooling had an opinion about that, because none of it was designed to.
This is the same shape as the Hugging Face breach in July: a chain of individually reasonable trust decisions that composes into something nobody signed off on. And the .claude/settings.json payload is a preview of where this goes next. Agent configs are executable, they're committed to repos, and almost nobody reviews them in a pull request. If you've been thinking about what a hostile input can do to an AI workflow, a config file your agent runs on startup belongs on that list.
Check your lockfile. Then go read your .claude and .vscode directories, and ask when you last looked at either one.
Sources: Socket, Snyk, Wiz, Aikido, and Cloudsmith.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


