This started as a chore and turned into two separate lessons about how pnpm
decides what actually lands in node_modules. Neither lesson is in the error
output, because there is no error output. Everything succeeds. That's the
problem.
The chore: one real signal in 166 findings
Our monorepo had a weekly scheduled audit workflow that failed every single
Sunday. Not "sometimes red" — unconditionally red, because
pnpm audit --audit-level=moderate across an 18-package workspace with 40+
example apps finds 166 vulnerabilities (12 low / 68 moderate / 79 high /
7 critical), most of them in dev dependencies and dormant packages nobody
ships. A check that is always red is not a check; it's alarm fatigue with a
cron schedule. I deleted the workflow and did the triage by hand instead,
filtering the audit JSON by which
workspace package each finding's path actually starts at.
The filtered picture was completely different from the scary total. Nearly every critical and high lived in example apps and packages with zero consumers. Exactly one published package had real production-dependency advisories: our email package, with
handlebars≤4.7.8 — a critical JavaScript-injection via AST type confusion (GHSA-2w6w-674q-4c4q), patched in 4.7.9, andnodemailer— a high: the message-levelrawoption bypasseddisableFileAccess/disableUrlAccess, enabling arbitrary file read and SSRF in the delivered message (GHSA-p6gq-j5cr-w38f), patched only in 9.0.1.
So: bump handlebars to ^4.7.9, bump nodemailer two majors to ^9.0.3,
bump resend to the latest 6.x while we're in there. Edit the package.json,
run pnpm install, done in 2.7 seconds.
$ node -e "console.log(require('nodemailer/package.json').version)"
7.0.11
The package.json says ^9.0.3. The install said Done. The tree says 7.
Why the install kept the old version
The answer was sitting in line 18 of the lockfile:
overrides:
glob: '>=10.5.0'
body-parser: '>=2.2.1'
esbuild: '>=0.25.0'
nodemailer: '>=7.0.7'
Months earlier, someone (me) had added a root pnpm.overrides floor for
nodemailer — the standard move when a transitive dependency somewhere deep
in the tree has an advisory and you can't wait for every intermediate package
to update. Floors like glob: '>=10.5.0' are exactly right for that.
But pnpm overrides don't combine with a package's declared range. They
replace it, everywhere in the workspace. My freshly-edited ^9.0.3 was
never consulted; the effective range was >=7.0.7, and since the lockfile
already had a resolution that satisfied it — 7.0.11 — pnpm correctly changed
nothing. No warning, no conflict, nothing to see. Every tool did its job.
The fix is one character class: the override becomes ^9.0.3, matching the
range the package itself declares. And that's the first lesson, because the
failure mode here is nastier than "old version": the published tarball's
package.json said ^9.0.3 all along. Overrides are a workspace-local
concept — they don't ship. A buyer installing the package from the registry
would have gotten nodemailer 9. It was my own workspace — the place where
the tests run — that was quietly resolving 7. The artifact was right and
the CI was lying, which is the worse direction.
Rule of thumb I've adopted: >= floors are for transitive phantoms you
don't declare; the moment an override covers a direct dependency of a
package you publish, the override must be the same range the package
declares, or your test matrix and your users' installs diverge silently.
The dependency that outlived its own removal
With nodemailer sorted, the audit for the email package was still not clean. The remaining highs all rode one path:
packages/email > resend@6.18.0 > @react-email/render@0.0.16 > js-beautify@1.15.4 > ...
Which is strange, because:
$ npm view resend@6.18.0 dependencies
{ "postal-mime": "2.7.5", "standardwebhooks": "1.0.0" }
resend 6.18.0 does not depend on @react-email/render at all. It moved it to
an optional peer with range * versions ago. So why is a 0.0.16 copy — old
enough to drag in a js-beautify chain with js-cookie, minimatch, and
brace-expansion advisories — still in my tree?
Lockfile inertia, again, wearing a different hat. Back when we first installed
resend 6.5.2, @react-email/render@0.0.16 genuinely was in its dependency
tree, and with autoInstallPeers: true it stayed installed when resend later
demoted it to an optional peer. From then on, every pnpm install looked at
the peer range *, found that the existing 0.0.16 resolution satisfied it
(everything satisfies *), and kept it. pnpm update -r @react-email/render
refreshed its inner dependencies and still wouldn't move the package
itself — peers don't update like dependencies. The fossil was load-bearing to
nobody and immovable by the normal tools.
The only thing that forced re-resolution was, ironically, the same mechanism that caused problem one — a root override:
"pnpm": {
"overrides": {
"@react-email/render": ">=2.1.0"
}
}
One install later, render resolves to 2.1.0 (whose dependency tree contains no js-beautify), the whole vulnerable chain evaporates, and the email package's production audit reads zero findings — down from 11 with a critical.
And note the asymmetry with lesson one: here the >= floor shape is
correct, because @react-email/render is precisely a transitive phantom —
nothing we publish declares it, so there is no shipped range to diverge from.
What actually changed
For the record, the whole remediation was four range bumps and two override edits — no source changes:
- "handlebars": "^4.7.8",
- "nodemailer": "^7.0.7",
+ "handlebars": "^4.7.9",
+ "nodemailer": "^9.0.3",
- "resend": "^6.0.0"
+ "resend": "^6.18.0"
Plus the real work, which was verifying the two nodemailer majors against the package's actual surface (the only breaking changes turned out to be an error code rename we never referenced, and TLS-validation-by-default on remote content fetches — a behavior change we documented as such and released as a major of our own).
What are the takeaways?
- An always-red check is worse than no check. Delete it or scope it; don't let it train everyone to ignore red.
- Audit totals are noise; audit paths are signal. Group findings by which package's production tree they actually reach before reacting.
- pnpm overrides replace declared ranges, workspace-wide. A floor you
added a year ago will silently eat the range bump you make today. After any
security bump, verify the resolved version (
node -e "require(...)"), never just the range you wrote. - Overrides don't ship. If an override covers a direct dependency of a published package, mismatched ranges mean your CI tests one version and your users install another. Match the shipped caret.
- Optional peers +
autoInstallPeers+ an old lockfile can preserve a dependency the ecosystem already removed. If an audit path runs through a package that shouldn't exist, check whether it's a stale peer resolution — and evict it with an override, because install and update won't.