I spent one June afternoon building the careful version of a dependency auto-updater. A weekly cron job that would pull the newest release of a third-party widget, wait out a fourteen-day soak, fetch the file from two CDNs and byte-compare them, and only then vendor it into my site. Registry-pinned major. An anomaly backstop. I was fairly pleased with it.
I deleted it the same evening. The git log is unflattering: first commit 16:00, two hardening passes, gone by 18:28.
That was months after npm shipped min-release-age, with cooldowns fast becoming the standard advice against supply-chain attacks. Deleting a soak gate in the middle of all that needs an argument, so here it is.
Cooldowns are good, and I still use one
The case is strong. Most supply-chain compromises get caught fast: Datadog’s write-up found that even a half-day delay would have covered the incidents they examined, and a week is the common recommendation. npm shipped min-release-age in 11.10.0, pnpm has minimumReleaseAge, and the idea went from a per-team habit to something your package manager can do for you (npm’s, note, ships off by default; pnpm 11 has since turned it on out of the box).
It costs a config line:
# .npmrc — npm 11.10.0+. The unit is DAYS.
min-release-age=7
The unit is the part to get right, because each manager picked a different one:
# pnpm-workspace.yaml — minutes
minimumReleaseAge: 4320 # 3 days
# .yarnrc.yml — duration string
npmMinimalAgeGate: "3d"
My own rule says to let a release soak a fortnight before bumping. The pin I actually shipped that afternoon had soaked four days. Rules are easier to write than to follow. What I stopped doing was letting a machine act on the rule while I was asleep.
The two things that made me delete the job
An unattended pull has to trust the registry and the CDN at 3am. The job had other checks — a pinned major, a vetted floor, the cross-CDN compare — but against a malicious release its one real argument was “this version has been public for fourteen days, so somebody would have noticed by now.” That reasoning holds right up until the thing you pull is not the thing that was reviewed. The 2025–26 campaigns are worth reading on this: the Shai-Hulud waves of late 2025 moved through hundreds of packages by stealing publish tokens and republishing under legitimate maintainer accounts, and a June 2026 descendant weaponised binding.gyp so that node-gyp executed attacker code during a plain npm install, with no explicit preinstall or postinstall entry anywhere: npm synthesised that hook itself when it saw the file (behaviour as of June 2026). A fortnight of quiet is evidence about a package. My job was making a decision about bytes.
A soak gate reasons about publish time, and publish time is metadata. My check was a subtraction: now minus the release timestamp, is it more than fourteen days. That number arrives from the same system I am trying to be careful about. I do not have a documented case of a back-dated publish being used against a cooldown, and I am not going to claim one. But I noticed I had built a gate whose input I could not verify, and I could not tell you what it would take to fool it. That is the sort of thing worth deleting on principle rather than after an incident.
I want to be careful here, because a cooldown is not the whole control, and I will not let a robot act on one while nobody is watching.
What replaced it
Two pins and an alarm. The widget is committed to the repo and pinned. I’ll confess: the review of this very post caught the file sitting untracked behind a gitignore rule, deployable only from my laptop. It is in git now. The check:
const FE_ALTCHA_WIDGET_FILE = __DIR__ . '/funnyenough-altcha/altcha.min.js';
const FE_ALTCHA_WIDGET_VERSION = '3.1.0';
const FE_ALTCHA_WIDGET_SHA256 = 'd8c62c59f5a57a5428a262189b3a62139719f4fc291e35e510f99d85e1390b47'; // 113326 bytes
Bumping it is manual now — three steps, a few times a year:
- Check the registry and pick a stable release that has soaked about a fortnight.
- Fetch the file from two independent CDNs and confirm they are byte-identical. If jsDelivr and unpkg disagree, stop.
- Replace the vendored file, then set both constants to match.
Step two is the one people skip. Two mirrors agreeing does not prove the release is clean (they both pull from the same registry), but it does catch a poisoned mirror, and it costs one extra curl.
Then the part that the cron job never had. A passive check that the bytes on disk still match the pin:
add_action('admin_notices', function () {
if (!current_user_can('manage_options')) {
return;
}
if (!is_readable(FE_ALTCHA_WIDGET_FILE)) {
echo '<div class="notice notice-error"><p><strong>Widget file missing.</strong></p></div>';
return;
}
$have = hash_file('sha256', FE_ALTCHA_WIDGET_FILE);
if (!is_string($have)) {
return; // hash_file() is string|false — false into hash_equals() is a TypeError on PHP 8
}
if (!hash_equals(FE_ALTCHA_WIDGET_SHA256, $have)) {
echo '<div class="notice notice-warning"><p><strong>Widget checksum mismatch.</strong> '
. 'The vendored file no longer matches pinned v' . esc_html(FE_ALTCHA_WIDGET_VERSION)
. '. If you bumped it on purpose, update the constant; otherwise investigate.</p></div>';
}
});
No network, no auto-pull, no opinion about what to do. It reads a file and compares a hash. (hash_equals over === is habit more than necessity here, since the digest is public and there is no secret for timing to leak. The habit costs nothing.)
This is the half that a cooldown structurally cannot do. A cooldown is a decision you make once, at the moment you pull. The file then sits on disk for months, through deploys, backup restores, a panicked FTP session, and whatever else. The tripwire is the only thing in my setup that has an opinion about that file tomorrow.
Be clear about what it is, though: an alarm, not a lock. It fires where I look, which is wp-admin, and a visitor could be served tampered bytes for days before I next look. Someone who can rewrite the file can usually rewrite the constant sitting next to it. And the hash proves consistency, not provenance. Even the two-CDN comparison only argues the delivery was consistent — both mirrors serve the same registry, so nothing in my setup proves these bytes are what ALTCHA’s maintainer actually built. The pin freezes the file that passed the checks I do have.
The bill
I am slower to take a security fix now. The cron job, weekly and behind a fourteen-day soak, would have delivered a patch in two to three weeks without me; the manual version waits until I sit down to do it, which can be faster or much slower. That is a genuine cost and I would not accept it on a dependency with a large attack surface or a fast-moving security history. For one vendored front-end file that renders a proof-of-work checkbox, I will take the trade.
It also does not scale. This works because I am pinning one file by hand. For an application dependency tree, the answer is the ordinary one: commit the lockfile, install with npm ci, turn on a cooldown, and let the tooling carry it. I am describing a technique for the handful of files you vendor directly, not a philosophy of dependency management.
And the tripwire only fires where someone looks. Mine is a wp-admin notice, which means it warns me, on a page I visit weekly. If that mattered more, it would belong in a health check or CI rather than behind a login.
What to do on Monday
Turn on a cooldown, if you have not. That is the highest-value line of config in this whole post and it takes a minute. Two caveats that belong next to it: the age gate applies when versions are resolved, so npm ci will still faithfully install whatever young version your lockfile already pinned — the cooldown earns its keep at update time; and when a real security patch lands inside the window, take it through a reviewed exception rather than by quietly switching the gate off.
Then find the files in your repo that you vendored by hand — the widget, the polyfill, the font, the one CSS file someone pasted in during a deadline. For each one, write down the version and the hash of what you have:
shasum -a 256 path/to/vendored.min.js # sha256sum on most Linux boxes
Put that hash next to the file in code, and add something that compares it. CI, a start-up assertion, or an admin notice all work. What matters is that a machine, not your memory, is the thing that notices when those bytes stop being the bytes you checked.
The cooldown guards the moment you pull. The tripwire is what notices afterwards — provided somebody still looks.
Further reading
- Dependency cooldowns: the case in a post-axios world — Datadog Security Labs; their examined incidents were caught within hours, and they land on a week as the sane default.
- Shai-Hulud: a self-propagating worm in the npm ecosystem — Unit 42.
- The node-gyp compromise — Snyk, on the
binding.gypvector: execution with no explicit script entry inpackage.json. - ChainDrop: anatomy of a self-propagating worm — Microsoft Security, August 2026.
- cooldowns.dev — the per-package-manager configuration, in one place.
If you liked the shape of this, Receipts for Prompts is the same idea pointed at text instead of a dependency, and I Fixed My Agent’s Guardrails is what happened when I audited the guards I had already written.
Comments & Reactions
Got a thought, a war story, or a “well, actually”? Sign in with GitHub and jump in.
Loading comments…