Skip to content
Fran Gonzalez
← Back to blog
·Clanker·17 min read

Why pnpm audit --fix=update kept a vulnerable nanoid behind an auto-installed peer

A pnpm peer-resolution path dropped the update depth, retained nanoid 3.3.16, and made a full lockfile and node_modules refresh the clean no-override fix.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

A high-severity nanoid advisory produced three different outcomes across sibling repositories. I fixed the stubborn repository by deleting both pnpm-lock.yaml and node_modules, then traced the behavior through pnpm’s released source. The underlying problem was an update depth lost while pnpm auto-installed Vite as a Vitest peer.

Note

I performed the original remediation on macOS with pnpm 11.18.0 on 2026-08-10. I reproduced the relevant update behavior with pnpm 11.18.0 and 11.20.0, then reproduced and instrumented pnpm audit --fix=update with pnpm 11.18.0, 11.20.0, and 11.21.0.

The Incident

GHSA-2v37-7h3g-55p8 affects nanoid before 3.3.17. Calling a custom generator with size zero can leave it looping indefinitely.

The vulnerable version reached the repositories through the Vitest toolchain:

$ pnpm why nanoid
nanoid@3.3.16
└─┬ postcss@8.5.25
  └─┬ vite@8.2.0
    └─┬ @vitest/mocker@4.1.10
      └─┬ vitest@4.1.10

postcss@8.5.25 declares nanoid as ^3.3.16. That range admits 3.3.17 and 3.3.18, so a compatible lockfile refresh could resolve the advisory without a direct nanoid dependency.

The three repositories started from different states:

  • bswire had already floated to nanoid 3.3.17.
  • bsnews had postcss 8.5.21 and eight audit findings. Its broader update moved postcss and the surrounding graph.
  • bscraper had postcss 8.5.25, nanoid 3.3.16, and one remaining high-severity finding.

Renovate manages routine dependency updates in these repositories. This maintenance pass also removed stale transitive pins. bscraper had an overrides block for Vite, brace-expansion, and js-yaml, and the goal was to let the declared dependency ranges select patched releases.

What I Tried During the Fix

I started with the narrow commands that should have touched the relevant path. They left nanoid at 3.3.16.

$ pnpm update postcss
$ pnpm audit
1 high severity vulnerability

Deleting only the wanted lockfile produced the same result:

$ rm pnpm-lock.yaml
$ pnpm install
✓ Lockfile passes supply-chain policies (445 entries in 2.3s)
Done in 2.6s using pnpm v11.18.0

$ pnpm why nanoid
nanoid@3.3.16

The command that resolved the repository cleanly removed both sources of installed resolution state:

$ rm -rf node_modules pnpm-lock.yaml
$ pnpm install
+ typescript-eslint 8.65.0 (8.66.0 is available)
+ vitest 4.1.10
Done in 3.2s using pnpm v11.18.0

$ pnpm why nanoid
nanoid@3.3.18

That fresh resolve also let me remove the existing Vite, brace-expansion, and js-yaml overrides. Their real parents selected patched releases through compatible ranges.

The resulting work stayed small:

  • bsnews PR #62 moved from eight findings to zero. Its checks passed.
  • bscraper PR #35 moved from one high finding to zero and removed the overrides block. Its checks passed.

The clean install solved the incident. I continued because the result conflicted with the expected update behavior and with guidance from pnpm’s maintainer.

Reproducing the Behavior After the Fix

I checked out bscraper immediately before the remediation commit (7cf37b1) and repeated each command from a clean copy. This separated the later investigation from the commands used during the live maintenance session.

With pnpm 11.18.0, the matrix was:

CommandResolved nanoidPersistent configuration change
pnpm update3.3.16None
pnpm update postcss3.3.16None
pnpm update nanoid3.3.16None
pnpm dedupe3.3.16None
rm pnpm-lock.yaml && pnpm install3.3.16None
pnpm audit --fix=update3.3.16Added an age exclusion
pnpm audit --fix=update && pnpm install3.3.16Kept the age exclusion
pnpm audit --fix=override && pnpm install3.3.18Added a nanoid override and age exclusion
rm -rf node_modules pnpm-lock.yaml && pnpm install3.3.18None

pnpm update nanoid did perform a resolution pass. It simply retained the vulnerable version:

$ pnpm update nanoid
Progress: resolved 445, reused 328, downloaded 0, added 0, done
Done in 1.1s using pnpm v11.18.0

I repeated the ordinary update tests across bsnews and bscraper with pnpm 11.18.0 and 11.20.0. bsnews moved because its graph contained an ordinary update path through an older postcss. bscraper stayed pinned in both pnpm versions. That result made the repository topology more important than the pnpm patch version.

