ENGINEERING

Matching installed software to CVEs (why 81% of our index couldn’t match anything)

We just built VulnScan, a free tool that reads what’s installed on a Windows machine and tells you which published CVEs affect the versions you’ve actually got.

Run the signed PowerShell script and, if the box has internet, that’s the whole thing: it pulls the vulnerability index down, matches locally, and drops an HTML report on your desktop. No account, no agent, no install. On a machine that can’t reach the internet it writes a JSON snapshot instead, which you carry to the tool page and drop in the browser - the index loads there and the matching happens in the tab. Either way your software inventory never leaves the machine, which is the point: a full list of what you run and which versions is real disclosure.

Described that way it sounds like a join. You’ve got a list of applications and versions on one side, NVD’s catalog on the other, match them up.

It isn’t a join, and this is a writeup of why. Most of what follows we got wrong at least once while building it.


Problem 1: nothing agrees on what an application is called

Windows says Mozilla Firefox (x64 en-US). NVD says cpe:2.3:a:mozilla:firefox. Those have to become the same thing before anything else can happen.

Substring matching is the obvious first attempt and it’s wrong. Real display names put the version in the middle:

ImageMagick 7.1.2-21 Q16-HDRI (64-bit) (2026-04-21)

Search that for the contiguous string “imagemagick imagemagick” and you get nothing. What works is word-subset matching: tokenise both sides, and every word of the needle has to appear somewhere in the name, in any order.

export function tokenise(s: string): string[] {
  return s.toLowerCase().split(/[^a-z0-9+]+/).filter(Boolean);
}

Note the + in that character class. It’s there so notepad++ survives tokenisation as one word instead of becoming notepad.

Then you need a tiebreak, because more than one needle can match. Microsoft Edge WebView2 Runtime matches both microsoft edge and microsoft edge webview2 runtime. Most words wins, so the specific one takes it.

The needles themselves come from the CPE: mozilla:firefox gives you mozilla firefox and firefox. Emitting both is safe because of the most-words-wins rule.

Emitting the bare product name is where it gets interesting.


Problem 2: the bare product name is a false-positive machine

Take intel:graphics_driver. Derive the bare needle graphics driver from it and watch what happens on a real workstation:

intel:graphics_driver  <-  NVIDIA Graphics Driver 595.95

Every word of the needle is present. It’s a clean match by the rules, and it’s completely wrong. Same story for microsoft:visual_studio_2026 swallowing NVIDIA Nsight Visual Studio Edition.

The pattern in both is that the product name is made entirely of descriptive words, so it collides with any other vendor’s product built from the same words. The rule we landed on: only emit the bare product needle if the name carries at least one distinctive word. vlc media player keeps it because of “vlc”. graphics driver doesn’t.

That trade is deliberate in one direction. A missed CVE is a gap in the report. A fabricated one teaches somebody that the whole report is noise, and you don’t get that trust back. When the two are in tension, take the gap.

Some cases can’t be settled by any rule. Java 8 Update 481 is Oracle’s, but no Oracle CPE product contains the word “java”, so it binds to ibm:java instead - a real finding filed against the wrong vendor. That one’s a curated correction sitting in a committed JSON file with a written reason next to it, not code.


Problem 3: a bound that can never be satisfied

Here’s the one that cost the most.

NVD states affected versions two ways. Sometimes a range: everything below 152.0.7977.65. Sometimes an exact version: 1.8.0 update 481. We normalised both into four fields, [versionStart, startInclusive, versionEnd, endInclusive], like this:

out.append([
    m.get("versionStartIncluding") or m.get("versionStartExcluding") or
    (exact if exact not in ("*", "-") else None),
    1 if m.get("versionStartIncluding") else 0,
    m.get("versionEndIncluding") or m.get("versionEndExcluding") or
    (exact if exact not in ("*", "-") else None),
    1 if m.get("versionEndIncluding") else 0,
])

Follow an exact version through that. There’s no versionStartIncluding and no versionEndIncluding, so both inclusivity flags fall through to 0. Both bounds fall back to exact.

You get start = 1.8.0_481 exclusive, end = 1.8.0_481 exclusive. Which reads: installed > 1.8.0_481 AND installed < 1.8.0_481.

Nothing satisfies that. Not the version it describes, not anything else. Every CVE written as an exact version was encoded as a contradiction.

We counted them:

total rows                          : 383,958
rows with start==end, both EXCLUSIVE: 313,462  (81%)

Four rows in five couldn’t match anything. The tool only ever found CVEs that happened to be written as ranges, which is why browsers still produced findings and almost nothing else did. Chrome and Firefox look like a working vulnerability scanner all by themselves. They were the exception.

The fix is a branch rather than a fallback chain:

if vs_r or ve_r:
    # A real range. Missing side stays open.
    out.append([vs_r, 1 if m.get("versionStartIncluding") else 0,
                ve_r, 1 if m.get("versionEndIncluding") else 0])
elif exact not in ("*", "-"):
    # An EXACT version. Inclusive on both ends, so installed == exact matches.
    out.append([exact, 1, exact, 1])

Problem 4: rows with no version at all

Some CVE entries name a product without saying which versions are affected. Both bounds come back empty.

It’s tempting to treat that as a hit. It names the product, after all. Don’t: a row with no version constraint matches whatever you’ve got installed, so those CVEs land identically on a fully patched machine and a five-year-old one.

We had this guard in the browser matcher and not in the PowerShell one, which is how we found it. Same machine, same index, two implementations:

1,454 of the collector’s findings were version-independent noise. Chrome alone contributed 224 that had nothing to do with the installed Chrome version, and they vanished the moment the guard went in.

