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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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