
In 24 hours we scored the same CVE three times against three different projects, and it came back DEFER on one and SCHEDULE on the other two. Every call was right. The scanner’s severity label read “high” all three times. What changed was the asset context, and with it the verdict.
That difference is the whole argument for scoring vulnerability risk instead of sorting by severity. Severity is what arrives. Risk is what you assess.
score_risk assesses each vulnerability’s risk for your project. Your coding agent calls this k9 MCP tool to answer “should an engineer fix this finding now?” using reachability analysis, exploitation activity, and asset context. It renders the result in NIST SP 800-30 language your security team and auditors already recognize. The exact request and response shape is in the score_risk tool reference. This post is about why the verdicts come out the way they do.
The same CVE, three environments
CVE-2026-59950 is a missing Host/Origin validation in the MCP Python SDK’s WebSocket server transport. Dependabot labels it “high.” Its exploitation signals are quiet (as of July 20, 2026): it is not on VulnCheck’s Known Exploited Vulnerabilities (KEV) list and has an EPSS score around 0.15 percent, putting it near the 5th percentile. So exploitation is unlikely. We scored it in three projects:
An internal agent runtime, DEFER. The vulnerable WebSocket transport is never imported. The code cannot run, and the asset is internal. Nothing an attacker can reach, on a system that is not exposed. No action.
A public, tier-1 webapp, SCHEDULE. Same unreachable code, now on an internet-facing, business-critical webapp. The reachability evidence still says “not reachable,” and score_risk accounts for that. But a policy floor, public_asset_floor, schedules it anyway as defense in depth. Because a wrong “unreachable” call on a public crown-jewel asset is the expensive kind of wrong. More on that floor below.
Our own production MCP server, SCHEDULE. Here the non-reachability is provable from the code: the server mounts only the Streamable HTTP transport and never imports the vulnerable WebSocket module, so it cannot run. Still SCHEDULE, for the same reason as the previous case. It is a public, material asset.
Three defensible verdicts. Each records its evidence chain: the exploitation data, the reachability determination and the basis for it, and the asset context that drove the difference. The severity label was “high” every time and told you nothing about which of the three to work first.
Why severity can’t rank your backlog
A CVSS score, or a Critical/High/Medium/Low band, measures the worst case if a vulnerability is exploited. It says nothing about whether the vulnerable function runs in your application, whether anyone is exploiting it in the wild, or whether it sits on a payment system or a scratch VM. Those three facts determine whether you should act, and a scanner knows none of them. Sort a backlog by severity and the top of the list is as likely to be unreachable dead code as a live threat.
score_risk looks at the three the label leaves out:
- Reachability is the biggest lever. Vulnerable code that never executes cannot be exploited in your application. Your agent tells
score_riskwhether the code is reachable and on what basis: a call-graph tool confirmed it, the module is never loaded, no attacker-controlled input reaches it, or it is undetermined. A determined “not reachable” genuinely lowers the exposure signal. - Exploitability is whether the vulnerability is being exploited in the wild, or likely to be. You don’t supply this one;
score_riskresolves it from the CVE, using the KEV catalog for observed exploitation and EPSS for the forecast probability. A finding on KEV or in the top EPSS percentile is a live threat. - Asset context is how the same CVE scores differently across environments: criticality, data sensitivity, network exposure, lifecycle, regulatory scope. One rule matters most. Describe where the vulnerable code actually runs. A CVE in your CDK or test tooling carries the build environment’s context, not the public exposure of the service it deploys.
The inverse of a DEFER, a FIX_TODAY, is the two or three percent where all three line up: reachable code, active exploitation such as a KEV listing or a known ransomware association, and a material asset. That is the slice worth same-day work, and it is the slice severity sorting buries under a hundred unreachable “criticals.”
The number that can betray you
score_risk also rates each finding on the NIST SP 800-30 scales, both qualitative and semi-quantitative, for two practical reasons. Security teams asked for risk expressed on a standard they already report against. And the result often feeds their prioritization and SLA schemes. But the semi-quantitative number carries a trap, and you should understand it before you wire it into your remediation process.
Consider the unreachable finding on the tier-1 webapp asset. Its action verdict is SCHEDULE, and only because a policy floor lifted it there. The reachability evidence alone would DEFER it. But its NIST residual score is Moderate, the middle of the scale.
That is not a bug. NIST deliberately holds a high-value asset at Moderate even when the event is unlikely, because a crown-jewel asset carries irreducible tail risk. But if a downstream automation reads that Moderate, maps it to P3, and assigns an SLA, you have just re-queued the exact finding your reachability analysis cleared.
So score_risk separates the two questions the single number was conflating. It emits an inherent risk score (assume the flaw is exploited and reachable, on the real asset), a residual risk score (after the reachability and exploitation analysis), and their difference, retired risk. Action follows the verdict. The score is inherent-risk context and the input to your own priority mapping.
The retired risk delta is also the defensible way to show the value of the scoring process. It is the risk the analysis removed by clarifying exposure. On that tier-1 finding, inherent risk is 100, residual is 50, retired is 50: the analysis halved the risk while the residual still read Moderate because that’s the floor NIST 800-30 sets for tier-1 assets. When demonstrating the value of your risk-scoring process on your most critical assets, report the risk you removed, not the risk that remains.
You can see the trap in our example CVE scored across the three tier-1 assets in this post:
| Environment | Verdict | Inherent | Residual | Retired |
|---|---|---|---|---|
| Internal agent runtime | DEFER | 100 | 50 | 50 |
| Public, tier-1 asset | SCHEDULE | 100 | 50 | 50 |
| Production MCP server | SCHEDULE | 100 | 50 | 50 |
The NIST risk score is identical in every row. Only the verdict changes, because it weighs the reachability and exposure that a residual score, floored at Moderate on a tier-1 asset, cannot. If you map the risk assessment number to an SLA then all three land in the same queue. Follow the score_risk verdict and only the two public assets do.
Every score also carries a confidence tag. A Moderate built on real evidence and a Moderate built on defaults are not the same claim. Low confidence should be read as “go supply the missing input,” not “medium risk.”
When policy overrides evidence, name the floor
Back to the policy floor that scheduled two unreachable findings. How it is built is the difference between a triage tool you can defend in an audit and one you cannot.
k9 acts cautiously when an agent calls a vulnerability unreachable on an internet-facing asset, because the determination itself can be wrong. Reflection, dynamic loading, and metaprogramming can defeat both grep and call-graph analysis. The tempting way to encode that caution is to let the public-network signal override the reachability finding inside the Exposure factor, so the factor reads “exposed.”
That buries a lie in the evidence. The factor would claim “exposed” against affirmative proof of non-reachability, the NIST score would inflate from Moderate to High, and the override would be invisible in the very field built to record it.
score_risk does the opposite. The Exposure factor stays true to the evidence: not reachable. The caution is a named policy floor, public_asset_floor, that raises the action tier and shows up in activated_floors (cf. score_risk policy floors).
The verdict reads “unreachable, and we schedule it anyway by policy,” which survives an audit. “Exposed (it wasn’t)” does not. A triage tool is allowed to be paranoid. It is not allowed to lie about why.
The policy that actually decides
If you are scoring risk to get out from under a CVE backlog before a SOC 2 or ISO 27001 audit, here is the part some engineers miss. The audit does not hand you a list of CVEs to fix. It tests whether you followed your own written Information Security policy. The controlling paragraph is the vulnerability-remediation clause, and its wording decides whether any of this evidence counts.
If the clause says “remediate all Critical and High CVSS,” you are bound to fix them, unreachable or not, until you amend the policy. In that case, reachability evidence is irrelevant for compliance because your own policy made severity the remediation trigger.
If the clause allows risk-based assessment, then “not reachable, not exploited, no attacker-controlled input” is the documented justification that lets you defer. The auditor’s job is to confirm you followed your stated process, not to re-triage each finding.
The same evidence is worthless or decisive depending entirely on how one paragraph is worded. Before you argue with leadership about deferring criticals, go read that clause. It determines whether the argument is winnable.
That is also how to present a score_risk verdict to your security team. Lead with the action and use the NIST number as context: “DEFER; Moderate inherent risk on a tier-1 asset” is fair and grounded in a named standard, and heads off the “but the scanner said Critical” reflex. The k9 score_risk tool outputs the score and the factors. Your team owns mapping that output to your priorities and SLAs.
k9 adapts NIST SP 800-30 in five documented ways:
- Ternary factors, five NIST bands. k9’s threat, exposure, and impact factors are ternary: present, absent, or unknown. NIST scores on five bands from Very Low to Very High, so k9 projects each factor onto those bands by how strong its underlying evidence is, rather than treating “present” as one fixed level.
- An unknown is flagged, not invented. NIST’s scale has no “unknown” value, so a factor with no evidence would stall the math. k9 fills the gap with a neutral Moderate to let the calculation run, then tags the result low-confidence so a reader can tell a measured Moderate from a placeholder.
- Reachability drives adverse impact, not severity. NIST asks how likely a threat event is to actually cause harm once it fires. The common shortcut answers with CVSS severity; k9 answers with reachability, because a vulnerability the code can never reach causes no harm no matter how severe the label.
- Risk comes from NIST’s tables, not multiplication. Many tools compute risk as likelihood times impact. k9 reads the composite straight from NIST SP 800-30’s own risk matrices, which is what the standard actually prescribes.
- Attacker-driven threat model only. NIST 800-30 covers both adversarial threats (an attacker) and non-adversarial ones (an accident or failure). k9 uses only the adversarial path, because a software vulnerability’s risk is almost always about someone choosing to exploit it.
A reviewer can see exactly where and why it departs from the vanilla standard.
What it does not do
score_riskscores findings. It is not a scanner, so bring your own alert feed.score_riskdoes not analyze reachability directly, instead the risk scoring rubric instructs agents how to perform the reachability analysis. Recommendation: use a model such as Opus 4.8, GPT 5.4, or better. Underpowered models fail to exclude unreachable vulnerabilities, leaving the noise in.- Your team owns the mapping from a verdict to your priority levels and SLAs.
Try it
Point score_risk at a backlog and it will tell you the two or three percent worth working now and let you defer the rest with recorded evidence, in a standard your auditors already accept.
- Read the score_risk MCP tool reference
- See a set of CVEs scored for risk end to end
- Try Reachable Risk on your own findings
Recent Comments