Skip to content
Chapter 81Lesson 8

Supply-chain defaults after Shai-Hulud

How pnpm 11's on-by-default install-time controls harden your dependency tree against supply-chain attacks, the install-time gate that completes this chapter's security baseline.

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 and counting. Each one is a package some other package pulled in, each one runs code on your laptop and in CI, and not one of them is something you have read.

You will never read them, and that isn’t a discipline failure you can fix by trying harder. Two hundred packages is past the point where review is something a human can do. In 2025 and 2026, that became the problem. The Shai-Hulud worm tore through npm by hijacking maintainer accounts and re-publishing itself into package after package, and the chalk and debug maintainer compromises pushed malicious code into packages that sit under nearly every JavaScript project alive. After those, supply-chain attacks belong on the short list of the most likely ways a SaaS gets breached, right next to the cross-site scripting and credential-stuffing vectors the first two lessons of this chapter hardened.

So this lesson does not ask you to audit your dependencies. It asks you to reframe the problem. You cannot watch two hundred packages, so the defense cannot be vigilance. It has to be structure: a handful of config lines that make the dangerous moves unreachable whether or not anyone is paying attention. Most of these controls ship on by default in pnpm 11, and that fact is the spine of everything below. The senior move isn’t adding them. It’s understanding them well enough to keep them on under deadline pressure, when the fix is right there and the protection is the thing in the way.

Two lessons ago you wired Husky and Gitleaks to scan every commit for secrets, and one lesson ago you put your environment variables behind a schema that fails the build when one is missing. Those are your commit-time and runtime gates. This lesson adds the third one: the install-time gate. By the end you’ll have a dependency-hygiene report you can run against your own repo, the eighth and last entry in this chapter’s audit catalog.

Before any defense, you need to see the attack, because each control below only makes sense once you can point at the exact stage it breaks. We’ll walk one kill chain end to end. It isn’t exotic; it’s the simplest attack that touches install scripts, freshly-published versions, and the lockfile all at once. This one story motivates three of the lesson’s controls.

The setup is a typo. An attacker publishes axois, one transposed letter from axios, the package you actually 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.

In the following diagram, scrub through the five stages of that attack 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.

Look at which stage lights up on step three. The attacker’s code executes at install time: after pnpm install resolves the package, before a single line of your code runs, and before any test fires. That timing is the whole reason this lesson stands on its own. Your commit-time gate, Gitleaks, scans your commits, not the contents of node_modules. Your runtime gate, the env schema, checks values when the app boots, long after the postinstall script has already run and left. Neither one is anywhere near the blast radius.

That leads to the structural conclusion the rest of the lesson builds on: since you cannot review two hundred packages, you make the dangerous stages structurally unreachable instead. The dangerous stages are fresh versions, install scripts, and unpinned resolution. Three stages, three controls, taken below in order of leverage.

Here is the vocabulary you’ll need for the rest of the lesson, in one place. A transitive dependency is a package your dependencies depend on, not one you installed yourself; that’s where the two-hundred number comes from. Typosquatting is publishing a package whose name is a near-miss of a popular one, to catch your typos and an AI’s hallucinations. A postinstall script is an npm lifecycle script that runs automatically after a package is installed, with full access to the machine. And exfiltration is covertly shipping stolen data, such as secrets and tokens, off the machine.

Start with step two, because it’s the single highest-leverage control you have, and the one most likely to get switched off the first time it’s inconvenient.

The insight is about timing. A malicious version is most dangerous in its first few hours, the window between the attacker publishing it and the community noticing, raising the alarm, and getting it yanked. After that window, it’s a known-bad version that the audit databases and your tooling will flag. So the defense almost writes itself: don’t install anything that’s too new. Let the rest of the world be the canary for a day, and only resolve versions that have already survived that day.

That’s minimumReleaseAge, and in pnpm 11 it lives in pnpm-workspace.yaml:

pnpm-workspace.yaml
minimumReleaseAge: 1440

