2026.09.02

Your vulnerability scanner is ranking the wrong things

A CVSS score describes a vulnerability. It does not describe your problem.

Monday morning. The scanner has finished and there are 1,400 findings across the estate, forty of them rated Critical. Someone has to decide what the platform team actually works on this week, and the only ordering available is a column of numbers that describes each flaw in the abstract, as though every machine in the company were interchangeable.

They are not interchangeable. One of those Criticals is a Java library on the payment gateway, which is internet-facing and in PCI scope. Another is the same library, same version, same 9.8, sitting on a box in the data science lab that was decommissioned in March and never removed from the scan target list.

Your scanner gives both of them a 9.8. It has no way not to.

Severity is a property of the flaw. Risk is a property of your estate.

This is the distinction that gets flattened. CVSS is a good standard doing what it was designed to do: describe the intrinsic characteristics of a vulnerability so they are comparable across organisations. The base score deliberately excludes your environment, because it cannot know your environment.

So the mistake is not CVSS. The mistake is using a deliberately context-free number as a work queue.

The industry's first correction was EPSS, which estimates the probability that a CVE will be exploited in the wild within thirty days. It helps a great deal, because the distribution is brutally skewed: most published CVEs will never be exploited, and a 9.8 with a 0.2% EPSS is a different animal from a 7.5 with 40%. The second correction was CISA's Known Exploited Vulnerabilities catalogue, which replaces prediction with observation. Someone has actually been seen doing this.

Both are real improvements. Neither answers the question that decides your Monday: is it deployed here, can anyone reach it here, and what does it cost this business if they do?

Three questions, multiplied

I built a tool called vulnometry around one line of arithmetic.

BEI = 1000 * Threat^0.8 * Reachability^0.7 * Consequence^0.6

Three questions:

  • Threat. Will anyone actually try? From KEV (observed), EPSS (predicted), and whether working exploit code is circulating.
  • Reachability. Can they get to it here? From the CVSS attack vector, multiplied by what your topology actually looks like.
  • Consequence. What does it cost us? From the CVSS impact sub-metrics, scaled by asset criticality, data classification and regulatory scope.

The interesting decision is the multiplication.

Nearly every prioritisation scheme in this space is a weighted sum: some CVSS, some EPSS, a bonus for KEV, maybe a nudge for asset criticality. Sums have a specific failure mode. A large enough score in one term carries the finding regardless of the others, which is exactly how the decommissioned lab box ends up in your sprint.

Multiplying makes every factor a veto. If nobody can reach it, reachability approaches zero and so does the result, no matter how alarming the CVSS score.

The part I care about: this needs no special-case rule. There is no if not_deployed: override branch anywhere in the code. The arithmetic already says it. You cannot argue in a review meeting about an exception that does not exist.

Telling it what you run

Vulnometry reads one file. A minimal version is four lines:

assets:
  - name: checkout-api
    tier: 1
    internet_exposed: true

A realistic one carries more:

assets:
  - name: checkout-api
    tier: 1                          # 1 mission-critical, 3 supporting
    owner: [email protected]
    business_unit: Commerce
    environment: production
    internet_exposed: true
    data_classification: restricted
    regimes: [pci-dss, gdpr]
    components: ["org.apache.logging.log4j*", "spring-*"]
    hosts: ["checkout-*.prod.northwind.example"]

  - name: internal-wiki
    tier: 3
    internet_exposed: false
    data_classification: internal
    compensating_controls: ["VPN-only access"]

  - name: ml-sandbox
    tier: 3
    internet_exposed: false
    deployed: false                  # decommissioned in March

Every field except the name is optional. A missing field is not treated as a safe field: unknown internet exposure is discounted rather than dismissed, and an asset you have not described is scored pessimistically. Quietly treating undescribed assets as safe is how findings get lost, and lost findings are the ones that turn into incidents.

The moment it clicks

Same CVE, five places, one command.

Terminal output of vulnometry compare for CVE-2021-44228 across five assets, showing BEI scores from 928.9 down to 0 with different verdicts and due dates
Figure 1Log4Shell measured against five assets in the same estate. The Threat column is identical on every row, because threat is a property of the CVE. The 929-point spread is entirely reachability and consequence.

Log4Shell. One of the most consequential vulnerabilities of the last decade, CVSS 10.0, confirmed exploited, ransomware-linked. Your scanner writes 10.0 against all five rows.

Look at what diverges. Two days for the payment gateway. Next maintenance window for the wiki. Nothing at all for the machine that no longer exists, because deployed: false takes reachability to zero and the product collapses with it.

Showing its work

A scoring tool that will not explain itself is a tool nobody trusts, and rightly so.

