Skip to content
Chapter 81Lesson 8

Supply-chain hardening with pnpm

How pnpm 11's on-by-default install-time controls harden your dependency tree against supply-chain attacks.

Run pnpm install on the app you’ve been building and watch the package count. You declared maybe twenty direct dependencies; what lands in node_modules is two hundred. Each was pulled in by another package, each runs code on your laptop and in CI, and you have read none of them.

You never will: two hundred packages is past the point where a human can review them, and that gap is what attackers found. The Shai-Hulud worm tore through npm by hijacking maintainer accounts and re-publishing itself package to package, and the chalk and debug compromises pushed malicious code into dependencies nearly every JavaScript project pulls in. Supply-chain attacks now sit on the short list of likely ways a SaaS gets breached, alongside the cross-site scripting and credential-stuffing this chapter’s first two lessons hardened.

So the defense can’t be vigilance. It has to be structure: a handful of config lines that make the dangerous moves unreachable whether or not anyone is watching. Most ship on by default in pnpm 11, so the senior move isn’t adding them; it’s understanding them well enough to keep them on under deadline pressure, when that protection is all that stands between you and the fix.

Gitleaks already scans every commit for secrets, and your env schema fails the build when a variable is missing: your commit-time and runtime gates. This lesson adds the third, the install-time gate, and ends with a dependency-hygiene report you can run against your own repo, the chapter’s eighth and last audit entry.

Each control below makes sense only once you can point at the stage it breaks, so first walk one kill chain end to end. It’s the simplest attack that touches install scripts, freshly-published versions, and the lockfile at once.

The setup is a typo. An attacker publishes axois, one transposed letter from axios, the package you meant. The nastier variant is the chalk and debug shape: the attacker phishes a real maintainer’s npm token and pushes a malicious patch into a legitimate popular package, so there’s no typo to catch and the name is exactly right.

Scrub through the diagram and watch when the malicious code runs.

Your laptop + CI — quiet Nothing on your machine yet — the package is live on the registry, minutes old.
Publish. The attacker publishes the malicious version — a typosquat name (axois for axios), or a hijacked patch pushed to a real package with a phished maintainer token. It's now live on the registry, minutes old.
Your laptop + CI — quiet Resolution runs. Still no foreign code executing — pnpm is just picking a version.
Resolve. You — or an AI agent writing a package.json — run pnpm install. Resolution picks the brand-new version because it satisfies the range. Broken later by the 24-hour quarantine.
Your laptop + CI — EXECUTING attacker code The attacker's code is now running — full filesystem + network access, on your laptop and in CI.
postinstall runs. The package's postinstall script runs automatically during install — on your laptop and in CI — with full filesystem and network access. No code of yours has run yet. Broken later by the install-script gate.
Your laptop + CI — EXECUTING attacker code Still executing — it's reading files and opening connections off the machine.
Exfiltrate. The script reads .env.local and CI secrets and ships them off the machine.
Your laptop + CI — EXECUTING attacker code Still executing — using your stolen token to publish itself elsewhere.
Self-replicate. The Shai-Hulud variant goes further: it uses the stolen npm token to re-publish itself into other packages the victim maintains. That's the worm.

On stage three, the attacker’s code executes at install time: after pnpm install resolves the package, before a single line of your code runs, before any test fires. That timing is why your earlier gates miss it. Gitleaks scans your commits, not the contents of node_modules; the env schema checks values when the app boots, long after the postinstall script has run and left.

So you make the dangerous stages structurally unreachable instead: fresh versions, install scripts, and unpinned resolution, three stages and three controls, taken below in order of leverage.

Four terms for the rest of the lesson. A transitive dependency is one your dependencies pull in rather than one you installed; that’s where the two-hundred number comes from. Typosquatting publishes a package whose name is a near-miss of a popular one, to catch typos and AI hallucinations. A postinstall script runs automatically after install, with full access to the machine. And exfiltration is covertly shipping stolen data off the machine.

Delay new versions 24 hours: minimumReleaseAge

Section titled “Delay new versions 24 hours: minimumReleaseAge”

A malicious version is most dangerous in its first few hours, before the community notices and gets it yanked; after that, your audit databases and tooling flag it. So resolve only versions that have already survived a day, letting the rest of the world act as the canary.

That’s minimumReleaseAge, set in pnpm-workspace.yaml:

pnpm-workspace.yaml
minimumReleaseAge: 1440

1440 is minutes, twenty-four hours. pnpm 11 ships this on by default, so the move is keeping it and writing it out explicitly so you recognize it and don’t remove it.

A version published less than twenty-four hours ago doesn’t exist as far as resolution is concerned, so install picks the newest version older than the cutoff. This is not “never update”: you get every version, just one day late, and that day is the entire defense.

That carve-out is two or three lines:

pnpm-workspace.yaml
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
- 'jose' # critical CVE patch, < 24h old — revert when aged out

pnpm 11 gives you no per-command flag to skip the age check, so the exclude list is the only path, which forces the bypass into reviewed config instead of one person’s shell history.

Force registry-only resolution: blockExoticSubdeps

Section titled “Force registry-only resolution: blockExoticSubdeps”

