🍱 Lunchbox Hands

npm

npm audit Says 47 Vulnerabilities. How Many Are Real?

Why a fresh project reports a wall of critical findings, why two scanners disagree about the same package, and how to triage a dependency report in the right order — reachability before severity, lockfile before package.json, and what a CVSS base score is actually claiming.

You create a project, install nothing unusual, and npm greets you with 47 vulnerabilities (12 critical). You run npm audit fix. Now there are 44. You run it with --force, and either the count drops to zero or your build stops working. The number is real in the sense that those advisories exist. It is misleading in the sense that it counts advisories, not exploitable paths in your application — and the distinction is the entire job of triage.

This is not an argument that dependency scanning is useless. It is an argument that the output of a scanner is the input to a decision, and the decision needs three facts the scanner does not have.

What npm audit is actually doing

npm audit sends a description of your dependency tree to your configured registry — primarily the bulk advisory endpoint /-/npm/v1/security/advisories/bulk, falling back to the older quick-audit endpoint — and formats what comes back. Three flags carry most of the practical weight:

FlagEffect
--omit=devExcludes dev dependencies from the submitted tree; “omitted dependency types are skipped when generating the report”
--audit-level=<level>Sets “the minimum level of vulnerability for npm audit to exit with a non-zero exit code” — info, low, moderate, high, or critical
--force (with fix)Permits installing “modules outside your stated dependency range (including SemVer-major changes)”

That last one deserves its reputation. npm audit fix --force is allowed to take you across a major version boundary of a package you did not choose to upgrade, in service of an advisory that may not apply to you. It is a reasonable tool for a maintained app with a good test suite, and a footgun everywhere else.

npm audit signatures is a genuinely different and underused command: it verifies registry signatures and provenance attestations on what you downloaded, rather than matching versions against advisories. That one answers “did I get the bytes the publisher published,” which no advisory database can tell you.

The reachability problem

The canonical write-up is Dan Abramov’s “npm audit: Broken by Design” from July 7, 2021. A freshly generated Create React App reported five vulnerabilities; the ones he examined were ReDoS issues in browserslist and glob-parent and a denial-of-service issue in css-what — all build-time tooling, where the “attacker-controlled input” would have to be your own config files. His summary of that scenario is hard to argue with:

“If the attacker already has access to your machine and can change your configuration files, you have a much bigger problem than slow regular expressions!”

His warning about the second-order effect is the part worth internalizing: a permanently noisy report trains people to ignore it, which “at some point will lead to actually bad vulnerabilities slipping in unnoticed.” A scanner whose output is 95% inapplicable is not a cautious scanner; it is a broken alerting system.

Note what those three advisories have in common: they are all slow regular expression bugs, which are only a vulnerability when an attacker chooses the subject string. If you want to judge one yourself rather than defer to a severity label, catastrophic backtracking explained walks through how to build the pathological input and measure it — the same question the advisory is implicitly asking.

The three facts your scanner does not have:

  1. Is this dependency in the shipped artifact at all? A test runner, a bundler plugin, or a linter is not in your production runtime. --omit=dev answers part of this; a bundle analysis answers the rest, because plenty of prod dependencies contribute zero bytes to what actually ships.
  2. Is the vulnerable code path reachable from your code? Advisories are published per package, not per function. A package can be vulnerable in a parser you never call. This is what commercial “reachability analysis” sells, and what you approximate by reading the advisory and grepping your own call sites.
  3. Can an attacker control the input that reaches it? A path-traversal bug in a library you feed hardcoded strings is not exploitable. The same bug behind a file-upload endpoint is the top of your list.

Only after those three do you get to ask “what’s the severity.”

What a CVSS score is claiming — and what it isn’t

Severity labels feel authoritative because they’re numeric. Read what the CVSS v3.1 specification says they measure: “The Base group represents the intrinsic qualities of a vulnerability that are constant over time and across user environments.”

Constant across user environments is doing a lot of work in that sentence. The base score deliberately does not know whether the package is in your production build, whether the function is reachable, or whether the input is attacker-controlled. FIRST’s own guidance is to supplement the base score “with Temporal and Environmental Scores specific to their use of the vulnerable product” — and notes that impacts like customer harm and financial loss are “outside the scope of CVSS” entirely. Almost nobody computes environmental scores, so in practice the number you’re triaging by is the one the spec says is insufficient on its own.

The buckets themselves are simple arithmetic on the base score:

ScoreLabel
0.0None
0.1 – 3.9Low
4.0 – 6.9Medium
7.0 – 8.9High
9.0 – 10.0Critical

A useful complement when you need to prioritize across many findings is EPSS, which estimates the probability that a vulnerability will be exploited in the wild. “Critical but never exploited” and “medium and actively exploited” are different situations that a base score alone flattens.