I then repeated pnpm audit --fix=update on bscraper with pnpm 11.18.0, 11.20.0, and 11.21.0. Every version reported the same result:

0 vulnerabilities were fixed, 1 vulnerability remains.

The remaining vulnerabilities are:
- (high) "nanoid: custom generators can loop indefinitely when size is zero" nanoid

Clearing node_modules before pnpm audit --fix=update also left nanoid at 3.3.16. That result separated the audit-fix failure from pnpm’s materialized modules state.

In pnpm discussion #9843, maintainer Zoltan Kochan described his normal sequence: run pnpm update, then run pnpm audit --fix and pnpm install when the update leaves findings.1

pnpm 11 exposes two explicit fix modes. I tested both against the same pre-fix bscraper tree.

--fix=update

This mode is intended to update vulnerable packages in the lockfile without adding overrides. It matched the maintenance goal, but it left nanoid at 3.3.16.

It also wrote this entry:

minimumReleaseAgeExclude:
  - nanoid@3.3.17

That entry was unnecessary. nanoid 3.3.17 was published on 2026-08-03, seven days before the incident. nanoid 3.3.18 was published on 2026-08-07. Both had aged past the repository’s 24-hour minimumReleaseAge: 1440 policy.

--fix=override

The override mode wrote:

overrides:
  nanoid@<3.3.17: ^3.3.17

minimumReleaseAgeExclude:
  - nanoid@3.3.17

The command asks for a follow-up install. After that install, nanoid resolved to 3.3.18 and the audit passed.

This gave me two working remediations with different repository outcomes:

  1. pnpm audit --fix=override followed by pnpm install fixed the advisory and retained a new nanoid override.
  2. Removing node_modules and the lockfile before installing fixed the advisory without adding an override.

The second result matched the original maintenance goal.

Ruling Out minimumReleaseAge

The audit command’s generated exclusion looked like an explanation at first. Release dates ruled it out for nanoid.

The 24-hour policy did affect another dependency during the same maintenance session. @redocly/openapi-core@1.34.19, which carried a js-yaml fix, had been published less than a day earlier. That package required an intentional temporary exclusion.

nanoid occupied a different case. Its patched releases were already old enough, and pnpm audit --fix=update still retained 3.3.16 after adding the exclusion.

The extra entry came from a separate pnpm bug. In pnpm 11.21.0, fixWithUpdate called createMinimumReleaseAgeExcludes for every fixable advisory whenever minimumReleaseAge was configured. The helper selected the minimum patched version without checking its publication time. pnpm issue #11563 documented that behavior, and PR #13678 merged a publication-time check after pnpm 11.21.0 was released.2

That patch explains the unnecessary configuration entry. The resolver path that kept nanoid at 3.3.16 is a separate defect.

Reading pnpm audit --fix=update

I cloned pnpm’s source at the pnpm 11.21.0 release commit and followed the update path.

My first hypothesis was that pnpm had converted the audit into a version-pinned transitive update. The source disproved that explanation for audit --fix=update.

fixWithUpdate converts each advisory’s vulnerable range into a PackageVulnerabilityAudit predicate, then calls the normal update handler:

await update.handler(
  {
    ...updateOpts,
    packageVulnerabilityAudit,
  },
  [],
);

For this advisory, the predicate answers whether a nanoid version satisfies the vulnerable range. The install command then creates two controls:

  • An updateMatching function identifies locked nanoid versions that are vulnerable.
  • A preferred-version penalty lowers the resolver score for versions in the vulnerable range.

The high-severity penalty has weight -3000. Under the intended path, updateMatching marks nanoid as the target and pnpm strips nanoid’s lockfile-derived preference before selecting a compatible version.

This design came from pnpm audit --fix=update PR #10341. A later resolver change, PR #12558, states the relevant contract explicitly: a targeted update should produce the lockfile that a fresh install of the same manifests would produce.

The bscraper result violated that contract, so I instrumented the released pnpm 11.21.0 bundle.

What pnpm’s Repository Establishes

The intended outcome is clearer than my first reading suggested. pnpm’s repository contains three complementary signals:

  1. PR #8615 says updating one package should leave its automatically installed peer dependencies alone. Its regression test updates abc and asserts that unrelated peer-a and peer-c versions stay locked. The implementation forces updateToLatest: false during synthetic peer resolution.
  2. PR #9928 covers the opposite case. When a peer is declared in the importer’s own peerDependencies and explicitly selected for update, pnpm should update it.
  3. Issue #12487 reports that pnpm update <pkg> cannot update <pkg> when it exists only as an auto-installed peer. The repository tracks it as an open type: bug. Issue #10486 tracks the same class for dangling transitive peers, including a security-remediation case.

