diff --git a/src/shared/fetchers.py b/src/shared/fetchers.py index 3f069c1c7..b83ba095d 100644 --- a/src/shared/fetchers.py +++ b/src/shared/fetchers.py @@ -97,15 +97,27 @@ def to_camel_case(name: str) -> str: def make_metric(data: dict[str, Any]) -> models.Metric: - ctx: dict[str, Any] = dict() - ctx["format"] = "cvssV3_1" - raw_cvss = data.get("cvssV3_1", {}) + cvss_preference = ("cvssV4_0", "cvssV3_1", "cvssV3_0") + + raw_cvss: dict[str, Any] = {} + selected_format = "" + + for version in cvss_preference: + candidate = data.get(version) + if candidate: + raw_cvss = candidate + selected_format = version + break + + ctx: dict[str, Any] = {} + ctx["format"] = selected_format ctx["raw_cvss_json"] = raw_cvss if raw_cvss: ctx["scope"] = raw_cvss.get("scope") ctx["vector_string"] = raw_cvss.get("vectorString") - ctx["base_score"] = float(raw_cvss.get("baseScore")) + ctx["base_score"] = raw_cvss["baseScore"] + ctx["base_severity"] = raw_cvss.get("baseSeverity") vector_fields = ( "attack_complexity", diff --git a/src/shared/tests/test_fetchers.py b/src/shared/tests/test_fetchers.py new file mode 100644 index 000000000..bb1de15fd --- /dev/null +++ b/src/shared/tests/test_fetchers.py @@ -0,0 +1,47 @@ +from shared.fetchers import make_metric + + +def test_make_metric_uses_cvss_v4_0_when_v3_1_is_missing(db: None) -> None: + metric = make_metric( + { + "cvssV4_0": { + "version": "4.0", + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", + "baseScore": 9.3, + "baseSeverity": "CRITICAL", + } + } + ) + + assert metric.format == "cvssV4_0" + assert metric.raw_cvss_json["version"] == "4.0" + assert metric.vector_string == "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N" + assert metric.base_score == 9.3 + assert metric.base_severity == "CRITICAL" + + +def test_make_metric_uses_cvss_v3_0_when_v3_1_is_missing(db: None) -> None: + metric = make_metric( + { + "cvssV3_0": { + "version": "3.0", + "vectorString": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": "9.8", + "baseSeverity": "CRITICAL", + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "scope": "UNCHANGED", + "confidentialityImpact": "HIGH", + "integrityImpact": "HIGH", + "availabilityImpact": "HIGH", + } + } + ) + + assert metric.format == "cvssV3_0" + assert metric.base_score == 9.8 + assert metric.base_severity == "CRITICAL" + assert metric.attack_vector == "NETWORK" + diff --git a/src/webview/templatetags/viewutils.py b/src/webview/templatetags/viewutils.py index 1d3c18800..2e09b46ad 100644 --- a/src/webview/templatetags/viewutils.py +++ b/src/webview/templatetags/viewutils.py @@ -3,8 +3,9 @@ from typing import Any, TypedDict from urllib.parse import quote, urlencode -from cvss import CVSS3 -from cvss.constants3 import METRICS_ABBREVIATIONS +from cvss import CVSS3, CVSS4 +from cvss.constants3 import METRICS_ABBREVIATIONS as METRICS_ABBREVIATIONS3 +from cvss.constants4 import METRICS_ABBREVIATIONS as METRICS_ABBREVIATIONS4 from django import template from django.conf import settings from django.template.context import Context @@ -121,24 +122,50 @@ def notifications_badge( return {"count": count, "oob_update": oob_update} +CVSS_PARSERS = { + "cvssV3": (CVSS3, METRICS_ABBREVIATIONS3), + "cvssV4": (CVSS4, METRICS_ABBREVIATIONS4), +} + + @register.inclusion_tag("components/severity_badge.html") def severity_badge(metrics: list[dict]) -> dict: """ For now we return the first metric that has a sane looking raw JSON field. """ for m in metrics: - if "raw_cvss_json" in m and "vectorString" in m.get("raw_cvss_json", {}): - parsed = CVSS3(m["raw_cvss_json"]["vectorString"]) - return { - "vectorString": m["raw_cvss_json"]["vectorString"], - "version": m["raw_cvss_json"]["version"], - "metrics": { - # XXX(@fricklerhandwerk): Yes, the *value* description is also indexed by *key*! - f"{METRICS_ABBREVIATIONS[k]} ({k})": f"{parsed.get_value_description(k)} ({v})" - for k, v in parsed.metrics.items() - if not k.startswith("M") # Don't display modified metrics - }, + raw_cvss = m.get("raw_cvss_json", {}) + fmt = m.get("format", "") + if fmt.startswith(tuple(CVSS_PARSERS.keys())): + result = { + "vectorString": raw_cvss.get("vectorString"), + "version": raw_cvss.get("version"), + "baseScore": raw_cvss.get("baseScore"), + "baseSeverity": raw_cvss.get("baseSeverity"), + "attackVector": raw_cvss.get("attackVector"), + "attackComplexity": raw_cvss.get("attackComplexity"), + "privilegesRequired": raw_cvss.get("privilegesRequired"), + "userInteraction": raw_cvss.get("userInteraction"), + "scope": raw_cvss.get("scope"), + "confidentialityImpact": raw_cvss.get("confidentialityImpact"), + "integrityImpact": raw_cvss.get("integrityImpact"), + "availabilityImpact": raw_cvss.get("availabilityImpact"), + "metrics": {}, + } + + for prefix, (parser_class, abbrevs) in CVSS_PARSERS.items(): + if fmt.startswith(prefix): + parsed = parser_class(raw_cvss["vectorString"]) + break + else: + return result + + result["metrics"] = { + f"{abbrevs[k]} ({k})": f"{parsed.get_value_description(k)} ({v})" + for k, v in parsed.metrics.items() + if not k.startswith("M") } + return result return {} diff --git a/src/webview/tests/conftest.py b/src/webview/tests/conftest.py index 4c9da3339..2f857133e 100644 --- a/src/webview/tests/conftest.py +++ b/src/webview/tests/conftest.py @@ -78,3 +78,29 @@ def wrapped(user: User) -> Generator[Page]: yield page return wrapped + + +@pytest.fixture +def cvss_v3_metric(): + return { + "format": "cvssV31", + "raw_cvss_json": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 9.8, + "baseSeverity": "CRITICAL", + }, + } + + +@pytest.fixture +def cvss_v4_metric(): + return { + "format": "cvssV40", + "raw_cvss_json": { + "version": "4.0", + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", + "baseScore": 9.3, + "baseSeverity": "CRITICAL", + }, + } diff --git a/src/webview/tests/test_viewutils.py b/src/webview/tests/test_viewutils.py new file mode 100644 index 000000000..af4c4cdf4 --- /dev/null +++ b/src/webview/tests/test_viewutils.py @@ -0,0 +1,24 @@ +from webview.templatetags.viewutils import severity_badge + + +import pytest +from webview.templatetags.viewutils import severity_badge + + +@pytest.mark.parametrize( + "metric_input, expected_version, expected_score", + [ + ("cvss_v3_metric", "3.1", 9.8), + ("cvss_v4_metric", "4.0", 9.3), + ], +) +def test_severity_badge_handles_cvss_versions( + request, metric_input, expected_version, expected_score +): + metric = severity_badge([request.getfixturevalue(metric_input)]) + + assert metric["version"] == expected_version + assert metric["baseScore"] == expected_score + assert metric["baseSeverity"] == "CRITICAL" + assert metric["vectorString"].startswith("CVSS:") +