The biggest npm attack didn't touch me, and it wasn't foresight

The biggest npm attack didn't touch me, and it wasn't foresight
ContenidoContents

Yesterday someone stole the credentials of the maintainer behind keyv, a caching library with around 127 million weekly downloads, and used that access to publish poisoned versions of everything that maintainer touched.

The result, according to Microsoft’s analysis: over 400 compromised packages, and counting versions, more than thirteen hundred. Among them flat-cache and file-entry-cache, which on their own add up to more than a billion installs a month because they hang off ESLint, which hangs off almost everything. All told, on the order of two billion monthly installs affected.

The elegant and the ugly part of this attack are the same thing: the malicious versions were published with a valid signature, generated by GitHub Actions. The attacker pushed the files to the main branch and cut a release immediately. Everything an automated system looks at to decide whether a package is legitimate said yes.

Each package got two new files and one line in its package.json:

1"scripts": {
2  "preinstall": "node setup.mjs"
3}

preinstall means that runs before your install finishes. You don’t need to use the package, or import anything. Installing is enough. That script downloaded a Bun runtime, ran an obfuscated second stage, harvested credentials for npm, GitHub, AWS, Kubernetes and Vault, and used the stolen tokens to republish more packages. A worm.

I went to check my server expecting the worst

I have a Mac mini at home serving this site, a Moodle, a Jitsi and a few other things. And I have Node installed. So the first thing was to look for the specific indicators:

 1# are the compromised packages here?
 2for p in keyv cacheable flat-cache file-entry-cache cache-manager; do
 3  [ -d "node_modules/$p" ] && echo "$p PRESENT"
 4done
 5
 6# the malware files?
 7find node_modules -name "setup.mjs" -o -name "Math_Symbol.js"
 8
 9# do they show up even transitively?
10grep -c '"node_modules/flat-cache"' package-lock.json

Zero. Across the board. Not installed, not in the dependency tree, no trace of the files.

Then I looked at the dates, which is what really closes the case: my package-lock.json is from 8 January and the node_modules folder from 19 June. The attack was on 4 August. Even if I’d had those packages, I haven’t run npm install during the window when the versions were poisoned.

Why they weren’t there

Here’s the part worth telling, because there’s no merit in it.

This site has no dependencies. That’s not a figure of speech: in the entire directory Apache serves there isn’t a single node_modules folder. The homepage is hand-written HTML. The fifteen tools are HTML and JavaScript running in your browser, no libraries. The blog is generated by Hugo, which is a Go binary. There’s no bundler, no build step, nothing to install in order to publish: I write, I commit, and a hook pushes it out in seconds.

The only npm I have is for the 101 tests that run before each commit. That’s 360 packages in the lockfile and 57 MB of node_modules that never leave my machine and never reach the web server.

And now the awkward coincidence: two days ago I deleted Umami, the self-hosted analytics I had running. That was 1.5 GB of Node dependencies running permanently as a service. I didn’t remove it for security, I removed it because I wasn’t using it and wanted disk space. Forty-eight hours later, the ecosystem it hung off became a minefield.

That wasn’t foresight. It was luck, and before luck, laziness.

The boring things that actually protect you

If anything really saved me, it wasn’t a clever security decision. It was three deeply unglamorous things:

Not reinstalling. A lockfile frozen since January isn’t a best practice I’d advocate; I simply had no reason to touch it. But the effect is real: the poisoned versions were available for a few hours, and anyone who didn’t install anything during those hours never found out.

No build step in production. Every tool you add to the chain between your text and the served HTML is one more door. I have one: Hugo. And it doesn’t come from npm.

What runs on the server doesn’t come from a public registry. Apache, PHP and PostgreSQL come from Homebrew and from projects with decades behind them and slow release processes. Slow is a compliment here.

Where I am vulnerable, so I don’t fool myself

It would be very comfortable to end here with the moral that simpler is safer. But my exposure isn’t zero:

  • I have npm on the machine. If tomorrow I install anything and the lottery hits, the preinstall runs as my user, with my tokens and my keys sitting right there.
  • Moodle has its own dependencies, with its own build chain for stylesheets: grunt, less, shifter. That one isn’t my call.
  • My npm is old. Microsoft’s recommendation is to move to npm 12, which ships min-release-age so you don’t install versions published minutes ago — precisely this attack’s vector. I went to do it and couldn’t: npm 12 requires Node 22, 24 or 26, and I’m on 23. Upgrading Node would force me to touch Moodle’s build tooling, so for now I’m staying on the old npm and on not installing anything blindly. Debt noted.

What I did do was flush the npm cache, where fourteen entries with metadata for the affected packages were still sitting. Not infected code, but cleaning costs nothing.

What I take away

The takeaway isn’t “use fewer dependencies”. It’s more uncomfortable than that: almost nobody affected today chose to install flat-cache either. It came with ESLint, which came with the project template. The chain of decisions that ends with you running a stranger’s code on your laptop almost always starts with someone running create-something three years ago.

I’m not smarter. I just built this site the way sites used to be built, because for what it does I didn’t need more, and it turns out attack surface is a consequence of the size of what you build, not of how careful you are.

That said: tomorrow, when I do have to install something, I’ll have exactly the same problems as everyone else.

CompartirShare