ChainDrop / Shai-Hulud — keyv/cacheable npm Worm (August 2026)
Executive Summary
On 4 August 2026 an attacker in control of the GitHub account behind the keyv, cacheable and ecto npm package families pushed a credential-stealing worm directly to main and immediately cut releases. Because the malicious source was in the tagged repository state, the project's own release workflow built and published the poisoned tarballs with valid SLSA provenance signed by GitHub Actions. Every cryptographic check passes. The code is malware.
The attacker's objective is credentials, and then reach. Each compromised package adds "preinstall": "node setup.mjs" plus two payload files. setup.mjs is an 11 KB obfuscated loader whose only job is to obtain the Bun runtime (accepting any installed bun, otherwise downloading Bun v1.3.13 from GitHub releases into a temp directory named bun-dl-*) and run the 728 KB stage-2 stealer under Bun rather than under the npm-spawned Node process. The stealer harvests npm, GitHub, AWS, Kubernetes, Vault, Stripe, Slack and AI-assistant credentials, plus CI runner memory, then uses any working npm publish token to repack and republish other packages the token can reach. That self-propagation turned a handful of initial compromises into roughly 434–444 packages across ~1,381 versions, with tracking of up to 2,236 malicious package versions and a combined reach exceeding 2 billion monthly installs.
Two things make this wave meaningfully different from earlier Shai-Hulud activity, and both are the highest-value defensive angles:
- Bun as a monitoring-evasion runtime. Stage 2 never runs under Node. Any detection built on Node.js runtime internals, Node hooks, or npm's own script auditing is blind to it. Host-level process, file and network telemetry still sees everything — which is exactly what Section 8 hunts.
- Persistence in IDE and coding-agent configuration. The worm writes
.vscode/tasks.json,.claude/settings.json,.claude/setup.mjsand.vscode/setup.mjsinto up to 50 branches of every repository a stolen GitHub token can reach. The VS Code task runs onfolderOpen; the Claude settings register aSessionStarthook. Nonpm installis required — merely opening the checkout re-detonates the loader. Cleaningnode_modulesdoes not clean this.
The C2 channel is also new: the payload holds no hardcoded domain. It iterates 73 public Ethereum RPC endpoints and makes an eth_call against a smart contract to retrieve the current exfiltration domain list, then HTTPS-GETs /router on port 443 of the first healthy domain. A signed GitHub commit-search fallback and a public-repository exfiltration fallback (repo description "Shai-Hulud: Here We Go Again") back it up. Blockchain RPC cannot be sinkholed or taken down.
This pack targets developer laptops and build agents, not servers. Those hosts are where npm installs happen, where IDEs open untrusted checkouts, and where GitHub, npm, cloud, signing and deployment credentials sit in plaintext files. Scope your hunt accordingly.
Defender priority: Query 1 and Query 4 first. Q1 catches the install-time detonation, Q4 catches the IDE-config re-detonation that survives dependency cleanup. If either fires, treat the host as credential-compromised and rotate from a clean system — but hunt and disable gh-token-monitor persistence (Q8) before revoking GitHub tokens, because the payload's dead-man switch watches for exactly that revocation.
Source Review & Web Hunter Notes
Six sources were fetched and snapshotted verbatim on 2026-08-07 into KeyvShaiHulud-npm-Worm-Hunt-sources/. Every atomic indicator shipped in Section 10 traces to at least one of those snapshots. Indicators appearing in only one source are labelled below.
| Tier | Source | Key finding | Carry forward |
|---|---|---|---|
| 1 | Datadog Security Labs (2026-08-04, updated 08-05) | Full payload reverse-engineering: Bun loader mechanics, Ethereum contract + selector, four :443/router C2 URLs, seven SHA-256 artifacts, lock file, recursion-guard env var, GitHub commit-search markers, dormant token-monitor paths, 27-file commit timeline. | Yes — primary technical authority |
| 1 | Snyk Security Research (2026-08-04) | Independent tarball diffing without installation: only 3 paths differ between keyv@6.0.0-rc.1 and keyv@6.0.0. Exact byte sizes (setup.mjs 29,918; Math_Symbol.js 727,680), the 11 confirmed maintainer-linked releases, known-clean pin-back versions, advisory SNYK-JS-KEYV-18515941. | Yes — remediation + version scoping |
| 1 | Wiz Research (2026-08-04) | Lineage attribution to the "Mini" Shai-Hulud family / TeamPCP code base; the Bun/1.3.13 user-agent IOC; /tmp/bun-dl-*/ staging path; SHA-1 hashes for the IDE-planted loaders; ETH RPC hosts and resolved IPs; the StringListStore contract behaviour and its on-chain domain updates. | Yes — lineage + network scoping |
| 2 | Aikido Security (2026-08-04, updated 08-05) | Per-extractor breakdown of the credential stealer; the second-generation marker (math_init.js vs Math_Symbol.js); the original setup.mjs SHA-256; GitHub-branch infection detail (50 branches, skips dependabot/copilot, commit message chore: update config, author identity claude); 444 packages / 1,381 versions count. | Yes — collector detail + worm mechanics |
| 2 | Strobes Security (2026-08-04) | The semver analysis: keyv@6.0.0 is the version nothing resolves to transitively, while cacheable-request@13.0.20, cache-manager@7.2.10 and @cacheable/utils@2.5.1 are patch bumps inside live caret ranges and install silently. Provenance comparison against axios (Mar 2026) and TanStack (May 2026). | Yes — exposure scoping correction |
| 2 | Cloudsmith (2026-08-04) | The keyv-shai-hulud tracking tag; 2,236 malicious package versions; the npm v12 default (lifecycle scripts no longer run by default) as a material mitigating control; registry-proxy cooldown and malware policy as the structural fix. | Partial — hardening only, no atomic IOCs |
Web-hunter decisions
- Counts differ between sources and that is expected. Aikido says 444 packages / 1,381 versions at 13:15 CEST 2026-08-05; Cloudsmith says ~444 packages / 2,236 malicious versions; Strobes cites 868 packages / 1,381 versions at 13:37 CEST. All are snapshots of a live, growing incident. Treat any single number as a lower bound and re-derive package exposure from your own lockfiles, not from a published list.
- Cloudflare-fronted IPs are recorded but not blocked. Wiz published resolved addresses for the C2 and RPC hostnames. Two sit behind Cloudflare and one behind AWS; they are shared infrastructure. They appear in Section 4 as
enrichonly and are deliberately excluded from the Falcon import CSV. - Ethereum RPC hostnames are not indicators of compromise on their own. Datadog notes the payload iterates 73 RPC endpoints that are "mostly legitimate public infrastructure". Only the three named by Wiz and Strobes are carried forward, and only as a process-correlated hunt (Q6), never as a block.
- No prompt-injection or instruction-bearing content was found in any fetched page. All six sources are ordinary vendor analysis. No source attempted to direct tooling behaviour or supply an indicator out of band.
Lineage — how this differs from the packs already in the library
Two Shai-Hulud-lineage packs already exist: Mini-Shai-Hulud and ShaiHulud-Hades-PyPI. This pack is the new August 2026 keyv wave and deliberately does not rehash those waves' indicators. Everything below is new to this wave: (1) the four :443/router C2 domains and the Ethereum StringListStore dead-drop resolver that replaces hardcoded domains; (2) the setup.mjs → Bun two-stage loader, which is a runtime-swap evasion the earlier waves did not use; (3) IDE-config persistence via .vscode/tasks.json and .claude/settings.json, giving an execution path with no npm install; (4) a new operator RSA encryption key and a new lock file tmp.dpkg_14527.lock; (5) malicious releases carrying valid GitHub-Actions-signed provenance. Wiz assesses the payload as a descendant of the public "Mini" Shai-Hulud / TeamPCP code base, so the earlier packs' behavioural hunts remain complementary — run them alongside this one, not instead of it.
Hunt Brief & Attack Chain
Attack chain
| # | Step | Telemetry | Hunt angle |
|---|---|---|---|
| 1 | Maintainer GitHub account compromised; malicious files pushed straight to main, release cut immediately | GitHub audit log, org audit stream | Native hunt — unsigned/unexpected pushes to main, release tags deleted and recreated (the keyv v6.0.0 tag was deleted four times) |
| 2 | Release workflow builds and publishes to npm with genuine SLSA provenance | npm attestation / Rekor | Provenance is not a safety verdict — see Section 12 hardening |
| 3 | Victim runs npm install; preinstall hook executes node setup.mjs | ProcessRollup2 | Q1 — node process with setup.mjs on the command line |
| 4 | Loader accepts an installed bun, or downloads Bun v1.3.13 to a bun-dl-* temp dir and marks it 0755 | ProcessRollup2, network egress to GitHub releases | Q3 — Bun in a package-install process tree or executed from bun-dl-* |
| 5 | Stage 2 (Math_Symbol.js / math_init.js, 728 KB) runs under Bun, not Node | ProcessRollup2 | Q2 — bun executing a campaign-named script |
| 6 | Outside CI the payload respawns itself detached with _NODE_RUNTIME_INIT=1, takes lock tmp.dpkg_14527.lock, installs no-op SIGINT/SIGTERM handlers | ProcessRollup2, temp-dir file activity | Native hunt — lock file presence; detached bun with no console parent |
| 7 | Credential collection: ~470 file globs, gh auth token, full process.env, AWS STS/Secrets Manager/SSM across 17 regions, Kubernetes secrets, Vault KV, GitHub Actions runner memory via sudo python3 against Runner.Worker | ProcessRollup2; cloud audit logs | Q9 (gh token extraction), Q10 (privileged Python from a Node/Bun parent), plus CloudTrail native hunts |
| 8 | C2 discovery: eth_call to a smart contract over 73 public Ethereum RPC endpoints returns the live domain list | DnsRequest, NetworkConnectIP4 | Q6 — RPC hostname resolved by a node or bun process |
| 9 | Exfiltration: AES-256-GCM envelope, RSA-OAEP-wrapped key, POSTed to /router on port 443 of the selected domain; GitHub public repo fallback | DnsRequest, NetworkConnectIP4 | Q5 — resolution of the four known exfil domains |
| 10 | C2 response containing a code field is passed to eval() — unsigned, unsandboxed arbitrary code execution | None on-host beyond step 9 | GAP — no distinct telemetry; treat any Q5 hit as full host compromise |
| 11 | Worm propagation A: stolen npm token with bypass_2fa and package-write is used to repack and republish other packages (adds preinstall, injects setup.mjs + math_init.js, patch-bumps version, disables TLS verification for the publish) | ProcessRollup2; npm registry audit | Q7 — npm publish spawned by an automated parent |
| 12 | Worm propagation B: GitHub token writes IDE hooks into up to 50 branches per repo (skips dependabot/copilot), commit message chore: update config, author claude | GitHub audit log | Native hunt — see Section 7 |
| 13 | Re-detonation: VS Code folderOpen task and Claude SessionStart hook run the loader with no npm install | ProcessRollup2 | Q4 — node running setup.mjs from a .vscode or .claude path |
| 14 | Dead-man persistence: gh-token-monitor watches a stolen token and fires a handler when it stops working | ProcessRollup2, LaunchAgent / systemd user unit | Q8 — any process referencing gh-token-monitor |
Hunt hypotheses (ordered by fidelity)
| # | Hypothesis | MITRE | Falcon events | Expected FP | Conf |
|---|---|---|---|---|---|
| H1 | A developer or build agent ran npm install against a poisoned version, detonating node setup.mjs at preinstall time. | T1195.002, T1059.007 | ProcessRollup2 / SyntheticProcessRollup2 | A benign package shipping a root setup.mjs — rare, and verifiable from the path in CommandLine | HIGH |
| H2 | Stage 2 executed: the Bun runtime ran a file named Math_Symbol.js or math_init.js. | T1059.007, T1027 | ProcessRollup2 | None — campaign-specific filenames | HIGH |
| H3 | The IDE-config persistence path re-detonated the loader when a developer opened a poisoned checkout or started a coding-agent session. | T1546, T1195.002 | ProcessRollup2 | Repos legitimately running node tasks on folderOpen — the setup.mjs filename discriminates | HIGH |
| H4 | A host resolved one of the four :443/router exfiltration domains. | T1041, T1071.001 | DnsRequest / SuspiciousDnsRequest | None — no legitimate use | HIGH |
| H5 | The gh-token-monitor dead-man switch was installed on a developer host. | T1546, T1543 | ProcessRollup2 | None — not a GitHub CLI component | HIGH |
| H6 | Bun appeared inside a package-install process tree, or was executed from a bun-dl-* staging directory, on a host with no sanctioned Bun use. | T1105, T1059.007 | ProcessRollup2 | Teams that legitimately use Bun | MED |
| H7 | A Node or Bun process queried a public Ethereum RPC endpoint — the dead-drop C2 resolver. | T1102.001 | DnsRequest | Web3 / wallet engineering hosts | MED |
| H8 | A stolen npm token was used from a compromised host: npm publish was invoked by an automated parent rather than an interactive shell. | T1195.002, T1078 | ProcessRollup2 | Legitimate release pipelines | MED |
| H9 | Credential collection ran: gh auth token driven by a Node/Bun parent, or privileged Python spawned by Node/Bun on a Linux CI runner. | T1552.001, T1528, T1003 | ProcessRollup2 | Home-grown automation wrappers around gh | MED |
| H10 | A known-bad artifact hash was observed anywhere in endpoint telemetry. | T1195.002 | ProcessRollup2, PeFileWritten, ELFFileWritten, ImageHash | None — exact match | MED |
Chain steps with no mappable Falcon telemetry (carried into the Coverage Map as gaps): step 1 (maintainer account takeover), step 2 (provenance signing), step 10 (C2-delivered eval()), and step 12 (GitHub branch infection). All four are visible only in GitHub/npm audit logs — see Section 7.
Consolidated IOC Table
| Type | Value | Conf | Action | Context | Source | Expiry |
|---|---|---|---|---|---|---|
| domain | awqhnjewqjkl[.]icu | high | detect | C2 / exfil, HTTPS GET to /router on 443. Added to the contract on 2026-08-05. | Datadog | 2027-02-07 |
| domain | pypi-get[.]com | high | detect | C2 / exfil, :443/router | Datadog, Wiz | 2027-02-07 |
| domain | js-mirror[.]com | high | detect | C2 / exfil, :443/router | Datadog, Wiz | 2027-02-07 |
| domain | npm-cache[.]com | high | detect | C2 / exfil, :443/router. Registered 2026-05-22; at one point the sole domain the contract returned. | Datadog, Wiz, Aikido, Strobes | 2027-02-07 |
| eth-contract | 0xE1f2395ee43e45A1556EC6438a88c31B83493103 | high | enrich | Ethereum StringListStore dead-drop that returns the live C2 domain list. Not importable into Falcon; use for threat-intel pivoting and to justify the Q6 egress hunt. | Datadog, Aikido | 2027-02-07 |
| eth-selector | 0x53ed5143 | high | enrich | eth_call function selector used against the contract, block latest, 3-second timeout per RPC attempt. | Datadog | 2027-02-07 |
| sha256 | 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc | high | detect | Stage-2 stealer — Math_Symbol.js (first generation) and math_init.js (worm-seeded generation), 727,680 bytes | Datadog, Aikido, Snyk | 2027-02-07 |
| sha256 | 54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668 | high | detect | Original setup.mjs loader as shipped in keyv@6.0.0, 29,918 bytes | Aikido, Snyk | 2027-02-07 |
| sha256 | fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb | high | detect | setup.mjs as embedded and re-propagated by the worm (community-spread variant) | Datadog, Aikido | 2027-02-07 |
| sha256 | 927387d0cfac1118df4b383decc2ea6ba49c9d2f98b47098bcbcba1efc026e1f | high | detect | Malicious .vscode/tasks.json — runs node .claude/setup.mjs on folderOpen | Datadog | 2027-02-07 |
| sha256 | 14eb4ce01dd4307759887ff819359b70d7d9ff709ecde039a5abc1aac325b128 | high | detect | Malicious .claude/settings.json — SessionStart hook running node .vscode/setup.mjs | Datadog | 2027-02-07 |
| sha256 | 3f3f42d072bd36860ab7bd7fb5e10ac0d22c741c13c89505ccd6ec0ea572eea7 | high | detect | Injected GitHub workflow (.github/workflows/codeql_analysis.yml) that leaks toJSON(secrets) to an artifact | Datadog | 2027-02-07 |
| sha256 | 29ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7 | high | detect | Embedded Python piped into sudo python3 to scrape GitHub Actions Runner.Worker memory | Datadog | 2027-02-07 |
| sha256 | 619c56acf572df75b6004a6fc013c80900316a76099b241d64312da3a44f10b4 | high | detect | gh-token-monitor dead-man-switch installer | Datadog | 2027-02-07 |
| sha256 | d584f9b6af48b7ed1f93713944f033783bf149e1c25e1643eb8c0e9df5dc7782 | high | hunt | Tarball hash of keyv-6.0.0.tgz — hunt private registry mirrors and developer package caches | Snyk | 2026-11-07 |
| sha1 | 35a672cf34b996b91f3e1c28cbf3a05a37e036e4 | high | detect | Stage-2 stealer, SHA-1 of Math_Symbol.js / math_init.js | Wiz | 2027-02-07 |
| sha1 | 686aa40d0fc22c8d569494543a0f891f359f2f99 | high | detect | Loader planted at .claude/setup.mjs | Wiz | 2027-02-07 |
| sha1 | f525d52ceb966516686b482d3dc0137028cc6a63 | high | detect | Loader planted at .vscode/setup.mjs | Wiz | 2027-02-07 |
| filename | setup.mjs | high | hunt | Stage-1 loader at package root and at .claude/ / .vscode/. Behavioural — do not block on name alone. | All six | 2026-11-07 |
| filename | Math_Symbol.js, math_init.js | high | hunt | Stage-2 stealer. math_init.js indicates a second-generation, worm-seeded infection rather than the original maintainer compromise. | Datadog, Aikido, Wiz, Snyk | 2026-11-07 |
| filepath | .vscode/tasks.json, .claude/settings.json, .claude/setup.mjs, .vscode/setup.mjs, .claude/math_init.js | high | hunt | IDE / coding-agent persistence set written into up to 50 branches per reachable repository | Datadog, Snyk, Aikido, Wiz | 2026-11-07 |
| filepath | bun-dl-* under the OS temp directory | high | hunt | Bun download/extraction staging directory created by the loader; removed in a finally block, so absence proves nothing | Datadog, Wiz | 2026-11-07 |
| mutex | tmp.dpkg_14527.lock | high | hunt | Single-instance lock under the OS temp directory. New to this wave — earlier Shai-Hulud variants used a different lock name. | Datadog, Wiz | 2026-11-07 |
| env-var | _NODE_RUNTIME_INIT=1 | high | hunt | Recursion guard set on the detached respawn outside CI | Datadog | 2026-11-07 |
| user-agent | Bun/1.3.13 | medium | hunt | Observed on malicious calls. Proxy/egress-log hunt; also matches legitimate Bun 1.3.13 users. | Wiz | 2026-11-07 |
| string | thebeautifulmarchoftime | high | pivot | GitHub commit-search query used for fallback C2 discovery. The trailing space is significant. | Datadog | 2026-09-07 |
| string | thebeautifulsnadsoftime | high | pivot | Marker the parser actually accepts in signed fallback commit messages — deliberately different from the search string | Datadog | 2026-09-07 |
| string | IfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClients | high | pivot | Token-relay marker and abuse-team intimidation string in initial commits. A bluff, not a technical claim. | Datadog, Wiz, Strobes | 2026-09-07 |
| string | Shai-Hulud: Here We Go Again | high | pivot | Default description of the attacker-created public GitHub exfiltration repositories (~1,300 observed) | Datadog, Aikido, Wiz, Strobes | 2026-09-07 |
| string | chore: update config | medium | pivot | Commit message on IDE-hook infection commits, authored as claude. Blends with real commits — corroborate with file paths. | Aikido, Wiz | 2026-09-07 |
| filepath | ~/.local/bin/gh-token-monitor.sh · ~/.config/gh-token-monitor/ · ~/.config/systemd/user/gh-token-monitor.service · ~/Library/LaunchAgents/com.user.gh-token-monitor.plist · /tmp/gh-token-monitor.out.log · /tmp/gh-token-monitor.err.log | high | hunt | Dead-man-switch persistence. Disable before revoking tokens — revocation is the trigger condition. | Datadog, Snyk | 2026-11-07 |
| ipv4 | 104.21.35.216 | medium | enrich | Resolved address of npm-cache[.]com. Cloudflare shared edge — never block. | Wiz | 2026-09-07 |
| ipv4 | 35.175.164.77 | low | enrich | Resolved address of the nodereal ETH RPC host (AWS). Legitimate infrastructure — context only. | Wiz | 2026-09-07 |
| ipv4 | 185.44.207.215 | low | enrich | Resolved address of the getblock ETH RPC host. Legitimate infrastructure — context only. | Wiz | 2026-09-07 |
| ipv4 | 172.67.167.200 | low | enrich | Resolved address of the llamarpc ETH RPC host. Cloudflare shared edge — never block. | Wiz | 2026-09-07 |
Confirmed maintainer-linked malicious releases
Snyk enumerated all 61 packages published by the maintainer and confirmed exactly 11 malicious releases, all carrying byte-identical payload files:
keyv@6.0.0 · @cacheable/net@2.1.1 · @cacheable/node-cache@3.1.2 · cacheable@2.5.1 · flat-cache@6.1.24 · @cacheable/memory@2.2.1 · cacheable-request@13.0.20 · file-entry-cache@11.1.6 · @cacheable/utils@2.5.1 · cache-manager@7.2.10 · ecto@5.0.1
Scope on the right versions. keyv@6.0.0 is the version every headline names, and it is the one nothing in a normal dependency tree resolves to — no caret range on v4 or v5 accepts a v6 major. The releases that install silently are the patch bumps sitting inside live caret ranges: cacheable-request@13.0.20, cache-manager@7.2.10 and @cacheable/utils@2.5.1. Prioritise those in lockfile sweeps. Beyond these 11, the worm propagated to roughly 434–444 further packages across ~1,381 versions (2,236 malicious versions by one count), so a fixed package list is a lower bound — hunt the behaviour and the payload hashes, not the names.
Affected Surface & Telemetry Matrix
| Surface | Why it is in scope | Required telemetry | Priority | Gap risk |
|---|---|---|---|---|
| Developer workstations (macOS, Windows, Linux) | Where npm install runs and where IDE checkouts are opened. Holds plaintext npm, GitHub, AWS, SSH, Vault credentials. | Falcon sensor with ProcessRollup2 + DnsRequest | CRITICAL | Falcon coverage on engineer laptops is frequently thinner than on servers — confirm enrolment before trusting a zero-hit result |
| CI/CD build agents (GitHub Actions self-hosted, Jenkins, GitLab runners) | Runs installs with organisation-level secrets in memory. The runner-memory scraper is Linux-CI-specific. | ProcessRollup2 on runner hosts; GitHub Actions audit log | CRITICAL | Ephemeral containerised runners may terminate before telemetry ships |
| GitHub organisation (repos, Actions, tokens) | Branch infection, workflow injection, secret exfiltration via artifacts, public exfil repo creation | GitHub Enterprise audit log stream | CRITICAL | GAP — not Falcon-visible; requires audit-log ingestion (Section 7) |
| npm registry accounts and private mirrors | Stolen publish tokens republish poisoned tarballs; internal proxies cache malicious versions after npm removes them | npm audit log; registry proxy access logs | CRITICAL | GAP — not Falcon-visible |
| AWS accounts reachable from build/dev credentials | STS identity probe, Secrets Manager and SSM Parameter Store enumeration across 17 commercial regions, IMDSv2 | CloudTrail | HIGH | SSM GetParameters with decryption is high-volume; needs baselining |
| Kubernetes clusters running affected builds | In-cluster API queried with the service-account token; namespace Secrets base64-decoded | K8s API audit log | HIGH | Audit logging often sampled or disabled by default |
| HashiCorp Vault | KV v1/v2 read across the first 100 top-level keys per mount | Vault audit device | HIGH | Vault audit device may not be centrally shipped |
| Egress proxies / firewalls | The four :443/router C2 endpoints and Ethereum RPC dead-drop traffic | Proxy / NGFW logs, DNS resolver logs | HIGH | TLS-inspection gaps hide the /router path; hostname-level logging still works |
| Servers running compiled/bundled application artifacts | Only in scope if the build itself ran an affected install. The library behaves normally at runtime; the hook fires at install time. | ProcessRollup2 | MEDIUM | Low — deprioritise unless builds happen on the host |
ATT&CK Mapping
| Tactic | Technique | Observed behaviour | Query / control |
|---|---|---|---|
| Initial Access | T1195.002 — Compromise Software Supply Chain: Software Dependencies and Development Tools | Poisoned releases of keyv / cacheable / ecto and ~434+ downstream packages published to npm | Q1, Q11 · M1051, M1016 |
| Initial Access / Persistence | T1078 — Valid Accounts | Maintainer GitHub account takeover; stolen npm and GitHub tokens reused to publish and to push branches | Q7 · Section 7 native hunts · M1032 |
| Execution | T1059.007 — Command and Scripting Interpreter: JavaScript | node setup.mjs at preinstall; 728 KB obfuscated stage 2 executed by Bun | Q1, Q2, Q3, Q4 |
| Execution | T1204.002 — User Execution: Malicious File | Opening a poisoned checkout in VS Code or starting a Claude session triggers the hook | Q4 · M1017 |
| Persistence | T1546 — Event Triggered Execution | .vscode/tasks.json runOn: folderOpen; .claude/settings.json SessionStart hook | Q4 · Section 7 · M1042 |
| Persistence | T1543 — Create or Modify System Process | gh-token-monitor installed as a systemd user unit (Linux) or LaunchAgent (macOS) | Q8 |
| Defense Evasion | T1027 — Obfuscated Files or Information | Base91 string table (4,613 entries), 1,283-element constant array, per-call alphabet permutations, flattened generator state machines | Q2, Q11 |
| Defense Evasion | T1553 — Subvert Trust Controls | Malicious releases published with genuine SLSA provenance signed by GitHub Actions; Sigstore/Rekor path attempted for a further package | Section 12 (provenance is not a safety verdict) |
| Defense Evasion | T1218 / runtime substitution | Stage 2 executes under Bun specifically to escape Node.js-runtime-level monitoring | Q2, Q3 · M1038 |
| Credential Access | T1552.001 — Unsecured Credentials: Credentials In Files | ~470 platform-aware globs across .env, SSH, PEM/JKS, Terraform, Docker, KeePass, .ovpn, /etc/shadow, AI-assistant config | Q9 · M1041 |
| Credential Access | T1552.005 — Cloud Instance Metadata API | IMDSv2 (with no IMDSv1 fallback) and ECS container metadata endpoint queried for AWS credentials | CloudTrail native hunt · M1035 |
| Credential Access | T1555 — Credentials from Password Stores | Vault KV, Kubernetes Secrets, AWS Secrets Manager, SSM Parameter Store with WithDecryption:true | Cloud audit hunts · M1026 |
| Credential Access | T1528 — Steal Application Access Token | gh auth token; npm _authToken from .npmrc; GitHub OIDC tokens from Actions env | Q9 · M1032 |
| Credential Access | T1003 — OS Credential Dumping | Embedded Python piped to sudo python3 reads /proc/<pid>/mem of Runner.Worker to recover Actions secrets | Q10 · M1026 |
| Discovery | T1613 — Container and Resource Discovery | Kubernetes namespace and Secret enumeration via the in-cluster API | K8s audit hunt |
| Command and Control | T1102.001 — Web Service: Dead Drop Resolver | eth_call against an Ethereum StringListStore contract over 73 public RPC endpoints returns the live domain list; GitHub commit search as signed fallback | Q6 · M1037 |
| Command and Control | T1071.001 — Application Layer Protocol: Web Protocols | HTTPS GET to /router on port 443; only HTTP 400/404 counts as healthy | Q5 · M1037 |
| Command and Control | T1105 — Ingress Tool Transfer | Bun v1.3.13 fetched from GitHub releases (allowed by every corporate proxy), up to five redirects followed with no origin restriction and no signature verification | Q3 · M1038 |
| Exfiltration | T1041 — Exfiltration Over C2 Channel | gzip + AES-256-GCM envelope with RSA-OAEP-wrapped key POSTed to the selected domain | Q5 |
| Exfiltration | T1567.001 — Exfiltration to Code Repository | Public GitHub repos with description "Shai-Hulud: Here We Go Again"; results written under results/, split above 30 MiB | Section 7 native hunt |
| Impact / Lateral | T1080 — Taint Shared Content | Repacked tarballs republished with a patch bump; IDE hooks written to up to 50 branches per repo | Q7 · Section 7 |
Native Audit-Log Hunts (non-CQL)
Four attack-chain steps are invisible to endpoint telemetry. These checks close them. Run the local sweeps on any host that installed an affected version, and the audit-log queries across the whole organisation.
7.1 — Local filesystem sweep (developer host / build agent)
Safe to run without executing package code. Covers both the npm-delivered payload and the IDE-config persistence that survives a node_modules wipe.
# 1. Payload and persistence artifacts anywhere under HOME and /tmp
find "$HOME" /tmp \
\( -name setup.mjs -o -name Math_Symbol.js -o -name math_init.js \
-o -name 'gh-token-monitor*' -o -name 'tmp.dpkg_14527.lock' \
-o -name 'bun-dl-*' \) -print 2>/dev/null
# 2. Installed manifests carrying the exact lifecycle hook (no package code runs)
find node_modules -name package.json -print0 |
xargs -0 node -e '
const fs = require("node:fs");
for (const file of process.argv.slice(1)) {
try {
const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
if (pkg.scripts?.preinstall === "node setup.mjs") {
console.log(pkg.name + "@" + pkg.version + " " + file);
}
} catch {}
}
'
# 3. Confirm by content hash, not by filename
find "$HOME" -type f \( -name '*.mjs' -o -name '*.js' \) -size -1000k \
-exec shasum -a 256 {} + 2>/dev/null | grep -E \
'9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc|fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb|54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668'
# 4. IDE / coding-agent persistence inside every local checkout
find "$HOME" -type d -name .git -prune -o \
-path '*/.vscode/tasks.json' -print -o \
-path '*/.claude/settings.json' -print -o \
-path '*/.claude/setup.mjs' -print -o \
-path '*/.vscode/setup.mjs' -print 2>/dev/null
# 5. Dead-man switch -- CHECK AND DISABLE BEFORE REVOKING ANY GITHUB TOKEN
ls -la ~/.local/bin/gh-token-monitor.sh ~/.config/gh-token-monitor/ \
~/.config/systemd/user/gh-token-monitor.service \
~/Library/LaunchAgents/com.user.gh-token-monitor.plist 2>/dev/null
systemctl --user list-units 'gh-token-monitor*' 2>/dev/null
launchctl list 2>/dev/null | grep -i gh-token-monitor
7.2 — Lockfile and dependency-tree exposure
# Resolved tree, including transitive paths npm ls keyv flat-cache file-entry-cache cacheable-request cacheable \ @cacheable/utils cache-manager @cacheable/net \ @cacheable/node-cache @cacheable/memory ecto --all # Lockfiles keep a pinned version alive after npm moves the dist-tag. # Anchor on WHOLE package names -- substring matching produces false hits. rg -n '"(keyv|flat-cache|file-entry-cache|cacheable-request|cacheable|cache-manager|ecto)"|"@cacheable/' \ package-lock.json npm-shrinkwrap.json pnpm-lock.yaml yarn.lock # The three that install SILENTLY inside live caret ranges -- check these first rg -n 'cacheable-request@?13\.0\.20|cache-manager@?7\.2\.10|@cacheable/utils@?2\.5\.1' \ package-lock.json pnpm-lock.yaml yarn.lock # Which npm client, and can lifecycle scripts run at all? npm --version ; npm config get ignore-scripts
7.3 — GitHub organisation audit log
| Check | Audit-log query / API | What a hit means |
|---|---|---|
| Attacker exfiltration repositories | Search GitHub for public repos whose description contains Shai-Hulud: Here We Go Again; cross-reference the owner against your own users | A user of yours had a working GitHub token stolen and used to create a public drop repo |
| IDE-hook infection commits | action:git.push where the commit message is chore: update config and the author identity is claude / claude@users.noreply.github.com; corroborate against added paths .vscode/tasks.json, .claude/settings.json, .claude/setup.mjs, .vscode/setup.mjs, .claude/math_init.js | Branch infection. Up to 50 branches per repo, most-recently-active first, dependabot and copilot refs skipped |
| Injected secret-leaking workflow | Branch dependabot/github_actions/format/setup-formatter; file .github/workflows/codeql_analysis.yml; any workflow referencing toJSON(secrets); artifact named format-results.txt | Organisation/repository Actions secrets were converted from names into values. The run and branch are deleted afterwards, but audit-log, workflow-run and artifact records usually survive |
| Anomalous release behaviour | Tags deleted and recreated; a GitHub Release object published after the npm artifact; unsigned commits landing on main without a recorded PR merge | The publish path is not behaving like your normal release. A GitHub ReleaseEvent is not a reliable npm publication marker in this campaign |
| Token inventory | Enumerate classic PATs (ghp_, gho_) and App tokens (ghs_) with workflow, repo or public_repo scope held by anyone who ran an affected install | Defines rotation scope. A token with workflow scope enables the secret-leaking workflow path |
7.4 — npm registry and private mirror
- Review your own npm org publish history for unexpected patch-version releases in the incident window — the worm always bumps the patch and replaces the entire
scriptsobject with{"preinstall":"node setup.mjs"}. That replacement also breaks legitimate build/test scripts, which is a useful secondary tell. - Check granular npm tokens for
bypass_2fa: truecombined with package-write. Those are the tokens the propagation path requires. - Purge affected versions from private registries, proxies and developer caches. npm's own removal of a version does not delete copies already cached by an internal proxy.
7.5 — Cloud and secret-store audit
- CloudTrail:
sts:GetCallerIdentityfollowed within minutes bysecretsmanager:ListSecrets/GetSecretValueandssm:DescribeParameters/GetParameterswithWithDecryption=true, iterating many regions from one source. The collector pagesDescribeParametersat 50 and batchesGetParametersat 10 across 17 commercial regions — that multi-region fan-out from a build identity is the signature. - Kubernetes audit:
listonnamespacesthenget/listonsecretsacross non-system namespaces by a service account that has no business doing so. - Vault audit device:
sys/mountsenumeration followed by broad KV reads capped at 100 top-level keys per mount.
CrowdStrike LogScale CQL Hunt Queries
No query carries an in-query time filter. Set the window in the Falcon console instead — @timestamp compared against an expression such as now() - N is rejected by the parser and would stop every query on line 1. Start with a 30-day window: the campaign began 2026-08-04 and installs are bursty.
Looks for: a node process whose command line references setup.mjs — the exact shape of "preinstall": "node setup.mjs" firing during dependency installation. This is the primary detonation event and the first query to run. FP: a benign package shipping a root-level setup.mjs is rare; the full path in CommandLine tells you which package, so triage is seconds not minutes.
// HUNT: npm/yarn/pnpm preinstall hook launching the keyv-shai-hulud stage-1 loader // MITRE: T1195.002, T1059.007 | CONF: high FP: low COST: low // REQUIRES: ProcessRollup2 on developer workstations and CI build agents // TUNING: if a sanctioned internal package genuinely ships setup.mjs, exclude it by its // full path in CommandLine -- never by suppressing the node+setup.mjs pair itself #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | FileName=/^node(\.exe)?$/i | CommandLine=/setup\.mjs/i | table([@timestamp, ComputerName, UserName, GrandparentBaseFileName, ParentBaseFileName, FileName, CommandLine, SHA256HashData, aid])
Looks for: the bun runtime executing a file named Math_Symbol.js or math_init.js. This is stage 2 actually running — the point at which credentials leave the host. math_init.js specifically indicates a second-generation infection seeded by the npm tarball worm rather than the original maintainer compromise, which changes your blast-radius question from "did we install keyv" to "whose token published this". FP: none expected — both filenames are campaign-specific.
// HUNT: Bun runtime executing the keyv-shai-hulud stage-2 credential stealer // MITRE: T1059.007, T1027 | CONF: high FP: low COST: low // REQUIRES: ProcessRollup2 (macOS, Linux and Windows dev / CI hosts) // TUNING: none needed. Math_Symbol.js and math_init.js are campaign filenames with no // legitimate counterpart -- treat every hit as a confirmed stage-2 execution #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | FileName=/^bun(\.exe)?$/i | CommandLine=/(Math_Symbol|math_init)\.js/i | table([@timestamp, ComputerName, UserName, ParentBaseFileName, ImageFileName, CommandLine, ProcessStartTime, aid])
Looks for: bun executed either from a bun-dl-* staging directory (the loader's own download path) or as a child of a package-manager / shell process. This catches the evasion itself rather than the payload name, so it still fires if the operator renames stage 2. FP: medium — teams that legitimately use Bun will hit the parent-lineage branch. Baseline which hosts have sanctioned Bun use first; the bun-dl- branch is near-zero FP on its own.
// HUNT: Bun run from a bun-dl- staging dir, or spawned inside a package-install tree // MITRE: T1105, T1059.007 | CONF: medium FP: medium COST: medium // REQUIRES: ProcessRollup2 with parent lineage populated // TUNING: exclude hosts where Bun is a sanctioned runtime by aid or ComputerName, NOT by // dropping the ParentBaseFileName clause. On those hosts rely on Q2 and Q5 instead. // The ImageFileName=/bun-dl-/i branch stays in scope everywhere -- no legitimate // toolchain executes Bun out of a temporary bun-dl- directory. #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | FileName=/^bun(\.exe)?$/i | (ImageFileName=/bun-dl-/i or ParentBaseFileName=/^(node|npm|npx|yarn|pnpm|sh|bash|zsh|cmd\.exe|powershell\.exe)$/i) | table([@timestamp, ComputerName, UserName, ParentBaseFileName, ImageFileName, CommandLine, aid])
Looks for: node running setup.mjs from inside a .vscode or .claude directory — the VS Code folderOpen task and the Claude SessionStart hook. This path needs no npm install and survives deleting node_modules. Run it alongside Q1, not after: a host can be clean on Q1 and still be re-infecting itself every time an engineer opens the repo. FP: repositories legitimately invoke node from .vscode/tasks.json; the setup.mjs filename is the discriminator.
// HUNT: .vscode/tasks.json or .claude/settings.json hook re-launching the loader // MITRE: T1546, T1204.002 | CONF: high FP: low COST: low // REQUIRES: ProcessRollup2; VS Code / Claude Code appear as ParentBaseFileName // TUNING: a repo that legitimately runs node tasks on folderOpen will NOT reference // setup.mjs -- keep both CommandLine clauses. If an internal tooling repo does, // exclude that repo's checkout path, not the .vscode/.claude pattern. #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | FileName=/^node(\.exe)?$/i | CommandLine=/setup\.mjs/i | CommandLine=/\.(vscode|claude)/i | table([@timestamp, ComputerName, UserName, ParentBaseFileName, CommandLine, ProcessStartTime, aid])
Looks for: resolution of any of the four confirmed exfiltration hostnames. A hit means stage 2 reached the C2 discovery stage — assume credential exfiltration completed and that the host is exposed to the C2's eval() code-execution channel. FP: none — these hostnames have no legitimate purpose.
// HUNT: keyv-shai-hulud C2 / exfiltration domain resolved on an endpoint // MITRE: T1041, T1071.001 | CONF: high FP: low COST: low // REQUIRES: DnsRequest // TUNING: none. These four hostnames have no legitimate use; do not exclude anything. // The contract rotates domains, so re-check the IOC feed and extend this list. #event_simpleName=/^(DnsRequest|SuspiciousDnsRequest)$/ | DomainName=/^(awqhnjewqjkl\.icu|pypi-get\.com|js-mirror\.com|npm-cache\.com)$/i | table([@timestamp, ComputerName, UserName, DomainName, ContextBaseFileName, aid, aip])
Looks for: a public Ethereum RPC hostname resolved specifically by a node or bun process. The RPC endpoints themselves are legitimate shared infrastructure and must never be blocked — the signal is the requesting process. Datadog counted 73 RPC endpoints in the payload; only the three publicly named are listed here, so treat this as a starting point and extend it with your own web3 egress inventory. FP: medium — wallet, blockchain and web3 engineering hosts do this legitimately all day.
// HUNT: Ethereum RPC dead-drop C2 resolution driven by a Node or Bun process // MITRE: T1102.001 | CONF: medium FP: medium COST: medium // REQUIRES: DnsRequest with ContextBaseFileName populated // TUNING: exclude web3 / wallet / blockchain engineering hosts by aid. Do NOT block or // alert on the RPC hostnames themselves -- they are legitimate public providers // and the malware deliberately chose them for that reason. On excluded hosts, // fall back to Q2 and Q5 which carry no such ambiguity. #event_simpleName=/^(DnsRequest|SuspiciousDnsRequest)$/ | DomainName=/(nodereal\.io|getblock\.io|llamarpc\.com)$/i | ContextBaseFileName=/^(node|bun)(\.exe)?$/i | table([@timestamp, ComputerName, UserName, DomainName, ContextBaseFileName, aid])
Looks for: a publish operation whose parent is node or bun rather than an interactive shell or your release runner. This is the worm using a stolen token to republish poisoned tarballs — the step that turned 11 packages into 400-plus. A hit here means one of your npm tokens is being used to attack the ecosystem. FP: medium — legitimate release pipelines publish from automation too.
// HUNT: npm publish invoked by an automated Node/Bun parent -- worm republication // MITRE: T1195.002, T1078, T1080 | CONF: medium FP: medium COST: medium // REQUIRES: ProcessRollup2 with parent lineage populated // TUNING: exclude your release pipeline's build agents by aid and its service account by // UserName. Keep developer laptops in scope -- a publish from an engineer's // machine with a node or bun parent is the exact anomaly this hunts. #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | FileName=/^(npm|npm\.cmd|node|bun)(\.exe)?$/i | CommandLine=/\bpublish\b/i | ParentBaseFileName=/^(node|bun)(\.exe)?$/i | table([@timestamp, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine, aid])
Looks for: any process referencing gh-token-monitor — the dormant dead-man switch that watches a stolen GitHub token and runs a supplied handler when the token stops validating. Run this before you revoke anything. Revocation is the trigger condition, so a blind rotation can be the thing that detonates the handler. FP: none — gh-token-monitor is not a component of the GitHub CLI.
// HUNT: gh-token-monitor dead-man-switch persistence (systemd user unit / LaunchAgent) // MITRE: T1546, T1543 | CONF: high FP: low COST: low // REQUIRES: ProcessRollup2; pair with the Section 7 launchctl / systemctl checks // TUNING: none. gh-token-monitor is not shipped by the GitHub CLI or any common tool. // ORDER OF OPERATIONS: disable what this finds BEFORE revoking GitHub tokens. #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | CommandLine=/gh-token-monitor/i | table([@timestamp, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine, ProcessStartTime, aid])
Looks for: gh auth token executed with a node or bun parent. The stage-2 shell-and-environment collector shells out to exactly this to convert a GitHub CLI login into a usable token. A human running gh auth token has a shell as its parent, not a JavaScript runtime. FP: medium — home-grown automation that wraps gh in a Node script produces the same shape.
// HUNT: gh auth token harvested by a JavaScript runtime rather than an interactive user // MITRE: T1552.001, T1528 | CONF: medium FP: medium COST: low // REQUIRES: ProcessRollup2 with parent lineage populated // TUNING: exclude your own Node-based gh wrappers by pinning ParentBaseFileName plus the // wrapper's aid -- never by removing the ParentBaseFileName clause, which is the // only thing separating this from ordinary interactive gh usage. #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | FileName=/^gh(\.exe)?$/i | CommandLine=/auth\s+token/i | ParentBaseFileName=/^(node|bun)(\.exe)?$/i | table([@timestamp, ComputerName, UserName, ParentBaseFileName, CommandLine, aid])
Looks for: sudo or python3 whose parent is node or bun. On a Linux GitHub Actions runner the payload pipes an embedded Python program into sudo python3 to read Runner.Worker process memory and recover Actions secret values. Because the Python arrives on stdin, the program text is not in the command line — the parent-child shape is the detectable part. FP: medium — build steps do install and invoke Python, but rarely with a JavaScript runtime as the direct parent.
// HUNT: Runner.Worker memory scrape -- privileged Python launched by a JS runtime // MITRE: T1003, T1552.001 | CONF: medium FP: medium COST: low // REQUIRES: ProcessRollup2 on self-hosted Linux GitHub Actions runners // TUNING: exclude sanctioned setup-python / build steps by matching the CI job's own // wrapper aid. Keep node-or-bun parents in scope -- the payload text arrives on // stdin, so the process lineage is the only reliable signal here. #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | FileName=/^(sudo|python3(\.\d+)?|python)$/i | ParentBaseFileName=/^(node|bun)$/i | table([@timestamp, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine, aid])
Looks for: exact SHA-256 matches for the loader, the stage-2 stealer, the IDE-hook configs, the injected workflow, the runner-memory Python, the token-monitor installer and the keyv-6.0.0.tgz tarball. FP: none — exact hash match. Confidence is rated medium only because the payloads are text files (.mjs / .js), so Falcon's hash-bearing events cover them inconsistently; the Section 7.1 offline shasum sweep is the authoritative check and this query is the fleet-wide complement.
// HUNT: keyv-shai-hulud known-bad file hashes anywhere in endpoint telemetry // MITRE: T1195.002 | CONF: medium FP: low COST: medium // REQUIRES: any event carrying SHA256HashData // FALSE POSITIVES: none -- exact-hash match. Pair with the offline shasum sweep in // Section 7.1, which is authoritative for the .mjs / .js text payloads. #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2|PeFileWritten|NewExecutableWritten|ELFFileWritten|ImageHash)$/ | SHA256HashData=/^(9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc|54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668|fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb|927387d0cfac1118df4b383decc2ea6ba49c9d2f98b47098bcbcba1efc026e1f|14eb4ce01dd4307759887ff819359b70d7d9ff709ecde039a5abc1aac325b128|3f3f42d072bd36860ab7bd7fb5e10ac0d22c741c13c89505ccd6ec0ea572eea7|29ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7|619c56acf572df75b6004a6fc013c80900316a76099b241d64312da3a44f10b4|d584f9b6af48b7ed1f93713944f033783bf149e1c25e1643eb8c0e9df5dc7782)$/i | table([@timestamp, ComputerName, UserName, FileName, ImageFileName, TargetFileName, SHA256HashData, aid])
CrowdStrike Custom IOA Recommendations
Three queries are clean enough to promote to Custom IOAs. The rest stay Investigate-only hunts.
| IOA name | From | Rule type | Pattern | Exclusions | Action |
|---|---|---|---|---|---|
| SUPPLY-CHAIN — npm preinstall launches setup.mjs loader | Q1 | Process Creation | Image filename node; command line contains setup.mjs | Full path of any sanctioned internal package that legitimately ships a root setup.mjs. Never exclude on the node+setup.mjs pair. | Detect (High) → Prevent after a 7-day benign baseline |
| SUPPLY-CHAIN — Bun executes keyv-shai-hulud stage 2 | Q2 | Process Creation | Image filename bun; command line contains Math_Symbol.js or math_init.js | None | Prevent (Critical) — safe to deploy immediately |
| PERSISTENCE — IDE config hook re-runs npm loader | Q4 | Process Creation | Image filename node; command line contains setup.mjs AND .vscode or .claude | Checkout path of a named internal tooling repo, if one genuinely does this | Detect (High) → Prevent once baselined |
| PERSISTENCE — gh-token-monitor dead-man switch | Q8 | Process Creation | Command line contains gh-token-monitor | None | Detect (High). Deliberately not Prevent — you want the artifact preserved for IR and disabled in a controlled order relative to token revocation. |
Investigate-only (no alert email; pivot queries only)
Q3, Q6, Q7, Q9, Q10 and Q11 stay as scheduled hunts. Q3, Q6 and Q7 all have legitimate-use overlap that makes an org-wide IOA a noise generator until each environment has baselined its own Bun / web3 / release-pipeline footprint. Q11 is a hash sweep whose right home is Falcon IOC Management (Section 10), not an IOA.
Falcon IOC Management
Import the Section 10 CSV. Set the four C2 domains to detect rather than block-at-DNS if you rely on domain blocking elsewhere — but note the smart contract can rotate the domain list at any time without touching the payload, so domain blocking is a decaying control here. The behavioural IOAs above are the durable ones.
Machine-Readable IOC Appendix
Every atomic value below is traceable to a saved source snapshot in KeyvShaiHulud-npm-Worm-Hunt-sources/. Values that could not be traced to a fetched source do not appear.
type,value,action,severity,expiration,description,tags domain,awqhnjewqjkl.icu,detect,critical,2027-02-07,keyv-shai-hulud C2 exfil endpoint 443 router,campaign:KeyvShaiHulud domain,pypi-get.com,detect,critical,2027-02-07,keyv-shai-hulud C2 exfil endpoint 443 router,campaign:KeyvShaiHulud domain,js-mirror.com,detect,critical,2027-02-07,keyv-shai-hulud C2 exfil endpoint 443 router,campaign:KeyvShaiHulud domain,npm-cache.com,detect,critical,2027-02-07,keyv-shai-hulud C2 exfil endpoint 443 router,campaign:KeyvShaiHulud sha256,9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc,prevent,critical,2027-02-07,Stage2 stealer Math_Symbol.js and math_init.js,campaign:KeyvShaiHulud sha256,54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668,prevent,critical,2027-02-07,Stage1 loader setup.mjs original 29918 bytes,campaign:KeyvShaiHulud sha256,fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb,prevent,critical,2027-02-07,Stage1 loader setup.mjs worm propagated variant,campaign:KeyvShaiHulud sha256,927387d0cfac1118df4b383decc2ea6ba49c9d2f98b47098bcbcba1efc026e1f,detect,high,2027-02-07,Malicious .vscode/tasks.json folderOpen hook,campaign:KeyvShaiHulud sha256,14eb4ce01dd4307759887ff819359b70d7d9ff709ecde039a5abc1aac325b128,detect,high,2027-02-07,Malicious .claude/settings.json SessionStart hook,campaign:KeyvShaiHulud sha256,3f3f42d072bd36860ab7bd7fb5e10ac0d22c741c13c89505ccd6ec0ea572eea7,detect,high,2027-02-07,Injected GitHub workflow leaking toJSON secrets,campaign:KeyvShaiHulud sha256,29ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7,detect,high,2027-02-07,Runner.Worker memory scrape Python,campaign:KeyvShaiHulud sha256,619c56acf572df75b6004a6fc013c80900316a76099b241d64312da3a44f10b4,detect,high,2027-02-07,gh-token-monitor dead man switch installer,campaign:KeyvShaiHulud sha256,d584f9b6af48b7ed1f93713944f033783bf149e1c25e1643eb8c0e9df5dc7782,detect,high,2026-11-07,keyv-6.0.0.tgz malicious tarball for registry mirror sweep,campaign:KeyvShaiHulud
# Published by Wiz Research, 2026-08-04. # Falcon IOC Management takes SHA-256; these are for SHA-1-keyed tooling # (some registry scanners, SBOM tools and legacy AV consoles). 35a672cf34b996b91f3e1c28cbf3a05a37e036e4 Math_Symbol.js / math_init.js (stage 2) 686aa40d0fc22c8d569494543a0f891f359f2f99 .claude/setup.mjs (IDE-planted loader) f525d52ceb966516686b482d3dc0137028cc6a63 .vscode/setup.mjs (IDE-planted loader)
# EXECUTION
process: node cmdline contains: setup.mjs -> Q1
process: bun cmdline contains: Math_Symbol.js | math_init.js -> Q2
process: bun image path contains: bun-dl- -> Q3
process: node cmdline contains: setup.mjs AND .vscode|.claude -> Q4
# MANIFEST
package.json scripts object replaced entirely with:
{"preinstall":"node setup.mjs"}
package "files" array gains: setup.mjs, Math_Symbol.js
version bumped by exactly one patch, dist/ byte-identical to prior release
# RUNTIME MARKERS
env var : _NODE_RUNTIME_INIT=1 (detached-respawn recursion guard)
lock file: tmp.dpkg_14527.lock (single-instance lock, OS temp dir)
temp dir : bun-dl-* (Bun download staging, removed in finally)
user-agent: Bun/1.3.13 (egress / proxy logs)
signals : no-op SIGINT and SIGTERM handlers installed
# PERSISTENCE PATHS
.vscode/tasks.json runOn folderOpen -> node .claude/setup.mjs
.claude/settings.json SessionStart hook -> node .vscode/setup.mjs
.claude/setup.mjs .vscode/setup.mjs .claude/math_init.js
~/.local/bin/gh-token-monitor.sh
~/.config/gh-token-monitor/{token,handler,started_at}
~/.config/systemd/user/gh-token-monitor.service
~/Library/LaunchAgents/com.user.gh-token-monitor.plist
/tmp/gh-token-monitor.out.log /tmp/gh-token-monitor.err.log
# C2 / DEAD DROP
eth contract : 0xE1f2395ee43e45A1556EC6438a88c31B83493103 (StringListStore)
eth selector : 0x53ed5143 block: latest timeout: 3s per RPC
C2 health check: HTTPS GET /router on 443; only HTTP 400 or 404 = healthy
fallback: GitHub commit search "thebeautifulmarchoftime " (TRAILING SPACE)
parser accepts marker: thebeautifulsnadsoftime
token relay marker: IfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClients
exfil repo description: Shai-Hulud: Here We Go Again
infection commit message: chore: update config (author identity: claude)
# The 11 confirmed maintainer-linked malicious releases (Snyk, 2026-08-04).
# The starred three are the ones that install SILENTLY inside live caret ranges.
# keyv@6.0.0 (blocked by every normal caret range)
# * cacheable-request@13.0.20
# * cache-manager@7.2.10
# * @cacheable/utils@2.5.1
# flat-cache@6.1.24 file-entry-cache@11.1.6 cacheable@2.5.1
# @cacheable/memory@2.2.1 @cacheable/net@2.1.1
# @cacheable/node-cache@3.1.2 ecto@5.0.1
# Beyond these, ~434-444 further packages across ~1381 versions were worm-infected.
# A fixed list is a LOWER BOUND. Hunt behaviour and hashes, not names.
# Known-clean pins verified to have no install lifecycle hook:
{
"overrides": {
"keyv": "5.6.0",
"flat-cache": "6.1.23",
"file-entry-cache": "11.1.5",
"cacheable-request": "13.0.19",
"cacheable": "2.5.0",
"@cacheable/utils": "2.5.0",
"cache-manager": "7.2.9",
"@cacheable/net": "2.1.0",
"@cacheable/node-cache": "3.1.1",
"@cacheable/memory": "2.2.0",
"ecto": "5.0.0"
}
}
Deliberately not in the import CSV: the resolved IP addresses in Section 4. Two are Cloudflare shared edge and one is an AWS-hosted public Ethereum RPC provider — blocking any of them causes collateral outage and catches nothing durable. They are recorded as enrich context only. The three Ethereum RPC hostnames are likewise excluded: they are legitimate public infrastructure that the malware chose because blocking them is impractical.
Detection Validation Gates
| Gate | Check | Pass criteria | If it fails |
|---|---|---|---|
| 1 · Telemetry ready | Confirm ProcessRollup2 and DnsRequest are flowing from developer laptops and build agents, not just servers. Count distinct aid values reporting node or npm executions in the last 7 days and compare against your engineering headcount. | Sensor coverage on the dev/CI estate is at or above 95% and node/npm activity is visible | A zero-hit result from Q1–Q11 is meaningless. Fix enrolment before reporting "no evidence of compromise" |
| 2 · Benign baseline | Run Q3 (Bun lineage), Q6 (ETH RPC), Q7 (npm publish) and Q9 (gh auth token) over 30 days and record every legitimate source | You have a written list of hosts and accounts that legitimately use Bun, query web3 RPC, publish to npm, and wrap gh in Node | These four will alert-storm. Do not promote any of them to an IOA before baselining |
| 3 · Positive test — install path | On an isolated lab host, create a throwaway package with "preinstall": "node setup.mjs" where setup.mjs merely prints a string, then npm install it | Q1 returns the event with correct ParentBaseFileName and full CommandLine | Check the sensor's command-line capture setting; truncated command lines break Q1, Q4 and Q9 |
| 4 · Positive test — Bun path | Install Bun in the lab and run a benign script named math_init.js through it | Q2 returns the event; Q3 returns it when spawned from an npm parent | Confirm the sensor resolves FileName for the Bun binary on that platform |
| 5 · Positive test — IDE path | In the lab, add a .vscode/tasks.json with runOn: folderOpen invoking a benign node .vscode/setup.mjs, then open the folder | Q4 returns the event with the IDE as ParentBaseFileName | Verify VS Code workspace trust is not silently suppressing the task — if it is, note that as a mitigating control in your report |
| 6 · Negative control | Run an ordinary npm install of a clean package set, an interactive gh auth token, and a normal release-pipeline publish | Q1, Q9 and Q7 stay silent | Tighten the parent-lineage clauses rather than broadening exclusions |
| 7 · IOC import | Import the Section 10 CSV and confirm all 13 rows load without a parse error | 4 domains and 9 SHA-256 values present in IOC Management | Check the expiration date format your tenant expects |
| 8 · Promotion | Q2 to Prevent immediately; Q1, Q4 and Q8 to Detect now and Prevent after gate 2 completes | Two weeks of clean operation before any Prevent promotion beyond Q2 | Stay at Detect. A false Prevent on node breaks every developer on the estate |
Hardening — Tiered
H1 · Disable npm lifecycle scripts by default across CI and developer machines
This single control removes the entire install-time execution path. The preinstall hook is the delivery mechanism; with scripts off, pulling a poisoned version does not run it. npm v12 (released roughly a month before this campaign) already defaults to not running lifecycle scripts — organisations that had upgraded and not re-enabled them were substantially protected. Maintain a narrow allowlist for the handful of packages that genuinely need install scripts rather than re-enabling globally.
MITRE: M1042 Disable or Remove Feature · M1038 Execution Prevention. Authority: npm documented client behaviour (v12 default); OpenSSF npm Best Practices Guide (--ignore-scripts). Verify: npm config get ignore-scripts returns true; npm --version reports 12 or newer.
H2 · Purge affected versions from private registries, proxies and developer caches
npm removing a version from the public registry does not delete copies your internal proxy already cached, nor a developer's ~/.npm. Three of the eleven maintainer-linked versions were pulled by npm within two hours; the other eight were still on latest at the 11:16 UTC snapshot. Quarantine by version, then re-verify.
MITRE: M1051 Update Software. Authority: vendor incident guidance (Snyk, Cloudsmith). Verify: registry search returns no affected version; npm cache verify after npm cache clean --force on each host.
H3 · Sweep every local checkout for IDE and coding-agent config persistence
The npm path and the IDE path are independent. Deleting node_modules, pinning clean versions and reinstalling leaves .vscode/tasks.json and .claude/settings.json in place, and the payload re-detonates the next time anyone opens the folder. Treat unexpected .claude/ or .vscode/ executable content in a repository as an incident, not a config drift ticket.
MITRE: M1042 · M1047 Audit. Authority: ⚠ best-practice, no formal benchmark — no CIS or STIG control covers IDE-local task configuration as an execution surface. Verify: Section 7.1 sweep step 4 returns nothing unexpected across all checkouts.
H4 · Hunt and disable gh-token-monitor before revoking any GitHub token
The dead-man switch watches a stolen token and fires a supplied handler when it stops validating against api.github.com/user. Revocation is the trigger. Sequence matters: find it, preserve a forensic copy, disable it, then rotate.
MITRE: M1047 Audit. Authority: ⚠ best-practice, no formal benchmark — incident-specific ordering documented by Datadog and Snyk. Verify: Q8 returns no results and the Section 7.1 systemctl/launchctl checks are clean.
H5 · Put a registry proxy with a cooldown window in front of every install
This campaign's malicious versions were published and pulled into builds within minutes. A server-side cooldown that withholds newly published upstream versions for a defined window removes exactly that window, regardless of each developer's client version or local script settings. It also gives you one audit trail that can answer "were we exposed" from your own records instead of a fleet-wide reconstruction.
MITRE: M1016 Vulnerability Scanning · M1035 Limit Access to Resource Over Network. Authority: SLSA v1.0 build/source track; NIST SP 800-218 (SSDF) PW.4, PO.3. Verify: a version published upstream within the cooldown window is not installable through the proxy; direct-to-registry egress is blocked at the firewall.
H6 · Stop treating valid provenance as a pass condition
Every tarball in this campaign carries genuine SLSA provenance signed by GitHub Actions. Provenance binds an artifact to a repository, commit and workflow run — it answers where did this build come from, never is this code safe. When the attacker holds repository write access, the malicious commit is the source of truth and the signature over it is accurate. If your SCA gate passes on "has valid provenance", it passed this. Add a diff-based check instead: a release whose scripts object changed or whose published files array grew is worth a human look, and in this campaign only three paths differed between the clean release candidate and the poisoned release.
MITRE: M1016 · M1047. Authority: npm provenance documentation; SLSA v1.0 threat model. Verify: your gate rejects a test package that has valid provenance and an added preinstall hook.
H7 · Scope npm and GitHub tokens down, and kill 2FA-bypass publish tokens
Propagation requires a granular npm token with bypass_2fa: true and package-write. Removing that combination breaks the worm's primary spread mechanism from your side of the ecosystem. On GitHub, a classic PAT with workflow scope is what enables the secret-leaking workflow injection; move to fine-grained tokens and short-lived OIDC.
MITRE: M1026 Privileged Account Management · M1032 Multi-factor Authentication. Authority: CIS Software Supply Chain Security Guide v1.0 §3 (Dependencies) and §1 (Source Code); GitHub token best practice. Verify: npm token list shows no bypass_2fa publish tokens; GitHub org policy blocks classic PAT creation.
H8 · Egress policy for build and developer networks
The C2 discovery path depends on reaching public Ethereum RPC endpoints, and exfiltration on reaching an arbitrary attacker domain on 443. Neither is required for normal software builds. Default-deny egress from build agents with an allowlist for your registry proxy, your VCS and your artifact store closes both. Do not attempt to blocklist the RPC providers themselves — they are legitimate shared infrastructure and the list rotates on-chain.
MITRE: M1037 Filter Network Traffic. Authority: CIS Controls v8 §13.4 (deny by default), §4.8. Verify: from a build agent, an outbound HTTPS connection to an arbitrary internet host fails.
H9 · Turn off automatic task execution on folder open, and gate coding-agent hooks
VS Code's workspace-trust prompt is the only thing between opening a poisoned checkout and running the loader, and it is user-dismissable. Set automatic task execution off by policy and require explicit trust for any workspace containing .vscode or .claude executable configuration.
MITRE: M1042 · M1017 User Training. Authority: ⚠ best-practice, no formal benchmark — VS Code workspace-trust documentation is the closest vendor guidance. Verify: opening a lab repo with a folderOpen task produces a trust prompt and does not execute.
H10 · Treat the developer workstation as a production perimeter
This campaign is not aimed at servers. It is aimed at the machine that holds an npm token, a GitHub PAT, ~/.aws/credentials, an SSH key, a kubeconfig and a Vault token in plaintext, and that regularly executes untrusted third-party code as part of its normal job. Bring engineer laptops to the same EDR coverage, secret-hygiene and egress standard as production, and move developer credentials into short-lived, hardware-bound or brokered forms so that a filesystem scan finds nothing worth stealing.
MITRE: M1041 Encrypt Sensitive Information · M1026 · M1032. Authority: NIST SP 800-218 (SSDF) PO.5, PS.1; CIS Controls v8 §5, §6. Verify: a scripted sweep of the ~470 credential glob patterns the payload uses returns no long-lived secrets on a sampled engineer host.
H11 · Ephemeral, least-privilege CI with no ambient organisation secrets
The runner-memory scraper recovers secret values from Runner.Worker on Linux runners with sudo. The workflow-injection path converts secret names into values by running a job that assigns toJSON(secrets) to an artifact. Both depend on a job having ambient access to more secrets than it needs, and on the runner having sudo. Move to ephemeral runners, remove passwordless sudo from runner images, scope secrets per-job and per-environment, and prefer OIDC federation over stored cloud keys.
MITRE: M1026 · M1038 · M1047. Authority: CIS Software Supply Chain Security Guide v1.0 §2 (Build Pipelines); NIST SP 800-204D. Verify: a test workflow cannot enumerate secrets outside its own job scope; sudo -n true fails on the runner.
H12 · Dependency allowlisting and a real SBOM-driven exposure answer
The reason this campaign has a two-billion-install blast radius is that nobody chose these packages — flat-cache and file-entry-cache arrive under ESLint, cacheable-request under got, keyv under cacheable-request. When the next one lands you need to answer "which of our builds resolved this version" in minutes, from your own records. That requires per-build SBOMs retained and queryable, plus an allowlist that makes a brand-new transitive dependency a decision rather than a default.
MITRE: M1016 · M1051. Authority: NIST SP 800-218 (SSDF) PS.3, PW.4; CISA SBOM minimum elements. Verify: query your SBOM store for cacheable-request@13.0.20 and get a definitive build list within minutes.
Deployable Playbooks
PB-1 · Disable npm lifecycle scripts fleet-wide (implements H1)
Prerequisites: inventory of packages in your estate that legitimately require install scripts (native modules such as node-gyp builds, puppeteer, esbuild, sharp). Pilot on one team for a week — a missing install script fails loudly at build time, not silently. Reboot required: no. Rollback: npm config delete ignore-scripts on each host and remove the CI environment variable; for the repo-level file, git revert the .npmrc commit. Rollback restores the previous behaviour immediately with no reboot and no state left behind.
# 1. Per-repository (checked in, applies to everyone who builds the repo) cat >> .npmrc <<'EOF' ignore-scripts=true EOF # 2. Per-developer machine (user-level default) npm config set ignore-scripts true --location=user # 3. CI: environment variable beats config file and cannot be locally overridden # GitHub Actions -- add at workflow or org level # env: # NPM_CONFIG_IGNORE_SCRIPTS: "true" # Generic shell runner: export NPM_CONFIG_IGNORE_SCRIPTS=true # 4. Narrow allowlist for packages that genuinely need install scripts. # Run the general install with scripts off, then rebuild only what is allowed. npm ci --ignore-scripts npm rebuild esbuild sharp # replace with YOUR verified allowlist # 5. Verify on every host npm --version # 12 or newer already defaults to not running scripts npm config get ignore-scripts # must print: true node -e 'console.log(process.env.NPM_CONFIG_IGNORE_SCRIPTS)'
PB-2 · Eradicate the payload and IDE persistence from a suspected host (implements H3, H4)
Prerequisites: host isolated from the network (Falcon Network Contain or equivalent); forensic copies of ~/.npm, shell history and any gh-token-monitor artifacts taken before deletion; IR lead has approved the token-revocation sequence. Reboot required: yes, after step 4, to guarantee no detached Bun process survives. Rollback: none required for steps 1–3 — they remove attacker-planted artifacts that have no legitimate function, and the forensic copies taken in the prerequisites are the recovery path if a file turns out to be legitimate. For step 4, re-enabling a disabled unit is systemctl --user enable gh-token-monitor.service / launchctl load, but you should not need it: gh-token-monitor is not a component of any legitimate tool.
# RUN IN ORDER. Do not revoke GitHub tokens until step 4 is complete.
# 1. Preserve evidence FIRST
mkdir -p /var/tmp/ir-keyv && cd /var/tmp/ir-keyv
cp -a ~/.config/gh-token-monitor . 2>/dev/null
cp -a ~/.local/bin/gh-token-monitor.sh . 2>/dev/null
cp -a /tmp/gh-token-monitor.*.log . 2>/dev/null
ps -eo pid,ppid,user,etime,command | grep -Ei 'bun|setup\.mjs|math_init|Math_Symbol' > ps.txt
# 2. Kill live payload processes
pkill -f 'Math_Symbol\.js' ; pkill -f 'math_init\.js' ; pkill -f 'setup\.mjs'
rm -f "${TMPDIR:-/tmp}"/tmp.dpkg_14527.lock
rm -rf "${TMPDIR:-/tmp}"/bun-dl-*
# 3. Remove npm-delivered payload and rebuild the tree clean
rm -rf node_modules
npm cache clean --force
# apply the Section 10 overrides block to package.json first, then:
npm install --package-lock-only --ignore-scripts
npm ci --ignore-scripts
# 4. Disable the dead-man switch BEFORE any token revocation
systemctl --user disable --now gh-token-monitor.service 2>/dev/null
launchctl unload ~/Library/LaunchAgents/com.user.gh-token-monitor.plist 2>/dev/null
rm -f ~/Library/LaunchAgents/com.user.gh-token-monitor.plist \
~/.config/systemd/user/gh-token-monitor.service \
~/.local/bin/gh-token-monitor.sh
rm -rf ~/.config/gh-token-monitor
# 5. Strip IDE / coding-agent persistence from EVERY local checkout.
# Review each hit before deleting -- some repos have legitimate .vscode tasks.
find "$HOME" -type f \( -path '*/.vscode/tasks.json' -o -path '*/.claude/settings.json' \) \
-exec grep -lE 'setup\.mjs|math_init' {} + 2>/dev/null
find "$HOME" -type f \( -path '*/.claude/setup.mjs' -o -path '*/.vscode/setup.mjs' \
-o -path '*/.claude/math_init.js' \) -print 2>/dev/null
# 6. Reboot, then re-run the Section 7.1 sweep to confirm the host is clean.
PB-3 · Lock down GitHub and npm tokens (implements H7)
Prerequisites: completed PB-2 step 4 on every affected host — revoking before the dead-man switch is disabled is the trigger condition. A clean workstation for issuing replacement credentials. Change window agreed with any team whose release pipeline uses the tokens being rotated. Reboot required: no. Rollback: none required — revoked credentials cannot be un-revoked, and that is the intent. The recovery path is issuing replacements, so the safeguard is sequencing (replacement issued and validated before the old credential is revoked) rather than an undo step. Keep the old token's ID recorded so audit-log correlation still works afterwards.
# --- npm: find and kill 2FA-bypass publish tokens (the propagation prerequisite) npm token list # Revoke every token with bypass_2fa true or with unnecessary package-write: npm token revoke <token-id> # Reissue as granular, package-scoped, 2FA-enforced, short expiry. # --- GitHub: inventory then revoke gh auth status gh api /user | jq '.login' # Classic PATs and OAuth tokens (ghp_ / gho_) are the ones the payload targets, # plus App installation tokens (ghs_) used for the branch-infection path. # Revoke via Settings > Developer settings > Personal access tokens. # Org policy: block new classic PATs, require fine-grained tokens gh api -X PUT /orgs/<org>/personal-access-token-requests \ -f 'personal_access_token_requests_enabled=false' 2>/dev/null || \ echo "Set this in Org Settings > Personal access tokens instead" # --- Audit for the workflow-injection artifacts before you close the incident gh api "/repos/<org>/<repo>/branches" --paginate \ | jq -r '.[].name' | grep -i 'dependabot/github_actions/format/setup-formatter' gh api "/repos/<org>/<repo>/actions/artifacts" \ | jq -r '.artifacts[] | select(.name=="format-results.txt") | .created_at' # --- Cloud: rotate anything the affected host or runner could reach aws iam list-access-keys --user-name <user> aws iam update-access-key --access-key-id <id> --status Inactive --user-name <user> # Rotate Secrets Manager and SSM SecureString values that were readable from the # compromised identity -- the collector decrypts, it does not merely enumerate.
PB-4 · Fleet-wide exposure sweep (implements H2, H12)
Prerequisites: read access to all source repositories and the ability to run a scripted checkout. Run from a clean host. Reboot required: no. Rollback: none required — this playbook is read-only. It clones, greps and reports; it modifies no repository and installs nothing.
#!/usr/bin/env bash
# Read-only exposure sweep across every repo in an org. Modifies nothing.
set -uo pipefail
ORG="${1:?usage: sweep.sh ORG}"
OUT="keyv-exposure-$(date +%Y%m%d).txt"
: > "$OUT"
gh repo list "$ORG" --limit 1000 --json nameWithOwner -q '.[].nameWithOwner' |
while read -r REPO; do
TMP=$(mktemp -d)
gh repo clone "$REPO" "$TMP/r" -- --depth 1 --quiet 2>/dev/null || { rm -rf "$TMP"; continue; }
# a) lockfile hits on the three that install SILENTLY inside caret ranges
grep -REn 'cacheable-request.{0,12}13\.0\.20|cache-manager.{0,12}7\.2\.10|@cacheable/utils.{0,12}2\.5\.1' \
"$TMP/r"/{package-lock.json,pnpm-lock.yaml,yarn.lock,npm-shrinkwrap.json} 2>/dev/null |
sed "s|^|$REPO LOCKFILE |" >> "$OUT"
# b) the lifecycle hook itself, anywhere in the repo
grep -REn '"preinstall"[[:space:]]*:[[:space:]]*"node setup\.mjs"' "$TMP/r" 2>/dev/null |
sed "s|^|$REPO PREINSTALL |" >> "$OUT"
# c) IDE / coding-agent persistence
for P in .vscode/tasks.json .claude/settings.json .claude/setup.mjs \
.vscode/setup.mjs .claude/math_init.js; do
[ -e "$TMP/r/$P" ] && echo "$REPO IDEHOOK $P" >> "$OUT"
done
# d) the injected secret-leaking workflow
grep -REln 'toJSON\(secrets\)' "$TMP/r/.github/workflows" 2>/dev/null |
sed "s|^|$REPO WORKFLOW |" >> "$OUT"
rm -rf "$TMP"
done
echo "--- summary ---"; sort "$OUT" | uniq -c | sort -rn | head -40
Containment Runbook
| Phase | Actions | Owner | Evidence to capture |
|---|---|---|---|
| P0 · Triage (0–2h) | Run Q1, Q2, Q4, Q5 and Q8 across the whole estate. Any Q2 or Q5 hit means stage 2 executed and reached C2 — escalate immediately. Build the affected-host list from aid. In parallel, run the Section 7.2 lockfile checks in CI to establish which builds resolved an affected version. | SOC L2 | Query exports with aid, ComputerName, UserName, ProcessStartTime, full CommandLine |
| P1 · Isolate (0–4h) | Network-contain every host with a Q1/Q2/Q4 hit. Do not revoke credentials yet. Suspend affected CI pipelines and freeze npm publishing from the org. | SOC L2 + Platform | Containment timestamps; running process list before isolation |
| P2 · Locate persistence (2–8h) | On each contained host run PB-2 steps 1 and 5, plus Q8. Enumerate gh-token-monitor artifacts and every IDE hook across all local checkouts. Only when this is complete and documented may P3 begin. | IR | Forensic copies of the monitor's token, handler, started_at; list of infected checkout paths |
| P3 · Eradicate (4–24h) | Execute PB-2 in full, including the reboot. Where credential exposure is confirmed, prefer rebuild over clean — the C2 eval() channel means anything could have been delivered after stage 2 landed, and the fixed payload is only the floor of what may have run. | IR + Endpoint | Per-host remediation record; re-run of Section 7.1 showing a clean result |
| P4 · Rotate (8–48h) | Execute PB-3 from a clean workstation. Scope: npm tokens, GitHub PATs and App tokens, AWS keys and any Secrets Manager / SSM values readable from the affected identity, Kubernetes service-account tokens, Vault tokens, SSH keys, Stripe and Slack tokens, database connection strings, and every CI secret reachable by an affected job. | IAM + Platform | Rotation register mapping old credential ID to new, with timestamps |
| P5 · Hunt outward (24–72h) | Run the Section 7.3 GitHub audit hunts and Section 7.5 cloud hunts. Search public GitHub for exfiltration repos owned by your users. Review your own npm publish history for unexpected patch releases. Run Q7 to catch your tokens being used to attack others. | Threat Intel + SOC | Audit-log exports; list of any exfil repo owned by an org member |
| P6 · Recover (48h+) | Restore builds using the Section 10 clean pins. Purge affected versions from private registries and caches (H2). Re-enable pipelines only after PB-1 is enforced on the runner image. | Platform | Clean build logs; registry quarantine record |
| P7 · Harden (1–4 weeks) | Land H5 through H9. Promote Q2 to a Prevent IOA now and Q1/Q4/Q8 after the Section 11 gate-2 baseline completes. | Detection Eng + Platform | IOA deployment record; cooldown policy config |
Sequencing is the whole runbook. P2 must complete before P4. The dead-man switch fires on token revocation, so rotating credentials before locating and disabling gh-token-monitor hands the operator a trigger at the exact moment you are trying to lock them out.
Detection Coverage Map
| Technique | Behaviour | CQL | IOA | Coverage |
|---|---|---|---|---|
| T1195.002 | preinstall hook detonates node setup.mjs | Q1 | Yes | GOOD |
| T1059.007 / T1027 | Bun executes the 728 KB stage-2 stealer | Q2 | Yes | GOOD |
| T1105 | Bun v1.3.13 downloaded to a bun-dl-* temp dir and executed | Q3 | No | PARTIAL — the bun-dl- branch is high fidelity; the parent-lineage branch needs a per-environment Bun baseline before it is trustworthy |
| T1546 / T1204.002 | IDE and coding-agent config hooks re-detonate the loader | Q4 | Yes | GOOD |
| T1041 / T1071.001 | Exfiltration to a :443/router C2 endpoint | Q5 | IOC Mgmt | PARTIAL — the contract rotates domains on-chain, so this list decays. Behavioural queries are the durable layer. |
| T1102.001 | Ethereum contract dead-drop resolves the live C2 domain list | Q6 | No | PARTIAL — only 3 of the 73 RPC endpoints are publicly named, and the hosts are legitimate infrastructure that cannot be blocked |
| T1195.002 / T1078 / T1080 | Stolen npm token republishes poisoned tarballs | Q7 | No | PARTIAL — needs a release-pipeline baseline; npm registry audit is the stronger signal |
| T1546 / T1543 | gh-token-monitor dead-man switch installed | Q8 | Yes | GOOD |
| T1552.001 / T1528 | gh auth token harvested by a JavaScript runtime | Q9 | No | PARTIAL — catches one collector of nine; the file-glob collectors leave no distinctive process trace |
| T1003 | Runner.Worker memory scraped via sudo python3 | Q10 | No | PARTIAL — the Python program arrives on stdin, so only the process lineage is visible |
| T1195.002 | Known-bad artifact hashes present on disk | Q11 | IOC Mgmt | PARTIAL — the payloads are text files, so Falcon hash events cover them inconsistently. Section 7.1 shasum sweep is authoritative. |
| T1078 | Maintainer GitHub account takeover (initial access) | — | No | GAP — upstream of the customer estate entirely. Not detectable; mitigated by H5 cooldown and H6 diff-based gating. |
| T1553 | Malicious release signed with valid SLSA provenance | — | No | GAP — by design, provenance attests build origin and not code safety. Addressed by H6 as a policy change, not a detection. |
| T1102 / arbitrary RCE | C2 response code field passed to eval() | — | No | GAP — no distinct on-host telemetry beyond the Q5 network event. Treat any Q5 hit as unbounded compromise and rebuild rather than clean. |
| T1080 | IDE hooks written into up to 50 branches per reachable repo | — | No | GAP — visible only in the GitHub audit log. Covered by Section 7.3 and PB-4. |
| T1552.005 / T1555 / T1613 | Cloud, Kubernetes and Vault secret-store enumeration | — | No | GAP — not endpoint-visible. Covered by the CloudTrail / K8s audit / Vault audit hunts in Section 7.5. |
Validation status. Eleven CQL queries cover ten of the fourteen mapped attack-chain steps. Four steps (maintainer account takeover, provenance signing, C2-delivered eval(), and GitHub branch infection) have no endpoint telemetry by nature and are addressed through audit-log hunts in Section 7 and policy controls in Section 12 rather than detections. Overall pack confidence is high: four independent vendor analyses agree on the payload mechanics, and every atomic indicator traces to a saved source snapshot. Before deploying, complete Section 11 gates 1 and 2 — gate 1 because a zero-hit result is worthless without confirmed dev/CI sensor coverage, and gate 2 because Q3, Q6, Q7 and Q9 will alert-storm in any environment that legitimately uses Bun, web3 RPC, or Node-wrapped gh.
Hunt Summary Ticket
TITLE: Shai-Hulud -- npm worm, keyv / cacheable / ecto families, August 2026
(vendor tracking tag: keyv-shai-hulud)
SEVERITY: Critical -- active, self-propagating, credential theft with confirmed distribution
SCOPE: Developer workstations and CI/CD build agents that ran npm install between
2026-08-04 09:35 UTC and the date each affected version was purged from your
registry proxy and caches. Also every repository reachable by a GitHub token
held on those hosts. Servers are in scope only if builds run on them.
HYPOTHESIS: A poisoned package version executed "preinstall": "node setup.mjs", which
obtained the Bun runtime and ran a 728 KB obfuscated stealer under Bun to
evade Node-level monitoring. The stealer harvested npm, GitHub, AWS,
Kubernetes, Vault and AI-assistant credentials, resolved its C2 domain from
an Ethereum smart-contract dead drop, exfiltrated an encrypted bundle, and
planted IDE-config persistence plus a token-revocation dead-man switch.
QUERIES: Q1 preinstall hook detonates node setup.mjs CONF high FP low
Q2 Bun executes Math_Symbol.js / math_init.js CONF high FP low
Q3 Bun staged into a package-install tree CONF med FP med
Q4 .vscode / .claude config hook re-detonation CONF high FP low
Q5 DNS to a :443/router exfil domain CONF high FP low
Q6 Ethereum RPC dead drop from node or bun CONF med FP med
Q7 npm publish from an automated parent (worm) CONF med FP med
Q8 gh-token-monitor dead-man persistence CONF high FP low
Q9 gh auth token harvested by a JS runtime CONF med FP med
Q10 privileged Python from node/bun on a CI runner CONF med FP med
Q11 known-bad SHA-256 sweep CONF med FP low
DO FIRST: 1. Q1 and Q4 together -- Q4 catches the path that survives deleting node_modules
2. Q8 on every hit from step 1, BEFORE any credential revocation
3. Section 7.2 lockfile sweep, prioritising cacheable-request@13.0.20,
cache-manager@7.2.10 and @cacheable/utils@2.5.1 -- the three that install
silently inside live caret ranges. keyv@6.0.0 is the headline version and
the one nothing resolves to transitively.
4. Q5 -- any hit means exfiltration completed; escalate to rebuild, not clean
FINDINGS: ____ hosts with Q1 hits ____ hosts with Q2 hits
____ hosts with Q4 hits ____ hosts with Q5 hits
____ hosts with Q8 hits ____ repos with IDE hooks (PB-4)
____ npm tokens revoked ____ GitHub tokens revoked
GAPS: Maintainer account takeover, SLSA provenance signing, the C2-delivered eval()
channel, and GitHub branch infection have no endpoint telemetry. Covered by
Section 7 audit-log hunts and Section 12 policy controls, not by detections.
A zero-hit result is only meaningful once Section 11 gate 1 confirms sensor
coverage on the developer and CI estate.
ACTIONS: PB-1 disable npm lifecycle scripts fleet-wide (owner: Platform)
PB-2 eradicate payload + IDE persistence per host (owner: IR)
PB-3 rotate npm / GitHub / cloud credentials (owner: IAM)
PB-4 read-only org-wide exposure sweep (owner: AppSec)
Promote Q2 to a Prevent IOA now; Q1/Q4/Q8 to Detect now, Prevent after baseline
OWNER: ____________________ DUE: ____________________
VERSION: v0.2 (2026-08-30)
Changelog
References
| Tier | Source | Used for | Snapshot | Accessed |
|---|---|---|---|---|
| 1 | Datadog Security Labs — Worm compromises hundreds of popular npm packages | Payload reverse-engineering, C2 URLs, Ethereum contract and selector, seven SHA-256 artifacts, lock file, env-var guard, GitHub search markers, token-monitor paths, commit timeline | 01-datadog-security-labs.txt | 2026-08-07 |
| 2 | Aikido Security — Keyv and friends compromised in active Shai-Hulud supply chain attack | Per-collector credential-theft breakdown, original setup.mjs hash, second-generation marker, GitHub branch-infection mechanics, package counts | 02-aikido.txt | 2026-08-07 |
| 1 | Wiz Research — keyv and cacheable npm Package Hijacked in Supply Chain Attack | Mini Shai-Hulud / TeamPCP lineage, Bun/1.3.13 user-agent, bun-dl staging path, SHA-1 hashes, ETH RPC hosts and resolved IPs, StringListStore contract behaviour | 03-wiz.txt | 2026-08-07 |
| 1 | Snyk — Inside the keyv npm Compromise: preinstall Malware, Trusted Provenance, and IDE Hooks | Independent tarball diffing, exact byte sizes, the 11 confirmed releases, clean pin-back versions, IDE-hook commit detail, detection and remediation commands | 04-snyk.txt | 2026-08-07 |
| 2 | Cloudsmith — Keyv and Cacheable npm packages compromised in active supply-chain attack | keyv-shai-hulud tracking tag, 2,236 malicious version count, npm v12 lifecycle-script default as a mitigating control, registry-proxy cooldown guidance | 05-cloudsmith.txt | 2026-08-07 |
| 2 | Strobes Security — Keyv and Cacheable Supply Chain Attack Explained | Semver resolution analysis identifying the three silently-installing patch bumps; provenance trend comparison against axios and TanStack; ETH RPC C2 framing | 06-strobes.txt | 2026-08-07 |
| Ref | MITRE ATT&CK T1195.002 · T1102.001 · T1546 · T1552.001 | Technique and mitigation mapping in Sections 6 and 12 | — | 2026-08-07 |
| Ref | NIST SP 800-218 (SSDF) · CIS Software Supply Chain Security Guide v1.0 · CIS Controls v8 · SLSA v1.0 · OpenSSF npm Best Practices Guide | Framework citations for the hardening controls in Section 12 | — | 2026-08-07 |
Source integrity note. All six sources were fetched as plain text on 2026-08-07 and saved verbatim to KeyvShaiHulud-npm-Worm-Hunt-sources/. Each was reviewed for embedded instructions or out-of-band indicator delivery; none was found, and no source attempted to direct tooling behaviour. Every atomic indicator in Sections 4 and 10 is mechanically traceable to at least one of those snapshots.