npm Lockfile CVE Remediation
Status: Draft
Language: English
Companion: Deutsch
A tool-agnostic procedure for clearing lockfile-level CVEs in an npm project: fixing package-lock.json so known vulnerable versions no longer resolve, without touching application source and without silently breaking the install.
It is an operational companion to the Agentic Coding Playbook, useful whenever an agent or a human remediates dependency CVEs in a lockfile-based npm repository.
Purpose
Most CVEs against a Node project are lockfile or transitive issues, not application-code issues: the fix is to change what package-lock.json resolves, not to change source. That sounds mechanical, but three failure modes make it easy to ship a lockfile that looks fixed and is actually broken:
- a fix coherent by npm's own audit check but not installable by
npm cion the CI toolchain; - a fix that silently drops a nested lockfile record a peer dependency still needs;
- an override that closes the advisory but breaks a consumer at runtime, invisibly, because nothing in the test suite exercises the affected code path.
This procedure exists to catch all three before the change reaches review.
When to apply
Apply whenever a scanner (Dependabot, npm audit, or an equivalent) reports a vulnerability whose fixed version exists within the current major of the affected package, or whenever the fix needs the cross-major override recipe below for a coupled pair of packages.
Do not apply it to a fix that requires a breaking major bump of a package actually used in application logic; that is a separate, judgment-heavy change that belongs in ordinary upgrade review instead.
Procedure
1. Fetch before you count
npm audit reads the checked-out lockfile, not the remote HEAD. A checkout even a few commits behind the default branch will under- or over-report the count. git fetch and confirm you are counting against the current default branch before treating any count as a baseline.
2. Resolve the CI npm version, and pin it for the whole procedure
Different repositories can pin different Node majors, which determine the bundled npm major. Read the pin, do not assume it:
grep -A4 'actions/setup-node' .github/workflows/*.ymlRead node-version: from that step. If the workflow also pins an explicit npm version (an npm install -g npm@<x> step, or a packageManager field via corepack), that pin wins over the Node-bundled default. Otherwise resolve the Node-bundled npm for that major and pin node and npm together in the same shell, not nvm use alone. The example below uses nvm:
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" # no-op where nvm is already a shell function
nvm install <major> # no-op if already installed locally
nvm use <major> && export PATH="${NVM_BIN:?nvm activation failed}:$PATH"
node --version && npm --versionThe transferable requirement, regardless of which version manager a repository uses: activate node and npm together in the shell that will run the fix and the verification, then confirm both with node -v and npm -v in that same shell before proceeding.
A version manager's "switched to" banner is not a stamp of what the shell resolves: an earlier PATH entry can shadow the switcher's shim, so the banner can print the right version while node/npm still resolve elsewhere. Print npm -v yourself in the exact shell that runs the fix and the verification, and treat a match against the CI version as a check asserted before the run, not an assumption carried over from the switch command. If the stamp does not match, re-run the full sourcing-plus-export PATH form above in that exact shell; if it still does not match, check whether the workflow pins npm explicitly (corepack, or npm install -g npm@<x>) and install that pinned version directly instead of retrying the version-manager form. A bare absolute path to an npm binary is not a substitute: many npm launcher scripts carry a #!/usr/bin/env node shebang, so an absolute npm path pins npm alone while node keeps resolving from the ambient PATH, giving a node/npm pairing CI never runs.
This pin must stay active for every write and verification step below, in the same shell: activation does not carry over between separate shell invocations, so re-source and re-activate in every new shell rather than relying on an earlier activation still being in effect.
3. Fix the lockfile, staying within-major
npm audit fix --package-lock-only, under the pinned CI npm version, clears most within-major advisories.- For a direct dependency the fixer leaves behind (often conservatively pinned), bump it explicitly, under the pinned CI npm version:
npm install <pkg>@<fixedVersion> --package-lock-only -w <workspace>(drop-wfor a single-package repository). - Verify with the coherence gate below before moving on.
Only apply within-major bumps here. A fix needing a breaking major bump (typically dev, build, or test tooling) is out of scope: defer it as a separate, explicitly-reviewed upgrade rather than folding it into a CVE fix. Ignore audit suggestions that would actually downgrade an unrelated package; those are false positives from the resolver's constraint search. Never touch application source to clear a transitive CVE.
Lockfile writer major: do not let an older npm rewrite unrelated records
A lockfile written by a newer npm major and then rewritten by an older one (even by a no-op npm install --package-lock-only) can lose fields the older npm does not write. The observed symptom is the libc arrays on optional native-binary records (for example lightningcss-* or glibc/musl variants of @rollup/rollup-linux-*) disappearing from records the fix never meant to touch, which buries the real change in unrelated churn and forces the reviewer to re-verify records that did not need to change. The cause is the writer major, not the advisory. It was reproduced with npm 11 writing and npm 10 rewriting; the older npm drops the arrays.
Rule: the npm major that last wrote the lockfile is the one that keeps writing it. Decide it per repository (the CI npm from step 2, or the major that wrote the committed lockfile) and record it, for example in a packageManager field. If the CI npm is older than the writer of the committed lockfile, do not let it rewrite the file: hand-apply the change instead. This is the one exception to items 1 and 2 of step 3; the coherence gate in step 4 still runs under the CI npm.
Hand-apply recipe, for each target record:
- Edit only the target
node_modules/<pkg>record inpackage-lock.json(version,resolved,integrity,dependencies, andoptionalDependenciesas applicable), plus the matching range in the owningpackage.jsonand its lockfile manifest record when the package is a direct dependency:packages[""]for the root orpackages["<workspace-path>"]for a workspace. - Obtain the registry metadata, never relying on memory or a neighbouring record:
npm view <pkg>@<version> version dist.tarball dist.integrity dependencies optionalDependencies --json. - Verify all three checks below for every hand-applied record. The general dry-run gate in step 4 also applies; it does not replace these checks.
- Compare
version,resolved(againstdist.tarball),integrity(againstdist.integrity),dependencies, andoptionalDependencieswith thenpm view <pkg>@<version>metadata, including absent dependency fields. Investigate any mismatch before proceeding. - In a clean checkout, with the CI npm from step 2 active, run a real
npm ci --cache "$(mktemp -d)"(not--dry-run) and capture the unpiped exit code; it must be 0. npm's content-addressed cache can satisfy a warm-cache install without fetching the tarball and mask a wrong integrity value. An empty cache forces the integrity check against downloaded content.npm cinever writes the lockfile, so an unchanged lockfile after the command proves nothing. The exit code says nothing about optional records: npm skips an optional package whose tarball fails the integrity check (observed with the@next/swc-*platform binaries) and still exits 0, so the registry comparison above is the only check for those records. - In separate clean copies of the base and the proposed head, run npm 10
npm install --package-lock-onlyon both copies, then diff their normalizedpackage-lock.jsonfiles. The diff must contain only the intended target records (including the owning root or workspace manifest record for a direct dependency). npm 10 strips npm 11'slibcarrays from unrelated records, so normalizing only the head would make unrelated changes appear in the diff. Keep the hand-applied lockfile as the submitted file; use these copies only to compare the normalized results.
- Compare
4. The coherence gate: npm ci --dry-run, clean checkout, CI npm version
A lockfile npm audit --package-lock-only reports clean, and that npm install --package-lock-only reproduces byte-for-byte as a no-op, is not proof it is installable. npm audit only reads package-lock.json for known advisories; it never installs. A no-op npm install only shows nothing would change on top of what is already there, not that it is coherent enough for npm ci to accept.
Two npm majors can also disagree about the same lockfile: a newer npm can tolerate a missing nested record an older one refuses, so a fix that installs cleanly locally can still fail npm ci under CI's pinned npm major. Run this gate under the CI npm version specifically, immediately after the lockfile fix (step 3) and before review, on every affected tree (root plus every workspace or sub-package with its own lockfile). A tree that fails it goes back to repair, not forward to review.
a. Extract a clean checkout. An existing node_modules in a worked-in checkout can mask the discrepancy:
SNAP=$(mktemp -d)
git archive <branch-or-sha> -- <tree-subdir> | tar -x -C "$SNAP"
cd "$SNAP/<tree-subdir>" # or "$SNAP" directly when the tree is the repo rootb. Run the dry-run, CI npm version active, scripts disabled:
npm ci --dry-run --no-audit --no-fund --ignore-scripts--ignore-scripts matters: without it, a missing local dev tool referenced by a prepare script reports an unrelated exit code that looks like a lockfile break and is not one. A real break looks like:
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock
file with `npm install` before continuing.
npm error Missing: <pkg>@<version> from lock fileCapture the exit code directly, never behind a pipe: npm ci --dry-run ... | tail -N; echo $? reports tail's exit status, not npm ci's, and a truncated-but-passing tail can read as success when the install failed. Run the command bare and capture $? right after it, redirecting to a file first if the output needs paging: LOG=$(mktemp); npm ci --dry-run ... > "$LOG" 2>&1; echo $? (a fresh mktemp per run, not a fixed filename; see "Isolate parallel reviewers" below). If a pipe is genuinely unavoidable, put set -o pipefail before it rather than reading an exit code out of a shell array. Without pipefail, a pipeline reports the last command's exit status regardless of what failed earlier in it:
set -o pipefail; sh -c "exit 7" | tail -n1; echo $? # reports 7
sh -c "exit 7" | tail -n1; echo $? # reports 0, tail's own statusDo not rely on a PIPESTATUS-style array as a substitute: the spelling and indexing are shell-specific (${PIPESTATUS[0]} in bash, zero-indexed; ${pipestatus[1]} in zsh, lowercase, one-indexed), a shell without either reads it as empty, and a numeric guard against that empty value fails open. Prefer set -o pipefail over either array form.
c. Attribution control (mandatory): run the identical check against the pre-fix baseline (same tree, pre-fix default branch HEAD, same clean-checkout-then-npm ci --dry-run recipe, same CI npm version):
- baseline OK, fix-branch FAIL: the fix caused it. Repair now, before this tree proceeds.
- baseline FAIL, fix-branch FAIL: pre-existing debt, not this fix's. Log it; do not silently "fix" it or wave it through as caused by this change.
- Only a baseline-OK, fix-branch-FAIL pair is attributable to this fix. A red run alone is not proof; check the baseline first.
d. Repair, CI npm version active, in the real branch (not the disposable snapshot), then re-verify with a fresh clean-checkout extract:
npm install --package-lock-onlyrun with the CI-version npm binary re-nests whatever version a peer dependency actually needs, instead of leaving it hoisted away. Redo steps (a)-(b) against the repaired branch to confirm the dry-run exits 0 before moving to review.
5. Check for dropped nested records
npm audit fix --package-lock-only re-hoists packages, and hoisting can silently delete a nested record something still depends on: re-hoisting a package to a newer version at the root can drop a nested, older copy a dependent still requires via a narrower semver range, even though nothing in package.json says so, and even though npm audit reports the result clean.
After every npm audit fix, diff the lockfile's package keys against the pre-fix base and check for removed nested paths (any key containing more than one node_modules/ segment). Restore any that disappeared and that another entry still depends on. A nested record a previous commit placed on purpose is load-bearing even when nothing in package.json enforces it, and the coherence gate above does not always catch this: a workspace CI does not build or test can break this way and still show a green pipeline.
6. "Dev-only" is a claim, not an assumption
Deferring an advisory because the package is dev-only is a reasonable call, but verify it per repository rather than carrying the phrasing over from a previous fix. Check the lockfile's own dev flag, and read its polarity correctly: npm lockfile v2/v3 writes dev: true on a dev-only entry and omits the dev key entirely on a production-closure entry, so it never writes dev: false. An entry without dev: true is in the production install closure, regardless of what package.json implies. A package can land in the production closure indirectly, through an unrelated production dependency that declares the vulnerable package under dependencies even though it never uses it at runtime; the lockfile's dev flag determines exposure.
Verify with a positive command instead of eyeballing the flag by hand: npm ls --omit=dev <pkg> prints the package (exit 0) when it is reachable from the production tree, and prints (empty) (exit 1) when it is not. Run it before deferring any advisory as dev-only.
7. Defer majors
A fix needing a semver-major bump (typically build, lint, or test tooling, but sometimes a runtime dependency) is out of scope for a lockfile CVE fix. File it as a separate, explicitly-reviewed upgrade task instead of bumping it here, even when the audit tool suggests it.
The cross-major override recipe
Some advisories have no in-major fix for one of the package's major lines: every release on that line is vulnerable, and the fix only exists on the next major. A concrete example is GHSA-mh99-v99m-4gvg (a brace-expansion regex-denial-of-service advisory): its fix line is per-major (an ordinary within-major bump clears it on the 1.x/2.x/3.x lines), but the 4.x line has no in-major fix, closing only by moving into 5.x. Re-check the advisory's current fix lines before relying on this worked example; an advisory's fix lines can be revised after publication. When the next major also changes the exported API shape, and a coupled package was rewritten against that shape, closing the advisory can require forcing both packages to matching new majors via overrides:
"overrides": {
"brace-expansion": "^5.0.8",
"minimatch": "^10.2.5"
}Why both packages, together, as a general rule. brace-expansion 5.x changed its export shape (an object of named exports, not the callable expand() function older lines exported), and minimatch 10 was rewritten to call that shape, itself now exporting an object instead of the callable function most prior majors exported. Pinning one half of a coupled pair without the other breaks at runtime, not install time: npm install/npm ci succeed either way, so the coherence gate in step 4 does not catch it. As a general rule, do not assume the safe cutoff is exactly "new major safe, everything below unsafe" from the major number alone, and do not assume a consumer's declared range for the affected package is monotonic across that consumer's own patch releases: a later patch release of a consumer can revert to an older, still-vulnerable range even after an earlier patch moved to a fixed one. The fix line itself is one boundary per affected major; what varies is what range each consumer actually declares against it. Check the advisory's actual per-major fix line and the installed pair's actual declared range and exported shape in the repository at hand, rather than reusing a conclusion from elsewhere.
overrides at the top level, with no package-path qualifier, rewrites every consumer's request for that package to the same version, regardless of what range that consumer declared (an eslint plugin asking only for minimatch ^3.1.2 gets the override's 10.x substituted underneath it anyway). That makes it a cross-major forced substitution for consumers that were happily nested on an older, compatible pair before the override, exactly the shape "defer majors" above says to avoid. Only reach for it when nesting alone does not clear the advisory: in practice, when a consumer is actually pinned to the fully-vulnerable major line with no in-major escape. Prefer letting npm audit fix (or a plain transitive bump) nest the fixed version under only the consumers that already declare a compatible range for the new API, leaving other consumers on their own compatible older, nested pair; that outcome needs none of the checks below, because nothing crosses a major for a consumer that never agreed to it.
Precondition 1: probe for an ESM default-export break. If a consumer does an ESM default import of the affected package (for example, @eslint/eslintrc's config-array loader doing import minimatch from "minimatch") and the new major exports named bindings only, that consumer breaks as soon as the module is imported, before any of its own logic runs. Probe against the repository's own installed tree before applying the override anywhere:
node -e "import('@eslint/eslintrc').then(()=>console.log('ok'),e=>{console.error(e.constructor.name+': '+e.message);process.exit(1)})"A break looks like:
SyntaxError: The requested module 'minimatch' does not provide an export named 'default'A consumer that never loads the affected import path (for example, a flat-config-only setup with no legacy compatibility layer) does not hit this precondition; check whether that path is loaded at all first.
Precondition 2: probe for a CJS-callable break. If the affected package used to export a callable function and the new major exports an object, every installed consumer still calling it as a function (a direct require('minimatch')(...) chain, a local variable assigned from require and called later, or the Babel-interop equivalent of a default-import call) throws a "not a function" error the moment that code path runs, not at import time. The break is latent: it only shows up when the specific rule, option, or branch reaching the call actually runs, and it can span multiple call sites across several eslint plugins at once; treat any such list as a floor to re-derive per repository, never a closed count.
Enumerate against the repository's own node_modules, not a list carried over from a previous fix, since call shapes and counts vary between packages and versions:
grep -rnE "require\('minimatch'\)\(|\(0, *_minimatch[0-9]*(\.default|\['default'\]|\[\"default\"\])\)\(" node_modules/grep -rl "= require('minimatch')" node_modules/The first catches chained calls and Babel-interop shapes. The second finds files assigning the import to a local name that may be called later in the same file (a shape some eslint-plugin-react rules use, matching nothing in the first grep); treat its output as candidates to inspect by hand, not a final count. Treat both as a starting list to re-derive per repository, not a checklist to tick off once.
Confirm reachability against the repository's own real configuration, since the break only fires when the specific rule/option/branch is active:
npx eslint . --format jsonA break exits non-zero with no JSON on stdout at all; the run aborts rather than reporting a lint error, for example:
TypeError: minimatch is not a function
…/eslint-plugin-react/lib/rules/jsx-pascal-case.js:66:34A green run is not proof of safety, only of absence on paths exercised today:
SCRATCH=$(mktemp -d); OUT="$SCRATCH/eslint-out.json"
npx eslint . --format json > "$OUT"
node -e "console.log(require(process.argv[1]).length)" "$OUT"An empty $OUT means the run aborted before writing output, not that zero files were linted; check the exit code of the eslint invocation itself before trusting an empty or missing file as "zero findings." Confirm the run actually linted a non-trivial number of files before trusting a pass; a stale include/exclude glob can silently match zero files. A rule not enabled today can still reach the same broken call path once enabled tomorrow, or in a sibling repository not checked; a clean run today says nothing about either.
The coupling trap: do not "solve" it by downgrading the paired package. Pairing the fixed major of one package with an older major of its coupled package is not a smaller, safer version of this override; it is a third, worse break, because the first package's new major is exactly the API the second package's new major was rewritten to call. Forcing brace-expansion@5.0.8 over an older minimatch@3.1.5 (no nested compatible pair, a single flattened install) fails like this:
const minimatch = require('minimatch');
minimatch('a/x.ts', '**/*.{ts,tsx}');TypeError: expand is not a functionThe correct argument against downgrading the paired package is this runtime break, not a claim that it reintroduces the original CVE: an older minimatch release typically still resolves its own brace-expansion dependency to an already-fixed in-major version through its own declared range, so downgrading does not necessarily reintroduce the vulnerability, it introduces a different bug.
Default posture: this override is a deferral, not a same-fix action. Per "defer majors" above, a cross-major forced substitution belongs in a separate, explicitly-reviewed upgrade task, the same as any other breaking major, even when a consumer is genuinely stuck on the fully-vulnerable major line with no in-major escape. Apply the override inside an otherwise routine lockfile fix only with an explicit, recorded sign-off from whoever owns that risk decision, and cite both precondition probe results in that request. A passing test suite is not a substitute for either probe; see "A green suite is not evidence for an override" below.
Review checklist
Spawn a review pass for every lockfile CVE fix, never skip it, even for a lockfile-only change. Brief the reviewer to verify, with per-finding severity and an explicit approve / request-changes verdict:
- Scope: the diff is lockfile (plus minimal direct-dependency
package.jsonbumps) only; no unrelated changes, no source edits. - CVE resolution: the target advisories are actually resolved (the fixed version is actually reached).
- No majors: no semver-major bump slipped in unannounced.
- Lockfile coherence: the mandatory
npm ci --dry-runsync check (clean checkout, CI npm version,--ignore-scripts, attribution control against the pre-fix baseline; step 4 above) actually ran for every affected tree and passed, or a baseline-FAIL was logged as pre-existing rather than accepted or blamed on the fix. Require the evidence in that exact form: the unpiped exit code captured right after the barenpm ci --dry-runcommand, plus thenpm -vreading taken in that same shell invocation, cited per tree. Thenpm -vreading only means something when the CI-version activation (step 2) ran first in that same shell; reject evidence from a shell where that activation cannot be confirmed. For hand-applied records, also require evidence of all three checks in step 3, including the cold-cachenpm ciexit code under CI npm and the two-sided npm 10 normalization diff. A cleannpm auditon the same lockfile is not evidence of this; it never runs an install. - Nothing rode along: the diff closes the target advisories and does not also drift unrelated production dependencies.
npm audit fixtakes every within-major bump it can, not only the advisory packages. - No dropped nested records: no nested lockfile record present in the base has disappeared (see step 5).
- The repository's own build, not just CI's job list: run the build and test commands directly, not only the subset CI happens to cover. A workspace CI does not build or test can merge broken with every CI check green.
A green suite is not evidence for an override. When the fix is an overrides entry crossing a major for some consumer, a passing test suite (or a lint run that never touches the affected path) proves nothing: the break is latent and only fires when a specific rule/option/branch runs. If a reviewer signs off on such an override, require both precondition probe results as cited evidence, not a suite pass: the ESM-import probe result, and the reachability probe against the repository's own configuration with its linted-file count, plus confirmation of which enumerated call sites are actually reachable from that repository's enabled rules or options. A clean test or build run is a different property and not a substitute for either probe.
Isolate parallel reviewers: no shared scratchpad, no generic filenames. Running more than one review pass at a time turns a shared scratchpad plus a generic filename into a collision: two reviewers extracting a baseline to the same fixed path can silently overwrite each other's extract, and a review reporting a clean negative control may actually be reading a different repository's baseline. This is easy to miss when every repository under review carries the same advisory, since the wrong baseline still looks plausible.
Give every reviewer its own directory for anything it extracts (a fresh mktemp -d, never a fixed name in a shared scratchpad), and bind identity to repository and ref before trusting an extracted baseline: capture the resolved commit SHA for the ref alongside each extract and assert it against the ref you meant to pull, or name the extract <repo>-<ref> instead of a generic filename. A lockfile's own name field is not enough by itself: it only distinguishes repositories from each other, and is identical between the pre-fix baseline and the fix branch of the same repository.
Anti-patterns
- Trusting
npm audit --package-lock-onlyclean, or a byte-exactnpm install --package-lock-onlyno-op, as proof the lockfile is installable. Neither runs an install; onlynpm ci --dry-rundoes. - Treating "
npm cileaves the lockfile byte-identical" as coherence evidence.npm cinever writes the lockfile, regardless of its contents. - Checking a hand-applied integrity value with a warm npm cache; cached content can mask a bad value. Use a fresh empty cache for the real
npm cicheck. - Running the coherence gate under whatever npm is active locally instead of the CI-pinned npm major. Two npm majors can disagree about the same lockfile.
- Capturing an exit code behind a pipe (
... | tail -N; echo $?) instead of capturing it directly from the bare command. - Trusting a version manager's "switched to" banner instead of printing
npm -vin the exact shell that runs the fix. - Letting an npm major older than the lockfile's writer rewrite the lockfile, which strips unrelated
libcarrays, instead of hand-applying the single target record and verifying it against the registry. - Letting
npm audit fixsilently drop a nested lockfile record without diffing package keys against the pre-fix base afterward. - Carrying a "dev-only" conclusion from one repository to another without re-checking the lockfile's own
devflag in the repository at hand. - Applying a cross-major
overridesrecipe as a default fix instead of a deferral, or applying it without running both precondition probes and getting an explicit, recorded sign-off. - Treating a passing test suite as evidence that a cross-major override is safe, when the break it checks for is latent and only reachable through a specific rule, option, or code path.
- Skipping the review pass for a lockfile-only diff on the assumption that a mechanical-looking change needs no review.
- Running two review passes in parallel against a shared scratchpad path, risking a silently overwritten baseline extract.