In September 2025 a worm called Shai-Hulud went through npm and compromised more than 500 packages. Its install scripts harvested GitHub tokens and AWS, Google Cloud, and Azure keys from whatever machine ran the install. Then it used the developer's own npm credentials to publish poisoned versions of their other packages, which is how it spread. A second wave, Sha1-Hulud, followed later that year, and GitHub said campaigns like it drove a 69% increase in published malware advisories over 2024. Anyone who ran npm install at the wrong moment, on a laptop or a CI runner, handed over their credentials.
It isn't just npm. In March 2026 attackers used compromised credentials to publish a malicious Trivy v0.69.4, and it went out through GHCR, Docker Hub, deb and rpm packages, and Aqua's own Homebrew tap. The malicious release was live for about three hours. The pattern is usually the same: a trusted publisher's account gets taken over, a poisoned release goes out, and it gets caught and pulled within hours or days.
The bad versions don't live long. If you never install anything that was published in the last two weeks, you skip almost the whole window.
The recommendation: a release cooldown
A cooldown (often called a "minimum release age") means your package manager refuses to install any version newer than N days. I use 14. That's long enough for researchers, scanners, and the registry to catch and pull a malicious release, and short enough that I'm not running stale software.
Some package managers have this built in now. npm has min-release-age, pnpm has minimumReleaseAge, and uv and Deno have their own versions. Others, like Bundler and Homebrew, don't, and you have to build it yourself.
What a cooldown needs to actually work
Turning on a setting is the easy part. These are the rules I ended up with across every project and machine:
- Cover everything that resolves versions. That's your app dependencies, the toolchain (Node, Ruby, Bundler itself), system packages, and anything that resolves at deploy time instead of from a lockfile.
- Commit lockfiles and install exactly.
npm ci,bundle installwith a committedGemfile.lock,--frozen-lockfile. Then a new release can only arrive when someone deliberately updates, and that update is when the cooldown applies. - Don't run code at install time. Install scripts were how Shai-Hulud spread. Block them by default and allow the few that actually need to run.
- Allow an override. Sometimes a security fix can't wait two weeks. Make it a deliberate, one-package action, and note it in the commit message.
The tradeoff
A cooldown delays security fixes too. That is the main argument against it: a zero-day fix that waits 14 days protects nobody during those 14 days. The override is how I live with it. I watch for advisories on the things I depend on and skip the cooldown on purpose when one matters.
It also isn't a silver bullet. The xz backdoor was planted by a long-term maintainer and went unnoticed for about five weeks after release. A cooldown only helps against attacks that get caught quickly. Most do, which is why it's worth it, but it's one layer, not the whole defense.
How each package manager handles it
The rest of this series will look at the following package managers:
- Homebrew has no cooldown setting, and the maintainers have declined to add one. I keep the tap clones 14 days behind upstream with git,
brew.env, and a small script. - npm has
min-release-agebuilt in, plusnpm cito keep build servers on the lockfile. - Bundler has no setting either. I pin, update, unpin, and then verify every locked gem's publish date against the RubyGems API.
- Deno on Supabase Edge Functions resolves imports at deploy time with no lockfile, so I pin exact versions in the imports.