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.

Shai-Hulud Returns: keyv, cacheable and 800+ npm Packages Hijacked by a Self-Replicating Worm

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 main and 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.mjs and Math_Symbol.js — plus a "preinstall": "node setup.mjs" entry in package.json. The shipped dist/ output was left byte-identical to the last clean release.
  • setup.mjs downloads 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.json and .claude/settings.json so that merely opening a cloned repository — in an editor or with an AI coding agent — re-executes the loader without any npm install at 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 cacheable family 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/keyv repository shows force-pushes to main, the v6.0.0 tag being deleted and recreated repeatedly, and a commit whose message announces the addition of setup.mjs and Math_Symbol.js across the @keyv/* packages, followed by activity on the v5 release 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_OPTIONS instrumentation and EDR rules written around node process 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/config with all named profiles parsed, and the AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN environment 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 ListSecrets across 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 ~/.npmrc and any other .npmrc found on disk, including //registry.*:_authToken= entries, each validated live against registry.npmjs.org/-/whoami before 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_TOKEN and ACTIONS_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/mounts and 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. KUBECONFIG and ~/.kube/config are 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:

  1. Confirms the stolen identity via registry.npmjs.org/-/whoami.
  2. Enumerates publishable targets with registry.npmjs.org/-/v1/search?text=maintainer:<victim>.
  3. Where trusted publishing is configured, mints a publish credential through the OIDC exchange endpoint at registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/.
  4. For each target: downloads the current tarball, injects the same setup.mjs and Math_Symbol.js, adds the preinstall hook, recomputes the integrity and shasum fields so the manifest stays internally consistent, bumps the version, and PUTs 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:

  • GitHubSender creates repositories under the victim's own GitHub identity via POST /user/repos and commits the harvested data using the GraphQL createCommitOnBranch mutation. The repository descriptions contain the string Shai-Hulud: Here We Go Again.
  • DomainSender exfiltrates 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.json receives a task with a folderOpen trigger.
  • .claude/settings.json receives a SessionStart hook.

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.mjs process spawning a freshly downloaded bun binary.
  • Outbound requests to oven-sh/bun release 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/repos activity, or new public repositories with a description containing Shai-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

  1. Find every affected version in your lockfiles, including transitive ones. npm ls keyv flat-cache file-entry-cache cacheable cacheable-request cache-manager is a start, but check the full published list — it is still growing.
  2. 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 update hoping for a fix, while the maintainer account is still contested.
  3. Consider blocking the keyv, @keyv and cacheable scopes wholesale at your registry proxy or allowlist, rather than playing whack-a-mole with individual versions.
  4. 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.
  5. 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

  1. 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.
  2. 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.
  3. 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.
  4. Audit GitHub for newly created repositories, unexpected commits and unfamiliar OAuth grants under user accounts, not just org accounts.
  5. Review cloud audit logs for API activity from unfamiliar source IPs in the window following any affected install.
  6. Check .vscode/tasks.json and .claude/settings.json in 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:

We will update this post as the campaign develops. If you find affected versions in your environment, revoke first and investigate second.