Why two scanners disagree about the same package

Running npm audit and any second scanner over one lockfile routinely produces different counts. Four mechanical reasons, none of which means one tool is broken:

  • Different source databases. npm’s advisories come through the registry endpoint; OSV.dev aggregates the GitHub Advisory Database along with other ecosystem feeds and CVE conversions. Overlapping, not identical, with different publication lag.
  • Different affected-range data. Two databases can encode different introduced/fixed boundaries for the same CVE, so one flags your version and the other doesn’t.
  • Different severity provenance. Some advisories ship an explicit qualitative severity; some ship a CVSS vector to be scored; some ship both and they disagree. A scanner that prefers the qualitative label will report differently than one that computes the vector.
  • CVSS version skew. CVSS v4 uses a different scoring algorithm than v3.1. A tool that only understands v3 vectors cannot score a v4-only advisory.

What our dependency scanner does, precisely

Our dependency scanner exists for the case where you have a lockfile and don’t have (or don’t want) a checkout: a file a teammate pasted, a lockfile from CI, a project you’d rather not npm install first. The mechanics, stated plainly:

Parsing is local. package-lock.json (v1’s nested dependencies tree and v2/v3’s flat packages map), classic yarn.lock v1, and package.json are parsed in the browser and deduplicated to name@version pairs. The file’s contents never leave the machine.

One thing does leave. The name@version pairs are POSTed to https://api.osv.dev/v1/querybatch — the public API run by Google’s open-source security team, free and keyless — in chunks of 1,000 queries. That endpoint returns “vulnerability ids and modified field only,” so each unique advisory ID is then fetched from /v1/vulns/{id} for details. That is the one piece of data that must leave the browser for a real lookup, and it is worth knowing it’s your package list.

Severity is computed locally with a CVSS v3.1 base-score implementation, from the vector in the advisory. When an advisory carries an explicit qualitative severity, that is used instead. And the honest gap: advisories that ship only a CVSS v4 vector are reported as Unknown, because feeding a v4 vector to a v3 scorer produces a wrong number rather than an error — an unscored finding is better than a fabricated one, but it does mean Unknown rows deserve a click through to the advisory.

Fix versions are filtered to your package. One OSV advisory can cover several packages — lodash and lodash-es, say — with different fix versions, so the scanner only reads the fixed event from the affected[] entry matching the exact npm package it asked about.

Two limits worth stating before they surprise you:

  • package.json results are approximate. It records ranges, not resolved versions, so ^4.17.20 is scanned as 4.17.20 — the floor of the range, which is usually not what’s installed — and transitive dependencies are absent entirely. Scan a lockfile whenever you have one.
  • There’s no dev/prod split. Every package in the file is scanned. Separating build-time from runtime is npm audit --omit=dev’s job, and remains the first triage step regardless of which tool produced the list.

The triage order that actually works

  1. Split dev from prod first. npm audit --omit=dev before anything else. On a typical front-end project this alone removes most of the count, and it removes it for a correct reason.
  2. Read the advisory, not the label. Open the GHSA or CVE. What function is vulnerable? What input triggers it? Two minutes here beats any amount of arguing about a score.
  3. Grep your own code for the vulnerable entry point. If you never call it, and nothing you depend on calls it with your data, record that and move on.
  4. Ask who controls the input. Config file you wrote: not a vulnerability. Request body, filename, header, or webhook payload: top of the list.
  5. Fix in increasing order of blast radius. Bump the direct dependency → pin the transitive one with overrides (npm) or resolutions (yarn) → only then consider npm audit fix --force and its major-version jumps.
  6. Write down the ones you’re deferring, and why. A one-line note per accepted finding is what keeps the next person from re-triaging the same advisory, and what makes a genuinely new “critical” visible.

For the surrounding hygiene: HAR files leak your session covers what else you might be handing over when you share debugging artifacts, and the package.json viewer is handy for reading a dependency tree you didn’t write.

The short version

The assumptionThe mechanism
”47 vulnerabilities means 47 problems”It counts advisories matching versions in your tree, not exploitable paths in your app
”Critical means fix it today”CVSS base is “constant across user environments” by design — it can’t know if the code is reachable or the input attacker-controlled
”The scanner disagrees with npm audit, so one is wrong”Different source databases, different affected ranges, different severity provenance, and v3-vs-v4 scoring
npm audit fix --force is the fast path”It’s licensed to install “outside your stated dependency range (including SemVer-major changes)"
"Dev dependencies still count”They’re not in your artifact; --omit=dev is the correct first filter, not a way of hiding problems
”Scanning package.json is close enough”Ranges are scanned at their floor and transitive deps are missing entirely — use the lockfile