Safeguard
Vulnerability Analysis

The Version String Is Not the Vulnerability

A CVE that needs Windows, a dev server, and a reachable port is not exploitable because a version matched. Cataloguing what each advisory actually requires turns a lockfile diff into an argument.

Vikram Iyer
Application Security Researcher
5 min read

Software composition analysis is, in its default form, a join. Your lockfile has vitest@4.0.18. An advisory says versions below 4.0.19 are affected. The join succeeds, a finding appears, and it is labelled something like "Reachable".

Read the advisory and the picture changes. The CVE affects the Vitest UI/API server. Only on Windows. Only when that server is enabled. Only when its port is reachable by whoever is attacking you.

Three conditions, none of which a version comparison touched. The word "Reachable" was doing work the analysis had not done.

Advisories already tell you the conditions

The useful thing about this problem is that the answers are usually written down. Advisories are specific about scope, because their authors are trying to help people decide whether to act. The information is in the text; it is just not in the structured fields most tooling consumes.

Four real examples of the shape:

A test runner CVE. Affects a UI/API server, Windows-only, requires the server to be enabled and reachable. A project that runs the test suite headless in Linux CI has none of the three.

A framework CVE. Requires the App Router, plus a specific bundler, plus exactly one configured locale, plus authentication enforced in middleware. Four conditions, simultaneously. Any one absent and the code path does not exist.

An HTTP client CVE. Lives in proxy and NO_PROXY handling. Needs a proxy configuration in effect and a request destination an attacker can influence.

A monorepo tool CVE. Needs attacker-controlled repository contents and execution of the affected command. Neither is implied by the package being installed.

In every case the version match is one input among several, and it is the only one being checked.

Make it data, not code

The instinct is to write a plugin per CVE. Resist it. The moment adding an advisory means writing an evaluator, the catalogue stops being maintained, and you are back to inferring exploitability from a version string with extra steps.

Declare the conditions as data:

CVE-2026-XXXXX:
  - id: framework-app-router
    kind: config
    description: the project uses the App Router
    evaluator: config.next.appRouter
  - id: framework-single-locale
    kind: config
    description: the locale list contains exactly one entry
    evaluator: config.next.i18nLocaleCount

Adding an advisory becomes adding rows. The evaluators are a small shared library — "does this config key exist", "how many entries in this array", "is this dependency present" — reused across many advisories rather than written per CVE.

Transcribe each condition from the advisory text and keep the wording close to the source, so a reviewer can trace every row back to something an advisory author wrote rather than to your reading of it.

An evaluator names its own limits

Some conditions have no evaluator, and the catalogue should say so explicitly rather than leaving the field blank:

  • the endpoint is reachable by an attacker — network position is not a property of source code
  • attacker-controlled input reaches this sink — needs taint analysis, and if you do not have it, you do not have it
  • an attacker can control repository contents — a fact about your access model, not your code

Recording evaluator: "" and rendering it as unknown — requires manual review is more useful than an empty cell. A reader should be able to tell the difference between "we checked and could not decide" and "nothing here could ever check this".

What the evaluators read

Notably, not the dependency graph. The questions are about configuration: which bundler is active, whether a plugin is registered, how many locales are declared, whether a script invokes a command. None of that appears in a lockfile, and the version string is precisely the signal that produced the wrong verdict to begin with.

So the evaluators read what a checkout actually contains — package.json scripts and dependency names, framework config files, CI workflow definitions, the presence of specific files like a middleware entrypoint. One pass over the tree, skipping vendored directories, capped on file size.

Skipping node_modules and vendor matters more than it sounds. A fixture inside a dependency can contain a config file that looks exactly like the project's own. Read it and a vendored test fixture gets a vote on your customer's exposure verdict.

What it looks like when it works

Run it against two real applications:

Project A. App Router present, Turbopack enabled, authentication in middleware, locale count not determinable. Three of four conditions hold and the fourth is open, so the verdict is not demonstrated — with one specific question attached that the owner can answer immediately.

Project B. App Router present, but no --turbopack flag in any script and no turbopack block in the config. The default bundler is in use. One condition is definitively absent, so the finding is ruled out, with a reason the developer can verify in the file we named.

Project B is the case that pays for the whole exercise. Under version-matching alone it was an open framework CVE on a public marketing site. Under precondition evaluation it is closed, with evidence, and nobody spent an afternoon on it.

The rule that keeps it safe

Because a single no closes a finding, the standard for no has to be higher than for yes. Answer yes from positive evidence. Answer no only from evidence that actively rules the condition out — a declaration, a missing package, a config that says otherwise. Everything else stays open.

Inferring no from "we searched and found nothing" is how a triage system becomes a silence machine. The conditions that cannot be settled from a repository are supposed to stay unsettled; that is the honest output, and it is the one that leaves a human in a position to finish the job.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.