A Clean Audit Is Not a Safe Version

Two days after a Critical Next.js RCE shipped, npm audit still called the affected version clean.

Single continuous line drawing of a magnifying glass sweeping across a row of boxes on a shelf, with one box set back behind the others
a-clean-audit-is-not-a-safe-version On 16 October npm audit passed next@16.4.0, two days after two Critical RCE advisories named it, while the older 16.3.5 failed. GitHub's Advisory Database, OSV and the npm feed all lacked the new advisories; their review queue has run from one to fourteen days this year. Read the vendor's range yourself. npm audit, Next.js, CVE, GHSA-673q-mjm6-r5xr, GHSA-xqc2-xf4c-x3h3, advisory feed lag, supply chain, dependency scanning, use cache RCE, React Server Components, version range check

On 16 October I ran npm audit on a lockfile pinned to next@16.4.0. It printed "found 0 vulnerabilities" and exited 0. Two days earlier, Next.js had published two Critical remote-code-execution advisories that name 16.4.0 as affected.

TL;DR

npm audit on next@16.4.0 printed 0 vulnerabilities and exited 0 two days after two Critical RCEs named that version; 16.3.5 failed with 7. The npm feed, GitHub's database and OSV all lacked the new advisories. For a Critical, read the range on the vendor's own advisory and compare your version yourself.

Then I pinned the same lockfile to the older 16.3.5. The audit failed: seven advisories, one of them Critical. Moving to the newer version, with two unlisted Criticals in it, turned the audit green.

What shipped

The Next.js team warned this was coming. A pre-announcement promised an out-of-band release for "Next Wednesday, October 14, 2026," with two Critical issues and one High. It added that "Two of these fixes were postponed from the September security release."

The release landed on schedule. Fixes are in v16.4.1 and v15.5.28. Two of the three issues are unauthenticated RCEs. For the first, in use cache functions that return typed arrays, the notes say there is "no known workaround for affected applications. You should upgrade immediately even if your application does not have similar functions."

That bug is tracked as GHSA-673q-mjm6-r5xr: Critical, CVSS v4 9.5, no CVE, affected next >=16.0.0 <=16.4.0. Its upstream twin in React is GHSA-xqc2-xf4c-x3h3, also Critical and 9.5. The second RCE, through SVG images in the image optimizer, is GHSA-2464-fhr4-w9xw. It carries a CVE, CVE-2026-96889, and covers 16.4.0 too.

Version 16.4.0 was released on 6 October. The fix, 16.4.1, came eight days later.

What the CLI said

I wanted the real tool, not a proxy for it. So I made two lockfiles with no install scripts, on Node 24.16.0 and npm 11.16.0, against the public registry, and ran the audit.

$ npm audit                         # next 16.4.0
found 0 vulnerabilities
$ echo $?
0
$ npm audit --audit-level=critical  # next 16.4.0
found 0 vulnerabilities
$ echo $?
0
$ npm audit                         # next 16.3.5
next  16.0.0 - 16.3.7
Severity: critical
Next.js: Remote Code Execution in next/og ImageResponse
(six more advisories, trimmed)
1 critical severity vulnerability
$ echo $?
1
^ the newer version, inside two Critical RCE ranges, is the one that passes.

Here is what each version's audit missed. Both are inside the ranges of both new Criticals.

Versionnpm audit listsExit codeNew Criticals that name it
16.3.57 advisories, 1 Critical12, neither listed
16.4.0002, neither listed
16.4.1000

If your CI gate is npm audit --audit-level=critical, it passed 16.4.0 on 16 October. A team that bumped from 16.3.x to 16.4.0 to clear the September advisories got exactly what it asked for: a green build.

A clean audit says the database had no matching entry.It does not say your version is safe. Those are different sentences.

Where the answer comes from

The npm docs describe the mechanism plainly. The audit command "submits a description of the dependencies configured in your project to your default registry and asks for a report of known vulnerabilities." Since npm 7, that request goes to a bulk advisory endpoint on the registry.

I sent that endpoint the same versions directly. It returned nothing for 16.4.0. For 15.0.0, as a control, it returned 36. The probe works.

Every advisory it did return carried a link to GitHub's global Advisory Database, at github.com/advisories/. So I asked that database for the three new ones. All three came back 404. So did OSV, the open vulnerability database, for all three. The only place I found these advisories on 16 October was the page each project publishes for itself.

