Blog ·
A match that refuses to be sure
Four facts from four sources, kept apart. Why a KEV listing never raises a severity, and why only a bounded version range counts as a real advisory match.
By Provibr
A banner scanner reads SSH-2.0-OpenSSH_8.9p1 off a port and writes down a product and a version. A vulnerability feed says that a range of versions of that product has a known problem. Putting those two together is one line of code and one of the easiest ways to build a security screen that nobody trusts within a month.
The reason is that the two sentences are not the same kind of statement. One is a measurement of a service that could be lying to you. The other is somebody else's claim about a version range they may have written before the fix existed. A screen that renders them as one number has thrown away the only thing that would let a customer decide what to do.
Four facts from four sources
There are four separate claims involved in "this machine has CVE-1234".
- Reachable is measured. A TCP connection was established at a particular moment.
- The version is derived. We cut a token out of a greeting, and the service can be misconfigured to say anything, and a distribution can backport a patch without changing the number.
- The advisory belongs to somebody else. NVD says that a version range of a product is affected.
- Actively exploited is a third party again. CISA says that this CVE is being used in the wild.
They stay four columns. Multiplying them into a single score is exactly the meaningless composite that the specification for this feature forbids, and treating a listing in the known exploited catalogue as a severity bump is the same mistake in miniature.
So a KEV entry never raises a severity. Severity comes from the CVSS base score and nothing else, confidence stays low because the evidence is a banner, and exploitation is its own column with three states: yes, no, and not applicable for findings that are not advisory based at all. Measured on the fixtures: an Exim advisory at CVSS 9.8 is critical and carries a KEV flag; an OpenSSH advisory at 8.1 is high and carries none. The 9.8 is critical because of its score, not because of the flag.
The catalogue also cannot, by construction, produce a match on its own. It carries a CVE id, a vendor, a product name and a date, and no version ranges at all. Matching "Apache HTTP Server" against a banner would be guessing. It only ever sets a flag on an advisory that NVD has already tied to a version range.
The feeds are platform tables
Both feeds live in tables with no organisation column. A CVE belongs to nobody: it is not personal data, it is not a customer's data, and it is the same row for every account on the platform. It is therefore not in the purge that runs when an organisation is closed, and not in a data subject export. The link between a finding and an advisory is the customer's, and that one is in both.
The fetcher has an allowlist on the host rather than on the URL, and the URLs themselves are in code. A worker that reads a URL out of the environment or out of the database is a request forgery primitive with a scheduler around it: whoever can set that value makes our gateway issue a request from our network with our outbound addresses. The host of every final URL is checked as well, so a redirect to another host is refused rather than followed.
The truth table
Given a product name that matches and a CPE rule from the advisory, there are exactly three possible answers, and most of the interesting rows produce the middle one.
| Observed version | The CPE rule | Result |
|---|---|---|
| any | product name differs | nothing |
| not parseable | anything | possible match, unverified |
| parseable | exact version, equal, update matches | advisory match |
| parseable | exact version, equal, update differs | possible match, unverified |
| parseable | exact version, different | nothing |
| parseable | range with an upper bound, inside it | advisory match |
| parseable | range with an upper bound, outside it | nothing |
| parseable | lower bound only, above it | possible match, unverified |
| parseable | no bounds at all | possible match, unverified |
Only CPE entries with part a, for application, are considered. An o for operating system or an h for hardware would attach "Ubuntu 22.04" to an SSH banner, and that is precisely the kind of match that looks convincing and means nothing: we saw the service, not the system underneath it.
The row worth arguing about is "lower bound only". A rule that says everything from 8.0 upward, with no upper bound, includes versions released after the advisory that already fixed the problem. NVD writes ranges that way when the fix has not been published yet and does not always revise them afterwards. Applying such a rule to a machine would report a patched server as vulnerable, and after two of those a customer learns to ignore the whole screen. Unverified is the honest label, and unverified is not a smaller version of confirmed. It is a different claim.
When one advisory carries dozens of CPE rules, one per affected release, the strongest outcome wins. If one of them lands exactly, that is the answer even if ten others are vague.
A reassessment writes a new revision and touches no evidence
Feeds change. NVD revises a score, a range gets narrowed, a CVE that matched last week no longer does. The sweep that re-evaluates open findings against the current feeds has one hard rule: it never writes or modifies an occurrence.
An occurrence is the answer to "was it there at 03:14". A feed update changes nothing about that. What changes is the conclusion drawn on top of it, and that gets a new row in the assessment table with its own revision identifier naming both feed snapshots. Measured: after moving an advisory from CVSS 9.8 to 7.5, the existing occurrence rows came back byte identical.
A match that has disappeared closes the finding as closed_unverified and never as resolved_verified. The second one is a statement about a measurement, and there was no measurement. What changed is our own inference. The name is the entire reason that status exists.
Nothing is written when nothing changed, either. The snapshot is compared against the previous judgement first, because a sweep that files a row every run leaves a hundred and twenty identical assessments under each finding within a month, and then the timeline is unreadable.
No sweep without a successful feed
The last guard is the one that took the least code and prevents the worst outcome.
If no feed has ever completed successfully, the current advisory revision is empty, and the sweep returns immediately without examining anything. Without that check it would run, find that the candidate query returns nothing, and close every advisory based finding across every customer as no longer matching. An entire fleet would go green on the strength of a feed that is broken.
A failed fetch also leaves the previous cache in place with its own success timestamp, so the two questions "when did we last try" and "when did we last succeed" are separate columns rather than one hopeful one.
What this costs, and what it buys
The cost is that the screen shows a lot of "possible match, unverified" rows, and those are less satisfying than a number. An operator has to look at one and decide whether the version they are running is genuinely affected.
What it buys is that the rows marked as confirmed are worth acting on, and that every row can be taken apart into the four facts it was built from: what was measured, what was derived, what somebody else claimed, and what is simply not known. A finding that cannot explain where each part of itself came from is a finding somebody will eventually learn to skip.
From the security work on Provibr. Why an exposure scan that reads a banner sends no payload at all is in a scanner that sends zero bytes, and why an incident is never closed by silence is in an incident that never closes on silence.