keyv / cacheable worm
Keyv / cacheable compromise infected thousands of packages
I woke up this morning to a whole bunch of slack alerts that recently published packages had been rejected due to identified malware.
Below is our investigation of the attack.
The Greenflagged system detected the attack flawless, and no compromised versions were ever available in our package registry.
The signature
Every affected version carried an identical install hook:
"preinstall": "node setup.mjs"
Two-stage payload. Stage two is byte-identical across every affected scope; only the filename rotated mid-campaign.
| File | SHA-256 | Bytes | Versions |
|---|---|---|---|
package/setup.mjs (loader, variant A) | fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb | 11,017 | 582 |
package/math_init.js (stage 2) | 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc | 727,680 | 582 |
package/setup.mjs (loader, variant B) | 54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668 | 29,918 | 9 |
package/Math_Symbol.js (stage 2, renamed) | 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc | 727,680 | 9 |
Stage two is the same 727,680 bytes under both names.
Attack analysis
Below is the automated analysis of the attack:
Install-time execution of a deliberately-obfuscated 728KB blob via a Bun runtime fetched at install, grafted onto a React component library.
package/package.json:93 declares “preinstall”: “node setup.mjs”. The version diff shows every legitimate script (build, lint, test, storybook, clean, dev…) was REMOVED and replaced by this single preinstall.
package/setup.mjs (11KB) is true obfuscation, not bundler output: all identifiers are _0x…, every string literal is indirected through per-function wrapper objects (_0x19328d.WuGMv->“musl”, _0x2c3338.NIXVR->“node”) and even arithmetic is routed through call proxies (EWPHT: (a,b)=>a-b). No webpack/esbuild/vite banner, no sourcemap. Deobfuscated flow:
- mu() fingerprints host libc via execFileSync(“ldd”,["–version"]) looking for “musl”, falling back to reading /etc/os-release for “Alpine”.
- ra() maps process.platform+"-"+process.arch to one of six Bun asset names.
- main(): if a bun binary is on PATH -> execFileSync(“bun”, [
/math_init.js], {stdio:“inherit”, cwd:D}) immediately. else downloads setup.mjs:294 “https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/ .zip” (dl() follows 301/302/307/308 redirects), extracts via unzip -ojq, or PowerShell “-ExecutionPolicy Bypass Expand-Archive”, or a hand-rolled ZIP EOCD/central-directory parser + zlib.inflateRawSync (xn()) to avoid any dependency; chmodSync(…,493) then execFileSync( , [math_init.js]).
package/math_init.js (727,680 bytes) is // @bun CJS output subsequently run through a string-array obfuscator: WV8StW() is the array-rotation decoder, n7gIxM the rotated constant pool holding every string in the program. Semgrep flags eval-usage and new-function-constructor at line 2. My greps for atob/fetch(/child_process/webhook hosts/IPs return NOTHING from this file — expected, because all literals are encoded in the rotated pool, so no endpoint is statically visible. I could NOT read this payload’s behavior and explicitly do not treat the absence of grep hits as clearance.
Timeline (UTC, 2026-08-04)
Publish times from npm packument time.
| Time | Event |
|---|---|
| 09:35:00 | keyv@6.0.0 — patient zero |
| 09:38:13 | @thiennq/docs-viewer@1.6.2 — first cross-org hop, 3m13s in |
| 10:09:44 | @cacheable/net@2.1.1 — automated fan-out begins |
| 10:10–10:14 | cacheable, flat-cache, cacheable-request, file-entry-cache, cache-manager |
| 10:19 | @ornikar/* — second unrelated org, 44m in |
| 10:39 | @or-sdk/* — 65 packages inside ~20 seconds |
What the registry did
Detection fired on the deterministic analyzers: install-script:preinstall plus
obfuscated-file:* from the source-diff analyzer.
| Versions | Packages | |
|---|---|---|
| Rejected, analyzed locally | 1,565 | 417 |
| Rejected on external evidence (tarball 404’d mid-import) | 381 | — |
| Requested but never ingested (404’d before fetch) | 59 | — |
Ever approved or auto_approved | 0 | 0 |
No version carrying this malicious payload was ever served.
Affected scopes by volume: @servicetitan, @or-sdk, @ornikar, @onereach,
@qlik, @nebula.js, @cacheable, @umacloud, plus unscoped (keyv,
cacheable, flat-cache, file-entry-cache, cacheable-request,
cache-manager, the picasso.js / qlik-* visualization stack).
Cross-check against Socket
Diffed our detections against Socket’s published artifact export (2,232 artifacts / 444 unique packages).
| Socket | Ours (at diff time) | Overlap | Ours-only | |
|---|---|---|---|---|
| Artifacts | 2,232 | 647 | 647 | 0 |
| Packages | 444 | 146 | 146 | 0 |
Zero ours-only — every artifact we flagged appears in Socket’s independent list. After importing the gap, we hold and reject 417 of Socket’s 444 packages; the remaining 27 are packages never mirrored here.
Why? Well, I’m running on about $400/mo of compute power currently, so I have to pick and choose which packages I ingest and scan. The system would have confidently rejected all infected packages.
Provenance did not help
keyv@6.0.0 shipped with passing npm provenance — a valid signed SLSA
attestation — because the legitimate release workflow built already-trojanized
source. Signature verification succeeded on malware.
Our AI reviewer rejected it anyway, at 98% confidence:
Preinstall script runs obfuscated javascript-obfuscator-style code that imports child_process/https/os/fs, plus a newly added obfuscated Math_Symbol.js with eval/Function-constructor patterns — this is a classic supply-chain compromise of a hugely popular package (135M weekly downloads).
npm’s takedown is in progress
Measured ~15:00 UTC, ~5.5 hours after patient zero, by probing each rejected
artifact against registry.npmjs.org:
515 of 594 malicious versions were still live and installable.
| Scope | Still live | Pulled |
|---|---|---|
@servicetitan | 187 | 52 |
@or-sdk | 180 | 17 |
@ornikar | 101 | 0 |
@onereach | 29 | 0 |
| unscoped | 10 | 6 |
@cacheable | 0 | 3 |
Verified live at the time of measurement:
GET https://registry.npmjs.org/@servicetitan%2freact-hooks/7.7.1
→ 200
scripts: {"preinstall": "node setup.mjs"}
attestations: no
The high-download names were cleaned up first, the enterprise long tail was not.
By ~15:20 the sweep had reached @onereach 59 versions we had queued for
import began returning 404 {"error":"Not found"} mid-fetch.
Meanwhile latest had been reverted on the headline packages: keyv → 5.6.0,
cacheable → 2.5.0, flat-cache → 6.1.23, file-entry-cache → 11.1.5,
cacheable-request → 13.0.19, cache-manager → 7.2.9.
# Stage-two payload is identical under both filenames
shasum -a 256 math_init.js Math_Symbol.js
# 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc
Links
Interested?
Greenflagged.dev is a new package registry that scans every package before making it available. It helps prevent supply chain attacks just like this one.
Want to learn more? E-mail: sean@greenflagged.dev