Skip to content

Scanner gap: pnpm-audit source is blind to stale hoisted copies on disk; cve-lite OA008 caught what it missed #146

Description

@alamb-hex

A project can pass the pnpm-audit source with 0 findings while a vulnerable package sits on disk in node_modules. Found live while patching hexaxia.tech on 2026-09-08.

What happened

After fixing the override floors on hexaxia.tech (main and dev), verification looked complete:

pnpm audit  ->  0 critical / 0 high / 0 moderate / 0 low
lockfile    ->  js-yaml@4.3.2 only
pnpm why    ->  js-yaml@4.3.2 only

cve-lite 1.29.1 disagreed:

CRITICAL (1)
OA008  js-yaml  Override floor not applied - vulnerable copy still on disk
                package.json > /pnpm/overrides/js-yaml

cve-lite was right. On disk:

node_modules/js-yaml/package.json                      -> 5.2.3   <-- real directory, dated Aug 11
node_modules/.pnpm/js-yaml@4.3.2/node_modules/js-yaml  -> 4.3.2

js-yaml@5.2.3 is below the 5.x fix line (5.3.0), so it is vulnerable to GHSA-5p4m-2wfm-xmqj. It appeared in no lockfile entry.

Root cause

node_modules held a stale hoisted layer from an older install — 379 top-level entries where pnpm's isolated linker produces 38. pnpm's isolated install does not remove a pre-existing hoisted tree, so the orphaned copies persisted across every subsequent pnpm install and lockfile change.

This matters beyond dead weight: under pnpm's isolated layout, top-level node_modules/ is on the Node resolution path for the project's own source. A bare require('js-yaml') from project code would have resolved to the stale 5.2.3, not the 4.3.2 the lockfile promises.

rm -rf node_modules && pnpm install --frozen-lockfile cleared it (lockfile untouched, 379 -> 38 entries). OA008 then went clean on both projects.

The gap

PnpmAuditSource inherits pnpm audit's model: the lockfile is the truth. Anything on disk but not in the lockfile is invisible to it. So HexOps would have reported these two projects fully clean while a vulnerable package was physically present and resolvable.

This is the read-path twin of #80 (which fixed false-positive success for nested deps) — same family, different surface: there we trusted a top-level version check, here we trust the lockfile.

Proposed

  1. Divergence check — after any scan, walk node_modules for package package.json files whose name@version has no lockfile entry, and surface them as findings rather than silently ignoring them. This is the concrete detector Flag divergence between committed lockfile and installed node_modules in security scan #113 describes but does not specify.
  2. Hoisted-layer heuristic — for a pnpm project with no node-linker=hoisted in .npmrc, a top-level entry count far exceeding the direct-dependency count is strong evidence of a stale hoisted tree. Cheap to compute, high signal.
  3. Prune before re-verifyverifyAuditClear should consider a clean reinstall before declaring an advisory cleared, so a stale on-disk copy cannot read as either a false clear or a false "still vulnerable". Remediation engine mishandles grype Go-binary findings: bumps wrapper package, leaves stale binaries, falsely reports 'findings persist' #122 already asked for prune+reinstall on the grype path for the same underlying reason; this generalises it.
  4. Adopt OA008 as a source signal. cve-lite already detects this correctly and OverrideHygieneSource already runs the overrides subcommand. Worth confirming OA008 is surfaced at full weight rather than being treated as advisory-only config noise, since here it was the only scanner telling the truth.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions