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
+100
View File
@@ -96,6 +96,99 @@ _SEVERITY_TO_SCORE = {
}
# CWE → STRIDE-leg mapping for SARIF rule tagging. Each finding's rule gets one
# or more ``stride:<leg>`` 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
+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