The exact bscraper failure sits one level deeper than those reports. Vite was the auto-installed peer; nanoid was an ordinary transitive dependency under vite -> postcss. pnpm reset the update depth when it synthesized Vite, which disabled the nanoid update matcher for that entire subtree. The retained lockfile preference then outweighed the audit penalty and kept the vulnerable nanoid 3.3.16.

Issues #12487 and #10486 therefore track the same synthetic-peer update boundary, but neither issue documents this exact descendant audit --fix=update manifestation or its resolver weights. I treat the incident as a concrete case of that existing bug class rather than claiming either report already contains the complete nanoid reproduction.

These sources establish target-only behavior. An update should reach the package named by the update matcher, including through a synthetic peer path, while leaving unrelated auto-installed peers pinned. The no-op in bscraper is therefore a resolver defect rather than an intentional freeze of synthetic peers.

The newer Rust resolver also answers the depth question in de facto code. PR #13288 made update depth part of an operation-level UpdateSeedPolicy. Package names remain scoped per importer, but one maximum depth gates every node in that update. Required and optional peers are aggregated, synthesized at the importer level, and resolved through the same tree context. The Rust path therefore uses aggregated operation depth rather than retaining a separate depth for each path that reported the peer.

That model preserves the two contracts above. The name scope keeps unrelated peers pinned, while the operation depth lets the selected name remain reachable through a hoisted peer. It also makes the maximum-depth proof patch directionally consistent with pnpm’s newer resolver. In this incident, audit --fix=update targeted vulnerable nanoid at unlimited depth, so nanoid should have remained reachable.

Where the Update Depth Disappeared

Vitest and @vitest/mocker expose Vite as an optional peer. With autoInstallPeers: true, pnpm resolves the missing Vite peer in a separate root-level peer-hoisting pass.

Direct importer dependencies receive the update command’s depth, which is Infinity for these update calls. The generic importer resolver options keep a fallback depth of -1:

// pnpm11/installing/deps-resolver/src/resolveDependencyTree.ts
const resolveOpts = {
  currentDepth: 0,
  // ...
  updateDepth: -1,
  updateMatching: importer.updateMatching,
};

The peer-resolution pass creates synthetic wanted dependencies for required and optional peers:

// pnpm11/installing/deps-resolver/src/resolveDependencies.ts
const wantedDependencies = getNonDevWantedDependencies({
  optionalDependencies,
});

const resolveDependenciesResult = await resolveDependencies(
  ctx,
  preferredVersions,
  wantedDependencies,
  {
    ...options,
    parentPkgAliases,
    publishedBy,
    updateToLatest: false,
  },
);

Those synthetic dependencies omit updateDepth, so they inherit the fallback -1. The update matcher remains present, and the resolver calls it only while the current depth is inside the update depth:

const updateShouldContinue = options.currentDepth <= updateDepth
const updateRequested =
  updateShouldContinue &&
  // call updateMatching for the locked package

At the synthetic Vite root, 0 <= -1 is false. The whole vite -> postcss -> nanoid subtree continues with updateRequested: false.

This topology was the important difference. pnpm’s audit-fix tests cover vulnerable dependencies at depths two and three, including workspace cases. Their fixtures use ordinary dependency edges and omit the auto-installed-peer path that lost the update depth.

The Runtime Trace

I added local diagnostics to the released pnpm 11.21.0 bundle and ran pnpm audit --fix=update against bscraper before its fix.

The nanoid edge reached the resolver like this:

wanted: nanoid@^3.3.16
currentDepth: 2
optionsUpdateDepth: -1
updateDepth: -1
updateShouldContinue: false
updateRequested: false

The npm picker still saw every relevant release:

available: 3.3.16, 3.3.17, 3.3.18
picked:    3.3.16

Its preferred-version selectors explained the choice:

3.3.16:  version weight  1000000
<3.3.17: range weight      -3000

The million-point preference came from the existing lockfile. The negative range came from the high-severity vulnerability audit.

stripLockfileVersionPins removes the target’s lockfile preference when updateRequested is true. The peer path made that flag false, so the stale preference remained and outweighed the vulnerability penalty.

This also explains why the failure survived with node_modules cleared before audit --fix=update. The wanted lockfile still supplied the million-point preference.

Confirming the Cause with a Local Patch

I changed the released bundle only at the peer-resolution handoff. The experimental change propagated the importer’s maximum update depth into the synthetic peer dependencies:

const peerUpdateDepth = Math.max(
  -1,
  ...importer.wantedDependencies.map(
    (dependency) => dependency.updateDepth ?? -1,
  ),
);

const wantedDependencies = getNonDevWantedDependencies({
  optionalDependencies,
}).map((dependency) => ({
  ...dependency,
  updateDepth: peerUpdateDepth,
}));

I applied the same proof change to required and optional peer creation, then repeated the audit fix against the untouched pre-fix bscraper repository.

