When the attacker controls the publisher, everything the publisher signs is the attacker's.
On October 19, according to OVHcloud, someone used a developer's GitHub account to swap the OVHcloud CLI release binaries for v0.11.0 through v0.15.0 with an infostealer. For v0.15.0 they also rewrote checksums.txt, so if you did the responsible thing and checked your download against the release page, it matched. The swap was live for 94 minutes, and it was caught because a customer's CI noticed that the SHA-256 of the Linux arm64 archive had changed.
Two days later, LiteLLM 1.107.0 went out to PyPI and to Docker Hub and GHCR, including latest. Stolen developer GitHub credentials were used to point the release workflow at a lookalike repo, Berri-AI/litellm, and a second developer's credential passed the production approval gate. The project's own report says the official v1.107.0 source tag doesn't contain the malicious files. The real pipeline built, signed and shipped the attacker's code. And the payload sits in a .pth file, so, as Microsoft's write-up puts it, it runs at Python startup "without requiring the application to actively use LiteLLM gateway functionality."
Same shape, different month: in August the keyv packages went out from a hijacked maintainer GitHub account, with valid provenance signed by GitHub Actions. And on October 5 a modified SubQuery publish workflow swapped the @subql/common sources for a tarball from an outside domain right before publishing, with, in StepSecurity's words, "no hash or signature verification of that external archive in the added step."
The Publisher Is the Attack Surface
Provenance, trusted publishing and signed releases are good. They answer where an artifact was built and who published it. Trusted publishing with token publishing turned off kills a whole class of attacks -- a stolen npm token used from some random laptop, for example.
But look at what these attacks did instead. They didn't go around the publisher, they became the publisher. They took the accounts and the workflows, and from there every publisher-side signal said yes: the checksums file, the provenance, the signature, the latest tag.
I wrote about this in 2021 when we released Preflight: the chain of trust behind a curl | sh has too many links to verify, so move it to the one link you control, a digest that is "produced and maintained by you, and only you." Five years later, that's still the check an attacker with the maintainer's GitHub account can't touch. And there are two of them you can own: the hash, and the wait.
Mutable Things: Pin the Hash Yourself
GitHub release assets and container tags can change under the same name. That's how OVHcloud's binaries were swapped in place, and how a floating latest pointed at a bad LiteLLM image. For these, record the hash at the time you vetted the thing, in your own repo, and check against that:
# expected.sha256 holds the bare hex digest, recorded by you when you vetted this release
|
If someone replaces the archive, this fails and your pipeline stops, regardless of what the release page says. This is exactly what Preflight does for scripts and executables (preflight run sha256=...), and it's the kind of check that caught the OVHcloud swap.
For images, the same idea is a digest instead of a tag: deploy image@sha256:..., not image:latest. Microsoft's advice after LiteLLM says the same thing -- hash-pinned lock files, and container digests instead of floating tags.
For packages, your lockfile already does it. npm ci installs exactly what package-lock.json says and checks each tarball against the integrity hash stored there. On the Python side:
The first line resolves and writes a hash next to every package; the second refuses to install anything that doesn't match.
New Versions: Wait a Day
Hash pinning protects you from a version that changes. LiteLLM 1.107.0 and the keyv releases didn't change -- they were new versions. Bump your lockfile at the wrong hour and you'll pin the malware, with a perfectly matching hash.
The fix for that is old advice: time proven over just released. I wrote that in 2015 in Technological Inflation about adopting frameworks, and it applies to patch releases just as well. The LiteLLM wheels were on PyPI from 11:39 to 13:23 UTC, and the bad Docker Hub tags were gone by 14:20. A one-day delay on new versions sits that out.
Package managers support this now. In pnpm (10.16 and up), in pnpm-workspace.yaml:
minimumReleaseAge: 1440 # minutes; only install versions at least a day old
The pnpm docs list 1440 as the default from v11. In uv, in pyproject.toml:
[]
= "1 day"
uv accepts friendly durations like 24 hours or 1 week, as well as timestamps. Pick a longer window if you like; the point is that the delay is yours, not the publisher's.
The Tradeoff
A cooldown also delays the fix you actually want. When a real security patch lands, you'll need to let it through on purpose -- pnpm has minimumReleaseAgeExclude for that. That's fine. An exception you made deliberately, for one package, is the whole point: someone looked.
And yes, if everyone waits a day, someone still has to install first. That's what the scanners are for. Socket had malware alerts on the LiteLLM wheels at 11:46 UTC, seven minutes after they were published. Your day of waiting is what lets that work reach you before the package does.
None of this replaces what publishers are doing: immutable releases, provenance, signing. OVHcloud shipped v0.16.0 as an immutable release with a signed tag, which is the right call. But that protects their side. On your side, the only hash that counts is the one you recorded.