Tool: score_risk
Score a batch of software-vulnerability findings into an action: FIX_TODAY, SCHEDULE, or DEFER. score_risk composes the KEV and EPSS catalogs with the reachability and asset context you supply, applies the k9 risk-scoring rubric, and returns a per-finding verdict plus a NIST SP 800-30 risk score.
Ask your agent:
Score this project's dependency alerts for reachable risk.
Read the rubric once per session before scoring, so your agent supplies the right reachability basis and asset context. It is published on two MCP surfaces: the resource k9://rubric/risk-scoring and the prompt risk_scoring_rubric.
For why the verdicts look the way they do, including why a DEFER can still carry a Moderate NIST score, see How to Prioritize CVEs by Risk, Not Severity.
Request
score_risk(findings, scored_by_model?)
findings: a list of finding objects, max 50 per call.scored_by_model: optional string naming the model that drove the scoring.
Each finding:
| Field | Required | Type | Notes |
|---|---|---|---|
kind |
yes | "software_vuln" |
"software_vuln" is the only kind of finding supported currently. |
finding_key |
yes | string | Your unique key for this finding. Returned as the response key. Keep it stable across re-scans so usage dedupes. |
vuln_id |
yes | string | CVE-…, GHSA-…, GO-…, or ALAS-…. KEV/EPSS are CVE-keyed; a non-CVE id still scores, with threat unknown. |
reachable |
yes | object | {value, basis}. See below. |
asset_context |
no | object | Environment context. Omit and the rubric assumes production / tier_2 / internal. See below. |
project_id |
no | string | Groups findings by project. Does not affect a verdict. |
Note: severity is not an input for risk scoring (why).
reachable
Whether the vulnerable code is reachable, and the basis for that call. value is true, false, or null; basis names why and must match the value. Your agent can determine reachability using the rubric's guidance or supply it from a specialized tool.
basis |
Pairs with | Meaning |
|---|---|---|
tool_confirmed |
true or false |
A reachability tool (e.g. call-graph analysis) produced the verdict. |
manual_call_graph |
true |
Your own analysis confirmed the vulnerable symbol is reachable from an entry point with attacker-controlled input. |
code_not_loaded |
false |
The vulnerable package or module is not imported anywhere. |
unreachable_from_entrypoint |
false |
The code is loaded, but no path from any entry point reaches it. |
no_attacker_controlled_input |
false |
The code is reachable, but only with first-party / trusted inputs. |
undetermined |
null |
Neither a tool nor manual analysis could conclude. The floor of last resort. |
asset_context
All fields optional. Unknown fields are treated as unknown, not guessed. Unrecognized keys are rejected, so a typo surfaces as an error rather than a silent mis-score.
| Field | Values |
|---|---|
asset_criticality |
tier_1, tier_2, tier_3 |
data_classification |
public, internal, confidential, regulated |
network_exposure |
public_unauth, public_auth, internal, isolated |
lifecycle |
production, staging, dev |
regulatory_scope |
list of strings, e.g. ["PCI", "SOC2"] |
Response
The response is an object with the risk-scoring rubric_version, batch statistics (batch_stats), and a dictionary of verdicts keyed by finding_key. Each verdict contains:
| Field | Description |
|---|---|
bucket |
FIX_TODAY, SCHEDULE, or DEFER. The action recommendation. Drive your work from this. |
rationale |
One-line explanation of the verdict. |
factors |
threat, exposure, impact, each present / absent / unknown with a confidence and rationale. |
activated_floors |
Safety floors that changed the bucket. Empty means the factor assessment alone produced the verdict. |
evidence |
The KEV / EPSS / reachability inputs echoed back, plus default_context_applied. |
risk_assessment |
NIST SP 800-30 rendering: a qualitative band and a 0 to 100 composite, as inherent, residual, and retired (inherent minus residual). Report residual; retired is the risk the analysis accounted for. |
Batch statistics
batch_stats summarizes the whole call so you can sanity-check a run before trusting it. The statistics primarily measure three quality dimensions: reachability discipline, how much scoring ran on defaulted context, and the batch size behind any noise-reduction claim.
| Field | Type | Description |
|---|---|---|
findings_total |
int | Findings scored in this call. The denominator for a noise-reduction figure; reconcile it against the alert count you fetched. |
buckets |
object | Count per bucket, e.g. {"FIX_TODAY": 1, "SCHEDULE": 3, "DEFER": 38}. |
reachability_basis_counts |
object | Per-basis histogram of the reachable.basis values you supplied. |
undetermined_fraction |
float | Share of findings scored undetermined. Above ~0.10 usually means reachability analysis was skipped, not that it was genuinely inconclusive. |
default_context_fraction |
float | Share scored without caller-supplied asset_context. |
quality_flags |
list of strings | Threshold-derived flags that name a remedy, e.g. undetermined_above_10_percent, default_context_above_25_percent. Resolve these before reporting results. |
Example:
"batch_stats": {
"findings_total": 42,
"buckets": { "FIX_TODAY": 1, "SCHEDULE": 3, "DEFER": 38 },
"reachability_basis_counts": {
"code_not_loaded": 25,
"no_attacker_controlled_input": 8,
"tool_confirmed": 5,
"undetermined": 4
},
"undetermined_fraction": 0.095,
"default_context_fraction": 0.0,
"quality_flags": []
}
An empty quality_flags means the batch met the quality thresholds. A non-empty list is a signal to fix the inputs and re-score before acting on the verdicts.
Policy floors
Floors raise the recommendation when a condition is serious enough, and appear in activated_floors only when they changed the outcome.
kev_emergency: a KEV-listed vulnerability is never left at DEFER.ransomware_floor: known ransomware-campaign use forces FIX_TODAY.tier_1_asset: a real threat against a tier-1 asset is never left at DEFER.public_asset_floor: a determined-unreachable finding on an internet-facing asset with real impact is raised to SCHEDULE, in case the reachability call is wrong.
Example
An illustrative unreachable finding on a public, critical asset. The values are representative of that case, not a captured run.
Request (one finding, unreachable but on a public, tier-1 service):
{
"findings": [
{
"kind": "software_vuln",
"finding_key": "payments-api/CVE-2025-12345",
"vuln_id": "CVE-2025-12345",
"reachable": { "value": false, "basis": "code_not_loaded" },
"asset_context": {
"asset_criticality": "tier_1",
"network_exposure": "public_auth",
"lifecycle": "production"
}
}
]
}
Response (illustrative, abridged):
{
"rubric_version": "2026.07.18-v15",
"verdicts": {
"payments-api/CVE-2025-12345": {
"bucket": "SCHEDULE",
"activated_floors": ["public_asset_floor"],
"factors": {
"threat": "absent",
"exposure": "absent",
"impact": "present"
},
"risk_assessment": {
"nist_800_30_qualitative": {
"inherent": { "band": "very_high", "label": "Very High" },
"residual": { "band": "moderate", "label": "Moderate" }
},
"nist_800_30_semi_quantitative": {
"inherent": { "composite": 100 },
"residual": { "composite": 50 },
"retired": 50
}
}
}
},
"batch_stats": { ... }
}
The code is not reachable, so exposure is honestly absent, but the public-asset floor keeps a finding on an internet-facing, material asset in the SCHEDULE queue. The residual score reflects the tail risk without inflating the exposure factor.
Related
- Concept and worked examples: How to Prioritize CVEs by Risk, Not Severity.
- The rubric: MCP resource
k9://rubric/risk-scoringor promptrisk_scoring_rubric. - Set up the server: Configure the k9 MCP server.