From 8f4730360511c885af8d3e453519a7144c97c83c Mon Sep 17 00:00:00 2001 From: Alexander Braml Date: Thu, 24 Sep 2026 21:06:44 +0200 Subject: [PATCH] Grade SARIF findings by score, then CWE, then level A result's own security-severity score is banded like CVSS; without one, the finding is graded by a hand-built CWE table; failing that, by its SARIF level, one band lower than before. The scanners' levels said more about the tool than about the risk: a container running as root came out Critical and a hard-coded password Medium. The summariser only reads security-severity off the rule, and Lynkeus scores each result, so the runs are summarised here to keep that score. Co-Authored-By: Claude Opus 5.5 --- report_generator/context.py | 40 +++++++---- report_generator/severity.py | 129 +++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 report_generator/severity.py diff --git a/report_generator/context.py b/report_generator/context.py index 726b414..be4efd3 100644 --- a/report_generator/context.py +++ b/report_generator/context.py @@ -22,10 +22,11 @@ from collections import Counter from datetime import UTC, datetime from typing import TYPE_CHECKING, Any -from .vendor.cipherverifier.core.services.sarif import summarize_sarif +from .vendor.cipherverifier.core.services.sarif import _build_finding, _build_report, _rules_by_id from .vendor.cipherverifier.core.services.vdr import summarize_vdr from .layout import TITLE +from .severity import finding_severity if TYPE_CHECKING: from .vendor.cipherverifier.core.services.sarif import Finding, SarifReport @@ -40,16 +41,6 @@ UNKNOWN = "" MOST_CRITICAL = 6 BARS = 10 -# The severity a SARIF level is reported under. SARIF names four levels and the report bands five, -# so the mapping is written down rather than inferred from the spelling. -SEVERITY_OF_LEVEL = { - "error": "Critical", - "warning": "High", - "note": "Medium", - "none": "Low", - "info": "Low", -} - SEVERITY_ORDER = ("Critical", "High", "Medium", "Low") # A full git commit SHA. @@ -118,7 +109,28 @@ def _tools(sarif: Any) -> list[tuple[str, SarifReport]]: # noqa: ANN401 runs = [run for run in sarif.get("runs") or [] if isinstance(run, dict)] - return [(_tool_name(run), summarize_sarif({"runs": [run]})) for run in runs] + return [(_tool_name(run), _summarize(run)) for run in runs] + + +def _summarize(run: dict[str, Any]) -> SarifReport: + """Summarise one run, as :func:`summarize_sarif` would, keeping each result's own score. + + The summariser reads ``security-severity`` off the rule only. Lynkeus scores each result instead, + and a result's own score is the more specific one, so it takes the rule's place. + """ + rules = _rules_by_id([run]) + findings = [] + + for result in run.get("results") or []: + finding = _build_finding(result, run, rules) + stated = (result.get("properties") or {}).get("security-severity") + + if stated is not None: + finding.rule_security_severity = str(stated) + + findings.append(finding) + + return _build_report(findings, ranked=any(finding.rank is not None for finding in findings)) def _tool_name(run: dict[str, Any]) -> str: @@ -732,8 +744,8 @@ def _anchor(title: str) -> str: def _finding_severity(finding: Finding) -> str: - """The severity band a SARIF level falls in.""" - return SEVERITY_OF_LEVEL.get(finding.level, "Low") + """The severity band a SARIF finding falls in; see :mod:`severity`.""" + return finding_severity(finding.rule_id, finding.level, finding.rule_security_severity) def _vdr_severity(vulnerability: Vulnerability) -> str: diff --git a/report_generator/severity.py b/report_generator/severity.py new file mode 100644 index 0000000..008f45b --- /dev/null +++ b/report_generator/severity.py @@ -0,0 +1,129 @@ +"""The severity a SARIF finding is reported under. + +In order of preference: + +1. The finding's own ``security-severity`` score, banded like CVSS. Where a tool scored the finding + itself -- Lynkeus does -- that is the most specific reading there is. +2. Its weakness class, from :data:`CWE_SEVERITY`. The tools do not agree on what their levels mean + -- Trivy's ``error`` for a container running as root and Bandit's ``note`` for a hard-coded + database password say more about the tool than about the risk -- so the CWE is preferred to them. +3. Its SARIF level, from :data:`SEVERITY_OF_LEVEL`, for a finding with neither. + +Each entry of the table is the severity a confirmed instance of that weakness typically carries, before any +context about the application is known: roughly where the CVEs filed under it land on the CVSS +bands. Critical is kept for classes that usually hand an attacker code execution or the whole data +store; weaknesses that only matter together with another flaw, or that harden rather than defend, +sit at Medium or Low. +""" + +from __future__ import annotations + +import re + +CWE_SEVERITY = { + # Injection that typically ends in code execution or full database access. + "CWE-77": "Critical", # Command injection + "CWE-78": "Critical", # OS command injection + "CWE-89": "Critical", # SQL injection + "CWE-94": "Critical", # Code injection + "CWE-95": "Critical", # Eval injection + "CWE-502": "Critical", # Deserialization of untrusted data + # Direct compromise of confidentiality or access control, usually needing some foothold. + "CWE-22": "High", # Path traversal + "CWE-96": "High", # Static code injection + "CWE-259": "High", # Hard-coded password + "CWE-287": "High", # Improper authentication + "CWE-306": "High", # Missing authentication for a critical function + "CWE-321": "High", # Hard-coded cryptographic key + "CWE-434": "High", # Unrestricted upload of dangerous file type + "CWE-611": "High", # XML external entities + "CWE-798": "High", # Hard-coded credentials + "CWE-862": "High", # Missing authorization + "CWE-863": "High", # Incorrect authorization + "CWE-918": "High", # Server-side request forgery + # Real weaknesses whose impact depends on context, or that need another flaw to be exploited. + "CWE-15": "Medium", # External control of a configuration setting + "CWE-20": "Medium", # Improper input validation + "CWE-79": "Medium", # Cross-site scripting + "CWE-200": "Medium", # Exposure of sensitive information + "CWE-250": "Medium", # Execution with unnecessary privileges + "CWE-269": "Medium", # Improper privilege management + "CWE-295": "Medium", # Improper certificate validation + "CWE-312": "Medium", # Cleartext storage of sensitive information + "CWE-319": "Medium", # Cleartext transmission of sensitive information + "CWE-326": "Medium", # Inadequate encryption strength + "CWE-327": "Medium", # Broken or risky cryptographic algorithm + "CWE-328": "Medium", # Weak hash + "CWE-330": "Medium", # Insufficiently random values + "CWE-338": "Medium", # Cryptographically weak PRNG + "CWE-352": "Medium", # Cross-site request forgery + "CWE-400": "Medium", # Uncontrolled resource consumption + "CWE-522": "Medium", # Insufficiently protected credentials + "CWE-532": "Medium", # Sensitive information in a log file + "CWE-601": "Medium", # Open redirect + "CWE-732": "Medium", # Incorrect permission assignment + "CWE-770": "Medium", # Allocation of resources without limits + "CWE-1333": "Medium", # Inefficient regular expression (ReDoS) + # Hygiene: worth fixing, rarely exploitable on its own. + "CWE-117": "Low", # Log injection + "CWE-209": "Low", # Information in an error message + "CWE-377": "Low", # Insecure temporary file + "CWE-390": "Low", # Error detected without action + "CWE-391": "Low", # Unchecked error condition + "CWE-614": "Low", # Sensitive cookie without the Secure flag + "CWE-703": "Low", # Improper handling of exceptional conditions + "CWE-754": "Low", # Improper check for unusual conditions + "CWE-755": "Low", # Improper handling of exceptional conditions + "CWE-1004": "Low", # Sensitive cookie without the HttpOnly flag +} + +# For a finding whose CWE is not in the table, or that has none. One band below the old one-to-one +# reading of the level, because a tool's "error" is not a statement that the finding is critical. +SEVERITY_OF_LEVEL = { + "error": "High", + "warning": "Medium", + "note": "Low", + "none": "Low", + "info": "Low", +} + +# The CVSS v3 qualitative bands, by their lower bounds. A score of 0 has no band of its own in the +# report and reads as Low. +SCORE_BANDS = ((9.0, "Critical"), (7.0, "High"), (4.0, "Medium")) + +_CWE = re.compile(r"CWE-(\d+)", re.IGNORECASE) + + +def finding_severity(rule_id: str, level: str, security_severity: str = "") -> str: + """The severity a SARIF finding is reported under. + + Args: + rule_id: The finding's rule. The pipeline's tools name their rules by CWE. + level: The SARIF level. + security_severity: The finding's ``security-severity`` score, empty where it has none. + + Returns: + One of Critical, High, Medium or Low. + """ + scored = _score_band(security_severity) + + if scored is not None: + return scored + + found = _CWE.search(rule_id) + known = CWE_SEVERITY.get(f"CWE-{found.group(1)}") if found else None + + return known or SEVERITY_OF_LEVEL.get(level, "Low") + + +def _score_band(stated: str) -> str | None: + """The band a ``security-severity`` score falls in, or nothing where it is not a score.""" + try: + score = float(stated) + except ValueError: + return None + + if not 0 <= score <= 10: + return None + + return next((name for bound, name in SCORE_BANDS if score >= bound), "Low")