Not long after I cleaned malware out of my GitHub repos, Singapore's Cyber Security Agency published an advisory about a different one. The attack on my repos needed someone's stolen GitHub credentials. This one only needs you to run npm install.
It's the latest wave of Shai-Hulud, a self-spreading npm worm named after the sandworms in Dune; researchers call this wave ChainDrop. It started in popular caching packages like keyv, cacheable and flat-cache, and the published lists now hold over 2,300 infected versions of 490 packages.
This post covers what one install does, the detail that made me look twice, how I checked my own machine and every repo on my GitHub account without installing anything, and the free scanner I wrote for it: shai-hulud-scan.
What one install does
An infected version looks like a normal patch release. The only visible change is in its package.json:
"scripts": { "preinstall": "node setup.mjs" } npm runs preinstall while it installs, before any of your code loads. From there:
-
setup.mjsfetches Bun if you don't have it, and uses it to run the real payload, a heavily obfuscated file calledmath_init.js(orMath_Symbol.jsin the first packages hit). - The payload collects credentials: npm and GitHub tokens, cloud keys, SSH keys, Kubernetes, Terraform and Vault credentials,
.envfiles, shell history. On a CI runner, it also reads the runner's memory for its secrets. - It uploads them, encrypted, to a new public repo it creates under your GitHub account, with the description "Shai-Hulud: Here We Go Again".
- It spreads through npm. With your npm token, it adds the same
preinstallline to every package you can publish, bumps the patch version, and publishes it. Everyone who depends on your package is next. - It spreads through GitHub. With your GitHub token, it commits copies of itself to the branches of repos it can reach, so anyone who clones and opens them runs it too.
Step 4 is why this is called a worm, not just malware. One maintainer's install becomes every one of their packages, and each of those packages has maintainers of its own.
The part that made me look twice
These are the files step 5 commits to your repos:
.vscode/tasks.json runs node .claude/setup.mjs when the folder opens in VS Code .claude/settings.json runs node .vscode/setup.mjs when a Claude Code session starts .claude/setup.mjs the loader .vscode/setup.mjs the loader again .claude/math_init.js the payload The VS Code task is the same trick the malware in my repos used: a task set to run on folder open. The new part is the second line, a Claude Code SessionStart hook. Your AI assistant's project settings now sit next to your editor's as a place an attacker can put code that runs on your machine. Both live in the repo, where any commit can change them.
It also adds a GitHub Actions workflow, named codeql_analysis.yml to look like GitHub's own code scanning, that copies every secret in the repo into one value with toJSON(secrets) and saves it as a downloadable file.
Checking without installing anything
The obvious way to find out whether you have an infected version is the one thing you shouldn't do: run npm install and see. If an infected version is there, that runs it.
You don't need to install anything, because the lockfile already records it. package-lock.json, yarn.lock, pnpm-lock.yaml and bun.lock each list the exact version of every package a project uses, including the dependencies of dependencies. Reading those and comparing them with the published list answers "would this install something infected?" without running a line of it.
So that's what the scanner does first. On my machine, I pointed it at the folder holding all my projects:
- 1,831 installed packages and 7 lockfiles checked,
node_modulesincluded. - Nothing found.
My GitHub account came next. A repo I haven't cloned in years still counts, because the worm commits to repos, not machines. With a read-only token, the scanner went through the latest commit of every branch in all 47 repos I own or collaborate on, checking lockfiles and the files above, and looked for any repo the worm had created under my account. Nothing found there either.
That was a relief after last month. But a clean result is only as good as what the scanner looks for.
Why a list of versions isn't enough
The list of infected versions always lags behind the worm: a package infected this morning won't be on it yet. So version matching is only one of the checks:
- The planted files. A
tasks.jsonor Claude Code settings file that runssetup.mjsis flagged whatever your dependencies are. So are the loader and payload files where the worm puts them. - The install script. Any
package.jsonwhosepreinstall,installorpostinstallrunssetup.mjsis flagged. That catches an infected package even before it reaches any list. - Fingerprints. Files with the worm's names are checked against the published SHA-256 hashes, and for strings that only appear in its code.
- The secrets workflow. Any workflow that dumps
toJSON(secrets)gets flagged. Normal workflows almost never need every secret at once. - The stolen-secrets repo. A repo under your account described "Shai-Hulud: Here We Go Again" means your credentials have already been taken, even if every other check comes back clean.
The false alarm
The first run on my machine did find something: a file called Math_Symbol.js, inside node_modules in one of my projects. That's one of the worm's payload names.
It's also the name of a real file. regenerate-unicode-properties, which Babel depends on, ships a Math_Symbol.js holding the Unicode "Math Symbol" character category. It's in a huge number of JavaScript projects.
So inside node_modules, a name alone no longer counts. A file there needs a matching hash or the worm's marker strings, and an infected package is caught by its version and install script anyway. A scanner that cries wolf on half the projects in the world wouldn't help anyone, so that case is now one of its tests.
If it finds something
The order matters, and it's in the README. In short:
- Treat the machine as compromised. If an infected version was installed, its install script has already run.
- Revoke and replace credentials first, from a clean device: npm and GitHub tokens, SSH keys, cloud keys, and anything in
.envfiles. Clean up before that and the worm can come straight back. - Delete what it made on GitHub, after saving what you need: the stolen-secrets repo, the workflow, and any Actions artifact named
format-results.txt, which holds your secrets in readable form. - Remove the planted files from every branch, and don't open those branches in VS Code or start Claude Code in them until you have.
- Pin clean versions and reinstall with scripts turned off:
npm ci --ignore-scripts.
Making it harder next time
- Turn install scripts off by default:
npm config set ignore-scripts true. A few packages need theirs; pnpm 10 already blocks them unless you allow a package by name. - Wait a few days before taking new versions, so someone else finds the bad release first. pnpm's
minimumReleaseAgedoes this for you. - Commit your lockfile and install with
npm ci, so a new version can't arrive without a commit you can see. - Review
.vscode/,.claude/and.github/workflows/like code. All three hold commands your tools will run. - Keep tokens short-lived and limited to one job, and publish packages through npm's trusted publishing rather than a long-lived token on your laptop.
What the scanner can't tell you
It knows what's been published so far: the versions on the lists from Datadog and Unit 42, and the file names, hashes and markers in their reports. A new wave with new names gets past it until someone adds them. It reads the latest commit of each branch, not the whole history. And for the earlier Shai-Hulud waves, it checks their files and repo descriptions, not their full package lists.
So "nothing found" means nothing known found. On my account that's the best answer available, and better than not checking.
Check your own
shai-hulud-scan needs Python 3.10 or later and nothing else, and it changes nothing. Point it at your projects folder:
python3 scan.py ~/code Add --github and a read-only token to check every branch of every repo on your account. It exits with an error when it finds something, so it can also fail a CI job. If you know of a version or file it misses, open an issue with the source.
If you get stuck, email me.
Sources: CSA advisory AD-2026-009, Datadog Security Labs, Unit 42.