1440 is minutes, which is twenty-four hours. The part that matters most is that pnpm 11 ships this on by default. You are not adding it. It’s already protecting you from the moment you chose pnpm 11. You write it out explicitly anyway so that you recognize it when you see it, and so you know not to remove it.

What it does to a resolve is quiet and exact. A version published less than twenty-four hours ago does not exist as far as resolution is concerned, so install picks the newest version that’s older than the cutoff. Read that carefully, because the common misread will cost you the control: this is not “never update.” You still get every version. You just get them one day late, and that one day is the entire defense.

Here is the moment this control gets tested, and the rule that decides whether you keep it. A genuine, critical security patch lands, and it’s three hours old. You need it now, the quarantine is blocking it, and there’s a config line sitting right there that would make the problem disappear.

That carve-out, written out, is two or three lines:

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

The difference is worth sitting with, because it separates someone who understands the control from someone who just disabled the thing that was beeping. A scoped exclude is one named package, visible in the diff, reverted in a follow-up. minimumReleaseAge: 0 is invisible after the fact and protects nothing ever again.

One more fact, and it’s a deliberate design choice worth appreciating rather than fighting: pnpm 11 gives you no per-command flag to skip the age check. There’s no --allow-fresh you can type once and forget. The exclude list is the only path, which is exactly right: it forces the bypass into reviewed config in your repo instead of leaving it buried in one person’s shell history where no reviewer will ever find it.

The registry-only contract: blockExoticSubdeps

Section titled “The registry-only contract: blockExoticSubdeps”

There’s a side door, and one short section closes it.

Everything minimumReleaseAge protects assumes packages come from a registry, because that’s 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 a tarball URL instead of a registry package. That sub-dependency skips the registry entirely, and with it every protection the registry layers on. This is a clean way for a compromised intermediate package to smuggle arbitrary code straight past your controls, since the age window never applies to a URL.

pnpm-workspace.yaml
blockExoticSubdeps: true

This is another pnpm 11 default. It enforces a registry-only contract across the whole transitive tree: every package down there must resolve from a configured registry, not from some URL you never vetted. Note the scope, which is transitive deps only. Your own direct dependencies can still point at an exotic source if you deliberately choose one and own that decision; what’s blocked is a package three levels down dragging in a git URL on your behalf.

That’s the whole section. It’s a sensible default with an obvious rationale, and the install-script gate and the lockfile have a better claim on your attention.

The term for the thing being blocked: an exotic dependency is one resolved from a git repo or a tarball URL rather than from a package registry.

Install scripts run with no sandbox: the approval gate

Section titled “Install scripts run with no sandbox: the approval gate”

Back to step three: the code-execution primitive itself, and the control most people don’t know exists.

Recall the sharpest fact from the kill chain: postinstall scripts, along with their siblings preinstall and install, run automatically and unsandboxed during install. This isn’t a fringe feature. It’s the actual mechanism every install-time attack uses, because it’s the one moment a package gets to execute arbitrary code on your machine just for being present. Historically npm and yarn ran these scripts for every package, no questions asked.

pnpm 11’s posture is the inverse, and it comes in three parts.

First, scripts are skipped by default. pnpm will not run a dependency’s lifecycle scripts unless that specific package is explicitly allowed. A package you haven’t vetted is treated as unreviewed, and an unreviewed package’s scripts don’t run.

Second, there’s an explicit allow-map, allowBuilds. This is the pnpm 11 mechanism, and if you’ve seen the pnpm 10 form, note that allowBuilds replaces the old onlyBuiltDependencies array. It’s a map of package matcher to boolean: true permits that package’s build scripts, false explicitly denies them. A small handful of packages legitimately do need to compile native code at install, with esbuild, @swc/core, and sharp as the canonical trio, and those get true. Everything else stays unreviewed.

Third, and this is the part with teeth, strictDepBuilds: true. This pnpm 11 default makes the install exit non-zero the moment any dependency has build scripts that aren’t in your allow-map. Without it, an unreviewed postinstall would silently not run and you’d never think about it. With it, an unfamiliar build script fails your install and forces a human to look. This is the enforcement behind the allow-map, the reason the gate can’t be quietly ignored.