minimumReleaseAge assumes packages come from a registry, which is what gives them a publish time, a provenance trail, and a yank mechanism. But a dependency can declare its own sub-dependency as a raw git URL or tarball URL, skipping the registry and every protection built on it, letting a compromised intermediate package smuggle arbitrary code past your controls.

pnpm-workspace.yaml
blockExoticSubdeps: true

This pnpm 11 default enforces a registry-only contract across the transitive tree: every package down there must resolve from a configured registry. Your own direct dependencies can still point at an exotic dependency when you own that decision; what’s blocked is a package three levels down dragging in a git URL on your behalf.

Skip install scripts unless allowed: allowBuilds

Section titled “Skip install scripts unless allowed: allowBuilds”

postinstall, preinstall, and install scripts run automatically and unsandboxed during install. This is the mechanism every install-time attack uses: the one moment a package executes arbitrary code on your machine just for being present. pnpm 11 inverts the old npm and yarn behavior of running them for every package, in three parts.

First, scripts are skipped by default: pnpm runs a dependency’s lifecycle scripts only if you’ve explicitly allowed that package.

Second, the allow-list is the allowBuilds map, which replaces pnpm 10’s onlyBuiltDependencies array, mapping a package to a boolean: true permits its build scripts, false denies them. A few packages legitimately compile native code at install, esbuild, @swc/core, and sharp being the canonical trio, and those get true.

Third, strictDepBuilds: true makes the install exit non-zero the moment any dependency has build scripts not in the map. Without it an unreviewed postinstall silently doesn’t run and you never notice; with it, an unfamiliar build script fails the install and forces a human to look.

pnpm-workspace.yaml
minimumReleaseAge: 1440
blockExoticSubdeps: true
strictDepBuilds: true
allowBuilds:
esbuild: true
'@swc/core': true
sharp: true
node-telemetry: false

These packages, and only these, may run install scripts. Every package not named here is treated as unreviewed, and its scripts don’t run.

pnpm-workspace.yaml
minimumReleaseAge: 1440
blockExoticSubdeps: true
strictDepBuilds: true
allowBuilds:
esbuild: true
'@swc/core': true
sharp: true
node-telemetry: false

esbuild gets true because it compiles a native binary at install. That’s the test for every true entry: does this package have a real native build step? If you can’t say why, it doesn’t belong. The false on node-telemetry is an explicit deny, a postinstall you’ve looked at and refused, recorded so the decision is documented.

pnpm-workspace.yaml
minimumReleaseAge: 1440
blockExoticSubdeps: true
strictDepBuilds: true
allowBuilds:
esbuild: true
'@swc/core': true
sharp: true
node-telemetry: false

A freshly-added dependency whose postinstall isn’t in the map fails the install until a human reviews it and decides whether to add it.

1 / 1

Whether a package belongs in allowBuilds is a code-review decision: the line grants it the right to run arbitrary code on every machine and in CI, on every install, forever. A reviewer signs off with a reason; one person doesn’t type past a failing install to make it green.

This closes the kill chain: a typosquat’s postinstall is unreviewed, so strictDepBuilds fails the install before the script runs, its code-execution primitive dead because the default refused it.

A lifecycle script is one of preinstall, install, or postinstall. A sandbox is an isolated execution context that limits what code can touch; install scripts run with none.

The earlier controls each guard one stage; the lockfile makes the whole tree deterministic. Without it, a fresh CI machine resolves whatever satisfies the range, which can be a version published minutes ago, after you tested. A quarantined, script-gated tree only protects you if it’s the same tree everywhere, and that is what the lockfile guarantees.

pnpm-lock.yaml pins every package, direct and transitive, to an exact version and to an integrity hash of the bytes that version shipped.

Commit it. It’s the contract, not a regenerable artifact, and because every install verifies bytes against the recorded hashes, a tampered tarball fails the check.

Run pnpm install --frozen-lockfile in CI. This mode fails the build when package.json and the lockfile disagree, instead of silently regenerating the lockfile and pulling whatever’s newest. Skip either step and you reopen the “whatever’s latest” resolution the earlier controls depend on.

CI
# pnpm-lock.yaml is committed — it is the contract, not an artifact
pnpm install --frozen-lockfile

Pin the tool too, or a stray npm install regenerates a package-lock.json with a different resolver and bypasses every pnpm control above: add a packageManager field in package.json and only-allow pnpm in a preinstall script.

Fill in the four settings below, and note which file each lives in: the pnpm-workspace.yaml-not-.npmrc placement is the easiest to get wrong.

Fill in the four supply-chain knobs. Pick the right option from each dropdown, then press Check.

pnpm-workspace.yaml
___: 1440
___: true
___:
esbuild: true
# CI command:
pnpm install ___

A lockfile pins every dependency to an exact version and integrity hash; --frozen-lockfile is the CI-correct install mode that fails rather than updating it.

