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.
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.
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.
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.
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.
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.
| Where | Link |
|---|---|
| Source | github.com/san3ncrypt3d/vulnometry |
| Package | pypi.org/project/vulnometry |
| Getting started | QUICKSTART.md |
| The scoring model | docs/SCORING.md |
| How the code fits together | docs/ARCHITECTURE.md |
| Release notes | v0.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.