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.

FileSHA-256BytesVersions
package/setup.mjs (loader, variant A)fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb11,017582
package/math_init.js (stage 2)9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc727,680582
package/setup.mjs (loader, variant B)54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b35066829,9189
package/Math_Symbol.js (stage 2, renamed)9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc727,6809

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.

  1. 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.

  2. 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]).
  3. 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.

TimeEvent
09:35:00keyv@6.0.0 — patient zero
09:38:13@thiennq/docs-viewer@1.6.2first cross-org hop, 3m13s in
10:09:44@cacheable/net@2.1.1 — automated fan-out begins
10:10–10:14cacheable, 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.

VersionsPackages
Rejected, analyzed locally1,565417
Rejected on external evidence (tarball 404’d mid-import)381
Requested but never ingested (404’d before fetch)59
Ever approved or auto_approved00

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).

SocketOurs (at diff time)OverlapOurs-only
Artifacts2,2326476470
Packages4441461460

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.

ScopeStill livePulled
@servicetitan18752
@or-sdk18017
@ornikar1010
@onereach290
unscoped106
@cacheable03

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

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