- What: The maintainer account behind the
keyvandcacheablenpm namespaces was compromised on 4 August 2026. Eleven packages were published with a maliciouspreinstallhook, and a worm then spread the same payload to 434 further packages across 1,381 versions. - Why it is bad:
eslintdepends onfile-entry-cache, which depends onflat-cache, which depends onkeyv. Tens of millions of weekly downloads sit behind that chain, and almost nobody installs these packages deliberately. - The payload steals npm tokens, GitHub PATs and OIDC tokens, AWS credentials including from the instance metadata service, Kubernetes service-account tokens, Vault credentials, SSH keys, Terraform state and Docker configs.
- The part that should worry you most: the worm republishes what it infects through npm OIDC trusted publishing, so the poisoned versions carry freshly minted, valid sigstore provenance.
- Are you affected: check installed versions, not your
package.json. The malicious releases are major-version jumps, so a pinned or lagging dependency tree is very likely clean. Ours was, and we show the check below.
On 4 August 2026 at 09:35 UTC, [email protected] was published to npm with a preinstall hook that had no business being there. Three minutes later the compromise had spread past the maintainer's own packages. By 10:14 UTC nine more packages in the cacheable family had gone out the same way.
The account belonged to Jaredwray, who maintains both namespaces. Socket reports force pushes to main and the v6.0.0 tag being deleted and recreated while the attack was in progress, which is what account takeover looks like from the outside rather than a single stolen publish token used quietly.
The packages
Eleven seed packages, all published within about forty minutes of each other:
| Package | Malicious version | Why you probably have it |
|---|---|---|
| keyv | 6.0.0 | Transitive under eslint, and under got |
| flat-cache | 6.1.24 | Transitive under eslint |
| file-entry-cache | 11.1.6 | Direct dependency of eslint |
| cacheable-request | 13.0.20 | HTTP client caching |
| cacheable | 2.5.1 | Caching layer |
| cache-manager | 7.2.10 | Widely used cache abstraction |
| @cacheable/memory | 2.2.1 | Cacheable family |
| @cacheable/node-cache | 3.1.2 | Cacheable family |
| @cacheable/utils | 2.5.1 | Cacheable family |
| @cacheable/net | 2.1.1 | Cacheable family |
| ecto | 5.0.1 | Template engine wrapper |
Then the worm took over. At least 434 packages across 1,381 versions were infected by automated republishing.
What it does once installed
Two files are added to the package. setup.mjs runs from a preinstall hook and downloads the Bun runtime, purely so the real payload can execute in an environment the attacker controls. Math_Symbol.js is a 728 KB obfuscated stealer.
It goes after almost everything a developer machine or build runner holds:
- npm tokens from
~/.npmrc - GitHub personal access tokens, OAuth tokens and OIDC tokens
- AWS credentials from files, environment variables, Secrets Manager, and the instance metadata service on both IMDSv2 and v1
- Kubernetes service-account tokens and secrets
- HashiCorp Vault credentials, Stripe and Slack tokens
- SSH private keys, Terraform state, Docker configs, and KeePass databases
Stolen material is encrypted and pushed to attacker-controlled repositories. Wiz notes the campaign also pulls command-and-control data from an Ethereum smart contract, which makes takedown considerably harder than seizing a domain.
The worm republishes packages using npm OIDC trusted publishing, which means the malicious versions arrive with valid, freshly generated sigstore provenance. The signature is real. It attests that a build produced the artifact, not that the artifact is safe. If your supply-chain policy is "require provenance", this campaign satisfied it.
Were we affected? Here is the actual check
We ran this across every repository we own, and it is worth publishing the method rather than the reassurance, because the method is the part you can reuse.
The result: not affected. Every tree resolves to [email protected], [email protected] or 4.0.1, and [email protected] or 8.0.0. The malicious releases are major-version jumps well above those, so a lockfile that has not been regenerated in the last day simply cannot reach them.
That is luck as much as discipline, and it is worth being honest about which. Lockfiles pin transitive versions, so a project that has not run an unconstrained update recently is insulated from a compromise published hours ago. The same property that makes stale dependencies a vulnerability problem made them a containment control here.
Three checks, in the order that actually resolves the question:
- Installed versions, not declared ranges.
npm ls keyv flat-cache file-entry-cache cacheable-request cache-manager ectoshows what resolved. Yourpackage.jsonwill not tell you. - Artifacts on disk. Search for
node_modules/keyv/Math_Symbol.js,math_init.js, and/tmp/bun-dl-*/. Also look for anypackage.jsoncarrying apreinstallhook you did not expect, which is the mechanism the whole campaign depends on. - Egress.
npm-cache[.]com,pypi-get[.]comandjs-mirror[.]com, plus the user agentBun/1.3.13from a machine that has no business running Bun.
Indicators
| Type | Indicator | Note |
|---|---|---|
| SHA-256 | 54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668 | setup.mjs |
| SHA-256 | fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb | setup.mjs variant |
| SHA-256 | 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc | Math_Symbol.js / math_init.js |
| Domain | npm-cache[.]com, pypi-get[.]com, js-mirror[.]com | Exfiltration and staging |
| File | /tmp/bun-dl-*/, ~/.local/bin/gh-token-monitor.sh | Runtime drop and persistence |
| User agent | Bun/1.3.13 | Unexpected on most build runners |
| GitHub | Repos described "Shai-Hulud: Here We Go Again" | Exfil destinations |
If you did install one
Treat the host as compromised rather than cleaning the package. The payload's entire purpose was to read credentials, and it had the privileges of whoever ran npm install. Removing the package does not un-steal a token.
- Rotate first, investigate second. npm tokens, GitHub PATs, AWS keys, Kubernetes service accounts, Vault credentials, Stripe and Slack tokens, and any SSH key on the machine.
- Assume CI is in scope. Build runners install dependencies with credentials attached and are frequently the richest target in the estate.
- Check what the token did, not just that it existed. Review npm publish history, GitHub audit logs and CloudTrail for the window after installation. The worm's whole purpose is republishing under stolen identities.
A process that would have caught it, and a tool to run it
Everything above is retrospective. The more useful question is what a team should change so the next one does not land, and the honest answer starts with something unglamorous.
Socket flagged the malicious release roughly six minutes after publication, and npm pulled the versions within hours. Anyone refusing to install versions younger than a few days was never exposed, with no analysis performed at all. If you adopt one control from this incident, adopt that one.
Beyond the cooldown, in rough order of value:
- Turn install scripts off. Every finding in this campaign traces back to one
preinstallhook. npm 12 disables install scripts by default; on older npm,--ignore-scriptsin CI costs nothing and removes the mechanism. - Install from the lockfile, not the range.
npm cirather thannpm install, and no unconstrained updates on a whim. Pinned transitive versions are why most teams, us included, were never in reach of this. - Diff the tarball, not the changelog. The attack was in the published artifact. Comparing the new tarball against the version you already run surfaces the shape immediately: a new lifecycle script, a new file at the package root, a large obfuscated blob in a small library.
- Treat build runners as credentialed hosts. Short-lived credentials, no long-lived npm or cloud tokens, and an egress allowlist. This payload's value came entirely from what it found lying around on the machine that installed it.
We built the third one and published it, MIT licensed. It is a single Python file with no dependencies, so it runs anywhere including a bare CI image. The mechanism is not specific to npm, so it covers PyPI and crates.io too:
python3 upgrade-audit.py npm keyv 4.5.4 6.0.0
python3 upgrade-audit.py pypi requests 2.31.0 2.32.3
python3 upgrade-audit.py cargo serde 1.0.200 1.0.210
python3 upgrade-audit.py npm --lockfile package-lock.json --check-updates --cooldown 7
Every ecosystem has one place where a package runs arbitrary code on the machine installing it, and that is where the CRITICAL check looks: npm lifecycle scripts, a setup.py in a PyPI sdist, a cargo build.rs.
It pulls both tarballs from the registry and diffs them. It raises CRITICAL on a lifecycle script the previous version did not have, and HIGH on new root-level executables, large or high-entropy new files, credential and metadata-service strings appearing in new code, and publisher identity changes.
Because the malicious versions have since been unpublished, we validated it by rebuilding the package from the documented indicators. It raises six findings, led by the CRITICAL that stops the install. On the genuine keyv 4.5.4 → 5.6.0 upgrade it stays quiet.
We now run it on our own pull requests, blocking on CRITICAL and warning on HIGH. That split is deliberate rather than timid: flat-cache really did move from an individual maintainer to GitHub Actions publishing between 4.0.1 and 6.1.23, which trips the publisher-change check correctly and is entirely benign. A new install hook is nearly always worth a human look; a new publisher usually is not.
Rolling that across our own repositories turned up something we had not gone looking for. The check works by diffing the version you have against the version you are about to get, which quietly assumes there is a record of the version you have. Our JavaScript projects commit a lockfile, so that record exists. Our Python projects depend on ranges like boto3>=1.34 and commit no lockfile at all, and for those the check had nothing to compare and nothing to say.
That is not a gap in the tool. It is a gap in the projects, and a more serious one than it first sounds: when a range picks up a newly published release, no file in the repository changes. There is no commit, no pull request, and therefore nothing for a pull-request gate to fire on. The build simply installs something different than it did yesterday.
So for those we resolve first, asking pip what it would install without installing it, and audit the releases that came out recently enough to matter. The first run against a real repository resolved a range to a release published that same day. Nothing was wrong with it. But a zero-day-old release reaching a build runner with credentials on it, with nothing in version control to indicate anything had moved, is precisely the window this campaign lived in. That check runs on a schedule rather than on pull requests, because for a floating range the trigger is time, not a diff.
What it will not do is out-detect a dedicated scanning vendor, and it cannot see a payload that behaves normally at install time and waits. It answers one narrow question: is there anything about this specific bump that a person should look at before it reaches a machine with credentials on it.
The lesson worth keeping
Two things about this campaign generalise beyond npm.
The first is that install-time code execution remains the softest part of the software supply chain. Every one of these compromises turned on a preinstall hook. npm 12 disables install scripts by default, and that single change would have neutered this attack completely for anyone running it. If you are on an older npm, --ignore-scripts in CI costs nothing and removes the mechanism.
The second is harder. Provenance answered the wrong question. Sigstore attestation proves an artifact came from a build pipeline; it says nothing about whether the pipeline was told to build something malicious. When an attacker holds a valid token and can trigger a trusted publish, they inherit the trust signal itself. Supply-chain controls built purely on "is it signed" will pass this class of attack every time.
Detection has to sit somewhere the package cannot lie about: outbound connections from build infrastructure, unexpected runtimes appearing on a runner, credential access patterns that do not match what a build should do. Those signals are visible from the network and the host, and they do not care what the manifest claims. We made the same argument from the other direction when we went through a teardown of CrowdStrike Falcon: the seams in any security tool sit exactly where the thing being watched gets to describe itself.
Sources
This is a fast-moving incident and the affected package list is still growing. The reporting here is drawn from Aikido, Wiz and Socket, who each did the primary analysis. Check their campaign trackers for the current package list rather than treating the table above as final.
