feat(report): tag SARIF rules with STRIDE legs derived from CWE (#708)
Builds on the SARIF 2.1.0 emitter (#626): give each SARIF rule one or more `stride:<leg>` tags (Spoofing / Tampering / Repudiation / Information disclosure / Denial of service / Elevation of privilege) derived from the finding's CWE, 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. - _CWE_TO_STRIDE maps common CWEs to legs (dominant leg first where a CWE spans several); unmapped / no-CWE findings fall back to a default (tampering + information-disclosure) so every finding carries >=1 leg and downstream reports have no coverage gaps. - Includes mappings for CWEs surfaced by real scans: 798 (hardcoded creds), 862 (missing authz), 259 (hardcoded password), 1391 (weak credential). Tests: tests/report/test_sarif_stride.py (14 cases — mapping, normalization of CWE-306/306/"cwe: 306" forms, default fallback, rule-tag emission). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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:<leg>`` 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
|
||||
Reference in New Issue
Block a user