npm dependency cooldown policy: what install-script blocking missed
npm 12 blocks install scripts by default, but the October 2026 @subql/common compromise also ran at import time. A release-age cooldown is the control that catches what script blocking cannot.
By VVV Ops ·
On 5 October 2026 a malicious release of @subql/common reached npm. The advisory for it tells anyone who installed the package to treat the machine as fully compromised and rotate every credential on it, preferably from a different computer. Known-vulnerability scanning would not have saved you, because on the day it shipped there was nothing yet to know. An npm dependency cooldown policy would have. It refuses any version published in the last few days, so your builds keep resolving last week's release while scanners and registry staff deal with today's. pnpm already turns this on by default. npm does not, and most teams we audit have never heard of the setting.
Why blocking install scripts left the hole open
npm 12 shipped on 8 July 2026 with a real change to its threat model. Per the release notes, "Dependency lifecycle scripts are now blocked by default unless allowed by the root package's allowScripts policy", and allow-git and allow-remote both default to none. You opt scripts back in with npm install-scripts approve and then npm rebuild. That closes the oldest and loudest npm attack path, the postinstall hook that runs the moment you type npm install.
It does not close the one that matters now. Look again at what @subql/common actually did. The advisory records a postinstall hook pointing at manifest-cache.js, which is the part npm 12 would block. It also records that ManifestCacheReader.warm() spawns a detached subprocess that re-enters the same decode-and-execute branch, and that the package's public API re-exports the affected module. Blocking the install script removes one of two triggers. Importing the package still runs the payload, and you import packages for a living.
This is the part teams get wrong. Install-script blocking is a control on installation. Nothing about it constrains code that runs when your application calls a function. A maintainer whose publishing token has been stolen can ship a patch release that behaves perfectly at install time and executes on first require, and every allowlist you have configured will wave it through.
What a cooldown window actually buys you
A cooldown does not try to decide whether a package is malicious. It buys time, and time is the thing these campaigns do not have. The OWASP DevSecOps Guideline puts the reasoning plainly: recent registry compromises "were detected and pulled within hours or days, but not before automated updates installed them." Delay the install past that detection window and somebody else's scanner finds the problem on your behalf.
The scale argument is not subtle either. Singapore's CSA advisory on the Shai-Hulud campaign, issued 6 August 2026, counts "over 1,300 package versions" compromised across packages with "a combined 2 billion monthly downloads", including keyv 6.0.0, flat-cache 6.1.24 and file-entry-cache 11.1.6. Those are not exotic dependencies. They sit three levels down in a lockfile nobody reads.
OWASP recommends a window of "commonly 3-7 days, longer for major bumps." We go with 7 days for minor and patch updates and 30 for majors on every client codebase that will tolerate it, because the cost is so lopsided. A seven-day delay on a non-urgent dependency bump costs you nothing you can measure. Catching one worm pays for the policy permanently.
Where to enforce the cooldown, and which option to pick
There are four places to put this control, and they protect different populations. The common mistake is configuring one and assuming you are covered.
| Enforcement point | Protects | Blind spot | Our take | |---|---|---|---| | Package manager setting (.npmrc, pnpm-workspace.yaml, .yarnrc.yml) | Every install: CI, laptops, coding agents | Needs the config committed and a recent package manager | Start here. It is free and covers the widest population | | Update bot (Dependabot cooldown, Renovate minimumReleaseAge) | Automated version-bump PRs | Manual installs and anything a developer adds by hand | Add it, but never instead of the setting above | | Registry proxy with a release-age filter (Cloudsmith, Nexus, Artifactory, Verdaccio) | Anything resolving through the proxy | Requires a proxy, and direct egress to registry.npmjs.org defeats it | Worth it if you already run a proxy for other reasons | | Egress firewall (GitHub Package Firewall, GitLab Dependency Firewall, Socket, Endor) | Endpoints you can force through it | Licensing and ecosystem limits, plus per-endpoint configuration | Buy only after the package manager setting is live |
Our order of operations for clients is the package manager setting first, the update bot second, and a proxy or firewall only when the first two are deployed and someone is funding the next step. Teams that start at the bottom of that table spend a quarter on procurement and ship no protection.
The configuration, and the unit trap
The keys differ per tool and so do the units. npm takes days. pnpm takes minutes. Yarn takes a duration string. OWASP warns explicitly against copying numbers between them, and we have seen a team set minimumReleaseAge: 7 in pnpm believing they had a week. They had seven minutes.
# npm, requires npm >= 11.10. Value is in DAYS.
# .npmrc
min-release-age=7
min-release-age-exclude=@acme/internal-*
# pnpm, requires pnpm >= 10.16. Value is in MINUTES. 10080 = 7 days.
# pnpm-workspace.yaml
minimumReleaseAge: 10080
minimumReleaseAgeStrict: true
minimumReleaseAgeExclude:
- '@acme/internal-sdk'
# Dependabot, graduated by semver impact. Value is in DAYS.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
Three details from the npm config reference and the pnpm settings reference are worth knowing before you roll this out.
npm's min-release-age defaults to null, meaning off. There is a sibling key, min-release-age-exclude, that takes package names or glob patterns and exempts them from both min-release-age and the older before filter. Use it for your own internal packages, which you publish and consume on the same afternoon.
pnpm is further ahead than most people realise. minimumReleaseAge defaults to 1440 minutes, one day, since pnpm v11. If you are on pnpm 11 you already have a 24-hour cooldown you did not ask for. Raise it to 10080 and set minimumReleaseAgeStrict: true, which controls what pnpm does when no version of a dependency satisfies the constraint. Watch minimumReleaseAgeIgnoreMissingTime, which defaults to true and silently skips the check for packages whose registry metadata has no time field. Set it to false if you would rather fail than guess.
Yarn calls its setting npmMinimalAgeGate and documents the value as a duration, with 1w in the yarnrc reference. Its per-package escape hatch is npmPreapprovedPackages.
Where GitHub Package Firewall fits
GitHub put a package firewall into public preview on 20 October 2026. Per the changelog, it sits between endpoints such as Actions runners and coding agents and the upstream registries, inspecting metadata requests and downloads. Its controls include a configurable cooldown period, checks against the GitHub Advisory Database for malware or vulnerability alerts, and supply-chain checks like attestation downgrades. Blocked packages get an exception request flow similar to other Advanced Security features.
Read the constraints before you plan around it. It is npm only. It is available to GitHub Enterprise Cloud customers, including data-resident Enterprise Cloud, and the changelog names no other plan. It is free during the preview and will be charged on metered usage at general availability, billed similarly to Actions, with no rates published yet. GitHub also notes the preview infrastructure is still scaling.
The architectural point is the one to take away. Policies set at the enterprise level protect nothing on their own, because each endpoint has to be pointed at the firewall instead of the upstream registry. That is the same work as rolling out a registry proxy, which means the honest comparison is against the proxy you may already run, not against doing nothing.
The coding-agent angle is why we think this category grows regardless of which vendor wins. An agent that installs its own dependencies is an endpoint making package decisions at machine speed with no human reading the diff. We covered the general shape of that problem in trust boundaries for AI agents in CI/CD. A release-age gate is one of the few controls that works on an agent without needing the agent to cooperate.
Build the exception path before you turn it on
OWASP lists "no emergency path" as an anti-pattern, for a good reason. Teams that cannot fast-track an urgent fix end up disabling the cooldown entirely, usually at 2am during an incident, and it never goes back on.
A cooldown must never delay a fix for an actively exploited vulnerability. Dependabot security updates and Renovate vulnerabilityAlerts bypass the delay by design, but verify it in your own configuration, because a later packageRules entry can quietly reapply an age. Note also that Dependabot's cooldown applies to version updates only.
When you do need to override, do it per package rather than globally. That means min-release-age-exclude in npm, minimumReleaseAgeExclude in pnpm, exclude under Dependabot's cooldown block, and npmPreapprovedPackages in Yarn. Require a human to review the release first and record who approved it and why. An override with an owner and a date is a decision. A lowered global window is a silent policy change that outlives everyone who remembers it.
What a cooldown will not catch
We would rather you deploy this with clear eyes about its limits.
An aged typosquat sails straight through. If an attacker published reqeusts-helper eight months ago and you add it today, every age check passes, because the package is old. Age gating defends against compromise of packages you already trust, not against your own typing.
A patient backdoor also passes. Cooldown assumes someone reports the problem inside your window. A payload that stays dormant for a month, or a compromise carrying valid provenance that nobody files an advisory for, arrives on schedule.
Lockfiles already protect you more than you think, and they also hide the gap. A build running npm ci against a committed lockfile installs exactly what the lockfile says and resolves nothing new, so the risky moment is the update, not the install. Cooldown is a control on dependency updates. Treat it that way.
Finally, this is not a substitute for scanning for known vulnerabilities, which answers a different question. The SCA stage in our DevSecOps pipeline guide catches CVEs with advisories attached. Cooldown catches the window before the advisory exists. You want both, and if you are documenting any of this for the EU Cyber Resilience Act, the inventory work in our CRA compliance checklist is where the evidence comes from.
Ship the .npmrc line this week. It is one commit, it costs nothing, and it would have stopped the last two campaigns from reaching your runners.
When to Get Help
Most teams can land a cooldown policy without outside help. The cases where we get called are messier: a monorepo mixing npm, pnpm and Yarn workspaces where the three settings disagree, an internal registry that strips the time field and breaks age checks silently, or a CI fleet where nobody can say which runners talk directly to registry.npmjs.org. Those are inventory problems wearing a security costume, and they take longer than the config change.
If you want a second pair of eyes on your dependency controls, or help building the exception path so the policy survives its first incident, get in touch.