Piotr Czerwiński

Writing · July 20, 2026 · 8 min read

A CVE three levels deep: patching a transitive dependency with npm overrides

npm · security · dependencies · CI/CD

TL;DR: npm audit flagged an XSS advisory in postcss on one of my side projects, a package I do not depend on directly: Next.js 16.2.x pins it to exactly 8.4.31, across the whole release line. I weighed six options, from waiting for upstream to accepting the risk in writing, and settled on overrides in the root package.json. The override is one line; the sharp edge is that adding it and running npm install does exactly nothing, and the obvious way to force it (delete the lockfile) re-resolved 927 package versions in one project and broke the build in another. The working method is surgical: remove only that package's lockfile entries, verify the diff touches nothing else, and prove the result with a build, not the audit. And after any hand-edit to a lockfile, npm ci is the only local command that predicts CI.

The problem: a CVE you cannot upgrade away

The situation: the audit reports a vulnerability in a package that is nowhere in your package.json. Your framework pulls it in, and pins it hard. In my case Next.js 16.2.x declares postcss at exactly 8.4.31, a version with a known advisory about XSS when stringifying CSS. The patched release has existed upstream for a long time; the framework just does not consume it, and this applies to any project on that framework line. The usual reflex, bump the thing the audit names, is unavailable: there is nothing in my manifest to bump.

The options on the table

Before touching anything I wrote down what could actually be done about it. Six candidates:

  • Upgrade the framework. The best case, because a newer release with a newer pin is a combination the framework author has tested. Checkable in seconds: npm view next@16.2.10 dependencies.postcss shows what any release pins. Here it failed: every 16.2.x release, including the newest, carried the same exact pin. Right first instinct, off the table.
  • Take npm's own suggested fix. The audit offered npm audit fix --force, and the fine print said: Will install next@9.3.3, which is a breaking change. A seven-major downgrade of the framework to silence a moderate-severity advisory in a CSS tool. Pro: none. Con: it demolishes the application. Always read what --force will install before running it; npm optimizes for a clean audit, not a working app.
  • Wait for the upstream pin to move. Zero effort and zero breakage risk, but the timeline is not mine, the pin showed no sign of movement, and a permanently red audit trains you to stop reading audits. A last resort, not a plan.
  • Replace or drop the dependency. Not real here. I do not consume the package directly; the only way to remove it would be to remove the framework.
  • Fork it, or patch it locally with a patch-on-install tool. The right shape when no fix exists upstream and you must carry a code change yourself. Here the fixed version was already published; forking would mean maintaining a copy of a package whose author had already solved my problem. Maximum ongoing cost, zero benefit.
  • Accept the risk, in writing. Legitimate when the advisory's attack vector does not exist in your usage: you document why, so the next person (or you, in six months) finds a decision instead of an unexplained red audit. The cons: the audit stays red unless you configure exceptions, and the justification must be re-checked whenever usage changes.
  • Force the version with npm overrides. Purpose-built for exactly this: a declarative, committed, reversible one-liner that pushes a transitive dependency past the vulnerable version. The cons are real, though: you are overriding a pin the framework author made deliberately, so you run a combination the framework was never tested against, and npm's resolution machinery around overrides has sharp edges, which the rest of this post is about.

What I chose, and exactly why

I chose the override, for three reasons. First, the jump was 8.4.31 to 8.5.x, a minor within the same major, so semver promises API compatibility and the exact pin looked like author conservatism rather than a known incompatibility. Second, it is fully reversible: delete one line and reinstall. Third, it is verifiable, because the project has a build that either passes or does not. Accept-the-risk stayed as the documented fallback if the build broke. I would not have made the same choice for a major-version jump, or for a pin the framework documents as intentional; there the calculus flips toward waiting or accepting.

Overrides, and where they go

{
  "overrides": {
    "postcss": "^8.5.16"
  }
}

One placement detail that cost me a round of confusion: in a monorepo with workspaces, the override must live in the root package.json. Put it in a workspace's manifest and npm silently ignores it. No warning, no error, just an override that never applies.

There is a second special case. If the package is also one of your direct dependencies, a plain override errors out:

npm error Override for postcss@^8.5.6 conflicts with direct dependency

The fix is to bump the direct dependency past the vulnerable version and make the override track it with the "$name" reference syntax:

{
  "devDependencies": { "postcss": "^8.5.16" },
  "overrides": { "postcss": "$postcss" }
}

That says: wherever this package appears in the tree, use the same version the direct dependency resolves to. One source of truth for the version, no drift between the two declarations.

The trap: the override that does nothing

Here is where I lost the most time. You add the override, run npm install, and npm replies up to date, audited 784 packages. The vulnerable copy is still sitting in node_modules, nested under the framework, and the audit still fails. npm did not apply the override because it never re-resolved the tree at all:

  • The existing package-lock.json looks current, so npm trusts it.
  • A hidden lockfile at node_modules/.package-lock.json lets npm reconstruct the installed tree without recomputing anything.
  • The already-installed nested copy of the package is never evicted.