Detailed vulnometry measure output showing factor bars, CVSS, EPSS, KEV status, fixed versions, a How this was measured breakdown, and a directive
Figure 2The forensic view. Every point in the score is attributable to a named piece of evidence, and the directive at the bottom is the sentence that goes in the ticket.

If you disagree with the tier weight or the regulatory multiplier, you can see precisely which line to argue with, and they all live in one file of roughly 250 lines that is meant to be edited. The derivation is written up in docs/SCORING.md.

The due date is policy rather than a guess. Contain starts at seven days, tier 1 halves it, PCI scope takes 60% of what remains, production another 80%. Two days. The same verdict on a tier-3 internal box lands at twelve.

The part that actually saves the week

Real vulnerability management does not happen at a prompt. It happens in a spreadsheet somebody was emailed.

vulnometry import northwind-scan.xlsx

That export has a title block above the header, a summary tab before the data tab, a tab with no findings in it at all, vendor-specific column names, and one cell containing two CVE identifiers separated by a comma. Vulnometry finds the header row rather than assuming row one, picks the sheet with findings in it, matches columns by header against an alias table, and splits multi-CVE cells into separate findings.

vulnometry import output showing 13 findings above a 300 BEI threshold, sorted by exposure, with verdicts, owners and due dates
Figure 3Twenty-four findings from a messy scanner export, scored against the inventory and filtered to what matters. Note the last line: four findings were reduced to negligible exposure by business context.

It also reads native JSON from Trivy, Grype, Snyk, OSV-Scanner, Dependabot and SARIF, plus lockfiles and SBOMs.

Then it hands back two artefacts. An annotated workbook with four sheets: Findings (every row colour-coded by verdict, filterable, header frozen), Action Plan (only what needs doing, sorted by due date), By Owner (workload per team, so the queue can be distributed), and Method (how the numbers were produced).

And a dashboard, which is one self-contained HTML file. No server, no CDN, no build step and no JavaScript at all. The charts are inline SVG generated in Python. It opens from a file:// URL, survives being forwarded to an executive, and works on an air-gapped machine.

Vulnometry HTML dashboard showing KPI tiles, a verdict donut chart, a reachability versus consequence scatter plot, exposure by business unit and actionable load by owner
Figure 4The chart I care about is on the right. Reachability against consequence, bubble size as threat. The top-right corner is the work. The left edge is everything a severity-only view would have ranked identically.

Optional intelligence, deliberately

Vulnometry speaks Model Context Protocol, so it drops into Claude Code, Claude Desktop, Cursor, Zed or Cline in one command:

claude mcp add vulnometry -- vulnometry serve mcp

Then you can ask "what do we run, and is CVE-2021-44228 urgent for any of it?" and get an answer grounded in your actual inventory. It also runs against Ollama, vLLM, llama.cpp, LM Studio, OpenAI, Azure AI Foundry, AWS Bedrock, Anthropic and Gemini. One action registry, seventeen actions, every provider generated from the same source.

But there is a rule underneath all of it: no model ever produces a number. Scoring is deterministic and reproducible on any machine. A model can explain a verdict; it can never compute one. Every command except vulnometry ask works with no inference stack installed at all.

You do not want a 7B model inventing a CVSS score. By construction, it cannot.

What this does not do

Worth being direct about the limits, because vulnerability tooling has a credibility problem and overclaiming is how it got one.

Reachability here is network and deployment reachability, derived from your inventory. It is not function-level reachability analysis, and it does not read your call graph to work out whether the vulnerable method is ever invoked. That is what commercial tools like Endor Labs and Snyk do, and it is a genuinely harder problem.

Exploit discovery is a heuristic GitHub search. A match means public code claims to exploit the CVE, not that it works.

The weights are reasoned defaults, not empirical constants derived from breach data. Nobody's are, publicly. That is exactly why every factor prints its reasoning, so you can find the default you disagree with and change it.

And the whole thing is only as good as your inventory. Vulnometry cannot tell you what you run. It can only make that knowledge count for something once you have written it down, which is a thing most organisations discover they should have done anyway.

Try it

pip install vulnometry

vulnometry doctor
curl -O https://raw.githubusercontent.com/san3ncrypt3d/vulnometry/main/examples/vulnometry.yaml
vulnometry compare CVE-2021-44228

Apache-2.0. Python 3.10 or newer. Four runtime dependencies. Fifty-eight tests that run offline in under a second.

WhereLink
Sourcegithub.com/san3ncrypt3d/vulnometry
Packagepypi.org/project/vulnometry
Getting startedQUICKSTART.md
The scoring modeldocs/SCORING.md
How the code fits togetherdocs/ARCHITECTURE.md
Release notesv0.1.0

The issue I most want to receive is a scoring dispute. If a verdict looks wrong to you, the "How this was measured" block shows exactly where we disagree, and that is a far more productive conversation than arguing about a number with no derivation attached. There is an issue template for exactly that.