Featured image of post Nobody's coming to clean your supply chain

Nobody's coming to clean your supply chain

Pick a week in May 2026 and there’s a supply-chain attack in it. On the 11th someone owned TanStack’s CI and pushed 84 poisoned package versions in six minutes. On the 14th, three malicious versions of node-ipc, a library with ten million weekly downloads, shipped an identical credential-stealer. Days later it was @antv, cascading down into a charting library a million projects depend on. Each one runs its payload the moment you install it, then quietly tries to publish itself from your machine.

You’ve heard this story so many times the outrage has worn smooth. So let me point at the one detail that should still make you sit up: the TanStack packages carried valid signing provenance. Real attestation, pointing at the real pipeline. The seal was genuine. The contents were poison.

A signature proves the sender, not the contents

I’ve spent a fair while building integrity and signing into my own tools, so this one stings a little. Signing is a trust mechanism, and a good one. It’s how I prove a binary you downloaded was built and published by me and nobody else, and in a world with this many ways to be impersonated, that matters more than ever.

But TanStack shows the limit in neon. If the pipeline doing the signing is itself compromised, the signature is still perfectly valid. It just now certifies a lie. Provenance answers “did this come from where it claims?” It does not answer “is what’s inside safe?”, and we have spent a few years quietly letting people treat those as the same question.

They aren’t. A signature is a promise about the sender. The thing we actually need is a promise about the contents: that whoever signed has done the diligence, the testing, the vetting, to vouch for what’s in the tin. A signature without that behind it isn’t a safety certificate. It’s a tamper-proof seal on a poisoned jar.

It was never just npm

It’s tempting to file all this under “npm being npm”. Resist it, because it’s a category error. The thing that makes these attacks work, a stranger’s code running on your machine as a side effect of installing or building, is not an npm bug. It’s a near-universal design choice.

EcosystemUntrusted code on install/build?Mechanism
npm (JS)Yes, at install (dependencies too)pre/postinstall scripts
PyPI (Python)sdist yes, wheel nosetup.py; wheels forbid hooks
RubyGemsYes, at installnative-extension build (extconf.rb)
cargo (Rust)Yes, at buildbuild.rs and proc-macros
Composer (PHP)Dependencies: noonly the root project’s scripts run, by design
Maven/Gradle (JVM)Yes, at buildbuild scripts and plugins
NuGet (.NET)Modern: noinstall.ps1, legacy format only
Go (modules)Nono install or build hooks

(Lifecycle hooks across ecosystems are catalogued at ecosyste.ms if you want the receipts.)

Read that and the lesson isn’t “npm is uniquely bad”, it’s “this was a choice, and several ecosystems chose differently”. Go runs no install or build hooks at all. PHP’s Composer flatly refuses to run a dependency’s scripts, only your own project’s. Python’s wheel format forbids install hooks. The hook was never inevitable.

And yes, that includes my own back yard. cargo’s build.rs is the same gun fired at build time instead of install time, and the TrapDoor campaign used exactly that to rifle through keystores on crates.io this year. Rust isn’t safe here. It’s a smaller, better-policed target, which is a different thing, and I’d rather say so than pretend one of my favourite languages is above it.

No registry can hand you a clean package

Here’s the uncomfortable core. Not one of these registries can guarantee the package you pull is clean. They can sign it, scan it, attest its origin and mandate 2FA on maintainers, and they should do all of that. But none of it is a guarantee, because the failure modes are endless and attackers keep finding new ones. A maintainer account gets phished. A CI token leaks. A trusted contributor turns. A dependency four levels down quietly changes hands.

So the onus lands, and will keep landing for a good while yet, on the consuming engineer. That isn’t a comfortable answer or a clever one. It’s the true one.

And it’s a genuinely rotten spot to stand in, because the advice contradicts itself. Patch slowly and you’re scolded for running known-vulnerable dependencies. Patch the instant a release drops and you’ve skipped the bedding-in that might have caught a poisoned one. There’s no setting on that dial that’s safe, only trade-offs you have to actually think about. Add CI that leaks credentials it never needed, and a dependency tree thousands of strangers deep, and you can see why there’s no single villain to blame and no single switch to flip.

The boring discipline that actually helps

What’s left isn’t heroic, it’s hygiene, and it’s the unglamorous stuff I keep banging on about. Pin your CI actions to commit SHAs so a moved tag can’t swap code under you. Commit your lockfiles. Run the auditors, cargo-audit, pip-audit, govulncheck, npm audit, or Google’s cross-ecosystem OSV-Scanner, on every build. Gate the dependency tree and give every exception an expiry date so “we’ll deal with it later” can’t quietly become “never”. Keep the tree small: every crate you don’t add is a stranger you don’t have to trust.

None of that is a solution. All of it is diligence, and diligence is the only thing that was ever going to stand behind the signature. When I sign a release, the cryptography is the easy part. The promise underneath it, that I pinned, locked, audited, vetted and tested before I put my name on it, is the part worth anything. That’s the contract. The signature is just how I countersign it.

The encouraging note is that the structural defences exist and they work. Go’s checksum database and its refusal to run hooks. Composer declining to trust a dependency’s scripts. Python’s wheels. cargo-vet and cargo-deny giving you somewhere to record human judgement at scale. More ecosystems should steal these shamelessly, because a registry that makes the safe path the default does the working engineer a far bigger favour than one that leaves it all to discipline.

The same shape, a third time

If this feels familiar, it should. I wrote recently about a bug bounty that collapsed because the cost of slop was deferred, and about a junior pipeline being cut because the bill lands years later. Supply-chain security is the same shape a third time. The convenience is now, the catastrophe is later, and the only thing standing in the gap is an engineer paying attention, doing the dull work, refusing to be rushed into trusting something they haven’t checked.

There is no clean package waiting to be found, no registry about to solve this for us, no signature that means “safe” all on its own. There’s the diligence you do before you put your name to something, and the judgement to know when an install is asking you to trust more than you should. For a good while yet, that is the whole job. Boring, unfashionable, and the only thing that works.

Built with Hugo
Theme Stack designed by Jimmy