Tech 9 min read

Blocking Shai-Hulud npm waves with pnpm 11 default minimumReleaseAge, Yarn 4.10 npmMinimalAgeGate, and npm v11.10 (with ignore-scripts gotchas)

IkesanContents

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.

IncidentWindow from publish to takedownScale
Axios npm hijack (2026-03)4-5 hoursRAT inside a package with 120M monthly DL
debug + chalk + 16 packages (2025-09)~2.5 hoursCore deps with 2B+ weekly DL
Shai-Hulud 2.0 (2025-11)~12 hoursSelf-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.

SettingDefaultRole
npmMinimalAgeGate3d (since 4.10)Release-age gate
enableScriptsfalse (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:

PackageWhat its install script doesSymptom when blocked
esbuildDownloads platform-specific binaryCannot find module 'esbuild-linux-64' etc.
sharpDrops libvips binaryCould not load the "sharp" module at runtime
node-gyp deps (bcrypt, canvas, …)C++ compileNative binding missing at runtime
CypressDownloads test runner binarycypress run errors out
PlaywrightDownloads browsersTests fail with “browser not found”
husky / simple-git-hooksRegisters Git hooksHooks 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 (SessionStart hooks, folderOpen tasks, gh-token-monitor services)
  • Already-installed versions in the lockfile getting weaponized via a separate route (rare)
  • Sources outside the npm registry. github:owner/repo#hash Git dependencies share the same attack surface — see the Mini Shai-Hulud post where @tanstack/setup was 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.