diff --git a/strix/report/sarif.py b/strix/report/sarif.py index 8237d7c..6d5db17 100644 --- a/strix/report/sarif.py +++ b/strix/report/sarif.py @@ -96,6 +96,99 @@ _SEVERITY_TO_SCORE = { } +# CWE → STRIDE-leg mapping for SARIF rule tagging. Each finding's rule gets one +# or more ``stride:`` tags (Spoofing / Tampering / Repudiation / +# Information disclosure / Denial of service / Elevation of privilege) so +# consumers — the GitHub code-scanning Security tab, ASPM dashboards, coverage +# reports — can group and filter findings by threat-model leg. SARIF results +# inherit their rule's tags via ``ruleId``, so tagging the rule is sufficient. +# +# Where a CWE plausibly spans multiple legs the dominant one is listed first. +# Unmapped / no-CWE findings fall back to ``_DEFAULT_STRIDE_LEGS`` so every +# finding carries at least one leg (no coverage gaps in downstream reports). +_CWE_TO_STRIDE: dict[str, tuple[str, ...]] = { + # Spoofing — authentication / identity + "287": ("S",), # Improper Authentication + "290": ("S",), # Authentication Bypass by Spoofing + "294": ("S",), # Authentication Bypass by Capture-replay + "306": ("S", "E"), # Missing Authentication for Critical Function + "345": ("S", "T"), # Insufficient Verification of Data Authenticity + "346": ("S",), # Origin Validation Error + "352": ("T", "S"), # Cross-Site Request Forgery + "384": ("S",), # Session Fixation + "521": ("S",), # Weak Password Requirements + "613": ("S",), # Insufficient Session Expiration + "640": ("S",), # Weak Password Recovery Mechanism + "259": ("S", "I"), # Use of Hard-coded Password + "798": ("S", "I"), # Use of Hard-coded Credentials + "1391": ("S",), # Use of Weak Credentials + # Tampering — integrity + "20": ("T",), # Improper Input Validation + "73": ("T", "I"), # External Control of File Name or Path + "78": ("T", "E"), # OS Command Injection + "79": ("T", "I"), # Cross-Site Scripting + "89": ("T",), # SQL Injection + "91": ("T",), # XML Injection + "94": ("T", "E"), # Code Injection + "434": ("T",), # Unrestricted File Upload + "502": ("T", "E"), # Deserialization of Untrusted Data + "915": ("E", "T"), # Improperly Controlled Modification (Mass Assignment) + "918": ("T", "I"), # Server-Side Request Forgery + "1336": ("T", "E"), # Server-Side Template Injection + # Repudiation — audit / logging + "117": ("R",), # Improper Output Neutralization for Logs + "223": ("R",), # Omission of Security-relevant Information + "778": ("R",), # Insufficient Logging + # Information disclosure — confidentiality + "200": ("I",), # Exposure of Sensitive Information + "201": ("I",), # Insertion of Sensitive Info into Sent Data + "209": ("I",), # Generation of Error Message Containing Sensitive Info + "256": ("I",), # Plaintext Storage of a Password + "311": ("I",), # Missing Encryption of Sensitive Data + "319": ("I",), # Cleartext Transmission of Sensitive Information + "327": ("I",), # Use of a Broken or Risky Cryptographic Algorithm + "328": ("I",), # Use of Weak Hash + "522": ("I",), # Insufficiently Protected Credentials + "525": ("I",), # Use of Web Browser Cache Containing Sensitive Info + "532": ("I",), # Insertion of Sensitive Information into Log File + "538": ("I",), # Insertion of Sensitive Info into Externally-Accessible File + "598": ("I",), # Use of GET Request Method With Sensitive Query Strings + # Denial of service — availability + "400": ("D",), # Uncontrolled Resource Consumption + "770": ("D",), # Allocation of Resources Without Limits or Throttling + "1333": ("D",), # Inefficient Regular Expression Complexity (ReDoS) + # Elevation of privilege — authorization + "269": ("E",), # Improper Privilege Management + "284": ("E",), # Improper Access Control + "285": ("E",), # Improper Authorization + "639": ("E",), # Authorization Bypass Through User-Controlled Key (IDOR/BOLA) + "732": ("E",), # Incorrect Permission Assignment for Critical Resource + "862": ("E",), # Missing Authorization + "863": ("E",), # Incorrect Authorization + "1220": ("E",), # Insufficient Granularity of Access Control + # Multi-leg + "22": ("T", "I"), # Path Traversal — write/read arbitrary paths (T) + file disclosure (I) + "611": ("I", "T"), # XML External Entity (XXE) +} + +# Default for unmapped / no-CWE findings: tampering + information-disclosure is +# the most-common shape for an unclassified bug (matches the fork's and +# strix-triage's DEFAULT_STRIDE_LEGS convention). +_DEFAULT_STRIDE_LEGS: tuple[str, ...] = ("T", "I") + + +def _stride_legs_for_cwe(cwe: str | None) -> tuple[str, ...]: + """Map a CWE id (``CWE-306`` / ``306`` / ``cwe: 306``) to STRIDE legs. + Returns ``_DEFAULT_STRIDE_LEGS`` for no-CWE / unrecognised input so every + finding gets at least one leg tag.""" + if not cwe: + return _DEFAULT_STRIDE_LEGS + digits = "".join(c for c in str(cwe) if c.isdigit()) + if not digits: + return _DEFAULT_STRIDE_LEGS + return _CWE_TO_STRIDE.get(digits, _DEFAULT_STRIDE_LEGS) + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -863,6 +956,13 @@ def _rule_tags(rule_id: str, report: dict[str, Any]) -> list[str]: cve = _string_value(report.get("cve")) if cve and cve not in tags: tags.append(cve) + # STRIDE-leg tags from the CWE → STRIDE mapping. Always at least one (the + # default legs for unmapped/no-CWE findings) so downstream threat-model + # coverage reports have no gaps. Emitted as ``stride:S``, ``stride:T``, ... + for leg in _stride_legs_for_cwe(report.get("cwe")): + tag = f"stride:{leg}" + if tag not in tags: + tags.append(tag) return tags diff --git a/tests/test_sarif_stride.py b/tests/test_sarif_stride.py new file mode 100644 index 0000000..70658bb --- /dev/null +++ b/tests/test_sarif_stride.py @@ -0,0 +1,115 @@ +"""STRIDE-leg tagging in the SARIF emitter (strix.report.sarif). + +Every finding's SARIF rule (and, by inheritance via ``ruleId``, its results) +carries one or more ``stride:`` tags derived from the finding's CWE, so the +GitHub code-scanning Security tab and ASPM dashboards can group/filter by +threat-model leg. Unmapped or no-CWE findings fall back to a default so coverage +reports have no gaps. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from strix.report.sarif import ( + _CWE_TO_STRIDE, + _DEFAULT_STRIDE_LEGS, + _stride_legs_for_cwe, + build_sarif_report, +) + + +def _finding(**overrides: Any) -> dict[str, Any]: + finding: dict[str, Any] = { + "id": "vuln-0001", + "title": "Missing authentication on gRPC endpoint", + "severity": "critical", + "cwe": "CWE-306", + "description": "The gRPC server registers no auth interceptor.", + } + finding.update(overrides) + return finding + + +def _rule_tags(doc: dict[str, Any]) -> list[str]: + return doc["runs"][0]["tool"]["driver"]["rules"][0]["properties"]["tags"] + + +def test_stride_tags_on_rule_for_known_cwe() -> None: + """CWE-306 (Missing Authentication) maps to S+E, alongside existing tags.""" + tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-306")])) + assert "stride:S" in tags + assert "stride:E" in tags + assert "security" in tags # existing tags preserved + assert "CWE-306" in tags + + +def test_stride_tags_attach_to_rule_not_duplicated_on_result() -> None: + """STRIDE tags live on the RULE; results inherit them via ruleId (standard + SARIF) rather than duplicating — the result carries the matching ruleId and + its own strix.* properties, not a redundant tags copy.""" + doc = build_sarif_report([_finding(cwe="CWE-306")]) + rule = doc["runs"][0]["tool"]["driver"]["rules"][0] + result = doc["runs"][0]["results"][0] + assert result["ruleId"] == rule["id"] # inherits via ruleId + assert {"stride:S", "stride:E"} <= set(rule["properties"]["tags"]) + assert "tags" not in result["properties"] # not duplicated + + +def test_stride_default_for_unmapped_cwe() -> None: + tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-99999")])) + assert "stride:T" in tags and "stride:I" in tags + + +def test_stride_default_for_no_cwe() -> None: + tags = _rule_tags(build_sarif_report([_finding(cwe=None)])) + assert "stride:T" in tags and "stride:I" in tags + + +def test_stride_sql_injection_is_tampering_not_spoofing() -> None: + tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-89")])) + assert "stride:T" in tags + assert "stride:S" not in tags # SQLi is tampering, not auth-shape + + +def test_stride_idor_is_elevation() -> None: + tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-639")])) + assert "stride:E" in tags + + +def test_stride_cleartext_transmission_is_info_disclosure() -> None: + tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-319")])) + assert "stride:I" in tags + + +def test_stride_hardcoded_credentials_is_spoofing() -> None: + """CWE-798 (Hard-coded Credentials) is Spoofing (+ Info disclosure), not the + generic default.""" + tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-798")])) + assert "stride:S" in tags + assert set(_stride_legs_for_cwe("CWE-798")) != set(_DEFAULT_STRIDE_LEGS) + + +def test_stride_missing_authorization_is_elevation() -> None: + """CWE-862 (Missing Authorization) is Elevation of privilege — sibling of + 863 Incorrect Authorization.""" + tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-862")])) + assert "stride:E" in tags + assert "stride:T" not in tags # not the default + + +@pytest.mark.parametrize("raw", ["CWE-306", "306", "cwe 306", "CWE306"]) +def test_stride_cwe_normalisation_variants(raw: str) -> None: + """CWE id variants all resolve to the same legs (S+E for 306).""" + tags = _rule_tags(build_sarif_report([_finding(cwe=raw)])) + assert "stride:S" in tags and "stride:E" in tags + + +def test_every_leg_letter_is_valid() -> None: + """Sanity: the mapping only emits the six canonical STRIDE letters.""" + valid = {"S", "T", "R", "I", "D", "E"} + for legs in _CWE_TO_STRIDE.values(): + assert set(legs) <= valid, f"invalid STRIDE leg in {legs}" + assert set(_DEFAULT_STRIDE_LEGS) <= valid