The half-measures do not help either. I tried npm install --package-lock-only, deleting only the lockfile (npm rebuilds it from node_modules), deleting only the hidden lockfile, and deleting only the nested package directory. Each one alone leaves npm enough state to avoid doing the work.

The full reset works, and is the wrong move

What does force a clean re-resolution is the nuclear option:

rm -rf node_modules package-lock.json
npm install

This is the advice you will find everywhere, and it taught me the most expensive lesson of the exercise. Deleting the lockfile makes npm resolve the tree from scratch, so every dependency floats up to the newest version its semver range allows. In one project that produced 927 changed package versions instead of one. The build happened to pass, which was luck, not method.

In a second project the same move broke the build outright: an icon library floated to a newer minor that had removed one named export, and the bundler failed with three errors on code I had not touched. I know the reset caused it because I measured the baseline separately: before the change, the build exited 0. I asked npm to bump one package for a CVE; it silently upgraded a thousand, and one of them changed its public API.

The surgical method

The version that respects the rest of the lockfile: delete only the entries for the package you are overriding, then let npm fill the hole. The lockfile is just JSON, and a package's entries are the keys ending in its node_modules path:

import json
l = json.load(open("package-lock.json"))
for k in [k for k in list(l["packages"]) if k.endswith("node_modules/postcss")]:
    del l["packages"][k]
json.dump(l, open("package-lock.json", "w"), indent=2)

Then clear the on-disk state for that package only, including the hidden lockfile, and reinstall:

rm -rf node_modules/next/node_modules/postcss \
       node_modules/postcss \
       node_modules/.package-lock.json
npm install

With the lockfile missing exactly one package and the hidden lockfile gone, npm has to resolve that one package, and now the override applies. In the project where the full reset had broken the build, this produced a 36-line lockfile diff, all of it the target package. And that claim is checkable, which is the point:

git diff package-lock.json \
  | grep -E "^[-+]\s+\"node_modules/" | grep -v postcss
# empty output = nothing moved except the target

Two more checks close the loop on resolution:

npm ls postcss --all   # expect ONE version, no nested copies
npm audit              # the advisory should be gone

Verify with the build, not the audit

0 vulnerabilities does not mean the application works. The override pushed a package past a version the framework author deliberately pinned, into a combination the framework was never tested against; the build is the only evidence that matters. Moving from 8.4.31 to 8.5.19 under Next 16.2 built cleanly, all 280-plus statically generated pages included. That had to be checked, not assumed, and because the package processes styles, I also compared the emitted CSS before and after. Had the build broken, the fallback was already decided: revert the override, keep the pin, document the accepted risk.

Keeping CI honest after lockfile surgery

Hand-editing a lockfile creates a follow-up obligation, and I learned it the hard way on a later deploy. The pipeline failed at npm ci:

npm error `npm ci` can only install packages when your package.json
and package-lock.json are in sync. (...)
Missing: esbuild@0.28.1 from lock file

The repo legitimately needed two majors of esbuild at once, pulled in by two different build tools, and an earlier lockfile edit had lost one of them. Two traps stacked on top of each other. First, local npm install kept saying up to date and changed nothing: with a populated node_modules, npm treats the installed tree as the truth and never notices the lockfile is missing entries that npm ci, which reads the lockfile alone, will demand. This is the same disease as the ignored override, just wearing a different coat: npm install trusts the installed tree, npm ci trusts only the lock.

Second, regenerating the lockfile from scratch with my local npm 11 produced a file that CI's npm 10 still rejected: the two majors hoist a dual-version dependency differently, while npm 11's own npm ci validated its layout happily. Everything green locally, red in CI: the artifact was judged by a different major than the one that wrote it. The fix is to regenerate the lock with the same npm major CI runs, without touching your local Node:

npx -y npm@10 install     # npm@<CI major>, not the local default
npm ci                    # the only local check that means anything
npm test && npm run build

You can read CI's npm major straight off the workflow: Node 22 ships npm 10, Node 24 and later ship npm 11. And if a bot maintains your dependency bumps, let it rebase over your hand-written lockfile and let its own PR run validate the result, because the bot writes locks with its npm version, not yours.

What this teaches

  • Enumerate the options before touching anything. Framework upgrade, upstream wait, override, fork or patch, replacement, documented acceptance: each has a cost profile, and writing them down turns a panic into a decision.
  • Never run npm audit fix --force without reading what it will install. Its idea of a fix can be a seven-major downgrade of your framework.
  • Overrides go in the root manifest, and a direct-dependency conflict is solved with a bump plus the "$name" reference, not by fighting the error.
  • Delete only the target package's lockfile entries, never the whole lockfile. A full reset re-resolves everything within semver and can break your build through a package you never meant to touch.
  • Prove the blast radius. Diff the lockfile and grep for any moved package that is not the target; the answer should be empty.
  • The audit going green is not verification. The build is, plus npm ls --all showing a single resolved version, plus comparing outputs when the package processes them. And measure the baseline first, so you know failures are yours.
  • After any lockfile surgery, npm ci is the only local command that predicts CI, and it must run under the same npm major CI uses. npm install over a populated node_modules will happily lie to you.