Shai-Hulud Returns: keyv, cacheable and 800+ npm Packages Hijacked by a Self-Replicating Worm
A compromised maintainer account pushed a preinstall dropper into keyv, cacheable, flat-cache and file-entry-cache on August 4, 2026. The payload fetches its own Bun runtime, drains cloud and CI credentials, and republishes itself through npm trusted publishing.
This is a live incident. As of publication on August 4, 2026, malicious versions are still reachable on the npm registry and the worm is still spreading to new maintainers. Treat every number in this post as a floor, not a ceiling.
On August 4, 2026, an attacker took control of the GitHub account belonging to the maintainer behind keyv — one of the most-installed key-value abstraction libraries in the Node ecosystem — and used that access to push a credential-stealing worm into the entire family of packages under their control. Because the same maintainer also owns cacheable, flat-cache, file-entry-cache, cacheable-request and cache-manager, a single account takeover reached deep into the dependency tree of half the JavaScript world in a matter of minutes.
By early afternoon CEST, Aikido was counting 868 compromised packages across 1,381 versions, together representing over two billion installs per month. Almost none of the affected teams installed these packages on purpose. They arrived as transitive dependencies of ESLint, of HTTP clients, of build tooling — the plumbing nobody audits.
The malware is a descendant of the Shai-Hulud family. Wiz Research links it to the earlier TeamPCP and antv campaigns, and the attacker's own exfiltration repositories carry a description reading "Shai-Hulud: Here We Go Again." This variant is meaningfully more capable than its predecessors, and the reasons why are worth walking through in detail.
The short version
- A GitHub maintainer account was taken over. The attacker committed malware directly to
mainand immediately cut releases, so the poisoned tarballs were published through the project's own GitHub Actions pipeline with valid npm provenance attestations. - Every affected package gained two files —
setup.mjsandMath_Symbol.js— plus a"preinstall": "node setup.mjs"entry inpackage.json. The shippeddist/output was left byte-identical to the last clean release. setup.mjsdownloads a standalone Bun runtime and uses it to execute the real payload, sidestepping the host's Node installation and any Node-level instrumentation.- The payload harvests cloud credentials, CI secrets, Kubernetes and Vault tokens, SSH and private keys, crypto wallets and AI tool configs, then exfiltrates them to attacker-created GitHub repositories, over DNS, and to a hardcoded endpoint.
- It then republishes itself into other packages the stolen npm identity can reach, minting publish credentials through npm's OIDC trusted-publishing exchange. That is what turned an account takeover into a worm.
- For good measure, it plants autostart hooks in
.vscode/tasks.jsonand.claude/settings.jsonso that merely opening a cloned repository — in an editor or with an AI coding agent — re-executes the loader without anynpm installat all.
Why keyv is such a good host
keyv is a thin, boring, extremely well-behaved abstraction over key-value stores, with adapters for Redis, SQLite, Postgres, MongoDB and others. cacheable, cacheable-request, flat-cache and file-entry-cache are caching utilities in the same maintainer's orbit, and several of them sit underneath tooling that virtually every JavaScript project uses — flat-cache and file-entry-cache are how ESLint remembers what it already linted.
That is precisely the profile an attacker wants. These packages are:
- Deep in the tree. Most victims never named them in a
package.json. - Low-churn and trusted. Nobody diffs a patch release of a caching helper.
- Maintained by one person across two namespaces. One account, enormous blast radius.
The approximate monthly install volumes reported for the initial wave:
| Package | Malicious version | Approx. installs/month |
|---|---|---|
keyv |
6.0.0 | 604M |
flat-cache |
6.1.24 | 580M |
file-entry-cache |
11.1.6 | 571M |
cacheable-request |
13.0.20 | 137M |
@cacheable/utils |
2.5.1 | 34M |
cacheable |
2.5.1 | 30M |
@cacheable/memory |
2.2.1 | 28M |
cache-manager |
7.2.10 | 16M |
@cacheable/node-cache |
3.1.2 | 6M |
@cacheable/net |
2.1.1 | 3.7K |
ecto |
5.0.1 | 4.5K |
Second-wave packages hit by worm propagation into other maintainers' accounts include @deliveroo/[email protected], @picsart/[email protected], @qlik/[email protected], @qlik/[email protected], [email protected], @thiennq/[email protected], [email protected] and the entire @ornikar/* tooling namespace. That list is growing while you read this.
Timeline
Vendor-reported timestamps do not perfectly agree, which is normal for a fast-moving campaign observed from different vantage points. Socket's reconstruction from npm registry publish times places the events on August 4, 2026 as follows:
- 14:05 UTC —
[email protected]published. - 15:39–15:44 UTC — the
cacheablefamily goes out in a five-minute burst:@cacheable/net,@cacheable/node-cache,@cacheable/memory,@cacheable/utils,cacheable,cacheable-request,flat-cache,file-entry-cache,cache-manager. - Concurrently — the upstream
jaredwray/keyvrepository shows force-pushes tomain, thev6.0.0tag being deleted and recreated repeatedly, and a commit whose message announces the addition ofsetup.mjsandMath_Symbol.jsacross the@keyv/*packages, followed by activity on thev5release branch.
That last point matters more than it looks. The attacker was not firing a one-shot payload and leaving. They retained account and CI control and were rewriting history in real time — which is why "just pin to the previous version and move on" is not a complete answer while the account remains contested.
Aikido's running count of 868 packages / 1,381 versions was published at 13:37 CEST the same day. Take the ordering here as approximate; the substantive facts are consistent across all three vendor reports.
The attack chain
Stage 0 — Account takeover, then a legitimate release
The attacker did not tamper with a published tarball. They committed to the source repository and let the maintainer's own release automation do the packaging and signing. The consequence is the single most uncomfortable detail of this incident: the malicious [email protected] shipped with a passing provenance attestation. The build pipeline honestly and correctly attested that this artifact was built from that commit in that repository. The commit was just already poisoned.
If your supply chain policy is "require provenance, then trust the package," this campaign walked straight through it.
Stage 1 — setup.mjs, the bring-your-own-runtime dropper
The trojanized manifest adds two files to the published set and one lifecycle hook:
"files": [ "dist", "LICENSE", "setup.mjs", "Math_Symbol.js" ],
"scripts": {
"preinstall": "node setup.mjs"
}
preinstall runs before the package is even unpacked into place. No import, no application code path, no reachability analysis required — installing the dependency is the exploit.
setup.mjs is a lightly obfuscated Node script whose only job is to fetch and launch a second-stage interpreter. It checks whether bun already exists on the host; if not, it downloads a platform-matched standalone Bun 1.3.13 build straight from the official oven-sh/bun GitHub releases:
https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/<target>.zip
The platform detection is thorough — it distinguishes architectures and detects musl/Alpine environments by inspecting ldd --version and /etc/os-release. Extraction falls back through three paths: the system unzip, PowerShell's Expand-Archive on Windows, and finally a hand-rolled pure-JavaScript ZIP parser if neither is available. Then:
execFileSync(bunBinary, [payloadPath], { stdio: "inherit", cwd: workDir })
This is the design decision worth internalising. By shipping its own runtime, the payload:
- ignores whatever Node version the host is pinned to,
- evades Node-level hooks, loaders,
NODE_OPTIONSinstrumentation and EDR rules written aroundnodeprocess trees, - and pulls its interpreter from a domain (
github.com) that no egress policy on earth is going to block.
The download is legitimate software from a legitimate release page. Only the thing it is asked to execute is malicious.
Stage 2 — Math_Symbol.js, the collector
The payload is a roughly 728 KB Bun bundle. Its strings are protected with polymorphic basE91 encoding: a single shared numeric opcode table drives dozens of per-scope alphabets that are decoded lazily at use. Recovering readable strings means reimplementing basE91 and brute-forcing each alphabet — a deliberate speed bump against static analysis rather than a serious cryptographic barrier.
Once decoded, the internal module log tags describe the architecture plainly: [collector], [dispatcher], [provenance] and [publish].
What it steals
The collector is not opportunistic. It is a systematic sweep of every credential store a modern developer machine or CI runner is likely to hold.
Cloud provider credentials
- AWS credential chains:
~/.aws/credentials,~/.aws/configwith all named profiles parsed, and theAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKENenvironment variables. - EC2 Instance Metadata Service at
169.254.169.254, attempting IMDSv2 first and falling back to IMDSv1. - ECS task metadata at
169.254.170.2. - AWS Secrets Manager — it calls
ListSecretsacross multiple regions and exfiltrates what it can read. - GCP service account private keys and Azure client secrets.
CI/CD and registry identity
- npm tokens from
~/.npmrcand any other.npmrcfound on disk, including//registry.*:_authToken=entries, each validated live againstregistry.npmjs.org/-/whoamibefore exfiltration. - GitHub credentials in four flavours: classic PATs (
ghp_), OAuth tokens (gho_), App server-to-server tokens (ghs_) and JWT OIDC tokens — sourced from~/.config/gh/hosts.yml, environment variables and a filesystem sweep. - On GitHub Actions runners specifically, the payload shells out to read the runner process memory directly, dumping the entire in-memory secret store — which defeats the usual assumption that masked secrets are safe from the job they were scoped to.
ACTIONS_ID_TOKEN_REQUEST_TOKENandACTIONS_ID_TOKEN_REQUEST_URL, which are the keys to npm OIDC publishing.
Infrastructure secrets
- HashiCorp Vault tokens from six sources in priority order:
VAULT_TOKEN,~/.vault-token, the Actions runner path/home/runner/.vault-token, several well-known container paths, a Kubernetes auth login using a stolen service account JWT, and Vault's AWS IAM auth endpoint using stolen AWS keys. After authenticating it enumerates every mount via/v1/sys/mountsand reads all KV v1 and v2 paths. - Kubernetes service account token, CA cert and namespace from
/var/run/secrets/kubernetes.io/serviceaccount/, then uses that token against the API server to pull every secret in the namespace.KUBECONFIGand~/.kube/configare also targeted.
Application and developer secrets
- Stripe keys (
sk_,pk_, both test and live) and Slack tokens (xox[baprs]-). - Cryptocurrency wallets — carried over from earlier Shai-Hulud generations.
- AI tool configuration files, a newer addition that reflects where credentials now live.
Generic filesystem sweep
A platform-aware scanner (separate macOS and Linux behaviour) runs roughly 200 glob patterns, skipping files over 5 MB and running up to 64 concurrent reads. Targets include .env, .env.* and .envrc; private keys (*.pem, *.key, *.p12, *.pfx, *.jks); SSH material (id_rsa, id_ed25519, .ssh/config); Terraform state and .tfvars; Docker registry credentials in docker/config.json; KeePass databases (*.kdbx); OpenVPN configs (*.ovpn); and IDE configuration including .vscode/tasks.json and .claude/settings.json. A TruffleHog-style regex engine runs across everything it touches, flagging PEM blocks, SSH public keys, Azure storage keys, database connection strings with embedded credentials and generic key=value pairs matching common secret field names.
How it becomes a worm
Credential theft is table stakes. What makes this campaign expensive is the [publish] module.
With a validated npm token or a minted OIDC credential, the payload:
- Confirms the stolen identity via
registry.npmjs.org/-/whoami. - Enumerates publishable targets with
registry.npmjs.org/-/v1/search?text=maintainer:<victim>. - Where trusted publishing is configured, mints a publish credential through the OIDC exchange endpoint at
registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/. - For each target: downloads the current tarball, injects the same
setup.mjsandMath_Symbol.js, adds thepreinstallhook, recomputes the integrity and shasum fields so the manifest stays internally consistent, bumps the version, andPUTs it back to the registry.
Every developer who installs one of those republished packages becomes the next publisher. This is why the count went from ten packages to several hundred inside a single working day, and why it jumped organisational boundaries into Deliveroo, Picsart, Qlik and Ornikar namespaces.
The [provenance] module closes the loop: because republishing can happen through legitimate OIDC trusted publishing, the worm's output can inherit valid attestations too. Provenance is a statement about where a build came from, not about whether that source was honest.
Exfiltration and C2
The operators deliberately avoided a single chokepoint that could be sinkholed:
GitHubSendercreates repositories under the victim's own GitHub identity viaPOST /user/reposand commits the harvested data using the GraphQLcreateCommitOnBranchmutation. The repository descriptions contain the stringShai-Hulud: Here We Go Again.DomainSenderexfiltrates over DNS, which walks out of most build networks unimpeded.- A hardcoded HTTPS endpoint at
npm-cache[.]com:443/router, a name chosen to look unremarkable in proxy logs. - Ethereum RPC endpoints used as command-and-control. Wiz flags this as the notable evolution in this variant: reading operator instructions off public Ethereum infrastructure (
eth-mainnet.nodereal[.]io,go.getblock[.]io,eth.llamarpc[.]com) means there is no attacker-controlled domain to seize, and the channel survives takedowns entirely.
There is also a taunt embedded in the binary: a string reading IfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClients, apparently daring defenders to revoke a shared credential.
Persistence that targets your editor — and your AI agent
The part of this campaign most likely to bite teams after they think they have cleaned up: the attacker also planted autostart hooks in the source repositories themselves.
.vscode/tasks.jsonreceives a task with afolderOpentrigger..claude/settings.jsonreceives aSessionStarthook.
Both execute the same loader. Neither requires npm install. Clone the repository to investigate the incident, open it in your editor or point a coding agent at it, and you have re-infected the machine you were using to respond.
This is the first widely-observed campaign to treat AI coding agent configuration as a first-class persistence mechanism, and it will not be the last. If your incident response runbook says "clone the affected repo and look at the diff," that runbook now needs a sandbox.
Indicators of compromise
File artifacts
| Artifact | SHA-256 |
|---|---|
setup.mjs (initial wave) |
54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668 |
setup.mjs (community-spread variant) |
fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb |
Math_Symbol.js / math_init.js |
9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc |
Also hunt for the presence of setup.mjs or Math_Symbol.js inside any node_modules/* directory, and for temporary directories matching /tmp/bun-dl-*/.
Network
| Indicator | Role |
|---|---|
npm-cache[.]com (104.21.35[.]216) |
Exfiltration endpoint, :443/router |
eth-mainnet.nodereal[.]io (35.175.164[.]77) |
Ethereum RPC used for C2 |
go.getblock[.]io (185.44.207[.]215) |
Ethereum RPC used for C2 |
eth.llamarpc[.]com (172.67.167[.]200) |
Ethereum RPC used for C2 |
Behavioural
- A
node setup.mjsprocess spawning a freshly downloadedbunbinary. - Outbound requests to
oven-sh/bunrelease assets from a CI runner that has no business fetching a runtime. - Cloud instance metadata reads (
169.254.169.254,169.254.170.2) originating from build agents. - Unexpected
POST /user/reposactivity, or new public repositories with a description containingShai-Hulud: Here We Go Again. - Unexpected npm package versions published from your organisation on August 4, 2026.
- Anomalous DNS query volume from build infrastructure.
What to do right now
If you build software
- Find every affected version in your lockfiles, including transitive ones.
npm ls keyv flat-cache file-entry-cache cacheable cacheable-request cache-manageris a start, but check the full published list — it is still growing. - Pin to the last known-clean release and lock by integrity hash. Do not leave caret or tilde ranges in place, and do not run
npm updatehoping for a fix, while the maintainer account is still contested. - Consider blocking the
keyv,@keyvandcacheablescopes wholesale at your registry proxy or allowlist, rather than playing whack-a-mole with individual versions. - Disable install scripts by default going forward —
npm config set ignore-scripts true, with explicit exceptions — and use a package firewall that checks packages before install rather than after. - Rebuild, do not clean. Any workstation or runner that installed an affected version and executed install scripts should be treated as compromised and rebuilt, not scrubbed.
If you run security
- Revoke, don't just rotate, every npm token and GitHub token reachable from an affected host. Rotation leaves the old credential valid until expiry; the attacker already has it.
- Rotate everything else in blast radius: AWS, GCP and Azure keys, Vault tokens, Kubernetes service account tokens, SSH keys, Terraform credentials, Stripe and Slack tokens, CI organisation and repository secrets, and anything the regex sweep would have caught in a
.env. - Audit your npm organisation for package versions published on August 4, 2026 that nobody on your team authorised — you may be a propagation node, not just a victim.
- Audit GitHub for newly created repositories, unexpected commits and unfamiliar OAuth grants under user accounts, not just org accounts.
- Review cloud audit logs for API activity from unfamiliar source IPs in the window following any affected install.
- Check
.vscode/tasks.jsonand.claude/settings.jsonin every repository your team has cloned, including ones you cloned during the investigation.
What this incident actually teaches
Three lessons are worth carrying past this week.
Provenance answers a narrower question than people think. Signature and attestation verification confirms that an artifact came from a specific commit in a specific repository via a specific pipeline. It says nothing about whether that commit was authored by the person who was supposed to author it. Account security is supply chain security; attestation is a complement to it, not a substitute.
Install-time execution remains the weakest link in the ecosystem. Nothing in this campaign required a single line of the victim's application to call into keyv. Reachability analysis, SAST, runtime protection — none of it engages before preinstall fires. The only controls that matter at that moment are "don't run scripts" and "don't install the package at all."
Your credential blast radius is your real dependency graph. The interesting question after an incident like this is not "which packages did we install" but "what could an attacker reach with everything that machine could see." A CI runner with an over-scoped AWS role, a long-lived npm token, and a Vault login is one npm install away from being someone else's initial access broker. Short-lived credentials, scoped OIDC roles and per-package publishing tokens are the difference between an inconvenient rotation and a breach.
The external attack surface angle
Credential theft is a build-time event, but the damage lands on your externally reachable estate — the S3 buckets those AWS keys open, the staging hosts an SSH key unlocks, the API endpoints a leaked bearer token authenticates against, the subdomain someone can now stand up under your DNS.
After you have finished rotating, the follow-up question is harder and more useful: do you actually know what an attacker holding your credentials could touch from the outside? In practice most organisations discover the answer is "more than we documented" — forgotten staging environments, cloud assets outside the main account, API paths and keys embedded in shipped JavaScript that nobody catalogued.
That inventory problem is what we built Jsmon to solve: continuous discovery of subdomains, cloud assets, API endpoints and secrets exposed in client-side JavaScript, so that when an incident like this forces a credential rotation, you are working from a real asset list rather than a hopeful one. If this week has you rebuilding that picture from scratch, that is worth a look — but rotate your tokens first.
Sources and further reading
This post synthesises the ongoing research of three teams tracking the campaign in parallel. All three are updating their write-ups as the investigation continues, and you should read them directly for the latest package lists and IOCs:
- Socket Threat Research Team — Popular npm Packages in the keyv and Cacheable Namespaces Compromised in Active Supply Chain Attack
- Merav Bar, Wiz Research — keyv and cacheable npm Package Hijacked in Supply Chain Attack
- Ilyas Makari, Aikido Security — Keyv and friends compromised in active Shai-Hulud supply chain attack
We will update this post as the campaign develops. If you find affected versions in your environment, revoke first and investigate second.