Blocking Shai-Hulud npm waves with pnpm 11 default minimumReleaseAge, Yarn 4.10 npmMinimalAgeGate, and npm v11.10 (with ignore-scripts gotchas)
Contents
TL;DR
Bottom line Short-burst npm compromises like the Mini Shai-Hulud wave get stopped by a package-manager “do not install versions newer than N days” gate. As of May 2026, pnpm 11.0 and Yarn 4.10 ship this gate ON by default. npm v11.10 supports it but ships it OFF, so you have to write it in
Action pnpm: upgrade to v11 (for v10, set minimumReleaseAge: 1440 in pnpm-workspace.yaml). Yarn Berry: upgrade to 4.10+ with enableScripts: false. npm: v11.10+ with min-release-age=7 and ignore-scripts=true in .npmrc
Caveat Setting ignore-scripts: true blindly breaks esbuild, sharp, node-gyp deps, Cypress, Playwright. Allow specific packages via pnpm onlyBuiltDependencies or Yarn npmPreapprovedPackages
Limits Release-age gates only catch versions that get deleted within hours-to-days. They do not catch long-dwell compromises (maintainer account fully taken over for weeks). Lockfile pinning, least-privilege secrets, and periodic checks of SessionStart hooks and folderOpen tasks still matter (see Mini Shai-Hulud post and Shai-Hulud OSS release post)
As of May 2026, most short-burst npm compromises get stopped by your package manager’s default settings alone. pnpm 11.0 and Yarn 4.10 both turned on their release-age gate — “do not install versions published less than N days ago” — by default. npm v11.10 added the same setting but leaves it OFF, so you still have to write it in.
I pulled the trigger on this writeup after reading Lu Zaramburo’s dev.to piece on min-release-age. The config values there are correct, but it predates pnpm 11.0 and Yarn 4.10’s default shifts. This post lines up the current state of each package manager, plus what actually breaks when you turn ignore-scripts: true on.
The attack window is small enough for a gate to work
Why “wait a few days before installing” works is easier to see if you line up takedown times.
| Incident | Window from publish to takedown | Scale |
|---|---|---|
| Axios npm hijack (2026-03) | 4-5 hours | RAT inside a package with 120M monthly DL |
| debug + chalk + 16 packages (2025-09) | ~2.5 hours | Core deps with 2B+ weekly DL |
| Shai-Hulud 2.0 (2025-11) | ~12 hours | Self-spreading worm |
| Mini Shai-Hulud (TanStack, 2026-05-11) | ~3 hours (225k DL in 3h) | Spread to ~200 packages |
A simple “wait 24 hours, or 7 days, before pulling” rule blocks almost everything above.
Attackers can stretch this window, but stretching it raises detection probability. npm, pnpm, and Yarn have all leaned on this asymmetry — recent hardening is centered on the release-age gate.
pnpm 11 ships the gate ON by default
pnpm 11.0 (April 2026) changed minimumReleaseAge’s default from 0 (immediate) to 1440 minutes (one day).
That’s the single biggest behavior change between v10 and v11.
flowchart LR
A["pnpm 10.x<br/>minimumReleaseAge=0<br/>(unless explicit)"] --> B["Fresh malicious versions<br/>are eligible for install"]
C["pnpm 11.0+<br/>minimumReleaseAge=1440<br/>(1 day default)"] --> D["Versions less than 24h old<br/>are not resolved"]
style A fill:#7f1d1d,color:#fff
style C fill:#14532d,color:#fff
If you’re on v11+, no config change is needed. The visible symptom is “install suddenly won’t go through” — when CI uses --frozen-lockfile and a new lockfile entry lands, check whether that dep is under 24 hours old.
To extend the window, write it explicitly in pnpm-workspace.yaml. For non-workspace projects, the pnpm field in package.json works the same way.
# pnpm-workspace.yaml
minimumReleaseAge: 10080 # in minutes, 7 days
Still on v10? The same explicit setting gives you equivalent protection. For v10, the "pnpm" key in package.json is the most stable location.
{
"pnpm": {
"minimumReleaseAge": 10080
}
}
For local “I cannot wait” situations, --ignore-min-release-age bypasses per-invocation. Forbid this flag in CI so the bypass stays on developer machines.
Yarn 4.10 ships a 3-day gate, scripts disabled since v2
Yarn Berry (v4) moved in the same direction in 2026.
| Setting | Default | Role |
|---|---|---|
npmMinimalAgeGate | 3d (since 4.10) | Release-age gate |
enableScripts | false (since v2) | Block postinstall and friends |
npmPreapprovedPackages | -(explicit) | Glob list of exceptions to the gate |
Write it in .yarnrc.yml:
# .yarnrc.yml
npmMinimalAgeGate: "7d"
enableScripts: false
npmPreapprovedPackages:
- "@types/*"
- "typescript"
- "esbuild"
- "sharp"
enableScripts: false is the heavy hammer — it removes most postinstall-based attack surface on npm packages by itself. The catch (covered below) is that deps needing a native build (esbuild, sharp, node-gyp users) stop working, so you carve them out via npmPreapprovedPackages.
Yarn Classic (v1) is in maintenance mode since 2024 and has no equivalent gate. Projects still on v1 either migrate to Yarn Berry or move to pnpm.
npm v11.10 needs explicit config
npm officially supports min-release-age from v11.10.0, but ships it OFF. Node 24 LTS is required.
# .npmrc
min-release-age=7
ignore-scripts=true
Note the unit difference: npm’s min-release-age is days, pnpm’s minimumReleaseAge is minutes. Monorepos with both tools in the same config tree have hit this footgun.
With bulk OIDC or provenance enforcement, layer these on:
# .npmrc
min-release-age=7
ignore-scripts=true
audit-level=high
prefer-offline=true
prefer-offline=true is a side benefit — it prefers cached versions over fresh registry fetches, so the chance of pulling a brand-new malicious version drops. Not a complete defense, but it shrinks the new-fetch surface anywhere outside fresh CI runners.
What breaks when ignore-scripts: true
ignore-scripts=true is strong, but it breaks packages that download binaries or compile native code in install scripts. The usual suspects:
| Package | What its install script does | Symptom when blocked |
|---|---|---|
| esbuild | Downloads platform-specific binary | Cannot find module 'esbuild-linux-64' etc. |
| sharp | Drops libvips binary | Could not load the "sharp" module at runtime |
| node-gyp deps (bcrypt, canvas, …) | C++ compile | Native binding missing at runtime |
| Cypress | Downloads test runner binary | cypress run errors out |
| Playwright | Downloads browsers | Tests fail with “browser not found” |
| husky / simple-git-hooks | Registers Git hooks | Hooks don’t fire |
The workaround differs by package manager.
pnpm
Recent pnpm versions ship onlyBuiltDependencies as the allowlist.
# pnpm-workspace.yaml
onlyBuiltDependencies:
- esbuild
- sharp
- "@swc/core"
pnpm approve-builds walks you through this interactively at first install, which works well as a team workflow.
Yarn Berry
Keep enableScripts: false global, then carve out specific packages via npmPreapprovedPackages. If a package needs postinstall on top of that, dependenciesMeta.<package>.built: true opens it per-package.
# .yarnrc.yml
enableScripts: false
npmPreapprovedPackages:
- "esbuild"
- "sharp"
// package.json
{
"dependenciesMeta": {
"esbuild": { "built": true },
"sharp": { "built": true }
}
}
npm
Leave ignore-scripts=true globally, then run npm rebuild <pkg> --ignore-scripts=false only when needed. In CI, write this as an explicit step instead of relying on the postinstall phase.
npm ci --ignore-scripts
npm rebuild esbuild sharp --ignore-scripts=false
Keep the allowlist as short as possible. “Build allowed” means “lifecycle scripts allowed,” so each allowed package restores its full supply-chain attack surface. esbuild, sharp, prisma — the kind of packages that need builds but have small maintainer teams — each get their own risk budget.
Docker and CI
CI and container environments benefit more from the release-age gate than local dev. CI runners do a clean npm install every time, so brand-new malicious versions land immediately on a fresh runner.
Push it through env vars in your Dockerfile:
FROM node:24-alpine
# pnpm form, minutes
ENV npm_config_min_release_age=10080
# pnpm-specific env var
ENV PNPM_MINIMUM_RELEASE_AGE=10080
ENV npm_config_ignore_scripts=true
On GitHub Actions with actions/setup-node and cache: 'pnpm', only PRs that update pnpm-lock.yaml resolve new deps. Versions already in the lockfile are unaffected by the age gate, so the gap between lockfile commit time and install time effectively acts as a natural age gate (a stale lockfile means you don’t pull the fresh malicious version).
To extend that, push the gate into the dependency update bot. Renovate has it natively:
// renovate.json
{
"minimumReleaseAge": "7 days"
}
Renovate’s minimumReleaseAge won’t open a PR for versions younger than that. Dependabot has a similar one tracked in dependabot-core issue #14134 (npmMinimalAgeGate support). Stopping malicious versions at the lockfile-update entry point keeps them off CI and prod runners.
What still gets through
Release-age gates plus ignore-scripts are strong, but they aren’t a complete defense.
Things they don’t catch:
- Long-dwell compromises that aren’t published-then-deleted within hours but live for days or weeks (maintainer account fully taken over)
- Post-install persistence on developer machines (
SessionStarthooks,folderOpentasks,gh-token-monitorservices) - Already-installed versions in the lockfile getting weaponized via a separate route (rare)
- Sources outside the npm registry.
github:owner/repo#hashGit dependencies share the same attack surface — see the Mini Shai-Hulud post where@tanstack/setupwas distributed via an orphaned commit (a Git commit with no parent, isolated from any branch history)
Even with ignore-scripts blocking the entry point, the persistence checks from the Mini Shai-Hulud post and Shai-Hulud OSS-release post need their own cadence. Specifically: ~/.claude/settings.json for SessionStart hooks, each repo’s .vscode/tasks.json for folderOpen tasks, and ~/Library/LaunchAgents/com.user.gh-token-monitor.plist on macOS hosts.
Git-dependency attacks slip past release-age gates entirely — they reference a commit hash, not a registry version number. Auditing lockfile diffs for newly added github: prefixes is the realistic extra check.
The original dev.to writeup is compact and useful, but copying its config values without the May 2026 default shifts misses the point. The quickest path is: run pnpm --version and check for 11+, run yarn --version for 4.10+, and for npm bump to v11.10+ with explicit .npmrc.
If your environment already touched the Mini Shai-Hulud wave, narrowing the entry point isn’t the response. Token revocation has to happen in the right order — get it wrong and gh-token-monitor runs rm -rf $HOME. The full response runbook lives in the Mini Shai-Hulud post.