Skip to content

keyv / cacheable — "ChainDrop" (2026)

The Lesson: The signature was valid. The provenance attestation was real, minted by GitHub Actions and logged to a public transparency ledger. The package was malware anyway — because provenance proves where a build happened, not what went into it. If your supply chain defense stops at "it's signed," you were never defended.

The Green Checkmark Problem

We spent five years telling people to demand signed packages. Sigstore, SLSA, npm provenance, verified badges — build the plumbing, get the attestations, trust the checkmark. The plumbing works. It did exactly what it was designed to do on August 4th, and what it attested to was a credential-stealing worm. The attacker didn't forge a signature. They didn't need to. They poisoned the source, and the honest machine honestly signed it.


What Happened

On August 4, 2026, an attacker gained control of the GitHub account belonging to the maintainer of keyv — a key-value storage abstraction that sits, mostly invisibly, under a large fraction of the Node ecosystem's caching layer. The same maintainer owns cacheable, flat-cache, file-entry-cache, cache-manager, and a cluster of related utilities. Between them, the family accounts for downloads in the hundreds of millions per month.1

The attacker pushed malicious files directly to main and immediately cut a release. keyv@6.0.0 hit npm at 09:35 UTC carrying a preinstall hook. The industry settled on a name for what followed: ChainDrop, after the Ethereum dead-drop it takes its orders from.4

Final tally: 2,234 poisoned versions across 444 package names, spanning twelve-plus organizations.5

And it did not stay in the caching ecosystem. Between 10:12 and 10:46 UTC — a thirty-four minute window — eight other organizations published most of their namespace, at roughly one package per second each:5

Organization Package names Poisoned versions
ServiceTitan 147 1,082
OneReach 155 487
Ornikar 47 537
Qlik 59 59

Plus Picsart, Deliveroo, ARV-Bedrock, AdminIDE-Stack, and Umacloud. None of those companies did anything wrong. Their CI runners installed a caching library, the worm read their npm tokens out of the environment, and it published in their name at machine speed. A package-per-second cadence isn't an attacker at a keyboard — it's a for loop.

Attribution: a descendant of the "Mini" Shai-Hulud family, sharing structure with the TeamPCP and antv campaigns, and reusing the editor-hook persistence and pinned Bun runtime documented in the April 2026 PyPI compromises.6 This is not a new tool. It's a maturing one that got a bigger target.

The Chain

Stage one — the loader. The compromised packages shipped a preinstall script running node setup.mjs. On any machine where npm lifecycle scripts are enabled — the default for every npm before version 12 — this executes the moment you type npm install. You do not have to import the package. You do not have to run your application. Resolution is execution.

The loader downloads a standalone Bun runtime (v1.3.13) from GitHub, unverified, and hands off to the second stage. That choice is deliberate: security tooling that watches node processes doesn't see a bun process doing the work.2

Stage two — the payload. A 728 KB compiled Bun bundle (Math_Symbol.js) that does three jobs:

Harvest. Over 140 filesystem paths, plus cloud APIs. AWS instance metadata (169.254.169.254, 169.254.170.2), AWS Secrets Manager and SSM Parameter Store across sixteen regions, HashiCorp Vault and Kubernetes with protocol-aware authentication, GitHub Actions OIDC tokens, npm tokens, SSH keys, database credentials.4

On CI runners it goes further and dumps the Actions runner's memory through /proc/<pid>/mem, scanning for "isSecret":true — reading the secrets out of the process that was told to keep them.4

And it takes your AI tooling's credentials. .claude/credentials.json is on the target list.4 The assistant you handed a token to is now a credential store like any other, and this worm knew where it lived.

Propagate. Enumerate every package the compromised account is authorized to publish. For each: download the current tarball, inject the preinstall hook and payload, recompute the integrity fields, bump the version, PUT it back — with a freshly minted Sigstore and SLSA provenance bundle attached.5 That loop is what turned one account into 444 package names before lunch.

Exfiltrate. Data encrypted with a per-run AES-256-GCM key, committed to attacker-controlled GitHub repositories and shipped over DNS-resolved destinations.7

C2 is the part worth studying. The payload queries an Ethereum mainnet contract0xE1f2395ee43e45A1556EC6438a88c31B83493103 — through seventy-five public RPC endpoints, and gets back an encrypted list of C2 domains.4 There is no server to seize and no domain to sinkhole; the operator rotates infrastructure by writing a transaction to a chain nobody can take down, and every already-infected machine picks up the new address on its next call. If that fails, it falls back to searching GitHub commits for a literal marker string. Takedown as a defense strategy does not work against this design, which is precisely the point of building it that way.

Why the Provenance Held

This is the part that should change how you read a package page.