Everything above stops novel attacks: versions nobody has flagged yet, unfamiliar install scripts, drifting resolution. pnpm audit covers the opposite case, checking your installed versions against a database of published advisories to catch already-known vulnerabilities. A codebase needs both layers.

  • pnpm audit checks your tree against the GHSA database. The older CVE IDs still exist, but GHSA is what pnpm reads now.
  • pnpm audit --prod drops dev-only dependencies, since a high-severity advisory in a build-time tool shouldn’t block a release the way one in a production dependency must. The misread runs both ways: waving off a production finding “because it’s transitive” is exactly what --prod exists to stop.
  • pnpm audit --fix updates the lockfile toward a non-vulnerable version when one exists.
Terminal window
pnpm audit
pnpm audit --prod
pnpm audit --fix

The command is the easy part; the senior contribution is the policy: zero high-severity findings in production dependencies as a release gate, mediums triaged within the sprint, lows tracked. Run it locally before you merge; wiring it up as a blocking CI gate comes later.

No config can make this call: it’s the human gate before pnpm add runs, because the cheapest supply-chain defense is one fewer dependency.

Before pulling a package in, ask:

  1. Has it shipped a release in the last six months or so? Abandoned packages are a prime takeover vector: attackers hunt dormant but still-popular namespaces because nobody’s home to notice a malicious version.
  2. Do the download numbers match the reputation? A “popular” package with oddly low downloads, or a sudden unexplained spike, warrants a second look.
  3. Is the maintainer responsive? Triaged issues and recent commits mean someone would notice a hijack and react.

Behind all three sits one fact: every dependency is attack surface and maintenance debt. So the first question isn’t “which package solves this,” it’s “can I do this with the platform, or a few lines of my own?”

Sort the dependencies below; the middle category, worth a verify first, is the one to develop a feel for.

Sort each dependency by what you'd do before adding it. Drag each item into the bucket it belongs to, then press Check.

Add it Signals all check out
Investigate first Could be fine — verify before committing
Avoid Clear red flags
12M weekly downloads, last release 3 weeks ago, 40 maintainers, issues triaged
The framework’s own first-party package, actively released
New package, solves your exact need, 200 downloads, published last week
Solid downloads, but the last release was 14 months ago and issues are piling up
Last release 2021, one maintainer, name is one character off a popular library
5M downloads last week, ~2k the week before, no changelog for the jump

Update dependencies automatically, merge them manually

Section titled “Update dependencies automatically, merge them manually”

Staying patched is itself a security control: outdated dependencies quietly accumulate known vulnerabilities, undoing the posture you just set. But the automation that keeps you current must not bypass the human at the gate.

Use Renovate , chosen over Dependabot for better grouping and scheduling. Batch patch and minor updates into one weekly pull request, and let majors come as individual PRs, since they carry breaking changes a human has to read.

Bot updates land as pull requests gated by your full CI suite — tests, pnpm audit, --frozen-lockfile, the 24-hour quarantine — and are never auto-merged blindly. Auto-merging every green bot PR re-creates the exact risk these controls exist to manage: a malicious patch riding a clean PR straight to main.

One layer deeper is Socket , with Snyk in the same category: these scan for behavioral indicators rather than known advisories, answering a different question — “is this package suddenly doing something it never did?” — and earn their place once a team reaches roughly five engineers.

The deliverable: your dependency-hygiene report

Section titled “The deliverable: your dependency-hygiene report”

Every lesson in this chapter ends in one grep-able deliverable; this is the eighth and final one. Run it against your own repo, where each item is a single fact, true or false.

pnpm-lock.yaml is committed to the repo.
minimumReleaseAge is not zeroed out in pnpm-workspace.yaml (the default 1440 is intact); any minimumReleaseAgeExclude entry is justified in its PR.
untested
blockExoticSubdeps is left on (not set to false).
untested
The allowBuilds map is reviewed: every true entry is justified by a real native build step, and strictDepBuilds is not disabled.
untested
CI runs pnpm install --frozen-lockfile.
pnpm audit --prod is clean (no high-severity), or every finding is triaged.
packageManager is pinned in package.json and only-allow pnpm runs in preinstall.
untested
Renovate (or Dependabot) is enabled, with auto-merge off for non-trivial updates.
untested
Any unmaintained or suspicious direct dependency is flagged (the three-question check applied).
untested

This completes the eight-control catalog the chapter built, the set the next chapter audits a seeded codebase against.

One consolidation drill before the quiz. Match each threat to the control that neutralizes it.

Match each threat to the control that neutralizes it. Click an item on the left, then its match on the right. Press Check when done.

A malicious version published minutes ago
minimumReleaseAge — the 24-hour quarantine
A transitive dep pulled from a git or tarball URL
blockExoticSubdeps — registry-only contract
A postinstall script exfiltrating secrets
allowBuilds + strictDepBuilds — unreviewed scripts fail the install
”Whatever’s latest” resolving on a fresh CI machine
Committed lockfile + --frozen-lockfile
A known CVE in an installed dependency
pnpm audit
A takeover of an abandoned, still-popular package
The three-question maintained check
A malicious patch riding a green auto-merge to main
Renovate PRs gated by CI, no blind auto-merge

pnpm’s documentation is the canonical reference for the exact shape of these settings. The Socket writeup is the forensic account of the attack this lesson opens with, and the OpenSSF guide is the vendor-neutral checklist for vetting dependencies.