Shai-Hulud is the worm we designed for
In September the npm ecosystem had its worst month on record. A self-propagating worm the researchers named Shai-Hulud tore through roughly 800 packages, harvesting cloud tokens, planting a secret scanner, and using each set of stolen npm credentials to publish the next generation. Days earlier, a phished maintainer account pushed trojanized versions of chalk and debug and sixteen other packages with a combined 2.6 billion weekly downloads. If you ship JavaScript, some machine you control almost certainly pulled a poisoned tarball this month.
We wrote about the IronWorm class earlier this year and made a specific architectural bet. Shai-Hulud is that bet's stress test. So here, plainly, is what our no-code-on-your-runner architecture does when a worm like this is live, and, just as importantly, the things it doesn't touch.
The mechanism hasn't changed. The scale has.
The propagation loop is the same one every modern npm worm runs:
- A poisoned version lands in a lockfile. Something runs
npm install. - A lifecycle hook (
preinstall,postinstall) fires the payload the instant the package is installed. - The payload reads every secret bound to that environment, cloud tokens, CI secrets, npm publish credentials, and exfiltrates them.
- With a stolen npm token it republishes trojanized versions of the victim's own packages, and the loop repeats.
Shai-Hulud added two nasty refinements. It bundled a secret-scanning tool (TruffleHog) to find the credentials rather than guess at env-var names, and it wrote what it found to public repositories it created under the compromised account. The blast radius wasn't “the attacker has your secrets”; it was “your secrets are now indexed on a public git host.”
Every link in that chain after the first install is automatic. The one human-gated moment in the whole path is where a new lockfile entry gets merged. That is the moment we are built around.
What Pwnkemon actually does here
Add the Pwnkemon Scan Action to a workflow and it runs as a PR gate. When a pull request touches your lockfile, a bumped version, a new transitive dependency, the scan runs against that exact commit and can fail the build before the change reaches main. For a Shai-Hulud-class campaign, three things follow.
1. Known-bad versions get blocked at the gate. Within hours of the worm going public, the compromised package-and-version list was enumerated and published. A dependency scan flags any PR that pins to one of those versions, triages it, and blocks the merge. The poisoned version never reaches your default branch, your deploy step, or the next engineer who pulls.
2. The daily self-scan catches what you already merged. Worms don't wait for your PR cadence. A version you merged clean on Monday can be retroactively malicious when the advisory lands on Tuesday night. The same daily scan that caught seventeen overnight CVEs six hours before our launch tells you a dependency already in your tree is now known-bad, while you sleep, not at your next commit.
3. Git-history secret scanning tells you the blast radius. The entire point of this worm is credential theft. If any of the secrets it hunts ever touched a commit, and in our experience they have, full-history secret scanning tells you exactly what's exposed and what to rotate first. When you're doing incident response at 2am, the difference between “rotate everything” and “rotate these four keys in this order” is the difference between a bad night and a bad week.
The part most scanners can't say
Here's the architectural point Shai-Hulud makes for us better than we could:
A worm that steals CI secrets cannot steal them from a step that has none.
The standard security-vendor GitHub Action downloads a scanner binary onto your runner and executes it next to your deploy keys, with the same ambient permissions every other step in the job holds. For your own build step, fine. For third-party scanner code during an active worm campaign that targets exactly those secrets, that's a process running attacker-adjacent code inside the environment the attacker is trying to reach.
Pwnkemon's Action makes one HTTPS call, polls, prints a comment, and exits. The scan itself, cloning the repo, parsing the lockfile, running the toolchain, happens in an ephemeral container on infrastructure we control, destroyed the moment it finishes. Whatever secrets are loaded into your runner when our step runs are irrelevant to us, because our code never runs on your runner to read them. The gate that blocks the worm doesn't become the worm's next entry point.
What this does not do, honestly
A dependency scanner is not a whole security program, and we're not going to pretend otherwise.
- It won't stop a developer running
npm installon a laptop. If someone pulls a poisoned package locally before an advisory exists, the lifecycle hook fires on their machine. The PR gate protects your shared branch and pipeline; it is not endpoint protection. - It can't flag a zero-hour version nobody has classified yet. In the window between publication and disclosure, a version-match gate has nothing to match. That's what the daily self-scan is for, it re-checks what you already have the moment the indicator list lands.
- It doesn't rotate your keys for you. It tells you which ones are exposed and in what priority. Cycling them is still your job, and the faster you start, the smaller the window.
What we do is close the human-gated gap, the merge, and refuse to be an execution vector ourselves. That's a meaningful chunk of this attack's path. It is not all of your defense, and anyone selling you a scanner as all of your defense is selling you the severity label, not the fix.
The uncomfortable takeaway
The thing worth sitting with is that none of these packages had a CVE. They weren't vulnerable in the classic sense, they were legitimate packages that turned hostile for a version or two. Most AppSec programs are built on CVE enrichment, and CVE enrichment arrives too late to matter here and can't see a compromised-but-legitimate package at all. We wrote a whole separate post on why that gap is the real 2026 story.
The defense is symmetric with the attack: don't trust the label, check the thing. Pin to the commit, not the branch. Read what runs in your CI, our Action's source is open and about 250 lines. Scan the history, not just the head. And don't run untrusted code next to your secrets if you can run it somewhere else instead.
See pricing for plans, or the Action docs to add the gate to a workflow before the next campaign.