npm provenance works by having a trusted builder — GitHub Actions — attest to what it built and where it built it from. The build produces a DSSE envelope, a short-lived Fulcio signing certificate, and an entry in the Rekor transparency log. Anyone can verify the chain. It is genuinely good engineering.

The attacker pushed malware to main and then triggered that pipeline. The pipeline built the malware. The pipeline signed the malware. Rekor logged the malware. keyv@6.0.0 arrived on npm with valid OIDC and SLSA provenance, and a commit planting the persistence hooks carried a green GitHub-verified badge attributed to github-actions[bot].3 Socket's assessment is the cleanest statement of the problem I've read this year:

The npm and sigstore pipeline did exactly what it is designed to do and still produced a signed, verifiable attestation for malware, because the source it built from was already trojanized.2

It gets worse. The payload ships a provenance component of its own — code that builds DSSE envelopes, requests Fulcio certificates, and submits Rekor entries. The worm doesn't strip attestations off the packages it republishes. It mints fresh, verifiable ones.2

So: attestation answers "was this built by the pipeline it claims?" It does not answer "is the source this was built from the source you reviewed?" Those are different questions, and only one of them has a green checkmark next to it.

The week of analysis that followed compressed this into one sentence, and it's the sentence to remember:

Provenance proves build origin, not authorization.8

Note what the attacker specifically did not do. They had a stolen npm token — they could have published manually. Instead they compromised the source repositories and triggered the projects' own legitimate GitHub Actions workflows, because a manual publish produces a package with no provenance, and in 2026 that stands out. The trusted-publishing path was the better disguise. We built a system where signed artifacts look safe and unsigned ones look suspicious, and the attacker read that and chose to sign. This is the same shape as the Trust on the Wire rule about certificates — a valid cert proves a CA vouched for a name, not that the party behind the name is honest. A valid attestation proves a builder vouched for a build. Signature verification moves the trust problem; it does not solve it.

The Agent-Era Persistence

The payload plants two autostart hooks in the repositories it touches:

  • .claude/settings.json — a SessionStart hook pointing at .vscode/setup.mjs
  • .vscode/tasks.json — an Environment Setup task with runOn: folderOpen

Nothing needs to be installed for these to fire. Someone clones the repo and opens it. That's the trigger.3

Both editors block automatic task execution in untrusted workspaces by default, which is the only reason this isn't worse. But "untrusted workspace" is a prompt, and prompts get clicked through by people who are trying to get work done. If you have ever marked a repo trusted so the tooling would stop nagging you, you know exactly how thin that defense is.

The wider point belongs next to the LLM Threat Landscape: your agent's configuration files are now executable content, and they live in the repository. .claude/settings.json, .vscode/tasks.json, .devcontainer/, MCP server definitions, hook scripts — these are code that runs on open, distributed through the same channel as the source. They deserve the same review as a postinstall script and they almost never get it, because they read like config. Diff them like they're code. They are.

The Dead-Man's Switch

Read this before you touch a single credential.

The payload installs a host-level watcher — ~/.local/bin/gh-token-monitor.sh, with state in ~/.config/gh-token-monitor/ and a LaunchAgent or systemd unit to keep it alive — that monitors whether the stolen GitHub token is still valid.2 Revoking the token is the signal. Rotating first tells the attacker they've been caught, at the exact moment you're mid-cleanup.

Correct order:

  1. Hunt and remove the watcher first. The script, the config directory, the LaunchAgent/systemd unit.
  2. Remove the installer artifacts. setup.mjs, the downloaded bun binary, bun-dl-* directories, Math_Symbol.js, and the .claude / .vscode autostart hooks.
  3. Then rotate everything. npm tokens, GitHub PATs, cloud keys, Vault tokens, Kubernetes credentials, anything that was in reach of an affected install or CI job.
  4. Audit what was published in your name. Check your npm account for versions you didn't cut, and your GitHub account for repositories you didn't create.

Treat any environment that executed an affected release as credential-compromised. Not "possibly exposed." Compromised.

What To Check

Compare lockfiles against exact versions, not tags. latest moved during the incident and moved back — a clean latest today says nothing about what your CI installed this morning. The initially confirmed set included keyv@6.0.0, @keyv/redis, @keyv/sqlite, @keyv/mongo at 6.0.0, cacheable@2.5.1, @cacheable/net@2.1.1, @cacheable/memory@2.2.1, @cacheable/node-cache@3.1.2, @cacheable/utils@2.5.1, flat-cache@6.1.24, file-entry-cache@11.1.7, cacheable-request@13.0.20, and cache-manager@7.2.10.2 The list grew all day. Check against the current advisories, not this paragraph.

Know whether you have these at all. Almost nobody installs flat-cache on purpose. It arrives under ESLint, under build tooling, four levels down a tree you've never printed. npm ls flat-cache is a ten-second question and most teams can't answer it from memory. This is what SBOMs are for, and it's the same lesson log4shell taught in 2021 — the org that can answer "am I affected?" in minutes has already won most of the incident.

