The keyv npm compromise: how to check if you're exposed
Eleven malicious releases in the keyv and cacheable family shipped a preinstall script that harvested GitHub tokens, cloud keys and SSH keys. Check your lockfiles, hunt the indicators of compromise, avoid the obvious false positive, and detect the next one at runtime.
The AlertKick team
Quick answer. Eleven malicious releases in the
keyvandcacheablenpm family were published on 4 August 2026 with apreinstallscript that stole GitHub tokens, cloud keys, SSH keys and database credentials. To check exposure, runnpm ls keyv flat-cache file-entry-cache cache-manager cacheable-requestin each project and compare against the affected versions below. Most projects are safe: the usualeslintdependency chain pinskeyvto 4.x, which cannot reach the malicious 6.0.0. Then search forsetup.mjs,math_init.jsandgh-token-monitorto confirm the payload never ran.
The essentials:
- Published: 4 August 2026, all eleven releases within a short window
- Advisory:
SNYK-JS-KEYV-18515941, CWE-506 (embedded malicious code) - Vector:
preinstalllifecycle script, plus editor and AI-assistant folder-open hooks - Impact: credential theft with a persistence service installed
- Most likely status: not affected, if your lockfile resolves
keyvto 4.x
Which packages and versions are affected?
Eleven releases are malicious. Every earlier version of each package is fine.
| Package | Malicious version |
|---|---|
keyv | 6.0.0 |
flat-cache | 6.1.24 |
file-entry-cache | 11.1.6 |
cacheable | 2.5.1 |
cacheable-request | 13.0.20 |
cache-manager | 7.2.10 |
ecto | 5.0.1 |
@cacheable/net | 2.1.1 |
@cacheable/node-cache | 3.1.2 |
@cacheable/memory | 2.2.1 |
@cacheable/utils | 2.5.1 |
Several have since been removed from the registry. That does not help a machine that already installed one.
Why is keyv in my project if I never installed it?
Because eslint pulls it in three levels down. Almost nobody depends on keyv directly.
The chain is eslint -> file-entry-cache -> flat-cache -> keyv.
That is why this compromise has such wide reach, and why the thing you need to check is a linting dependency you have never thought about.
How do I check which versions I resolve to?
Check the lockfile, not package.json, because the lockfile is what installs.
# npm - shows the resolved version and the parent that pulled it in
npm ls keyv flat-cache file-entry-cache cache-manager cacheable-request cacheable ecto
# pnpm
pnpm why keyv flat-cache file-entry-cache
# yarn
yarn why keyv
To sweep a whole workspace of repositories without running an install, read the lockfiles directly:
grep -rE '"(keyv|flat-cache|file-entry-cache|cacheable|cacheable-request|cache-manager|ecto)"' \
--include=package-lock.json . | grep -v node_modules
What does a safe result look like?
Safe is [email protected], [email protected] or 4.0.1, and [email protected] or 8.0.0.
Those sit a full major version below the malicious releases. The caret ranges in the eslint chain (^4.5.3, ^3.0.4, ^6.0.1) cannot cross a major boundary, so even an unpinned npm install would not have reached the bad versions.
You need to act if you see any version from the table above, or a floating range like * or latest anywhere in the chain.
Did the payload already run on my machine?
A clean lockfile today does not prove a clean machine yesterday. Someone may have installed on a feature branch, or CI may have resolved differently before you pinned.
The npm cache keeps a record of every tarball ever fetched:
grep -rlE 'keyv/-/keyv-6\.0\.0|flat-cache-6\.1\.24|file-entry-cache-11\.1\.6|@cacheable' \
~/.npm/_cacache/index-v5/ 2>/dev/null
No output means those tarballs were never downloaded here. That is a much stronger statement than a lockfile check alone.
What does the malware actually do?
It harvests credentials, then installs persistence so it keeps harvesting.
The payload runs in two stages: a setup.mjs loader of roughly 30KB that fingerprints the platform, then a second stage of roughly 730KB that does the collection.
What it targets:
- GitHub tokens and npm publish credentials
- Cloud access keys
- SSH and other private keys
- Database connection strings
- Kubernetes service-account tokens
Persistence is installed as a gh-token-monitor service, which keeps watching for GitHub tokens long after the install is forgotten.
What indicators of compromise should I search for?
Three filenames and one service name.
find / -xdev \( -name 'setup.mjs' -o -name 'math_init.js' -o -name 'gh-token-monitor*' \) 2>/dev/null
# persistence, per platform
systemctl --user list-units | grep -i gh-token
ls ~/.config/systemd/user/ /etc/systemd/system/ 2>/dev/null | grep -i gh-token
ls ~/Library/LaunchAgents/com.user.gh-token-monitor.plist 2>/dev/null # macOS
Why does Math_Symbol.js show up on clean machines?
Because a completely legitimate package ships a file with that exact name. The second-stage payload is called Math_Symbol.js, and so is a Unicode data file in regenerate-unicode-properties/General_Category/, a transitive dependency of Babel present in a very large share of JavaScript projects.
Searching for the filename alone will light up on it. Tell them apart by size:
find . -name 'Math_Symbol.js' -exec ls -l {} \;
The legitimate file is about 1KB and starts with const set = require('regenerate')(0x2B, 0x7C, ...), a table of Unicode codepoints. The payload is about 730KB and obfuscated.
The collision is not an accident. Indicator sweeps need a second discriminator before you escalate.
Does this only trigger on npm install?
No. Two of the three delivery paths fire when you open the project, not when you install it.
Alongside preinstall, this campaign shipped VS Code tasks configured to run on folder open, and AI-assistant session hooks:
# VS Code tasks that run automatically when a folder is opened
find . -path '*/.vscode/tasks.json' -not -path '*/node_modules/*' \
-exec grep -l 'folderOpen' {} \;
# AI assistant session hooks
find . -path '*/.claude/settings*.json' -not -path '*/node_modules/*' \
-exec grep -l 'SessionStart\|hooks' {} \;
Read every hit rather than trusting the filename. Both files are legitimate and common, and plenty of teams write their own on purpose. What you are looking for is one you did not write, in a repository where nobody remembers adding it.
The wider point is worth internalising: cloning a repository has quietly become an execution event. Editor task automation and AI-assistant hooks both run commands from repository-local config, so the old mental model where nothing happens until you type npm install is no longer true.
What should I do if I find something?
Order matters more than speed.
- Kill persistence before you rotate anything. If
gh-token-monitoris still running, it captures the new GitHub token you are about to create. Remove the service and confirm the process is gone first. - Isolate, do not power off. Memory is evidence, and the outbound connection history in it tells you what was reached.
- Rotate from a clean machine. GitHub tokens and SSH keys, npm publish tokens, cloud access keys, database connection strings, Kubernetes tokens, and anything sitting in a
.envfile. Assume everything the process could read is public. - Audit the blast radius. Check GitHub audit logs for new SSH keys, new personal access tokens and unexpected Actions runs. Check npm for publishes you did not make, because a stolen publish token is how one compromise becomes the next one.
- Rebuild the machine. A cleaned developer laptop is a laptop where you hope you found everything.
Does npm provenance prevent this?
No, and this incident is the clearest demonstration yet. These releases carried valid GitHub Actions provenance attestation.
The build was genuinely performed by the workflow it claimed, from the repository state it claimed. The malicious code was simply present in that state when the workflow ran.
Provenance answers “was this built where it says it was”. It does not answer “should you run it”. Both are worth having, and treating the first as if it settles the second is how a signed artefact ends up trusted more than an unsigned one a human actually read.
How do I harden against the next one?
Assume there will be a next one and tune for the general case.
- Install with
--ignore-scriptsand mean it. Addignore-scripts=trueto.npmrc, then allow lifecycle scripts explicitly for the handful of packages that genuinely need to compile something. - Never let a lockfile fallback exist. A CI line like
pnpm install --frozen-lockfile || pnpm installturns a build failure into an unpinned install at exactly the moment resolution has changed under you. Let it fail. - Pin the fix with overrides. Use npm
overridesor pnpmresolutionsto hold the affected family at known-good versions, rather than trusting the next patch release by default. - Keep publish credentials off developer machines. Publish tokens belong in CI behind a hardware-backed approval, not in an
.npmrcin someone’s home directory. - Treat CI runners as the highest-value target. A runner installs dependencies constantly, holds cloud credentials by design, and is the one machine where nobody notices an extra outbound connection.
What would have caught this before the disclosure?
Runtime detection, because the payload has to do observable things regardless of how it arrived.
Everything above answers “what is true right now”, and every command in it runs only after a disclosure exists. This campaign published on 4 August and was analysed publicly within days, which was fast. It was still days during which the payload ran on every machine that installed it.
The interesting question was never which version you depend on. It was what happened on the machine while nobody was looking.
That question is answerable, because the malware cannot avoid leaving kernel-visible traces. It executes a package manager, spawns a child process, reads private keys and .env files, opens an outbound connection somewhere it has no reason to reach, and writes a persistence unit. None of that depends on knowing the package name in advance.
That is what AlertKick’s eBPF agent watches on your servers and build runners:
- Package manager and fetch-tool execution, including downloads from external URLs
- Outbound connections to known-bad IPs and domains from live threat-intelligence feeds
- DGA and DNS-tunnelling patterns in lookups
- Reads against SSH keys and other sensitive paths
- New listening ports opened by a process
- Changes to
/etcand cron configuration
Detections are mapped to MITRE ATT&CK and triaged by AI, so a credential-harvesting process on a build runner does not arrive in the same undifferentiated stream as a restarted service.
Start free and put the agent on the runner that installs your dependencies. Next time, the disclosure should be confirming something you already saw.
Frequently asked questions
- Which keyv versions are malicious?
- [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], @cacheable/[email protected], @cacheable/[email protected], @cacheable/[email protected] and @cacheable/[email protected], all published on 4 August 2026. Earlier releases are unaffected.
- Is [email protected] safe?
- Yes. Only [email protected] is malicious. The 4.x line is unaffected, and it is what almost every project resolves to because eslint's dependency chain requests a caret range on 4.x that cannot reach version 6.
- How do I check if I am affected by the keyv compromise?
- Run npm ls keyv flat-cache file-entry-cache cache-manager cacheable-request in each project and compare the resolved versions against the affected list. Then search the filesystem for setup.mjs, math_init.js and gh-token-monitor artefacts to confirm the payload never executed.
- Does npm audit catch this?
- It flags the advisory once your registry data is current, but it only reports what your lockfile resolves to. It will not tell you whether the payload already ran on the machine, so pair the dependency check with an indicator sweep of the filesystem.
- Does package provenance prevent this?
- No. These releases carried valid GitHub Actions attestation. Provenance proves a package was built by the workflow it claims, not that the source going into that workflow was trustworthy. It is a supply-chain integrity control, not a malicious-code control.
- Is --ignore-scripts enough to stay safe?
- It blocks the preinstall path, which is the primary vector here, but not everything. The malicious code still lands on disk, and this campaign also used editor and AI-assistant hooks that fire when a project folder is opened rather than when it is installed.
- What does the keyv malware steal?
- GitHub tokens, npm credentials, cloud access keys, SSH and other private keys, database connection strings, and Kubernetes service-account tokens. It also installs a gh-token-monitor persistence service that keeps watching for GitHub tokens after the install.
- Why does Math_Symbol.js appear in my node_modules?
- Almost always because of regenerate-unicode-properties, a legitimate transitive dependency of Babel that ships a file with that exact name. The legitimate file is around 1KB of Unicode codepoint data. The malicious second stage is around 730KB and obfuscated. Check the size before you escalate.
- Should I rotate credentials if I installed an affected version?
- Yes, but remove the gh-token-monitor persistence service first. If it is still running while you issue a new GitHub token, the replacement gets captured too. Rotate from a machine you know is clean.