Step through the allowBuilds map below.

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

This map is the entire allow-list. 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 genuinely 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 here. The false on node-telemetry is the explicit deny: a package whose postinstall you’ve looked at and decided to refuse, recorded so the decision is intentional and documented rather than incidental.

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

With this on, a freshly-added dependency whose postinstall isn’t in the map doesn’t slip by. It fails the install until a human reviews it and decides whether to add it. That decision is the whole point.

1 / 1

Deciding whether a package belongs in allowBuilds is a code-review decision, full stop. Adding a line to that map grants a package the right to run arbitrary code on every developer’s machine and in CI, on every install, forever. That’s not something one person types past a failing install to make it green; it’s something a reviewer signs off on, with a reason.

Now close the loop on the kill chain. With this gate on, the typosquat from step three never fires. Its postinstall is unreviewed, strictDepBuilds fails the install before the script runs, and the attack’s code-execution primitive is structurally dead, not because anyone caught it but because the default refused it.

The vocabulary: a lifecycle script is one of preinstall, install, or postinstall, the npm scripts that run automatically at install phases. A sandbox is an isolated execution context that limits what code can touch; the thing to internalize here is that install scripts run with none, meaning full machine access.

The three controls so far each protect a stage. This one makes all of them deterministic.

So ask the senior question: without a lockfile, what does install actually resolve? It resolves whatever satisfies the range, which on a fresh CI machine an hour from now can mean a version published five minutes ago that didn’t exist when you tested. Your carefully-quarantined, script-gated dependency tree is only real if it’s the same tree everywhere, and the lockfile is what makes it the same tree.

pnpm-lock.yaml pins every package, direct and transitive, to an exact version and to an integrity hash of the exact bytes that version shipped. Two rules follow, both non-negotiable.

First, pnpm-lock.yaml is committed. It is not a build artifact you can regenerate and forget; it’s the contract. The integrity hashes mean every install verifies that it received the exact bytes that were vetted, so if someone swaps a tarball for a tampered one, the hash check fails the install.

Second, CI runs pnpm install --frozen-lockfile. This mode fails the build if package.json and the lockfile disagree, instead of silently regenerating the lockfile and pulling whatever’s newest. Tie it straight back to the kill chain: skipping the commit, or skipping --frozen-lockfile, reopens the exact “whatever’s latest” resolution that step two depends on.

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

One more piece, because the lockfile contract is only airtight if the package manager itself can’t be swapped out from under it. Pin the tool: a packageManager field in package.json plus only-allow pnpm in a preinstall script. Without that, a stray npm install regenerates a package-lock.json and quietly bypasses every pnpm control you just read about, because it uses a different resolver, a different lockfile, and none of these defaults. The lockfile and the tool-pin belong together.

A quick recall check before we move on. Fill in the four settings below, and notice the file each one lives in, because the pnpm-workspace.yaml-not-.npmrc placement is the easiest thing 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 ___

The term, stated once: a lockfile is a file that pins every dependency to an exact resolved version and an integrity hash; --frozen-lockfile is the install mode that fails rather than updating that file, which is the CI-correct mode.

Scanning for known-vulnerable versions: pnpm audit

Section titled “Scanning for known-vulnerable versions: pnpm audit”

There’s a distinction students conflate constantly, and making it clear is the only reason this section exists.

Everything above stops novel attacks: versions nobody has flagged yet, install scripts you’ve never seen, and resolution that drifts. pnpm audit does the opposite job. It catches already-known vulnerabilities by checking your installed versions against a database of published advisories. These are two different layers. Config defends against the unknown; audit defends against the known. Neither replaces the other, and a codebase needs both.