Turn off install scripts. npm 12 and later block lifecycle scripts by default; earlier clients don't. npm config set ignore-scripts true. This single setting neutralizes stage one of this attack and most of its ancestors — see below for what it does and doesn't cover.

Use npm ci, and pin with integrity hashes. A lockfile with integrity hashes pinned to pre-compromise versions is not glamorous and it would have held here.

Block the scopes at the proxy. If you run a registry proxy, blocking keyv, @keyv, and cacheable until the accounts are confirmed clean is a cheap, reversible control.

What Would Have Held

Every control that did any work here was configured before the incident. That's not a moral — it's arithmetic. The worm crossed nine organizations in thirty minutes; the fastest human incident response on the planet loses that race. So the useful question isn't "how do I respond to this?" It's "which settings would have made this a non-event?"

Three of them, in order of leverage:

A release cooldown would have stopped it outright. The poisoned versions went up on August 4th and clean releases were restored as latest the same day. A build configured to refuse anything published in the last twenty-four hours never had a window to install the malware — it simply arrived late and found the mess already cleaned up. Same story for Shai-Hulud in April (detected in ~12 hours) and the 2025 debug/chalk compromise (~2.5 hours). One line of config, three incidents. See Time as a Control.

Blocking install scripts would have stopped it cold. The entire attack chain hangs off a preinstall hook. No hook, no Bun download, no payload. npm 12 and pnpm 10+ block scripts by default; anything older needs npm config set ignore-scripts true. Be clear-eyed about why it worked, though: it worked because this attacker chose a lifecycle hook. A payload in the module body would have run at require() time and this setting would have done nothing.

A warm cache would have held the line — with an asterisk. A pull-through mirror that already had the good versions has nothing to fetch, so a stable build environment behind it never sees the poisoned release. That's real protection, and it's the reason a private registry earns its operational cost.

But it's a time defense, not an integrity defense, and it cuts both ways. A mirror that warmed after the compromise cached the malware and served it faithfully. Worse — your mirror does not receive the unpublish. npm yanked the bad versions; every proxy that had already pulled them kept its copy. There is a real scenario where upstream is clean, the advisories say resolved, and your own infrastructure is the last place still handing out keyv@6.0.0.

So the control isn't owning a cache. It's knowing how to purge one. Write that runbook before the next incident, not during it.

Why It Mattered

Provenance is a build attestation, not a code review. The entire "just check for signed packages" posture assumed an attacker would have to forge something. They don't. They compromise the source and let your trusted builder do the signing. Verification tells you the package came from the repository it claims — which is only useful if you also know the repository wasn't the thing that got taken.

Account takeover beats every artifact control. Sigstore, SLSA, trusted publishing, 2FA on publish — all of it assumes the human's identity is intact. Take the maintainer's GitHub account and every downstream control faithfully certifies your malware. The hardest link in the chain is still a person and their session tokens.

Worms compress your response window to zero. event-stream took months to notice. tj-actions took a day. This one crossed nine organizations in thirty minutes, because self-propagation runs at registry API speed and your incident response runs at human speed. Any control that requires a person to react is already too slow. Only the controls that were configured before the incident — lockfiles, ignore-scripts, pinned digests — did any work here.

The blast radius of a caching library is the whole build. Nobody threat-models file-entry-cache. It's boring infrastructure that landed in your tree via ESLint. Same lesson as tj-actions: the utility nobody audits has exactly the same privileges as the dependency everybody scrutinizes, because they run in the same process with the same environment.

Your editor is now part of the supply chain. Persistence via .claude/settings.json and .vscode/tasks.json means the attack surface extends past npm install to git clone plus "open folder." Agent and editor configs are executable, they're committed, and they are not being reviewed.

Where It Sits

tj-actions (2025) Bitwarden CLI (2026) keyv / cacheable (2026)
Entry Compromised upstream action Compromised CI action Maintainer GitHub account takeover
Signed? N/A N/A Yes — valid OIDC + SLSA provenance
Spread Tag rewrite, 23,000 repos Single package, manual Self-propagating worm, 420+ names in hours
Payload Print env to public logs Credential harvester Credential harvester + republisher + persistence
Persistence None None Editor/agent autostart hooks + token watchdog
Speed ~1 day to detection Weeks ~30 minutes to cross-org spread

The trajectory is the story. Each incident automates one more step of the previous one. tj-actions needed the attacker to rewrite tags by hand. Bitwarden needed them to cut one release. This one needs them to cut one release and then goes and cuts the rest itself — while generating fresh, verifiable signatures for each.

The Pattern Nobody Noticed Twice