Resolvernanoid updateRequestedPicked nanoidAudit result
Released pnpm 11.21.0false3.3.161 high
Local peer-depth patchtrue3.3.180

The patched trace showed:

optionsUpdateDepth: Infinity
updateDepth: Infinity
updateShouldContinue: true
updateRequested: true
picked: 3.3.18

I treated that patch as a causal test rather than a complete TypeScript fix. Taking the maximum importer depth matches the operation-level aggregation already used by pnpm’s newer Rust resolver. A production change would still need focused regression tests for required peers, optional peers, multiple importers, finite --depth values, and PR #8615’s guarantee that unrelated auto-installed peers do not move.

The repository establishes both the user-visible semantics and the newer resolver’s aggregation direction: the explicit or vulnerability-matched target should update, unrelated peers should stay pinned, and one operation depth should gate matching nodes. The local experiment established that the missing depth handoff prevented those semantics in pnpm 11.21.0.

I kept the investigation local and stopped before creating an upstream issue or pull request.

Explaining Every Observed Result

The source trace connected the commands that originally looked inconsistent.

Why pnpm update nanoid retained 3.3.16

The command created an update matcher for nanoid. The inherited updateDepth: -1 kept that matcher inactive below the synthetic Vite peer, so the picker retained the lockfile’s preferred 3.3.16.

Why pnpm audit --fix=update retained 3.3.16

The audit command used a vulnerability-aware matcher and penalty. The same peer path disabled updateRequested, leaving the million-point lockfile preference in place. That preference outweighed the high-severity penalty.

Why pnpm audit --fix=override worked

The override changed the permitted resolution for vulnerable nanoid versions. It made the existing 3.3.16 selection inapplicable, so the follow-up install selected 3.3.18 even though the peer path still lacked update depth.

Why deleting only pnpm-lock.yaml retained 3.3.16

pnpm can reconstruct a missing wanted lockfile from node_modules/.pnpm/lock.yaml when the materialized graph still satisfies the manifests. PR #12004 added that optimization. bscraper also enables preferFrozenLockfile.

Keeping node_modules therefore kept a source for the old graph. pnpm regenerated the wanted lockfile with nanoid 3.3.16 instead of performing the same resolution as an empty installation.

Why deleting both worked

Removing both lockfiles of interest removed the million-point preference for nanoid 3.3.16. The peer path still lacked targeted-update context. With the stale selector gone, the fresh picker selected the highest compatible release available under the repository policies, nanoid 3.3.18.

Why the sibling repository behaved differently

bsnews had additional ordinary paths through packages that were already due for updates, including postcss 8.5.21. Those paths carried the normal update depth and moved the surrounding graph. The broader movement masked the peer-path defect that remained exposed in bscraper, where the relevant Vite and postcss chain was otherwise stable.

The Corrected Model

My initial explanation focused on ancestor movement: bsnews moved postcss, while bscraper kept postcss stable. That observation was real, but it described the outcome rather than the root cause.

The source-backed model is narrower:

  1. pnpm seeded nanoid 3.3.16 as a strong lockfile preference.
  2. The audit fix supplied a penalty for vulnerable nanoid versions.
  3. Vite entered bscraper through an auto-installed optional peer path.
  4. pnpm synthesized that peer without propagating the update depth.
  5. nanoid reached the picker with updateRequested: false.
  6. The picker retained the lockfile preference and selected 3.3.16.
  7. An override invalidated that selection, while a fully empty install removed the stale preference.

The maintainer’s recommended audit workflow remains useful. This repository reached an untested resolver edge where the update mode and a fresh install produced different lockfiles. The override mode worked as designed. The clean install remained the successful no-override remediation.

What I Would Do Next Time

I would still start with the narrow path:

pnpm why <package>
pnpm update
pnpm audit --fix=update
pnpm audit

If the vulnerable package sits below an auto-installed peer and remains pinned, I would compare two explicit outcomes:

  • pnpm audit --fix=override && pnpm install for a targeted fix that persists an override.
  • rm -rf node_modules pnpm-lock.yaml && pnpm install for a complete compatible re-resolution, followed by a full lockfile review and test run.

The second command can move unrelated packages within their declared ranges. In this maintenance pass that broader movement was intentional because the task included removing stale transitive overrides. For a single advisory under a tighter change budget, the generated override provides the smaller lockfile change.

References

Footnotes

  1. pnpm discussion #9843 records the maintainer’s update, audit-fix, and install sequence. pnpm audit --fix=update PR #10341 later added the lockfile-update mode discussed there.

  2. pnpm PR #13678 merged on 2026-08-10 at 17:54 UTC. The pnpm 11.21.0 release commit predates it, so all three pnpm versions I tested still contained the unconditional exclusion behavior.

This post was written with AI assistance.