From e82ee2c31e3d1516040d7afa97f222ea42d64f60 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Mon, 17 Aug 2026 09:45:04 +0000 Subject: [PATCH] Contextual CVSS as a full 8-metric breakdown, computed like a normal finding --- strix/report/writer.py | 13 +- .../skills/custom/dependency_cve_scanning.md | 85 ++++---- strix/tools/reporting/tool.py | 192 +++++++++++------- tests/test_reporting_fields.py | 101 ++++++++- 4 files changed, 256 insertions(+), 135 deletions(-) diff --git a/strix/report/writer.py b/strix/report/writer.py index 794a23a5..2cdbae22 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -215,14 +215,11 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL cvss = report.get("cvss") if cvss is not None: metadata.append(("CVSS", cvss)) - contextual_metrics = dep_meta.get("contextual_cvss_metrics") - if isinstance(contextual_metrics, dict) and contextual_metrics: - metadata.append( - ( - "Contextual CVSS Metrics", - "/".join(f"{metric}:{value}" for metric, value in contextual_metrics.items()), - ) - ) + advisory_cvss = dep_meta.get("advisory_cvss") + if advisory_cvss is not None and advisory_cvss != cvss: + metadata.append(("Advisory CVSS", advisory_cvss)) + if dep_meta.get("contextual_cvss_vector"): + metadata.append(("Contextual CVSS Vector", dep_meta["contextual_cvss_vector"])) if report.get("fix_effort"): metadata.append(("Fix Effort", str(report["fix_effort"]).title())) for label, value in metadata: diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md index 5f4d09a2..e5afd3de 100644 --- a/strix/skills/custom/dependency_cve_scanning.md +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -241,63 +241,68 @@ findings and rejects empty PoC fields): installed/affected version, fixed version, lockfile path, and the relevant trivy output excerpt. - **Always set `advisory_cvss` to the published advisory base score (0.0–10.0).** - Severity is derived *solely* from this number: read it off the advisory (`CVSS` - in trivy output, or the NVD/GHSA page) and pass the real value. The tool rejects - a call that omits it, because guessing a score both inflates low CVEs and - deflates critical ones. + It is the published reference, and it rates the finding whenever you give no + contextual breakdown: read it off the advisory (`CVSS` in trivy output, or the + NVD/GHSA page) and pass the real value. The tool rejects a call that omits it, + because guessing a score both inflates low CVEs and deflates critical ones. - Set `cwe` to the most specific `CWE-NNN` when the advisory names one. - Do NOT cap severity at LOW just because there is no dynamic reproduction — use the advisory score. - Set `reachability` + `reachability_evidence` from the usage analysis above; use `assumptions` for anything softer (confidence, caveats, analysis limits). -- Set `contextual_cvss_metrics` + `contextual_cvss_reasoning` when this codebase - clearly changes the risk the published score describes (see below). +- Set `contextual_cvss_breakdown` + `contextual_cvss_reasoning` when this + codebase clearly changes the risk the published score describes (see below). -### Contextual (environmental) CVSS +### Contextual CVSS -The published score rates the CVE in the abstract. `contextual_cvss_metrics` -rates it **here**, in this codebase, with CVSS v3.1 environmental metrics. The -advisory's base metrics stay fixed — you never restate them and never pass a -score, the adjusted score is computed from the resulting vector. +The published score rates the CVE in the abstract. `contextual_cvss_breakdown` +rates it **here**, in this codebase — the same 8-metric CVSS v3.1 object as a +normal finding's `cvss_breakdown` (`attack_vector`, `attack_complexity`, +`privileges_required`, `user_interaction`, `scope`, `confidentiality`, +`integrity`, `availability`). You never pass a score: the contextual score and +vector are computed from the breakdown, and when you provide one it determines +the finding's severity. `advisory_cvss` stays the published reference. -Set only what your usage analysis supports: +Start from the advisory's own published metrics and change only what your +evidence proves is different in this codebase: -- `MAV` `N`/`A`/`L`/`P` — the attack vector as deployed. A library reached only - by a local CLI is `L`, not `N`. -- `MAC` `L`/`H` — raise to `H` when the vulnerable path needs a precondition the - code enforces (input validation, a non-default flag, an internal-only route). -- `MPR` `N`/`L`/`H`, `MUI` `N`/`R` — privileges or interaction this deployment - requires before the path is reachable. -- `MS` `U`/`C` — whether exploitation here escapes the component boundary. -- `MC`/`MI`/`MA` `H`/`L`/`N` — the impact in this codebase. `not_imported` code - the build still ships is usually `N` across all three. -- `CR`/`IR`/`AR` `H`/`M`/`L` — the security requirement of the data or service - the package handles (credentials or payment data raise `CR`). +- `attack_vector` `N`/`A`/`L`/`P` — as deployed. A library reached only by a + local CLI is `L`, not `N`. +- `attack_complexity` `L`/`H` — raise to `H` when the vulnerable path needs a + precondition the code enforces (input validation, a non-default flag, an + internal-only route). +- `privileges_required` `N`/`L`/`H`, `user_interaction` `N`/`R` — what this + deployment requires before the path is reachable. +- `scope` `U`/`C` — whether exploitation here escapes the component boundary. +- `confidentiality`/`integrity`/`availability` `N`/`L`/`H` — the impact in this + codebase. `not_imported` code the build still ships is usually `N` across all + three. Ground every metric in the **source-to-sink trace** from the usage analysis (step 3 above), not in a general impression of the package. Derive the metrics -from that chain: `MAV`, `MPR`, and `MUI` come from what the source requires; -`MAC` comes from the preconditions the hops enforce; `MC`, `MI`, and `MA` come -from the data and privileges available at the sink; `CR`, `IR`, and `AR` come -from what that data is worth. +from that chain: `attack_vector`, `privileges_required`, and `user_interaction` +come from what the source requires; `attack_complexity` comes from the +preconditions the hops enforce; `confidentiality`, `integrity`, and +`availability` come from the data and privileges available at the sink. -No trace, no contextual metrics: if you did not reach a symbol hit, or you +No trace, no contextual breakdown: if you did not reach a symbol hit, or you could not follow a hop, omit the contextual fields instead of guessing. -`contextual_cvss_reasoning` is required with the metrics. Write two to four +`contextual_cvss_reasoning` is required with the breakdown. Write two to four sentences that another engineer can check without opening the repository. Name the chain hop by hop as `entry point -> intermediate call -> package call`, with a repository-relative `file:line` for each hop, say who controls the input, and -say what the adjustment changes. Example: `{"MAC": "H", "MC": "L"}` with "The -only caller of `yaml.load` is `parse_manifest` in `scripts/import.py:88`, which -`cli/commands.py:212` invokes for an operator-supplied path behind the -`--allow-unsafe-import` flag that `deploy/prod.yaml` never sets. No HTTP route -reaches that function, so an attacker must already hold shell access on the job -host, and the parsed data is build metadata rather than customer records." +say what the contextual rating changes. Example: lowering `attack_vector` to +`L` and `confidentiality` to `L` with "The only caller of `yaml.load` is +`parse_manifest` in `scripts/import.py:88`, which `cli/commands.py:212` invokes +for an operator-supplied path behind the `--allow-unsafe-import` flag that +`deploy/prod.yaml` never sets. No HTTP route reaches that function, so an +attacker must already hold shell access on the job host, and the parsed data is +build metadata rather than customer records." Omit all the contextual fields when the published rating already fits, and when -the evidence is thin. A contextual score is a claim you must be able to defend, -and this adjustment never replaces `advisory_cvss`. +the evidence is thin. A contextual rating is a claim you must be able to +defend, and it never replaces `advisory_cvss` as the published reference. Verify the CVE with `web_search` when available before reporting. Never guess or hallucinate a CVE id. @@ -308,12 +313,12 @@ hallucinate a CVE id. `create_dependency_report`. - Do not report a finding without a verified CVE id. - Do not batch multiple CVEs into one report. -- Do not omit `advisory_cvss` — the tool rejects it, and it is the single input - that determines dependency severity. +- Do not omit `advisory_cvss` — the tool rejects it, and it rates every finding + that carries no contextual breakdown. - Do not silently drop a known CVE because it lacks a dynamic PoC — that is the exact failure this skill prevents. - Do not downgrade advisory severity for lack of dynamic reproduction. - Do not claim a `reachability` level the evidence does not prove — `unknown` with a reason is always acceptable; an overclaimed level never is. -- Do not send `contextual_cvss_metrics` without evidence-backed reasoning, and +- Do not send `contextual_cvss_breakdown` without evidence-backed reasoning, and do not use it to quietly de-rate a CVE you simply could not analyze. diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index 1fb7b83f..55d711d3 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -749,37 +749,59 @@ def _validate_manifest_path(manifest_path: str | None) -> str | None: return None -# CVSS v3.1 environmental metrics an agent may set on a dependency finding, -# with their legal values. The base metrics are deliberately absent: they come -# from the published advisory, so a report can never restate them. -_CVSS_ENVIRONMENTAL_VALUES: dict[str, frozenset[str]] = { - "MAV": frozenset("NALP"), - "MAC": frozenset("LH"), - "MPR": frozenset("NLH"), - "MUI": frozenset("NR"), - "MS": frozenset("UC"), - "MC": frozenset("HLN"), - "MI": frozenset("HLN"), - "MA": frozenset("HLN"), - "CR": frozenset("HML"), - "IR": frozenset("HML"), - "AR": frozenset("HML"), -} _MAX_CONTEXTUAL_REASONING_CHARS = 2000 -def _clean_contextual_cvss_metrics(raw: dict[str, str] | None) -> dict[str, str]: - """Keep only well-formed CVSS environmental metrics from a report.""" - if not isinstance(raw, dict): - return {} - metrics: dict[str, str] = {} - for key, value in raw.items(): - metric = str(key or "").strip().upper() - metric_value = str(value or "").strip().upper() - allowed = _CVSS_ENVIRONMENTAL_VALUES.get(metric) - if allowed and metric_value in allowed: - metrics[metric] = metric_value - return metrics +def _validate_contextual_cvss( + breakdown: dict[str, str] | None, + reasoning: str | None, +) -> list[str]: + errors: list[str] = [] + if breakdown: + for name, valid in _CVSS_VALID.items(): + value = breakdown.get(name) + if value not in valid: + errors.append( + f"Invalid contextual_cvss_breakdown {name}: {value}. Must be one of: {valid}" + ) + if not (reasoning or "").strip(): + errors.append( + "contextual_cvss_reasoning is required when contextual_cvss_breakdown is " + "set: state what you observed in this codebase that justifies the " + "contextual rating. A contextual score with no reasoning is not shown." + ) + return errors + + +def _validate_advisory_cvss(advisory_cvss: float | None) -> str | None: + if advisory_cvss is None: + return ( + "advisory_cvss is required: read the published advisory base score " + "(0.0-10.0) off the advisory (trivy CVSS / NVD / GHSA). It is the " + "published reference the finding is rated against — do not omit it " + "or the finding cannot be rated." + ) + if not 0.0 <= advisory_cvss <= 10.0: + return f"advisory_cvss must be between 0.0 and 10.0, got {advisory_cvss}" + return None + + +def _resolve_dependency_rating( + advisory_cvss: float | None, + contextual_cvss_breakdown: dict[str, str] | None, +) -> tuple[float | None, str, float | None, str | None]: + """Rate the finding. + + A contextual breakdown works exactly like a normal finding's + ``cvss_breakdown``: the agent supplies the 8 metrics as observed in this + codebase and the score/vector are computed from them. When provided it + rates the finding; the advisory score stays as the published reference. + """ + if contextual_cvss_breakdown: + score, severity, vector = _calculate_cvss(contextual_cvss_breakdown) + return score, severity, score, vector + score, severity = _dependency_severity(advisory_cvss) + return score, severity, None, None def _build_dependency_metadata( @@ -793,13 +815,18 @@ def _build_dependency_metadata( manifest_path: str | None = None, reachability: str | None = None, reachability_evidence: str | None = None, - contextual_cvss_metrics: dict[str, str] | None = None, + advisory_cvss: float | None = None, + contextual_cvss_breakdown: dict[str, str] | None = None, + contextual_cvss_score: float | None = None, + contextual_cvss_vector: str | None = None, contextual_cvss_reasoning: str | None = None, ) -> dict[str, Any]: metadata: dict[str, Any] = { "package_name": package_name.strip(), "installed_version": installed_version.strip(), } + if advisory_cvss is not None: + metadata["advisory_cvss"] = advisory_cvss if package_ecosystem and package_ecosystem.strip(): metadata["package_ecosystem"] = package_ecosystem.strip() if manifest_path and manifest_path.strip(): @@ -816,12 +843,19 @@ def _build_dependency_metadata( metadata["reachability"] = reachability.strip() if reachability_evidence and reachability_evidence.strip(): metadata["reachability_evidence"] = reachability_evidence.strip() - # Contextual CVSS is only meaningful as metrics plus the reasoning a reader - # can check, so an incomplete pair is dropped. - cleaned_metrics = _clean_contextual_cvss_metrics(contextual_cvss_metrics) + # Contextual CVSS is only meaningful as the full breakdown, its computed + # score/vector, and the reasoning a reader can check — an incomplete set + # is dropped. reasoning = str(contextual_cvss_reasoning or "").strip() - if cleaned_metrics and reasoning: - metadata["contextual_cvss_metrics"] = cleaned_metrics + if ( + contextual_cvss_breakdown + and contextual_cvss_score is not None + and contextual_cvss_vector + and reasoning + ): + metadata["contextual_cvss_breakdown"] = contextual_cvss_breakdown + metadata["contextual_cvss_score"] = contextual_cvss_score + metadata["contextual_cvss_vector"] = contextual_cvss_vector metadata["contextual_cvss_reasoning"] = reasoning[:_MAX_CONTEXTUAL_REASONING_CHARS] return metadata @@ -894,7 +928,7 @@ async def _do_create_dependency( # noqa: PLR0912 manifest_path: str | None = None, reachability: str = "unknown", reachability_evidence: str | None = None, - contextual_cvss_metrics: dict[str, str] | None = None, + contextual_cvss_breakdown: dict[str, str] | None = None, contextual_cvss_reasoning: str | None = None, agent_id: str | None = None, agent_name: str | None = None, @@ -948,28 +982,21 @@ async def _do_create_dependency( # noqa: PLR0912 "govulncheck call path). Never claim a reachability level without evidence." ) - if _clean_contextual_cvss_metrics(contextual_cvss_metrics) and not ( - contextual_cvss_reasoning or "" - ).strip(): - errors.append( - "contextual_cvss_reasoning is required when contextual_cvss_metrics is set: " - "state in one or two sentences what you observed in this codebase that " - "justifies the adjustment. An adjusted score with no reasoning is not shown." - ) + errors.extend(_validate_contextual_cvss(contextual_cvss_breakdown, contextual_cvss_reasoning)) - if advisory_cvss is None: - errors.append( - "advisory_cvss is required: read the published advisory base score " - "(0.0-10.0) off the advisory (trivy CVSS / NVD / GHSA). Severity is " - "derived solely from it — do not omit it or the finding cannot be rated." - ) - elif not 0.0 <= advisory_cvss <= 10.0: - errors.append(f"advisory_cvss must be between 0.0 and 10.0, got {advisory_cvss}") + advisory_err = _validate_advisory_cvss(advisory_cvss) + if advisory_err: + errors.append(advisory_err) if errors: return {"success": False, "error": "Validation failed", "errors": errors} - cvss_score, severity = _dependency_severity(advisory_cvss) + try: + cvss_score, severity, contextual_score, contextual_vector = _resolve_dependency_rating( + advisory_cvss, contextual_cvss_breakdown + ) + except ValueError as exc: + return {"success": False, "error": "Validation failed", "errors": [str(exc)]} dependency_metadata = _build_dependency_metadata( package_name=package_name, installed_version=installed_version, @@ -980,7 +1007,10 @@ async def _do_create_dependency( # noqa: PLR0912 manifest_path=manifest_path, reachability=reachability, reachability_evidence=reachability_evidence, - contextual_cvss_metrics=contextual_cvss_metrics, + advisory_cvss=advisory_cvss, + contextual_cvss_breakdown=contextual_cvss_breakdown, + contextual_cvss_score=contextual_score, + contextual_cvss_vector=contextual_vector, contextual_cvss_reasoning=contextual_cvss_reasoning, ) evidence = _build_dependency_evidence( @@ -1093,7 +1123,7 @@ async def create_dependency_report( dependency_path: str | None = None, reachability: str = "unknown", reachability_evidence: str | None = None, - contextual_cvss_metrics: dict[str, str] | None = None, + contextual_cvss_breakdown: dict[str, str] | None = None, contextual_cvss_reasoning: str | None = None, ) -> str: """File a known-CVE dependency (SCA) finding — one report per CVE x package. @@ -1137,8 +1167,10 @@ async def create_dependency_report( proved a path from application code to the vulnerable function. - ``unknown`` — usage analysis was not performed or was inconclusive. - Severity is still derived solely from ``advisory_cvss`` — the - reachability level never changes the rating, only prioritization. + Severity comes from ``contextual_cvss_breakdown`` when you provide one + (computed exactly like a normal finding's ``cvss_breakdown``), otherwise + from ``advisory_cvss``. The reachability level alone never changes the + rating, only prioritization. **Formatting**: use markdown in text fields (``**bold**``, ``inline code`` for package/version identifiers, fenced code blocks for @@ -1159,8 +1191,9 @@ async def create_dependency_report( cwe: ``CWE-NNN`` (most specific) if certain, else omit. advisory_cvss: **Required.** Published advisory base score (0.0-10.0) — read it off the advisory (trivy CVSS / NVD / GHSA). - Severity is derived solely from this score, so it must be the - real published value; do not guess or omit it. + It is the published reference the finding is rated against and + rates the finding whenever you give no contextual breakdown, so + it must be the real published value; do not guess or omit it. technical_analysis: Optional deeper mechanism/root-cause detail. fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high`` (dependency upgrades are usually ``trivial``/``low``). @@ -1199,26 +1232,29 @@ async def create_dependency_report( is off in production), and say who controls the input. State it plainly when no entry point reaches the sink — that is the most useful result a reader can get. - contextual_cvss_metrics: Optional CVSS v3.1 **environmental** - metrics that reframe the published score for this codebase, - as a mapping of metric to value: ``MAV`` (N/A/L/P), ``MAC`` - (L/H), ``MPR`` (N/L/H), ``MUI`` (N/R), ``MS`` (U/C), ``MC`` / - ``MI`` / ``MA`` (H/L/N), ``CR`` / ``IR`` / ``AR`` (H/M/L). - Set only the metrics your evidence supports (for example - ``{"MAC": "H", "MC": "L"}`` when the vulnerable path needs a - precondition this deployment enforces and the data at risk is - limited). Derive every value from the **source-to-sink - trace** you recorded in ``reachability_evidence``: adjust - ``MAV`` / ``MPR`` / ``MUI`` from what the entry point - actually requires, ``MAC`` from the preconditions the hops - enforce, and ``MC`` / ``MI`` / ``MA`` from the data and - privileges reachable at the sink. You never supply base - metrics or a score: the base vector comes from the advisory - and the adjusted score is computed from the resulting vector. - Omit the field when the trace does not change the published - rating, or when you could not complete the trace. + contextual_cvss_breakdown: Optional full CVSS v3.1 rating of this + CVE **in this codebase** — the same 8-metric object as + ``create_vulnerability_report``'s ``cvss_breakdown``: + ``attack_vector`` (N/A/L/P), ``attack_complexity`` (L/H), + ``privileges_required`` (N/L/H), ``user_interaction`` (N/R), + ``scope`` (U/C), ``confidentiality`` / ``integrity`` / + ``availability`` (N/L/H). All 8 metrics are required when the + field is set, and the contextual score/vector are computed + from them — you never supply a score. Start from the + advisory's published metrics and change only what the + **source-to-sink trace** you recorded in + ``reachability_evidence`` proves is different here: derive + ``attack_vector`` / ``privileges_required`` / + ``user_interaction`` from what the entry point actually + requires, ``attack_complexity`` from the preconditions the + hops enforce, and the impact metrics from the data and + privileges reachable at the sink. When provided, this rating + determines the finding's severity; ``advisory_cvss`` stays as + the published reference. Omit the field when the trace does + not change the published rating, or when you could not + complete the trace. contextual_cvss_reasoning: **Required whenever** - ``contextual_cvss_metrics`` is set. Two to four detailed + ``contextual_cvss_breakdown`` is set. Two to four detailed sentences that a reviewer can verify without opening the repo: how the application uses the package, which call sites or configuration you inspected (repo-relative ``file:line``), @@ -1254,7 +1290,7 @@ async def create_dependency_report( manifest_path=manifest_path, reachability=reachability, reachability_evidence=reachability_evidence, - contextual_cvss_metrics=contextual_cvss_metrics, + contextual_cvss_breakdown=contextual_cvss_breakdown, contextual_cvss_reasoning=contextual_cvss_reasoning, agent_id=agent_id, agent_name=agent_name, diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py index 2fa78547..d19f7aec 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -160,6 +160,7 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta assert report["dependency_metadata"] == { "package_name": "lodash", "installed_version": "4.17.20", + "advisory_cvss": 7.2, "package_ecosystem": "npm", "manifest_path": "package-lock.json", "fixed_version": "4.17.21", @@ -463,6 +464,7 @@ async def test_dependency_report_dedupe_candidate_includes_dependency_metadata( "dependency_metadata": { "package_name": "sample", "installed_version": "1.0.0", + "advisory_cvss": 0.0, "package_ecosystem": "npm", "manifest_path": "package-lock.json", "fixed_version": "1.0.1", @@ -882,17 +884,29 @@ def test_vuln_tool_exposes_new_params() -> None: def test_dep_tool_exposes_contextual_cvss_params() -> None: dep_props = create_dependency_report.params_json_schema["properties"] for field in ( - "contextual_cvss_metrics", + "contextual_cvss_breakdown", "contextual_cvss_reasoning", ): assert field in dep_props - assert "source-to-sink" in dep_props["contextual_cvss_metrics"]["description"].lower() + assert "source-to-sink" in dep_props["contextual_cvss_breakdown"]["description"].lower() assert "source-to-sink" in dep_props["reachability_evidence"]["description"].lower() assert "file:line" in dep_props["contextual_cvss_reasoning"]["description"].lower() +_CONTEXTUAL_BREAKDOWN = { + "attack_vector": "L", + "attack_complexity": "H", + "privileges_required": "H", + "user_interaction": "N", + "scope": "U", + "confidentiality": "L", + "integrity": "L", + "availability": "N", +} + + @pytest.mark.asyncio -async def test_dependency_report_keeps_only_valid_contextual_metrics( +async def test_dependency_report_computes_contextual_cvss( report_state: ReportState, ) -> None: result = await _do_create_dependency( @@ -912,18 +926,25 @@ async def test_dependency_report_keeps_only_valid_contextual_metrics( cwe="CWE-94", fix_effort="trivial", manifest_path="package-lock.json", - contextual_cvss_metrics={"MAC": "H", "MC": "L", "AV": "N", "MPR": "Z"}, + contextual_cvss_breakdown=_CONTEXTUAL_BREAKDOWN, contextual_cvss_reasoning="Only scripts/import.py reaches the sink.", ) assert result["success"] is True, result - metadata = report_state.vulnerability_reports[0]["dependency_metadata"] - assert metadata["contextual_cvss_metrics"] == {"MAC": "H", "MC": "L"} + report = report_state.vulnerability_reports[0] + metadata = report["dependency_metadata"] + assert metadata["advisory_cvss"] == 7.2 + assert metadata["contextual_cvss_breakdown"] == _CONTEXTUAL_BREAKDOWN + assert metadata["contextual_cvss_vector"] == ("CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:L/I:L/A:N") + assert metadata["contextual_cvss_score"] == pytest.approx(3.0, abs=0.05) assert metadata["contextual_cvss_reasoning"] == "Only scripts/import.py reaches the sink." - assert "contextual_cvss_metric_reasoning" not in metadata + # The contextual rating determines the finding's score/severity, exactly + # like a normal finding's cvss_breakdown. + assert report["cvss"] == metadata["contextual_cvss_score"] + assert report["severity"] == "low" @pytest.mark.asyncio -async def test_dependency_report_rejects_contextual_metrics_without_reasoning( +async def test_dependency_report_rates_from_advisory_without_contextual( report_state: ReportState, ) -> None: result = await _do_create_dependency( @@ -943,7 +964,69 @@ async def test_dependency_report_rejects_contextual_metrics_without_reasoning( cwe="CWE-94", fix_effort="trivial", manifest_path="package-lock.json", - contextual_cvss_metrics={"MAC": "H"}, + ) + assert result["success"] is True, result + report = report_state.vulnerability_reports[0] + assert report["cvss"] == 7.2 + assert report["severity"] == "high" + metadata = report["dependency_metadata"] + assert metadata["advisory_cvss"] == 7.2 + assert "contextual_cvss_breakdown" not in metadata + assert "contextual_cvss_score" not in metadata + + +@pytest.mark.asyncio +async def test_dependency_report_rejects_incomplete_contextual_breakdown( + report_state: ReportState, +) -> None: + result = await _do_create_dependency( + title="CVE-2021-23337 in lodash 4.17.20", + description="Command injection via template.", + target="repo/package.json", + cve="CVE-2021-23337", + package_name="lodash", + installed_version="4.17.20", + impact="Arbitrary command execution.", + remediation_steps="Upgrade to 4.17.21.", + assumptions="Assumes the template sink is reachable.", + package_ecosystem="npm", + advisory_cvss=7.2, + technical_analysis=None, + fixed_version="4.17.21", + cwe="CWE-94", + fix_effort="trivial", + manifest_path="package-lock.json", + contextual_cvss_breakdown={"attack_vector": "L", "attack_complexity": "Z"}, + contextual_cvss_reasoning="Only scripts/import.py reaches the sink.", + ) + assert result["success"] is False + assert any("attack_complexity" in error for error in result["errors"]) + assert any("privileges_required" in error for error in result["errors"]) + assert report_state.vulnerability_reports == [] + + +@pytest.mark.asyncio +async def test_dependency_report_rejects_contextual_breakdown_without_reasoning( + report_state: ReportState, +) -> None: + result = await _do_create_dependency( + title="CVE-2021-23337 in lodash 4.17.20", + description="Command injection via template.", + target="repo/package.json", + cve="CVE-2021-23337", + package_name="lodash", + installed_version="4.17.20", + impact="Arbitrary command execution.", + remediation_steps="Upgrade to 4.17.21.", + assumptions="Assumes the template sink is reachable.", + package_ecosystem="npm", + advisory_cvss=7.2, + technical_analysis=None, + fixed_version="4.17.21", + cwe="CWE-94", + fix_effort="trivial", + manifest_path="package-lock.json", + contextual_cvss_breakdown=_CONTEXTUAL_BREAKDOWN, contextual_cvss_reasoning=" ", ) assert result["success"] is False