Where I lookedGHSA-673qGHSA-2464GHSA-xqc2
The project's own advisory pagePublishedPublishedPublished
GitHub Advisory Database404404404
npm bulk endpoint, for a version in rangeNothingNothingNothing
OSVNot foundNot foundNot found

GitHub's documentation says why its database might hold back. "We carefully review each advisory for validity." That review takes time, and the advisory is public on the vendor's page while it happens.

The same page says Dependabot notifies you "when a new GitHub-reviewed advisory reports a vulnerability." None of these three has been reviewed yet. By GitHub's own description, Dependabot has nothing to fire on either. I did not test Dependabot, so that is an inference, not an observation.

My first theory was wrong

The two advisories I started with both lack a CVE. That made a tidy explanation: a pipeline keyed on CVE numbers would skip them. I liked it. Then I tested it, and it broke twice.

First, GHSA-2464 has a CVE and is missing too. I sent the bulk endpoint next@15.5.27, the last 15.x release inside its range. Nothing came back. Its CVE belongs to librsvg, the SVG library underneath, and NVD has carried it since 23 September. Having a CVE did not get it into the feed for next.

Second, an earlier Next.js Critical with no CVE at all is in the feed now. GHSA-2xp9-vwfh-vxw4, an RCE in the image optimizer, has none. I checked the npm endpoint for 16.3.2, inside its affected range: the advisory is there. The CVE is not the gate. The review is.

So I measured how long the review has taken for Next.js this year. For each batch, I compared when the vendor published the advisory with when it appeared in GitHub's database.

AdvisoriesOn the vendor's pageIn GitHub's databaseGap
Two High, July21 July22 Julyabout 1 day
GHSA-2xp9 and GHSA-p293, both Critical25 August8 September14 days
GHSA-vcvr, Critical22 September30 September8 days
Six advisories, one High30 September7 October7 days
GHSA-673q, GHSA-2464, GHSA-xqc214 Octobernot yet, as of 16 October2 days and counting

The window is not a constant. It is a queue. This year it has run from about a day to two weeks. The Critical RCEs did not move faster than the moderate ones.

Attackers read the vendor's page

A two-week queue would be fine if attackers waited for it. They do not. They read the same advisory you could have read, on the day it goes up.

I have been on the wrong end of this. Last December, React2Shell, CVE-2025-55182, was an unauthenticated RCE in the same React Server Components layer. As I wrote in the fan was the only alert that worked, it was disclosed on 3 December 2025. Huntress logged the first observed exploitation on 4 December. A forgotten Next.js prototype of mine on a Raspberry Pi got picked up weeks later and ran a miner for about 80 hours. The only alert that fired was the cooling fan.

Hours from disclosure to exploitation. Up to fourteen days from disclosure to a red line in npm audit. Any process that waits for the tool is betting the attacker is slower than the review queue.

The same mistake wears a green check

I wrote about a cousin of this in the attestation was valid: a signature that passed while guarding the wrong thing. A passing check is only as good as the question it asks. npm audit asks "is this version in the database I can see." We read the answer as "is this version safe." When the two agree, nobody notices the substitution. When they disagree, you ship.

The check that would have caught it

The obvious fix is "do not trust the feed, check the advisory ranges." Before writing that down I put both checks side by side against the same lockfile. This is a toy, and it touches nothing but public advisory data.

Check A asks the npm feed. Check B tests the version against ranges copied from the vendor's own advisory pages. The feed response is the one I observed on 16 October, so the script runs with no network and still shows the split.

LOCKFILE = {"next": "16.4.0"}

# Observed 2026-10-16 from the npm bulk advisory endpoint: nothing for next.
FEED_OBSERVED = {}

# Copied from the vendor's own advisory pages, read 2026-10-16.
# Each range is (lowest affected, lowest is included?, highest affected).
VENDOR = [
    {"id": "GHSA-673q-mjm6-r5xr", "pkg": "next", "fixed": "16.4.1",
     "ranges": [("16.0.0", True, "16.4.0")]},
    {"id": "GHSA-2464-fhr4-w9xw", "pkg": "next", "fixed": "15.5.28 / 16.4.1",
     "ranges": [("10.0.0", True, "15.5.27"), ("16.0.0", False, "16.4.0")]},
]

def ver(v):
    return tuple(int(x) for x in v.split("-")[0].split("."))