The mechanics are short:

  • pnpm audit checks your dependency tree against the GHSA database. (pnpm moved from CVE-keyed to GHSA-keyed audits over the 2025–26 line; the older CVE IDs still exist, GHSA is just what pnpm reads now.)
  • pnpm audit --prod filters out dev-only dependencies. That filter exists for a real reason: a high-severity advisory in a build-time-only tool shouldn’t block a release the way one in a production dependency must. The watch-out runs the other way: waving off a production high-severity finding “because it’s transitive” is exactly the misread --prod is built to stop you from making.
  • 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 posture, which is a policy rather than a command: zero high-severity findings in production dependencies as a release gate, mediums triaged within the sprint, and lows tracked. Wiring that up as a blocking gate in CI is the job of a later chapter on continuous integration. Here it’s the habit you run locally before you merge, and the policy that says what “clean” means.

No config can make this call. This is the human gate, and it sits before pnpm add ever runs, because the cheapest supply-chain defense in existence is one fewer dependency.

Before you pull a package in, ask three questions:

  1. Has it shipped a release in the last six months or so? Abandoned packages are the takeover vector. Attackers go looking for dormant, still-popular namespaces precisely because nobody’s home to notice when they push a malicious version.
  2. Do the download numbers match the reputation? A “popular” package with oddly low downloads, or a sudden unexplained spike, is a flag worth a second look.
  3. Is the maintainer responsive? Triaged issues and recent commits are signs that someone would notice a hijack and react.

Behind all three sits the meta-question, which ties straight back to this course’s minimum-viable-stack stance: every dependency is attack surface and maintenance debt. 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, instead?” An attack surface is every entry point an attacker could exploit, and each dependency you add widens it.

Sort the dependencies below. Some are clear adds, some are clear avoids, and some genuinely warrant a closer look before you decide; that middle category is the one worth getting 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

Keeping deps current without losing the human gate

Section titled “Keeping deps current without losing the human gate”

Staying patched is a security control: outdated dependencies quietly accumulate known vulnerabilities, so letting them drift undoes the audit posture you just set. But the automation that keeps you current must not bypass the human decision at the gate.

The 2026 default for this is Renovate , generally chosen over Dependabot for its better grouping and scheduling. The pattern that works is to group patch and minor updates into a single batched pull request on a weekly schedule, and to let major updates come as individual PRs, since majors carry breaking changes that need a human to actually read them.

Here’s the load-bearing constraint, and it’s the same shape as every rule in this lesson. Bot updates land as pull requests gated by your full CI suite, including tests, pnpm audit, and --frozen-lockfile, and they are never auto-merged blindly. The reason is that auto-merging every green bot PR re-creates the precise risk all these controls exist to manage. A malicious patch could ride a clean, auto-merged PR straight to main while everyone’s asleep. The 24-hour quarantine and the audit gate still apply to bot PRs, so the bot doesn’t get to skip the line.

One layer deeper, named but not wired, is Socket , with Snyk in the same category. These scan for behavioral indicators rather than known advisories. That’s a different and deeper question, “is this package suddenly doing something it never did?”, and it earns its place once a team is around five engineers or more. Below that, the controls in this lesson are the baseline.

The deliverable: your dependency-hygiene report

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

Now turn all of this into the artifact. Every lesson in this chapter ends in one grep-able deliverable, and the next chapter audits a seeded codebase against all of them. This is the eighth and final one.

Run the following checklist against your own repo. Each item is a single observable fact, true or false, with no interpretation.

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 report joins the catalog the rest of the chapter built, alongside the security headers, the rate-limit coverage matrix, the audit-event catalog, the retention catalog, the consent reject-path, the secrets audit, and the env schema, all of which the next chapter audits a seeded codebase against. With this one, the catalog is complete: eight controls, the dependency layer being the last.

One consolidation drill before the quiz. The entire lesson was a single move repeated: a threat, then the config that neutralizes it. Match each threat on the left to the control that kills 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

If you can hold that mapping, you have the lesson. Every line of config above is just one entry in it.

pnpm’s own documentation is the canonical reference for these settings, and it’s worth a bookmark for the exact shape of minimumReleaseAge, allowBuilds, and blockExoticSubdeps. Past that, the Socket writeup is the definitive forensic account of the very attack this lesson opens with, and the OpenSSF guide is the neutral, vendor-independent checklist for the dependency-evaluation habits in the back half.