Shai-Hulud hit PyPI in April with the same editor hooks and the same pinned Bun runtime. Four months later it hit npm's caching layer, and the reporting in between treated the April incident as a Python story. It was never a Python story. It was a toolchain story, and the toolchain doesn't care which registry you use.

There's always a third time. When it comes, the question won't be whether you verified the signature. It'll be whether you turned off install scripts, whether your lockfile pins integrity hashes, and whether you can answer "am I affected?" without opening a browser.


Timeline

Date / time (UTC) Event
Apr 2026 "Mini" Shai-Hulud documented in PyPI compromises — same Claude Code/VS Code hooks, same Bun 1.3.13
Aug 4, 09:0x Maintainer's GitHub account compromised; malicious files pushed directly to main
Aug 4, 09:35 First poisoned publish — keyv@6.0.0, with preinstall hook and valid OIDC + SLSA provenance
Aug 4, 09:35–10:28 keyv, cacheable, ecto namespaces fall; 11 "full worm carriers" seeded
Aug 4, 10:12–10:46 Eight further organizations publish most of their namespace — roughly one package per second each
Aug 4, ~12:10 Pre-compromise releases restored as latest for at least nine packages, including keyv@5.6.0
Aug 4, 18:10 StepSecurity publishes analysis; names the worm ChainDrop for its Ethereum dead-drop C2
Aug 4–8 Final tally settles at 2,234 poisoned versions across 444 package names, twelve-plus organizations

Written the day of the incident and revised four days later as the counts settled. The initial figures on this page were roughly half the final ones — a reminder that during an active supply-chain event, every number you are given is a floor. No public incident statement from the maintainer, npm, or GitHub had been issued as of this revision.


Indicators of Compromise

# File hashes (SHA-256)
54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668  setup.mjs
9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc  Math_Symbol.js

# Host artifacts
~/.local/bin/gh-token-monitor.sh
~/.config/gh-token-monitor/
bun-dl-*/                    # downloaded Bun runtime staging
.claude/settings.json        # SessionStart hook -> .vscode/setup.mjs
.vscode/tasks.json           # "Environment Setup", runOn: folderOpen

# Network
169.254.169.254              # AWS instance metadata
169.254.170.2                # ECS task metadata
                             # Bun runtime pulled from GitHub releases
                             # npm registry token/OIDC API calls

# C2 — Ethereum dead drop (no domain to sinkhole)
0xE1f2395ee43e45A1556EC6438a88c31B83493103   # mainnet contract
0x53ed5143                                   # eth_call selector
                             # queried via ~75 public Ethereum RPC endpoints
                             # returns AES-256-GCM encrypted C2 domain list
thebeautifulmarchoftime      # fallback marker, GitHub commit search

# Credential targets worth alerting on
/proc/<pid>/mem              # Actions runner memory, scanned for "isSecret":true
~/.claude/credentials.json   # AI assistant tokens


  1. "keyv and cacheable npm Package Hijacked in Supply Chain Attack." Wiz. August 2026. https://www.wiz.io/blog/keyv-and-cacheable-npm-supply-chain-attack 

  2. "Popular npm Packages in the keyv and Cacheable Namespaces Compromised in Active Supply Chain Attack." Socket Research Team. August 2026. https://socket.dev/blog/popular-npm-packages-in-the-keyv-and-cacheable-namespaces-compromised-in-active-supply-chain 

  3. "Keyv-Linked npm Worm Poisons Hundreds of Packages, Plants Claude Code and VS Code Hooks." The Hacker News. August 4, 2026. https://thehackernews.com/2026/08/keyv-linked-npm-worm-poisons-hundreds.html 

  4. "ChainDrop npm Worm: Bun-loaded CI/CD credential harvester with Ethereum dead-drop C2." StepSecurity. August 4, 2026. https://www.stepsecurity.io/blog/chaindrop-npm-worm 

  5. "keyv and cacheable npm compromise: 400+ packages." SafeDep. August 2026. https://safedep.io/keyv-npm-supply-chain-compromise/ 

  6. "Shai-Hulud Returns: ChainDrop Worm Hits npm, Infecting Hundreds of Packages." OPSWAT. August 2026. https://www.opswat.com/blog/shai-hulud-returns-chaindrop-worm-hits-npm-infecting-hundreds-of-packages 

  7. "Worm compromises hundreds of popular npm packages." Datadog Security Labs. August 2026. https://securitylabs.datadoghq.com/articles/npm-worm-compromises-popular-npm-packages/ 

  8. "ChainDrop Had Valid Provenance. That Is the Enterprise Problem." August 2026. https://caiostrategy.substack.com/p/chaindrop-had-valid-provenance-that — see also Microsoft Security Blog, "ChainDrop supply chain compromise: Anatomy of a self-propagating worm," August 4, 2026, and Unit 42, "ChainDrop: Inside a Self-Propagating npm Worm."