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.
How a supply-chain attack runs
Section titled “How a supply-chain attack runs”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.
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. package.json — run pnpm install. Resolution picks the brand-new version because it satisfies the range. Broken later by the 24-hour quarantine. 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. .env.local and CI secrets and ships them off the machine. 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:
minimumReleaseAge: 14401440 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:
minimumReleaseAge: 1440minimumReleaseAgeExclude: - 'jose' # critical CVE patch, < 24h old — revert when aged outpnpm 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.
blockExoticSubdeps: trueThis 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.
minimumReleaseAge: 1440blockExoticSubdeps: truestrictDepBuilds: trueallowBuilds: esbuild: true '@swc/core': true sharp: true node-telemetry: falseThese packages, and only these, may run install scripts. Every package not named here is treated as unreviewed, and its scripts don’t run.
minimumReleaseAge: 1440blockExoticSubdeps: truestrictDepBuilds: trueallowBuilds: esbuild: true '@swc/core': true sharp: true node-telemetry: falseesbuild 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.
minimumReleaseAge: 1440blockExoticSubdeps: truestrictDepBuilds: trueallowBuilds: esbuild: true '@swc/core': true sharp: true node-telemetry: falseA freshly-added dependency whose postinstall isn’t in the map fails the install until a human reviews it and decides whether to add it.
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.
Commit the lockfile and freeze it in CI
Section titled “Commit the lockfile and freeze it in CI”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.
# pnpm-lock.yaml is committed — it is the contract, not an artifact
pnpm install --frozen-lockfilePin 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.
___: 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.
Catch known vulnerabilities: pnpm audit
Section titled “Catch known vulnerabilities: pnpm audit”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 auditchecks your tree against the GHSA database. The older CVE IDs still exist, but GHSA is what pnpm reads now.pnpm audit --proddrops 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--prodexists to stop.pnpm audit --fixupdates the lockfile toward a non-vulnerable version when one exists.
pnpm auditpnpm audit --prodpnpm audit --fixThe 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.
Vet a dependency before you add it
Section titled “Vet a dependency before you add it”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:
- 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.
- Do the download numbers match the reputation? A “popular” package with oddly low downloads, or a sudden unexplained spike, warrants a second look.
- 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.
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.blockExoticSubdeps is left on (not set to false).allowBuilds map is reviewed: every true entry is justified by a real native build step, and strictDepBuilds is not disabled.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.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.
minimumReleaseAge — the 24-hour quarantineblockExoticSubdeps — registry-only contractpostinstall script exfiltrating secretsallowBuilds + strictDepBuilds — unreviewed scripts fail the install--frozen-lockfilepnpm auditExternal resources
Section titled “External resources”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.
pnpm's official guide to the install-time defenses this lesson configures.
The exact shape of minimumReleaseAge, allowBuilds, blockExoticSubdeps, and more.
The forensic writeup of the exact phishing-to-install-time attack this lesson is built around.
A vendor-neutral guide to vetting dependencies and hardening CI against supply-chain risk.