ChainDrop / Shai-Hulud — keyv/cacheable npm Worm (August 2026)

ChainDrop alias · keyv / cacheable / ecto package families · setup.mjs preinstall loader · Bun stage-2 stealer · Ethereum-contract C2 dead drop · IDE-config persistence
Threat
ChainDrop / Shai-Hulud (keyv/cacheable wave)
Severity
ACTIVE WORM
Type
Software supply chain / credential theft
Access
npm install-time lifecycle script
Version
v0.2 · 2026-08-30
Author
HuntPack
Confidence
High (4 independent vendor analyses)
01

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.mjs and .vscode/setup.mjs into up to 50 branches of every repository a stolen GitHub token can reach. The VS Code task runs on folderOpen; the Claude settings register a SessionStart hook. No npm install is required — merely opening the checkout re-detonates the loader. Cleaning node_modules does 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.

02

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.

TierSourceKey findingCarry forward
1Datadog 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
1Snyk 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
1Wiz 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
2Aikido 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
2Strobes 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
2Cloudsmith (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 enrich only 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.

03

Hunt Brief & Attack Chain

Attack chain

#StepTelemetryHunt angle
1Maintainer GitHub account compromised; malicious files pushed straight to main, release cut immediatelyGitHub audit log, org audit streamNative hunt — unsigned/unexpected pushes to main, release tags deleted and recreated (the keyv v6.0.0 tag was deleted four times)
2Release workflow builds and publishes to npm with genuine SLSA provenancenpm attestation / RekorProvenance is not a safety verdict — see Section 12 hardening
3Victim runs npm install; preinstall hook executes node setup.mjsProcessRollup2Q1 — node process with setup.mjs on the command line
4Loader accepts an installed bun, or downloads Bun v1.3.13 to a bun-dl-* temp dir and marks it 0755ProcessRollup2, network egress to GitHub releasesQ3 — Bun in a package-install process tree or executed from bun-dl-*
5Stage 2 (Math_Symbol.js / math_init.js, 728 KB) runs under Bun, not NodeProcessRollup2Q2 — bun executing a campaign-named script
6Outside CI the payload respawns itself detached with _NODE_RUNTIME_INIT=1, takes lock tmp.dpkg_14527.lock, installs no-op SIGINT/SIGTERM handlersProcessRollup2, temp-dir file activityNative hunt — lock file presence; detached bun with no console parent
7Credential 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.WorkerProcessRollup2; cloud audit logsQ9 (gh token extraction), Q10 (privileged Python from a Node/Bun parent), plus CloudTrail native hunts
8C2 discovery: eth_call to a smart contract over 73 public Ethereum RPC endpoints returns the live domain listDnsRequest, NetworkConnectIP4Q6 — RPC hostname resolved by a node or bun process
9Exfiltration: AES-256-GCM envelope, RSA-OAEP-wrapped key, POSTed to /router on port 443 of the selected domain; GitHub public repo fallbackDnsRequest, NetworkConnectIP4Q5 — resolution of the four known exfil domains
10C2 response containing a code field is passed to eval() — unsigned, unsandboxed arbitrary code executionNone on-host beyond step 9GAP — no distinct telemetry; treat any Q5 hit as full host compromise
11Worm 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 auditQ7npm publish spawned by an automated parent
12Worm propagation B: GitHub token writes IDE hooks into up to 50 branches per repo (skips dependabot/copilot), commit message chore: update config, author claudeGitHub audit logNative hunt — see Section 7
13Re-detonation: VS Code folderOpen task and Claude SessionStart hook run the loader with no npm installProcessRollup2Q4 — node running setup.mjs from a .vscode or .claude path
14Dead-man persistence: gh-token-monitor watches a stolen token and fires a handler when it stops workingProcessRollup2, LaunchAgent / systemd user unitQ8 — any process referencing gh-token-monitor

Hunt hypotheses (ordered by fidelity)

#HypothesisMITREFalcon eventsExpected FPConf
H1A developer or build agent ran npm install against a poisoned version, detonating node setup.mjs at preinstall time.T1195.002, T1059.007ProcessRollup2 / SyntheticProcessRollup2A benign package shipping a root setup.mjs — rare, and verifiable from the path in CommandLineHIGH
H2Stage 2 executed: the Bun runtime ran a file named Math_Symbol.js or math_init.js.T1059.007, T1027ProcessRollup2None — campaign-specific filenamesHIGH
H3The IDE-config persistence path re-detonated the loader when a developer opened a poisoned checkout or started a coding-agent session.T1546, T1195.002ProcessRollup2Repos legitimately running node tasks on folderOpen — the setup.mjs filename discriminatesHIGH
H4A host resolved one of the four :443/router exfiltration domains.T1041, T1071.001DnsRequest / SuspiciousDnsRequestNone — no legitimate useHIGH
H5The gh-token-monitor dead-man switch was installed on a developer host.T1546, T1543ProcessRollup2None — not a GitHub CLI componentHIGH
H6Bun 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.007ProcessRollup2Teams that legitimately use BunMED
H7A Node or Bun process queried a public Ethereum RPC endpoint — the dead-drop C2 resolver.T1102.001DnsRequestWeb3 / wallet engineering hostsMED
H8A stolen npm token was used from a compromised host: npm publish was invoked by an automated parent rather than an interactive shell.T1195.002, T1078ProcessRollup2Legitimate release pipelinesMED
H9Credential 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, T1003ProcessRollup2Home-grown automation wrappers around ghMED
H10A known-bad artifact hash was observed anywhere in endpoint telemetry.T1195.002ProcessRollup2, PeFileWritten, ELFFileWritten, ImageHashNone — exact matchMED

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.

04

Consolidated IOC Table

TypeValueConfActionContextSourceExpiry
domainawqhnjewqjkl[.]icuhighdetectC2 / exfil, HTTPS GET to /router on 443. Added to the contract on 2026-08-05.Datadog2027-02-07
domainpypi-get[.]comhighdetectC2 / exfil, :443/routerDatadog, Wiz2027-02-07
domainjs-mirror[.]comhighdetectC2 / exfil, :443/routerDatadog, Wiz2027-02-07
domainnpm-cache[.]comhighdetectC2 / exfil, :443/router. Registered 2026-05-22; at one point the sole domain the contract returned.Datadog, Wiz, Aikido, Strobes2027-02-07
eth-contract0xE1f2395ee43e45A1556EC6438a88c31B83493103highenrichEthereum 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, Aikido2027-02-07
eth-selector0x53ed5143highenricheth_call function selector used against the contract, block latest, 3-second timeout per RPC attempt.Datadog2027-02-07
sha2569fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcchighdetectStage-2 stealer — Math_Symbol.js (first generation) and math_init.js (worm-seeded generation), 727,680 bytesDatadog, Aikido, Snyk2027-02-07
sha25654dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668highdetectOriginal setup.mjs loader as shipped in keyv@6.0.0, 29,918 bytesAikido, Snyk2027-02-07
sha256fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1ebhighdetectsetup.mjs as embedded and re-propagated by the worm (community-spread variant)Datadog, Aikido2027-02-07
sha256927387d0cfac1118df4b383decc2ea6ba49c9d2f98b47098bcbcba1efc026e1fhighdetectMalicious .vscode/tasks.json — runs node .claude/setup.mjs on folderOpenDatadog2027-02-07
sha25614eb4ce01dd4307759887ff819359b70d7d9ff709ecde039a5abc1aac325b128highdetectMalicious .claude/settings.jsonSessionStart hook running node .vscode/setup.mjsDatadog2027-02-07
sha2563f3f42d072bd36860ab7bd7fb5e10ac0d22c741c13c89505ccd6ec0ea572eea7highdetectInjected GitHub workflow (.github/workflows/codeql_analysis.yml) that leaks toJSON(secrets) to an artifactDatadog2027-02-07
sha25629ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7highdetectEmbedded Python piped into sudo python3 to scrape GitHub Actions Runner.Worker memoryDatadog2027-02-07
sha256619c56acf572df75b6004a6fc013c80900316a76099b241d64312da3a44f10b4highdetectgh-token-monitor dead-man-switch installerDatadog2027-02-07
sha256d584f9b6af48b7ed1f93713944f033783bf149e1c25e1643eb8c0e9df5dc7782highhuntTarball hash of keyv-6.0.0.tgz — hunt private registry mirrors and developer package cachesSnyk2026-11-07
sha135a672cf34b996b91f3e1c28cbf3a05a37e036e4highdetectStage-2 stealer, SHA-1 of Math_Symbol.js / math_init.jsWiz2027-02-07
sha1686aa40d0fc22c8d569494543a0f891f359f2f99highdetectLoader planted at .claude/setup.mjsWiz2027-02-07
sha1f525d52ceb966516686b482d3dc0137028cc6a63highdetectLoader planted at .vscode/setup.mjsWiz2027-02-07
filenamesetup.mjshighhuntStage-1 loader at package root and at .claude/ / .vscode/. Behavioural — do not block on name alone.All six2026-11-07
filenameMath_Symbol.js, math_init.jshighhuntStage-2 stealer. math_init.js indicates a second-generation, worm-seeded infection rather than the original maintainer compromise.Datadog, Aikido, Wiz, Snyk2026-11-07
filepath.vscode/tasks.json, .claude/settings.json, .claude/setup.mjs, .vscode/setup.mjs, .claude/math_init.jshighhuntIDE / coding-agent persistence set written into up to 50 branches per reachable repositoryDatadog, Snyk, Aikido, Wiz2026-11-07
filepathbun-dl-* under the OS temp directoryhighhuntBun download/extraction staging directory created by the loader; removed in a finally block, so absence proves nothingDatadog, Wiz2026-11-07
mutextmp.dpkg_14527.lockhighhuntSingle-instance lock under the OS temp directory. New to this wave — earlier Shai-Hulud variants used a different lock name.Datadog, Wiz2026-11-07
env-var_NODE_RUNTIME_INIT=1highhuntRecursion guard set on the detached respawn outside CIDatadog2026-11-07
user-agentBun/1.3.13mediumhuntObserved on malicious calls. Proxy/egress-log hunt; also matches legitimate Bun 1.3.13 users.Wiz2026-11-07
stringthebeautifulmarchoftimehighpivotGitHub commit-search query used for fallback C2 discovery. The trailing space is significant.Datadog2026-09-07
stringthebeautifulsnadsoftimehighpivotMarker the parser actually accepts in signed fallback commit messages — deliberately different from the search stringDatadog2026-09-07
stringIfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClientshighpivotToken-relay marker and abuse-team intimidation string in initial commits. A bluff, not a technical claim.Datadog, Wiz, Strobes2026-09-07
stringShai-Hulud: Here We Go AgainhighpivotDefault description of the attacker-created public GitHub exfiltration repositories (~1,300 observed)Datadog, Aikido, Wiz, Strobes2026-09-07
stringchore: update configmediumpivotCommit message on IDE-hook infection commits, authored as claude. Blends with real commits — corroborate with file paths.Aikido, Wiz2026-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.loghighhuntDead-man-switch persistence. Disable before revoking tokens — revocation is the trigger condition.Datadog, Snyk2026-11-07
ipv4104.21.35.216mediumenrichResolved address of npm-cache[.]com. Cloudflare shared edge — never block.Wiz2026-09-07
ipv435.175.164.77lowenrichResolved address of the nodereal ETH RPC host (AWS). Legitimate infrastructure — context only.Wiz2026-09-07
ipv4185.44.207.215lowenrichResolved address of the getblock ETH RPC host. Legitimate infrastructure — context only.Wiz2026-09-07
ipv4172.67.167.200lowenrichResolved address of the llamarpc ETH RPC host. Cloudflare shared edge — never block.Wiz2026-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.

05

Affected Surface & Telemetry Matrix

SurfaceWhy it is in scopeRequired telemetryPriorityGap 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 + DnsRequestCRITICALFalcon 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 logCRITICALEphemeral containerised runners may terminate before telemetry ships
GitHub organisation (repos, Actions, tokens)Branch infection, workflow injection, secret exfiltration via artifacts, public exfil repo creationGitHub Enterprise audit log streamCRITICALGAP — not Falcon-visible; requires audit-log ingestion (Section 7)
npm registry accounts and private mirrorsStolen publish tokens republish poisoned tarballs; internal proxies cache malicious versions after npm removes themnpm audit log; registry proxy access logsCRITICALGAP — not Falcon-visible
AWS accounts reachable from build/dev credentialsSTS identity probe, Secrets Manager and SSM Parameter Store enumeration across 17 commercial regions, IMDSv2CloudTrailHIGHSSM GetParameters with decryption is high-volume; needs baselining
Kubernetes clusters running affected buildsIn-cluster API queried with the service-account token; namespace Secrets base64-decodedK8s API audit logHIGHAudit logging often sampled or disabled by default
HashiCorp VaultKV v1/v2 read across the first 100 top-level keys per mountVault audit deviceHIGHVault audit device may not be centrally shipped
Egress proxies / firewallsThe four :443/router C2 endpoints and Ethereum RPC dead-drop trafficProxy / NGFW logs, DNS resolver logsHIGHTLS-inspection gaps hide the /router path; hostname-level logging still works
Servers running compiled/bundled application artifactsOnly in scope if the build itself ran an affected install. The library behaves normally at runtime; the hook fires at install time.ProcessRollup2MEDIUMLow — deprioritise unless builds happen on the host
06

ATT&CK Mapping

TacticTechniqueObserved behaviourQuery / control
Initial AccessT1195.002 — Compromise Software Supply Chain: Software Dependencies and Development ToolsPoisoned releases of keyv / cacheable / ecto and ~434+ downstream packages published to npmQ1, Q11 · M1051, M1016
Initial Access / PersistenceT1078 — Valid AccountsMaintainer GitHub account takeover; stolen npm and GitHub tokens reused to publish and to push branchesQ7 · Section 7 native hunts · M1032
ExecutionT1059.007 — Command and Scripting Interpreter: JavaScriptnode setup.mjs at preinstall; 728 KB obfuscated stage 2 executed by BunQ1, Q2, Q3, Q4
ExecutionT1204.002 — User Execution: Malicious FileOpening a poisoned checkout in VS Code or starting a Claude session triggers the hookQ4 · M1017
PersistenceT1546 — Event Triggered Execution.vscode/tasks.json runOn: folderOpen; .claude/settings.json SessionStart hookQ4 · Section 7 · M1042
PersistenceT1543 — Create or Modify System Processgh-token-monitor installed as a systemd user unit (Linux) or LaunchAgent (macOS)Q8
Defense EvasionT1027 — Obfuscated Files or InformationBase91 string table (4,613 entries), 1,283-element constant array, per-call alphabet permutations, flattened generator state machinesQ2, Q11
Defense EvasionT1553 — Subvert Trust ControlsMalicious releases published with genuine SLSA provenance signed by GitHub Actions; Sigstore/Rekor path attempted for a further packageSection 12 (provenance is not a safety verdict)
Defense EvasionT1218 / runtime substitutionStage 2 executes under Bun specifically to escape Node.js-runtime-level monitoringQ2, Q3 · M1038
Credential AccessT1552.001 — Unsecured Credentials: Credentials In Files~470 platform-aware globs across .env, SSH, PEM/JKS, Terraform, Docker, KeePass, .ovpn, /etc/shadow, AI-assistant configQ9 · M1041
Credential AccessT1552.005 — Cloud Instance Metadata APIIMDSv2 (with no IMDSv1 fallback) and ECS container metadata endpoint queried for AWS credentialsCloudTrail native hunt · M1035
Credential AccessT1555 — Credentials from Password StoresVault KV, Kubernetes Secrets, AWS Secrets Manager, SSM Parameter Store with WithDecryption:trueCloud audit hunts · M1026
Credential AccessT1528 — Steal Application Access Tokengh auth token; npm _authToken from .npmrc; GitHub OIDC tokens from Actions envQ9 · M1032
Credential AccessT1003 — OS Credential DumpingEmbedded Python piped to sudo python3 reads /proc/<pid>/mem of Runner.Worker to recover Actions secretsQ10 · M1026
DiscoveryT1613 — Container and Resource DiscoveryKubernetes namespace and Secret enumeration via the in-cluster APIK8s audit hunt
Command and ControlT1102.001 — Web Service: Dead Drop Resolvereth_call against an Ethereum StringListStore contract over 73 public RPC endpoints returns the live domain list; GitHub commit search as signed fallbackQ6 · M1037
Command and ControlT1071.001 — Application Layer Protocol: Web ProtocolsHTTPS GET to /router on port 443; only HTTP 400/404 counts as healthyQ5 · M1037
Command and ControlT1105 — Ingress Tool TransferBun v1.3.13 fetched from GitHub releases (allowed by every corporate proxy), up to five redirects followed with no origin restriction and no signature verificationQ3 · M1038
ExfiltrationT1041 — Exfiltration Over C2 Channelgzip + AES-256-GCM envelope with RSA-OAEP-wrapped key POSTed to the selected domainQ5
ExfiltrationT1567.001 — Exfiltration to Code RepositoryPublic GitHub repos with description "Shai-Hulud: Here We Go Again"; results written under results/, split above 30 MiBSection 7 native hunt
Impact / LateralT1080 — Taint Shared ContentRepacked tarballs republished with a patch bump; IDE hooks written to up to 50 branches per repoQ7 · Section 7
07

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

CheckAudit-log query / APIWhat a hit means
Attacker exfiltration repositoriesSearch GitHub for public repos whose description contains Shai-Hulud: Here We Go Again; cross-reference the owner against your own usersA user of yours had a working GitHub token stolen and used to create a public drop repo
IDE-hook infection commitsaction: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.jsBranch infection. Up to 50 branches per repo, most-recently-active first, dependabot and copilot refs skipped
Injected secret-leaking workflowBranch dependabot/github_actions/format/setup-formatter; file .github/workflows/codeql_analysis.yml; any workflow referencing toJSON(secrets); artifact named format-results.txtOrganisation/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 behaviourTags deleted and recreated; a GitHub Release object published after the npm artifact; unsigned commits landing on main without a recorded PR mergeThe publish path is not behaving like your normal release. A GitHub ReleaseEvent is not a reliable npm publication marker in this campaign
Token inventoryEnumerate classic PATs (ghp_, gho_) and App tokens (ghs_) with workflow, repo or public_repo scope held by anyone who ran an affected installDefines 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 scripts object 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: true combined 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:GetCallerIdentity followed within minutes by secretsmanager:ListSecrets / GetSecretValue and ssm:DescribeParameters / GetParameters with WithDecryption=true, iterating many regions from one source. The collector pages DescribeParameters at 50 and batches GetParameters at 10 across 17 commercial regions — that multi-region fan-out from a build identity is the signature.
  • Kubernetes audit: list on namespaces then get/list on secrets across non-system namespaces by a service account that has no business doing so.
  • Vault audit device: sys/mounts enumeration followed by broad KV reads capped at 100 top-level keys per mount.
08

CrowdStrike LogScale CQL Hunt Queries

Pick your tenant's cloud first — every "Open in Falcon" button below uses this selection.

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.

Q1 · preinstall lifecycle hook detonates the loader
CONF HIGHFP LOWCOST LOW

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])
Q2 · Bun executing the stage-2 stealer
CONF HIGHFP LOWCOST LOW

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])
Q3 · Bun staged into an install tree (runtime-swap evasion)
CONF MEDFP MEDCOST MED

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])
Q4 · IDE / coding-agent config persistence re-detonation
CONF HIGHFP LOWCOST LOW

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])
Q5 · DNS resolution of a :443/router exfiltration domain
CONF HIGHFP LOWCOST LOW

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])
Q6 · Ethereum dead-drop resolver queried by Node or Bun
CONF MEDFP MEDCOST MED

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])
Q7 · Worm propagation — npm publish from an automated parent
CONF MEDFP MEDCOST MED

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])
Q8 · gh-token-monitor dead-man persistence
CONF HIGHFP LOWCOST LOW

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])
Q9 · GitHub CLI token extraction driven by Node or Bun
CONF MEDFP MEDCOST LOW

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])
Q10 · Privileged Python spawned by Node/Bun on a CI runner
CONF MEDFP MEDCOST LOW

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])
Q11 · Known-bad artifact hash sweep
CONF MEDFP LOWCOST MED

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])
09