Worth saying plainly: we only caught that because two implementations existed to disagree. With one, 1,909 looks plausible.


Problem 5: version numbers that aren’t comparable at all

Java 8 Update 481 is exactly what a tool like this should catch. Old, everywhere, and a well-trodden way onto a workstation.

The registry reports its version as 8.0.4810.10, which is Oracle’s installer version. NVD calls the same thing 1.8.0_481. Compare those numerically, segment by segment, and 8 beats 1 on the first one, so the machine looks newer than every published range. It reports clean.

There’s a second layer under that. CPE keeps the update level in its own field, separate from the version:

version='1.8.0'  update='update481'
version='1.4.2_25'  update='*'

Read only the version and hundreds of distinct Java CVEs collapse onto an indistinguishable bare 1.8.0. You have to fold the update field back in, using NVD’s own in-version spelling so both forms compare the same way.

For the installed side we didn’t try to decode 4810 into 481. That’s arithmetic inferred from one sample, and a wrong guess here invents findings. The display name says “Java 8 Update 481” in plain text, so we read it from there.

Where no rule exists, the app doesn’t get counted clean. It gets named:

484 distinct CVEs across 17 applications  (3566 ms to match)
  2 application(s) could NOT be checked - the installed version and the published
  CVE versions use different numbering schemes. This is NOT a clean result for them:
    Microsoft .NET SDK 8.0.130 (x64) (8.1.3026.37309)
    Microsoft .NET SDK 8.0.424 (x64) (8.4.2426.37308)

Less satisfying than a number, and a lot more useful than a wrong one.


Problem 6: components aren’t the product

Microsoft Teams Meeting Add-in for Microsoft Office contains both words of microsoft teams, so it binds to Teams and collects Teams CVEs. Its version is 1.26.20101, which is the add-in’s version and has nothing to do with which Teams build you’re running.

The comparison is meaningless in both directions. It invents findings on a patched machine and hides them on an unpatched one. Same for Microsoft.Office.OneNoteVirtualPrinter at version 1.0.0.0 picking up Office CVEs.

Word-subset matching has no way to express “not this”, so these live in an explicit exclusion list that ships inside the index and both matchers apply.


Making it fast: the obvious fix wasn’t the fix

Matching took 57 seconds on a machine with 186 applications.

The obvious culprit is the comparison count, so we restructured the index to group CVEs by version bound instead of storing one row per CVE. The bounds repeat enormously: 249 Firefox CVEs all share < 152.0.7977.65. Across the whole index, 482,617 rows collapse to 48,849 distinct bounds.

That’s a 9x cut in comparisons. It took 57 seconds to 38.

Real, but nowhere near 9x, which meant comparisons weren’t the bottleneck. The actual cost was one line sitting in the wrong loop:

foreach ($needle in $prop.Value) {
    $parts = Split-Words ([string]$needle)

Resolving an application name tokenised every alias needle, and it did that once per application. 186 applications times 878 needles is 163,000 regex splits, each running a Where-Object pipeline, which is one of the slower things you can do repeatedly in PowerShell 5.1. The needles never change between applications.

Flatten that table once, before the loop, and 38 seconds becomes 3.5.

There’s a sting in the tail. Sorting the flattened table longest-needle-first lets the search stop at its first match, since the table order now guarantees it’s the best one. That’s a further win, and it also changed the results: 484 findings became 483. Sort-Object isn’t stable, so equally long needles started tiebreaking differently from the browser implementation. A 10x speedup that quietly alters a finding isn’t worth having. Pinning the tiebreak with an explicit ordinal restored 484 and kept the 3.5 seconds.


The things that still bite

Java that isn’t Oracle’s Java: “Azul Zulu JRE 8.96.0.19”, “Amazon Corretto JRE 8” and “Eclipse Temurin JRE with Hotspot 11.0.32+9” all carry the JRE and JDK words, so they land on oracle:jre. They’re separate distributions with their own CPEs and their own patch cadence, and Zulu 8.96 is not Oracle 8u96. The report calls those unchecked instead of guessing. If you’re running one, the vendor’s own advisory page is the answer until we add their CPEs.

Backported fixes: A vendor can fix a CVE and leave the version number where it was. Version comparison can’t see that, so a build that’s actually patched can still show up in the list. Every tool that works by comparing versions has this blind spot, the commercial ones included. Read a finding as “worth checking” rather than “confirmed exploitable”.

Embedded libraries: We read Add/Remove Programs, which is a list of things that were installed. If an app ships its own copy of OpenSSL or zlib inside its program folder, no uninstall key mentions it and nothing here will find it.

Coverage is a pruned list: Roughly the top 500 Windows applications by published CVE count, plus Windows itself. The full NVD catalog is far too big to hand a browser, and most of it is server and appliance software you’ll never see on a workstation. If something you care about is missing, data/fleet-cpes.json is a plain array of CPE strings: add yours and rebuild, or send a PR and everyone gets it.


If you’re building something similar

The thing worth internalising is that zero is a claim. A vulnerability tool that reports nothing looks identical whether it checked everything and found nothing, or never managed to check at all. Ours did the second for a while and returned in 197 milliseconds while doing it.

So the useful question during development isn’t “did it run”. It’s “could this have found something, if something were there”. That’s a harder question, and it’s the one that catches the encoding bug in problem 3.

The index builders are standard-library Python and the whole thing is on GitHub, including a Dockerfile if you’d rather build the index from NVD yourself with your own API key than take ours on faith.


RFF is a Windows fleet management tool - patch, deploy, audit, remote desktop, all from a browser tab, no VPN. getrff.com