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:
seanturner83
2026-07-07 02:19:53 +01:00
committed by GitHub
parent 754508c70b
commit 375fc9c3d0
2 changed files with 215 additions and 0 deletions
+115
View File
@@ -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