CrowdStrike Custom IOA Recommendations

Three queries are clean enough to promote to Custom IOAs. The rest stay Investigate-only hunts.

IOA nameFromRule typePatternExclusionsAction
SUPPLY-CHAIN — npm preinstall launches setup.mjs loaderQ1Process CreationImage filename node; command line contains setup.mjsFull 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 2Q2Process CreationImage filename bun; command line contains Math_Symbol.js or math_init.jsNonePrevent (Critical) — safe to deploy immediately
PERSISTENCE — IDE config hook re-runs npm loaderQ4Process CreationImage filename node; command line contains setup.mjs AND .vscode or .claudeCheckout path of a named internal tooling repo, if one genuinely does thisDetect (High) → Prevent once baselined
PERSISTENCE — gh-token-monitor dead-man switchQ8Process CreationCommand line contains gh-token-monitorNoneDetect (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.

10

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.

Falcon IOC Management CSVbulk import
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
SHA-1 hashesfor tools that key on SHA-1
# 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)
Behavioural signaturesdurable — survives IOC rotation
# 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)
Affected versions & clean pinslockfile remediation
# 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.

11

Detection Validation Gates

GateCheckPass criteriaIf it fails
1 · Telemetry readyConfirm 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 visibleA zero-hit result from Q1–Q11 is meaningless. Fix enrolment before reporting "no evidence of compromise"
2 · Benign baselineRun Q3 (Bun lineage), Q6 (ETH RPC), Q7 (npm publish) and Q9 (gh auth token) over 30 days and record every legitimate sourceYou have a written list of hosts and accounts that legitimately use Bun, query web3 RPC, publish to npm, and wrap gh in NodeThese four will alert-storm. Do not promote any of them to an IOA before baselining
3 · Positive test — install pathOn an isolated lab host, create a throwaway package with "preinstall": "node setup.mjs" where setup.mjs merely prints a string, then npm install itQ1 returns the event with correct ParentBaseFileName and full CommandLineCheck the sensor's command-line capture setting; truncated command lines break Q1, Q4 and Q9
4 · Positive test — Bun pathInstall Bun in the lab and run a benign script named math_init.js through itQ2 returns the event; Q3 returns it when spawned from an npm parentConfirm the sensor resolves FileName for the Bun binary on that platform
5 · Positive test — IDE pathIn the lab, add a .vscode/tasks.json with runOn: folderOpen invoking a benign node .vscode/setup.mjs, then open the folderQ4 returns the event with the IDE as ParentBaseFileNameVerify VS Code workspace trust is not silently suppressing the task — if it is, note that as a mitigating control in your report
6 · Negative controlRun an ordinary npm install of a clean package set, an interactive gh auth token, and a normal release-pipeline publishQ1, Q9 and Q7 stay silentTighten the parent-lineage clauses rather than broadening exclusions
7 · IOC importImport the Section 10 CSV and confirm all 13 rows load without a parse error4 domains and 9 SHA-256 values present in IOC ManagementCheck the expiration date format your tenant expects
8 · PromotionQ2 to Prevent immediately; Q1, Q4 and Q8 to Detect now and Prevent after gate 2 completesTwo weeks of clean operation before any Prevent promotion beyond Q2Stay at Detect. A false Prevent on node breaks every developer on the estate
12

Hardening — Tiered

Immediate — deploy this week

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.

Near term — 1 to 4 weeks, pilot on a ring first

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.

Strategic — 1 to 3 months

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.

13

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
14

Containment Runbook

PhaseActionsOwnerEvidence 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 L2Query 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 + PlatformContainment 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.IRForensic 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 + EndpointPer-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 + PlatformRotation 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 + SOCAudit-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.PlatformClean 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 + PlatformIOA 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.

15

Detection Coverage Map

TechniqueBehaviourCQLIOACoverage
T1195.002preinstall hook detonates node setup.mjsQ1YesGOOD
T1059.007 / T1027Bun executes the 728 KB stage-2 stealerQ2YesGOOD
T1105Bun v1.3.13 downloaded to a bun-dl-* temp dir and executedQ3NoPARTIAL — the bun-dl- branch is high fidelity; the parent-lineage branch needs a per-environment Bun baseline before it is trustworthy
T1546 / T1204.002IDE and coding-agent config hooks re-detonate the loaderQ4YesGOOD
T1041 / T1071.001Exfiltration to a :443/router C2 endpointQ5IOC MgmtPARTIAL — the contract rotates domains on-chain, so this list decays. Behavioural queries are the durable layer.
T1102.001Ethereum contract dead-drop resolves the live C2 domain listQ6NoPARTIAL — only 3 of the 73 RPC endpoints are publicly named, and the hosts are legitimate infrastructure that cannot be blocked
T1195.002 / T1078 / T1080Stolen npm token republishes poisoned tarballsQ7NoPARTIAL — needs a release-pipeline baseline; npm registry audit is the stronger signal
T1546 / T1543gh-token-monitor dead-man switch installedQ8YesGOOD
T1552.001 / T1528gh auth token harvested by a JavaScript runtimeQ9NoPARTIAL — catches one collector of nine; the file-glob collectors leave no distinctive process trace
T1003Runner.Worker memory scraped via sudo python3Q10NoPARTIAL — the Python program arrives on stdin, so only the process lineage is visible
T1195.002Known-bad artifact hashes present on diskQ11IOC MgmtPARTIAL — the payloads are text files, so Falcon hash events cover them inconsistently. Section 7.1 shasum sweep is authoritative.
T1078Maintainer GitHub account takeover (initial access)NoGAP — upstream of the customer estate entirely. Not detectable; mitigated by H5 cooldown and H6 diff-based gating.
T1553Malicious release signed with valid SLSA provenanceNoGAP — by design, provenance attests build origin and not code safety. Addressed by H6 as a policy change, not a detection.
T1102 / arbitrary RCEC2 response code field passed to eval()NoGAP — no distinct on-host telemetry beyond the Q5 network event. Treat any Q5 hit as unbounded compromise and rebuild rather than clean.
T1080IDE hooks written into up to 50 branches per reachable repoNoGAP — visible only in the GitHub audit log. Covered by Section 7.3 and PB-4.
T1552.005 / T1555 / T1613Cloud, Kubernetes and Vault secret-store enumerationNoGAP — 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.

16

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

Changelog

v0.22026-08-30Added the ChainDrop alias used by Microsoft, StepSecurity, and Elastic for this same August 2026 keyv/cacheable Shai-Hulud wave. Preserved the existing 11-query coverage and all original provenance-backed IOCs, hardening controls, playbooks, and containment guidance.
v0.12026-08-07Initial pack. Six sources fetched and snapshotted; 11 CQL hunt queries; 4 Custom IOA recommendations; 13-row Falcon IOC CSV plus SHA-1, behavioural and version-pin blocks; 12 tiered hardening controls; 4 deployable playbooks with prerequisites, reboot and rollback; 8-phase containment runbook sequenced so persistence removal precedes credential rotation. Covers the NEW August 2026 keyv/cacheable/ecto wave only — new C2 infrastructure, the setup.mjs to Bun two-stage loader, the Ethereum-contract dead-drop resolver, and IDE-config persistence. Cross-references but does not duplicate the Mini-Shai-Hulud and ShaiHulud-Hades-PyPI packs already in the library.
18

References

TierSourceUsed forSnapshotAccessed
1Datadog Security Labs — Worm compromises hundreds of popular npm packagesPayload reverse-engineering, C2 URLs, Ethereum contract and selector, seven SHA-256 artifacts, lock file, env-var guard, GitHub search markers, token-monitor paths, commit timeline01-datadog-security-labs.txt2026-08-07
2Aikido Security — Keyv and friends compromised in active Shai-Hulud supply chain attackPer-collector credential-theft breakdown, original setup.mjs hash, second-generation marker, GitHub branch-infection mechanics, package counts02-aikido.txt2026-08-07
1Wiz Research — keyv and cacheable npm Package Hijacked in Supply Chain AttackMini 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 behaviour03-wiz.txt2026-08-07
1Snyk — Inside the keyv npm Compromise: preinstall Malware, Trusted Provenance, and IDE HooksIndependent tarball diffing, exact byte sizes, the 11 confirmed releases, clean pin-back versions, IDE-hook commit detail, detection and remediation commands04-snyk.txt2026-08-07
2Cloudsmith — Keyv and Cacheable npm packages compromised in active supply-chain attackkeyv-shai-hulud tracking tag, 2,236 malicious version count, npm v12 lifecycle-script default as a mitigating control, registry-proxy cooldown guidance05-cloudsmith.txt2026-08-07
2Strobes Security — Keyv and Cacheable Supply Chain Attack ExplainedSemver resolution analysis identifying the three silently-installing patch bumps; provenance trend comparison against axios and TanStack; ETH RPC C2 framing06-strobes.txt2026-08-07
RefMITRE ATT&CK T1195.002 · T1102.001 · T1546 · T1552.001Technique and mitigation mapping in Sections 6 and 122026-08-07
RefNIST SP 800-218 (SSDF) · CIS Software Supply Chain Security Guide v1.0 · CIS Controls v8 · SLSA v1.0 · OpenSSF npm Best Practices GuideFramework citations for the hardening controls in Section 122026-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.