def in_range(v, lo, lo_included, hi):
    above = ver(v) >= ver(lo) if lo_included else ver(v) > ver(lo)
    return above and ver(v) <= ver(hi)

def feed_check(lockfile):
    return {pkg: len(FEED_OBSERVED.get(pkg, [])) for pkg in lockfile}

def vendor_check(lockfile):
    hits = []
    for pkg, v in lockfile.items():
        for a in VENDOR:
            if a["pkg"] == pkg and any(in_range(v, *r) for r in a["ranges"]):
                hits.append((pkg, v, a["id"], a["fixed"]))
    return hits

feed = feed_check(LOCKFILE)
print("A. npm feed:", {k: f"{n} advisories" for k, n in feed.items()})
print("   verdict:", "clean" if sum(feed.values()) == 0 else "flagged")
for pkg, v, gid, fixed in vendor_check(LOCKFILE):
    print(f"B. {pkg} {v} in {gid} -> VULNERABLE, fixed in {fixed}")
A. npm feed: {'next': '0 advisories'}
   verdict: clean
B. next 16.4.0 in GHSA-673q-mjm6-r5xr -> VULNERABLE, fixed in 16.4.1
B. next 16.4.0 in GHSA-2464-fhr4-w9xw -> VULNERABLE, fixed in 15.5.28 / 16.4.1

The feed calls the version clean. The range check flags it twice. I also ran check B over 16.3.5, 15.5.27, 16.4.1 and 15.5.28. It flagged the first two and passed the fixed releases, so it is not just flagging everything.

Note where check B reads from. My first version of this advice said to check "the GitHub Advisory Database" instead of the npm feed. That would have failed the same way: the database returned 404 for all three. The ranges have to come from the vendor's page. GitHub serves published repository advisories through its API too, at /repos/OWNER/REPO/security-advisories, which is how I read these three.

If you run this later and the feed has caught up, the audit will flag 16.4.0 and the window will have closed. That is the honest way for this piece to expire.

What this does not tell you

This is not "npm audit is useless." On 16.3.5 it listed seven published advisories, one of them Critical, and failed the build. Most days it does its job. That is why a clean result feels like safety.

I do not know why the review queue ran one day in July and fourteen in August. Nobody has said, and I am not going to guess. I tested one CLI version against the public registry. A private registry or a mirror may answer differently, and I did not test one.

One more trap, in the React advisory. A Next.js app's lockfile has no react-server-dom-webpack entry; my 16.4.0 lockfile had 54 packages and it was not among them. Next ships its own compiled copy inside the next package. So even after the React advisory reaches the feed, it cannot match a lockfile that does not contain the package. For Next.js users, only the next advisory can.

And I am not naming a villain. A review queue after a coordinated release is a process gap, not malice. The Next.js team announced the release a week ahead so people could plan. Dependencies carry this risk by their nature, which is the argument in every dependency is technical debt. A monitoring signal that lags its own ground truth is the pattern in your dashboard shows a request nobody makes.

Before You Trust a Clean Audit

Five checks for the window right after a disclosure.

  • Treat a clean npm audit as a statement about the feed at that moment, not a verdict on your version.
  • When a Critical lands for something you ship, open the GitHub advisory and read the affected version range yourself.
  • Compare your installed version to that range directly, by hand or with a check that reads the vendor's own advisory. On 16 October the GitHub Advisory Database, OSV and the npm feed all lacked these three.
  • Do not assume a CVE gets an advisory into the feed faster. The SVG RCE had one and was missing too; two earlier Criticals with no CVE are listed. The gate is the review queue.
  • Re-run the check a day later, and expect to wait. For Next.js this year the queue has run from about one day to fourteen, so do not wait on it for a Critical.
  • Automate what you can: GitHub's per-repo endpoint, /repos/{owner}/{repo}/security-advisories, returns a maintainer's advisory the moment they publish it, before the global database or the npm feed catch up.

The Bottom Line

A clean npm audit is a statement about one database's contents at one moment, not about your version. This year that database has trailed Next.js's own advisories by one to fourteen days. When a Critical lands for something you ship, open the vendor's advisory page, read the affected range, and compare your installed version to it. The tool reads a database for you. Only the vendor's page reads the bug.

"A clean audit says the database had no matching entry. It does not say your version is safe. Those are different sentences."

Sources

Disagree? Have a War Story?

I read every reply. If you've seen this pattern play out differently, or have a counter-example that breaks my argument, I want to hear it.

Send a Reply →