DEV Community

Cover image for The npm Supply Chain Worm Wasn't a Laptop Problem. It Was a CI Problem.
Phil Rentier Digital
Phil Rentier Digital

Posted on • Originally published at rentierdigital.xyz

The npm Supply Chain Worm Wasn't a Laptop Problem. It Was a CI Problem.

A worm hit npm this week. Not a metaphor, an actual worm: stolen credentials used to republish more packages, over and over, feeding on itself. keyv, cacheable, flat-cache, file-entry-cache, the stuff sitting 3 dependency layers deep, the packages you never explicitly choose. They just show up.

It doesn't touch your code. It touches the moment you type install.

So I did what any dev does 2 seconds from mild panic: I grepped my own laptop. Not because I maintain anything popular, just because "a package I've never heard of got poisoned" is close enough to my daily reality to lose 10 minutes over. The real question wasn't "am I infected." It was whether staying clean meant I'd done something right, or whether I'd gotten lucky without noticing.

3 Paths That Would Have Ruined My Week

Security researchers published exact IoC (indicator of compromise) paths for this campaign. A LaunchAgent, a shell script, a config folder. If any of them exist on your Mac, the worm is running, quietly renewing its own foothold and waiting for you to revoke a token so it can react.

~/Library/LaunchAgents/com.user.gh-token-monitor.plist
~/.local/bin/gh-token-monitor.sh
~/.config/gh-token-monitor/
Enter fullscreen mode Exit fullscreen mode

None of it was there. No suspicious LaunchAgent, no script, no folder. I also checked the actual versions of the poisoned packages sitting in my projects. Older, clean releases across the board, nothing from the bad list.

Worth saying plainly: absence of these 3 paths means no infection observed on this machine, on this day. It doesn't mean every worktree, every cache, every CI runner is clean too. A scan is a photo, not a warranty.

The Bug Was Never In The Code

The part that should bother you more than the worm itself: for the initial batch of poisoned packages, the actual library code, the part you'd read in a diff, was often identical to the legitimate release. The malicious part lived in package.json and in a couple of new files. A preinstall hook, a setup.mjs, sometimes a second-stage loader with an oddly mathematical name.

Turns out "looks fine, runs fine" has been lying to us since forever. It's the same lie as "works on my machine," just with better production values this time. The library ships clean. The install pipeline doesn't.

The install script downloads a runtime, harvests credentials (npm tokens, GitHub tokens, cloud keys, anything in .env), exfiltrates them, and republishes itself into any package the stolen tokens can reach. Then it moves on. The code you imported was never the attack surface. The moment you ran install was.

Bun Says No By Default (Mostly)

TITLE "Install Time Is Attack Time" + subtitle "what runs before your code does". Metaphor: a factory conveyor belt with a locked gate at the entrance, most crates rolling straight through, a few flagged crates stopped and inspected by a robot guard. Style: engineer blueprint, blue-line technical drawing on cream paper, thin white linework, stamped icons. Palette: navy #14213D, amber #FCA311, muted red #C1121F, cream #FFF8E7, black #111111. Content: left side labeled UNTRUSTED PACKAGE, arrow through a gate labeled DEFAULT TRUST LIST 368 ENTRIES, most crates pass through to NO SCRIPT EXECUTED, a few flagged crates route to RUN INSTALL SCRIPT. Highlight: the gate glows amber, flagged crates outlined in muted red. Footer: copyright rentierdigital.xyz. NOT flat corporate vector, NOT minimalist tech aesthetic.


Package Installation Security: Default Trust vs Script Execution

I run Bun as my package manager, and Bun ships with an opinionated default: it does not execute lifecycle scripts, preinstall or postinstall, for a dependency unless that package sits on an internal allowlist of roughly 368 packages, mostly native modules that genuinely need a build step. Everything else installs inert. No script, no problem.

I checked. keyv, cacheable, flat-cache, file-entry-cache: none of them are on that list. Which means that even in the counterfactual world where I'd pulled a poisoned version, the worm's entry point would never have fired. The same allowlist that protects me by default is also something you can quietly turn off, one trust command away, for a package that genuinely needed it once and never got revisited.

I think that's the actual reason my laptop is clean, though I'll admit I can't fully rule out that timing just worked in my favor too. Maybe I hadn't run an install in the exact window that mattered. Security postmortems love a tidy causal story, and I'm wary of handing myself one.

(Small tangent: digging through this, I found an .npmrc from a project I'd forgotten I ever built, some scraper thing from years ago, sitting quietly in a folder I hadn't opened since. Nothing sensitive in it. Just a reminder that your filesystem remembers projects longer than you do.)

Clean Isn't The Same As Safe

A clean scan tells you what didn't happen. It says nothing about what could have. Bun being default-secure on lifecycle scripts is a property of the tool, not a decision I made on purpose that day. I benefited from an architecture choice someone else shipped, and calling that "my security posture" would be generous to myself.

Convenient security is still an accident, not a policy.

The instinct after a clean scan is to relax. The better instinct is to ask what made it clean, and whether that thing is a rule you can rely on or a default you got handed. Old, boring, widely trusted infrastructure is exactly where attackers go looking, precisely because attention moves on and nobody circles back to audit the boring stuff twice. I wrote before about an old trusted file hiding a rootkit, and this campaign is the same shape wearing different clothes: trust accumulated over years, spent in a single afternoon.

Modern JavaScript projects run on a trust chain nobody actually reads end to end. You audit your own code, maybe you audit your direct dependencies if you're diligent, but past that it's turtles, hundreds of packages 3 and 4 levels deep, each one able to run arbitrary code at install time by default on most tooling, each one maintained by someone whose GitHub password you'll never know the strength of. That's not a flaw somebody forgot to fix. That's the design. It's been the design since npm decided lifecycle scripts were a feature worth shipping. Trust is the whole foundation, and trust scales terribly.

The Laptop Was Never The Real Target

What I actually sat with for a while: my own laptop being clean tells me exactly one thing, and that thing is that my own laptop is clean. It says nothing about the machines that matter more.

My CI pipelines run on self-hosted runners, real hardware I own, not disposable sandboxes that vanish after every job. If a worm like this one ever executed there, it wouldn't be sandboxed. It would be sitting on production infrastructure with production credentials, patiently waiting to react the moment anyone tried to clean it up. That dead man's switch behavior, fire on revocation, is straight out of a 90s hacker movie plot device, except this one ships in a real setup script and runs on somebody's actual server right now. Possibly, this week, yours 😬

Today the door's shut. My CI installs use frozen lockfiles and package managers that don't run scripts by default, same protection as the laptop, extended to production. I found that out the hard way, on the last time my own VPS blindsided me, self-hosted infrastructure surprises you in ways a managed sandbox never will. It only takes a stray dependency update or a careless trust entry to open that door again.


Right now, my lockfiles are frozen, my install scripts don't run, and my CI still lives on hardware I control instead of a sandbox that resets itself every job. That's the whole safety net, and it's thinner than "I passed a scan" makes it feel. Keeping it tight is a decision I have to keep making. It was never going to be a decision I got to make once. C'est la vie.

Sources

  • Aikido Security, "keyv and friends compromised in npm supply chain attack"
  • Socket, "Popular npm packages in the keyv and cacheable namespaces compromised in active supply chain attack"

This post may contain affiliate links. If you click them, I might earn a small commission (costs you nothing, and helps me keep shipping quality articles every day for your reading pleasure.

Top comments (0)