On the morning of 4 August the news landed that anyone running Node.js in production reads with a knot in their stomach: there is a live worm on npm. A compromised maintainer account for the keyv and cacheable package family, a malicious preinstall hook, credential and token theft, and republication into further packages using the stolen tokens. It propagates itself. Elastic Security Labs named it CHAINDROP; others file it as the third wave of the Shai-Hulud lineage.
I spent five minutes reading about it. I spent the next two hours doing something else entirely, and that is the whole reason this post exists. Almost everything published that day reported the threat. Very little of it answered the question I actually had: what specifically do I have to check so that the sentence "we're clean" means something other than a feeling?
It turned out to have seven parts. Six of them are boring. The seventh taught me something I did not expect.
What the worm does
Briefly, because the deep technical analysis was written by people who have a lab for it. The attacker got into the maintainer's GitHub account and committed straight to the main branch, then immediately cut a release. The publishing pipeline did the rest. The package gained a setup.mjs preinstall script that pulls down a standalone Bun runtime, runs an obfuscated second stage, and starts collecting whatever it can reach: cloud credentials, registry tokens, GitHub tokens, database connection strings, private keys. It then uses the stolen publish tokens to trojanise further packages. Hence "worm".
The blast radius was being rewritten hourly on day one, so I give it as a range rather than a number: sources land between 400 and 450 packages and 1,300 to 2,250 poisoned versions, together somewhere around 1.3 to 2 billion monthly downloads (Elastic, StepSecurity, Aikido, Datadog). Anyone quoting you an exact figure for day one either measured later or picked the source that suited them.
One property matters more today than the numbers: the worm plants persistence in developer tooling hooks. It does not settle for stealing once. It tries to stay.
Seven checks
This is the procedure I actually ran, in this order, because each step closes a hole the previous one left open.
1. Lockfiles. I searched every lockfile on the machine for the known poisoned versions and for the suspicious @cacheable/* and @servicetitan/* scopes. Eight lockfiles; the family does not appear at all. If that were the whole check it would be worthless, because:
2. What is actually installed — inside the running container. A lockfile is an intent, not a state. It says what should have been installed the last time someone wrote it down. What is sitting on disk in a running container today is a different question, and only that container can answer it. One project of mine genuinely does carry that package family; inside the running container it reported keyv 4.5.4, flat-cache 4.0.1, file-entry-cache 8.0.0. Safe versions, matching what the lockfile claimed. Only that second, independent measurement made the first sentence mean anything.
3. setup.mjs. The file the worm plants as a preinstall hook. I looked for it locally, on the server, and inside the container. Zero hits.
4. Bun. The worm downloads it as the runtime for its second stage. It belongs to none of my projects, which makes its mere presence a finding in itself. Not in PATH, not in ~/.bun, not on the server, not in the container.
5. ~/.npmrc. The target of the token theft. Mine contains exactly one line, prefix=. No token. On the server the file does not exist at all.
6. Editor and Claude Code hooks. This is the interesting one, and I will admit it made me slow down. This is precisely where the worm plants persistence — and I had been editing those hooks myself that same day. A hostile change would have been hiding among my own. "Looks normal" would have meant nothing, because it always looks normal. I had to walk the working tree, the hook registry and the last five commits and recognise every change as mine. Not "nothing suspicious", but "I wrote this one, and this one, and that commit is mine". A check you cannot close by name is not a check.
7. npm audit --package-lock-only. I verified that this command genuinely does not install anything — it ships the lockfile contents to the official advisory database and returns an answer. That matters more than it sounds: the moment you freeze your resolver, you need a check that touches neither the registry nor node_modules. This one touches neither.
The hinge: audit asks the database, the scanner already knows
Here is the distinction that makes both tools necessary and neither sufficient.
npm audit compares your lockfile against a database of known vulnerabilities. It knows only what has already been written down. Between the moment an attacker publishes a poisoned version and the moment that version lands in the advisory database, time passes — in this attack the first malicious release went out shortly after 09:00 UTC and the analyses kept arriving all day. Inside that window, audit will tell you "clean" with total confidence. It is not lying. It is answering a different question from the one you think you asked.
An IOC scanner is the inverse. You hard-code the versions and indicators from the fresh reporting, and it asks nobody — it already knows the answer. The catch is that it knows exactly one answer: the attack somebody described in it. To anything else it is blind.
So I wrote about a hundred lines of Python that walk the lockfiles, hunt for setup.mjs, look at the hooks, and print every version of the family it finds. Nothing clever. Twenty minutes of work bought me a check I can re-run next week and get a comparable result — and comparability is the value here, not sophistication. One measurement proves nothing. Two identical measurements a day apart prove that nothing moved.
The part that surprised me: provenance did not help
I went in holding a belief that turned out to be wrong, and it seems only fair to say so, because I still held it when I started writing this.
I assumed the malicious publications would be identifiable by a missing OIDC binding — that a fraudulent release would lack the signature of a trusted publishing path and give itself away. It sounds logical. It is not true. The attacker committed to the main branch and let the maintainer's own legitimate publishing pipeline ship the poisoned versions. They therefore went out with valid provenance signed by GitHub Actions. StepSecurity put it in a sentence I copied down: "Provenance proves which commit was built. It cannot prove the commit was authorized."
My own publishing flow runs on OIDC trusted publishing with no long-lived token to steal. That is still correct and I am not changing it. But it does one thing fewer than I thought: it protects my token; it does not make a fraudulent release visible. That was a piece of false comfort I had been carrying for weeks, and I only caught it because I went to verify the claim instead of writing it down.
The operational freeze — and the two times I broke it
All of the above collapses into one rule, and it is simple: until the registry is cleaned up, do not run npm install or npm update. Those are the only paths by which a dependency can resolve onto a version nobody has flagged yet. npm ci is fine, because it installs exactly what the lockfile says and resolves nothing.
And now the part a security post would normally leave out.
Within days that rule was broken twice. Once by a command-line tool updating itself through npm install -g — the tool updated on its own and knew nothing about my freeze. Once by an automated agent that, in the middle of an entirely unrelated task, bumped some framework versions and rewrote a lockfile: 2,280 changed lines, no malice, just ordinary work.
Both came out clean. But they came out clean because the keyv family was not in those trees — not because any guardrail did its job. That is luck, not process.
The lesson fits in one line and is worth all the preceding paragraphs put together: a rule that exists only in one person's head and in their notes is not a control. Tools do not know about it. Agents do not know about it. Colleagues do not know about it. A control is ignore-scripts=true and min-release-age in .npmrc (npm 11.10 and newer), npm ci --ignore-scripts in CI, a committed lockfile, an egress allowlist on CI runners. Things that hold at the moment nobody remembers they exist. Every source on this incident converges on that advice, and they are right.
The near miss worth naming
On the very day the attack started, I ran a database client generation step through npx and a build in one of my projects. Neither resolves dependencies — both use whatever already sits in node_modules. That is why they were harmless.
npm install would have been a different story, and from the perspective of a human at a keyboard the two commands differ by about three characters. From a security perspective that gap is the entire difference between an incident and a quiet evening: one command reaches for the registry, the other only uses what has already been fetched. It is worth sitting down once and working out which of your daily commands falls on which side. I did it only after this week.
What I take from it
"We're clean" is, nine times out of ten, shorthand for "I didn't notice anything". What separates that from an actual claim is not the confidence it is delivered with, but whether there is a named list of what was measured, where, and with what. Lockfile and installation. Locally, on the server, and in the container. Known indicators and the advisory database. Hooks I had to recognise as my own.
And a less comfortable second point: any check a human performs is a one-off. Mine holds for 4 and 5 August and for one repeat run after that. What holds next Tuesday, when nobody is thinking about it, is only those four lines of configuration. I did the work described above properly — which is exactly how I know it is not the solution.