npm Audit Counts Are Not Root Causes
Jul 02, 2026
Restoring a clean high-severity dependency gate sounds like a package upgrade. Often the code change is tiny—a lockfile override, a regenerated lock, two files touched. The hard part is proving you fixed the right thing without weakening CI policy, broadening scope, or writing acceptance criteria for dependency paths that do not exist.
The problem
The goal was straightforward: get back to zero high and zero critical npm audit findings on the main branch, with unchanged CI policy, a clean install, and a successful production build. No application code changes. No pulling a wider development branch into the release path.
The proposed fix was equally small: bump one transitive override and regenerate the lockfile.
That simplicity was misleading. Three complications surfaced before the work could be accepted:
- Severity drift. An early audit described a second high finding through an indirect path. Later live evidence classified that same path as low. Scope had to follow current advisory evidence, not the first interpretation.
- Count instability. Under one Node/npm toolchain, two high package records appeared. Under the supported Node 22 / npm 10 toolchain, the same underlying advisory propagated through ten dependency records. The headline count changed; the root did not.
- Unprovable proof obligations. An acceptance criterion required proving that a separate override resolved to a specific version—but that dependency path was dormant in the resolved graph. The criterion had to be corrected to test preservation and non-regression, not to invent a synthetic dependency.
What actually happened
The high-severity exposure came from one vulnerable leaf: a patched transitive package sitting several hops below a gRPC loader. Its severity propagated through gRPC clients, telemetry exporters, SDK auto-instrumentation, and background job libraries. The many reported high entries were not many independent remediation problems. They were one root advisory expressed through a transitive graph.
A separate override for a newer major version of the same package already existed in the manifest, but its path was not active in the resolved graph. The correct obligation was to preserve the declaration and prove the graph remained dormant—not to force a lockfile node into existence.
The final implementation matched the bounded outcome:
- zero high findings;
- zero critical findings;
- unchanged CI policy;
- clean install and successful production build;
- exactly two files changed;
- no wider dependency-chain upgrade.
Five low or moderate findings remained. They were below the enforced threshold and outside scope.
The lesson
For npm dependency remediation, treat the audit output as presentation-layer data. The durable unit of analysis is:
- the advisory root;
- the affected version range;
- the installed resolved version;
- the full dependency path;
- whether the package is direct or transitive.
Do not treat metadata.vulnerabilities.high as the number of independent problems. One patched leaf can surface as many high records when severity propagates through observability, RPC, and instrumentation stacks.
Run the decisive audit under the repository's actual supported Node and npm versions. Counts and classifications can differ across toolchains even when the underlying advisory is the same.
When writing acceptance criteria, verify each proof obligation against the resolved graph:
- Path exercised → prove the resolved version meets the ceiling.
- Path dormant → prove dormancy and non-regression; do not require evidence for nodes that are not in the graph.
A lockfile override can be the smallest correct fix when a compatible patched transitive version already exists. Broaden scope only after that bounded fix fails empirically.
The broader principle
Dependency items can have trivial implementation and non-trivial evidence requirements. The failure modes are not "we picked the wrong version." They are:
- scoping to a stale severity classification;
- hard-coding a pre-change vulnerability count instead of a zero-high outcome;
- weakening CI policy to get green;
- accepting proof contracts that cannot be satisfied without fabricating evidence;
- treating propagated audit records as separate remediation work.
Capture raw npm audit --json before and after under the supported toolchain. Record advisory roots, paths, and resolved versions—not just the summary line. Define success as zero high/critical findings with the right versions installed, plus install and build proof. Keep low/moderate remediation in separate items unless broader dependency health work is explicitly authorized.
Branch-policy exceptions for direct-to-main fixes should be explicit, item-scoped, and exhausted after use. They are governance decisions, not CI outcomes.
How to apply it
- Before scoping, run
npm audit --jsonon the supported Node/npm toolchain. Map each high finding to its advisory root and dependency path. - Scope to roots, not counts. Ask how many distinct advisories you are fixing, not how many rows npm prints.
- Try the bounded fix first—compatible override or targeted bump plus lockfile regeneration—before opening a wider upgrade.
- Write proof that matches reality. If an override is dormant, acceptance criteria should prove dormancy, not force the path active.
- Store raw evidence for audit, install, build, and dependency-graph inspection. Prose summaries are not durable proof.
- Separate residual risk. Low/moderate findings, advisory reclassification, flaky unrelated tests, and documentation debt can each be real—but they are not automatically in scope for a high-severity gate restoration.
A one-line manifest change can still deserve a careful evidence model. The goal is not the smallest diff. It is the smallest correct fix with proof that matches what the repository actually resolves.