npm audit Noise: Triaging CVEs Without Turning Off the Alarm
It is Friday, the release is cut, and CI goes red on a step nobody has looked at in months. Three high severity vulnerabilities. Nobody on the team knows what any of them are. Someone suggests running npm audit fix --force, someone else suggests adding --audit-level=critical, and the third option, the one that usually wins, is to comment out the audit step "for now".
I have watched that sequence at three different companies. It always ends the same way: six months later the audit step is still commented out, and when a genuinely exploitable, genuinely reachable vulnerability lands in a runtime dependency, nobody finds out.
The root of it is a category error. npm audit does not tell you that you are vulnerable. It tells you that a package version somewhere in your dependency tree falls inside an advisory's affected range. Those are different questions, and treating them as the same is what produces both the initial panic and the eventual blanket suppression. What you need is not a louder alarm or a quieter one. You need a repeatable triage that takes about ten minutes per finding and ends in a written decision.
run each finding through four gates, in order, and stop as soon as one closes.
- Is it in the runtime tree?
npm audit --omit=dev. A build-only tool is a different risk class from something serving requests. - Why is it there?
npm explain <pkg>tells you which parent pinned it, which decides whether you can fix it at all. - Is the vulnerable function reachable? Read the advisory, find the affected API, and check whether your code or your framework ever calls it with input an attacker controls.
- Can you patch cheaply? Use
overridesinpackage.jsonto pin a patched transitive version when the parent is slow.
Whatever you decide, write it down with a reason and a review date. Never npm audit fix --force without reading what it plans to install.
What npm audit actually prints, and what it is not telling you
This is real output from a small Next.js site of mine, not an example I made up:
# npm audit report
postcss <=8.5.11
Severity: high
PostCSS has XSS via Unescaped </style> in its CSS Stringify Output - https://github.com/advisories/GHSA-qx2v-qp2m-jg93
PostCSS: Arbitrary file read and information disclosure via attacker-controlled sourceMappingURL in CSS comments - https://github.com/advisories/GHSA-6g55-p6wh-862q
fix available via `npm audit fix --force`
Will install next@9.3.3, which is a breaking change
node_modules/next/node_modules/postcss
3 high severity vulnerabilities
To address all issues (including breaking changes), run:
npm audit fix --force
Read the fourth line again. The suggested fix for a project running Next.js 15 is to install Next.js 9.3.3, a version from 2020, because that is the newest release of next whose pinned postcss falls outside the advisory range. That is not a security fix. That is a time machine into a codebase with six years of its own unpatched CVEs.
This is the single most important thing to understand about the tool: npm audit fix --force is a constraint solver, not a security engineer. It optimises for "no package matches an advisory range" and has no opinion about whether the result is a working, supported application.
Why the noise exists
Version matching is not reachability
An advisory says "postcss versions below 8.5.11 mishandle an attacker-controlled sourceMappingURL in a CSS comment". npm audit checks whether a matching version is on disk. It does not check whether your build ever processes CSS you did not write. On a site where every stylesheet is in your own repository, there is no attacker-controlled CSS, so there is no path from an attacker to the vulnerable code. The package is present. You are not exposed. Both of those statements are true at the same time, and no scanner in the default npm toolchain can tell them apart.
CVSS scores the worst case in the abstract
A CVSS base score is computed once, by the advisory author, against an imagined worst-case deployment. It deliberately excludes your environment. That is what the environmental metric group in the CVSS spec is for, and essentially nobody fills it in. So a regular-expression denial of service in a package that only ever runs inside your build container, on inputs you committed yourself, arrives labelled "high" and sits next to a genuine remote code execution in your HTTP router wearing the same badge.
Use the score as a sort order. Do not use it as a verdict.
You often cannot fix it where the problem is
The vulnerable package is usually three levels down, pinned by a parent that has not cut a release yet. npm explain is the command that tells you the truth here:
npm explain postcss
postcss@8.4.31
node_modules/next/node_modules/postcss
postcss@"8.4.31" from next@15.5.21
node_modules/next
next@"^15.5.21" from the root project
Now you know exactly why npm audit fix proposed a downgrade: the version is hard-pinned by next, so the resolver's only lever is the next version itself. That single command turns "the tool wants to do something insane" into "of course it does, here is the constraint".
The triage, step by step
Step 1: Split runtime from build time
npm audit --omit=dev
--omit takes dev, optional or peer and can be repeated. If a finding vanishes under --omit=dev, it lives in a package that never gets deployed. That does not make it a non-issue, your build machine is a real machine with real credentials on it, but it moves it into a different queue with a different urgency.
Expect the count to drop noticeably. On a typical front-end project most of the tree is tooling.
Step 2: Find out why the package is in the tree
npm ls postcss
npm explain postcss
npm ls shows you every copy and where it was deduplicated. npm explain shows the constraint chain that put it there. You need both: ls tells you whether the vulnerable copy is the one your app imports or a nested one used by a single tool, and explain tells you whether you can move it.
Step 3: Do the reachability check by hand
Open the advisory URL that npm printed. You are looking for three things: which function is affected, what input triggers it, and what the impact is. Then answer one question honestly: does untrusted input ever reach that function in my deployment?
Worked example from the output above. Two postcss advisories: XSS via an unescaped </style> in stringify output, and file read via an attacker-controlled sourceMappingURL in a CSS comment. Both require the attacker to control the CSS being processed. In this project, postcss runs once at build time over stylesheets that live in the repository. Nobody outside the team can put CSS into that pipeline. The path does not exist. Decision: defer to the next routine dependency bump, note it, move on.
Same audit, different verdict for sharp: it inherits vulnerabilities from libvips and, unlike postcss, it runs at request time behind image optimisation. If the app allows remote images from domains it does not control, then untrusted bytes reach the decoder. That one gets patched now. Two findings, identical severity label, opposite outcomes. That is the entire point.
Step 4: Pin a patched transitive version with overrides
When the parent package is slow but a patched version of the child exists and is compatible, npm's overrides field forces it across the whole tree:
{
"overrides": {
"postcss": "8.5.11"
}
}
You can scope it so you only affect one parent, which is much safer:
{
"overrides": {
"next": {
"postcss": "8.5.11"
}
}
}
Yarn calls this resolutions; pnpm uses pnpm.overrides. Then reinstall and confirm:
rm -rf node_modules package-lock.json
npm install
npm ls postcss
An override is a lie you are telling the dependency graph, so treat it as temporary. Add a comment in your changelog or a tracking issue, and remove it once the parent catches up. Overrides that outlive their reason are how you end up with a build that breaks mysteriously two years later.
Step 5: Gate CI on severity, but never on nothing
npm audit --omit=dev --audit-level=high
--audit-level accepts info, low, moderate, high, critical or none, and controls the minimum severity that makes the command exit non-zero. This is the honest version of "turn down the noise": you are still scanning everything, you are just choosing what blocks a release.
Step 6: Write the exception down
npm audit has no native ignore file, which is precisely why teams disable the whole check instead of a single finding. A twenty-line script fixes that. Commit a file that says what you accepted and until when:
[
{
"advisory": "GHSA-qx2v-qp2m-jg93",
"package": "postcss",
"reason": "Build-time only. No attacker-controlled CSS enters the pipeline.",
"decided_by": "karani",
"review_by": "2026-10-01"
}
]
Then have CI read npm audit --json and fail on anything not in that list, or on any entry whose review_by has passed. The --json output uses auditReportVersion: 2, with a vulnerabilities object keyed by package name; each entry carries severity, isDirect, fixAvailable, and a via array whose object members contain the advisory url and cvss. That is enough to match on.
The expiry date is the part that matters. An exception without a review date is a suppression with better manners.
Verification
You have finished triage when all three of these hold:
# 1. Nothing unreviewed above your gate in the runtime tree
npm audit --omit=dev --audit-level=high
echo $? # expect: 0
# 2. Every override actually took effect
npm ls postcss # expect: only patched versions listed
# 3. The build still works
npm run build # expect: a successful production build
That third one is not a joke. Half the security regressions I have been called in to look at were introduced by a hurried dependency bump, not by an attacker.
What people get wrong
"Run npm audit fix --force and move on." Dismantled above. The flag exists to install changes outside your declared version ranges, including major downgrades. Always read the "Will install X" line before you agree to anything.
"Zero vulnerabilities is the goal." It is not achievable and chasing it burns the credibility you need on the day something real appears. A team that upgrades twelve packages a week to keep a dashboard green stops reading the dashboard. Aim instead for: every finding in the runtime tree has a decision attached, and no decision is older than its review date.
"It is only a devDependency, so it does not matter." Softer than a runtime finding, not zero. Your CI runner holds deploy keys, registry tokens and source code, and a compromised build tool is how supply chain attacks actually land. Lower priority, different queue, still a queue.
"We will just raise --audit-level to critical." Almost nothing is ever rated critical, so this is disabling the check while keeping the reassuring green tick. If you genuinely cannot process high findings, say so out loud and set the level to moderate with an exceptions file, rather than pretending.
When it is still broken
- The advisory has no fixed version yet. Check whether you can remove the dependency entirely. A surprising number of transitive packages are pulled in by a single convenience helper you could write in ten lines.
- The finding only appears in CI. Your lockfile and the registry advisory database drift. Run
npm auditlocally afternpm ci, not afternpm install, so you are auditing the same tree CI does. - You need reachability analysis at scale. Once you have more than a handful of services, hand-tracing every advisory stops scaling and you want a scanner that does call-graph analysis rather than version matching. Budget for it deliberately rather than drifting into it.
- Nobody owns the queue. This is the real failure mode. Put a named person on dependency review with a recurring half hour, the same way you would for backups. If you are running your own infrastructure, fold it into the same routine as the rest of your server security housekeeping.
The goal is not silence and it is not zero. It is that when a finding lands, somebody looks at it for ten minutes and writes down what they decided. That is a much lower bar than most teams think, and it is the difference between a security process and a green tick.
Frequently asked questions
- Is it safe to run npm audit fix --force?
- Not without reading what it plans to do. The --force flag lets npm install versions outside your declared ranges, including major downgrades, and it optimises purely for making advisory matches disappear. On a real Next.js 15 project it proposed installing next@9.3.3, a release from 2020, to satisfy a build-time postcss advisory. Always read the 'Will install X, which is a breaking change' line first.
- How do I ignore a single npm audit finding without disabling the whole check?
- npm has no native ignore file, so commit a small JSON exceptions list containing the advisory ID, the package, a written justification and a review date, then have CI parse npm audit --json and fail on anything not in that list or whose review date has passed. The JSON output uses auditReportVersion 2 with a vulnerabilities object keyed by package name, and each via entry carries the advisory URL, so matching is straightforward.
- Do vulnerabilities in devDependencies actually matter?
- They matter less than runtime findings but not zero. Build tooling runs on a machine that holds deploy keys, registry tokens and your source code, and compromised build tools are how most supply chain attacks land. Use npm audit --omit=dev to separate the two sets, then treat dev findings as a lower priority queue rather than as no queue at all.
- How do I patch a vulnerable transitive dependency when the parent package has not released a fix?
- Use the overrides field in package.json to force a patched version, ideally scoped to the specific parent so you do not change the whole tree. Yarn calls the same feature resolutions and pnpm uses pnpm.overrides. Reinstall from a clean lockfile and confirm with npm ls that only the patched version remains, and remove the override once upstream catches up because stale overrides break builds later.