"""SARIF 2.1.0 output for Strix vulnerability reports. Builds a GitHub code-scanning compatible SARIF document from Strix findings so CI pipelines can upload findings via ``github/codeql-action/upload-sarif``, ingest into ASPM platforms, or normalise across scanners. Schema: SARIF 2.1.0 (OASIS). The output is validated against the official schema at https://json.schemastore.org/sarif-2.1.0.json in tests. Integration: - ``ReportState._save_artifacts`` calls :func:`write_sarif` to emit a ``findings.sarif`` sidecar alongside the existing CSV + markdown + JSON artefacts on every save. The call is wrapped in try/except there so a SARIF failure never blocks the CSV + markdown + run-record path. Design notes: * Rules are keyed on CWE (``id = CWE-NNN``), falling back to CVE, then to finding-id, then to a title slug. CWE values are normalised from Strix output variants (``CWE-306``, ``cwe: 306``, ``306``) to the canonical ``CWE-NNN`` form so dedup works across runs. * SARIF only has three levels (error / warning / note). Strix's five severities collapse into them. The raw severity label and CVSS score survive in ``result.properties.strix`` for downstream tools that can distinguish CRITICAL vs HIGH. * GitHub code-scanning uses ``rule.properties['security-severity']`` (a 0.0-10.0 string) to rank alerts. We populate it from CVSS when available, otherwise from a conservative label -> score map. * File locations must be repo-relative POSIX paths. Paths that look like URIs, absolute paths, or traversal patterns are rejected rather than emitted as invalid code-scanning alerts. * Findings with a fix suggestion (``code_locations[].fix_before`` + ``fix_after``) are emitted as SARIF ``fixes`` so code-scanning can render a one-click suggested change. * Endpoint / target-only findings (typical of DAST) carry a SARIF ``logicalLocations`` entry so the finding keeps a meaningful anchor even without a source file + line. * When a repository context is supplied (repo URL / commit / branch), the run carries ``versionControlProvenance`` + ``automationDetails`` so code-scanning can bind alerts to the scanned commit and branch. * Findings without safe locations still appear in the SARIF output, anchored to SECURITY.md and flagged via ``properties.synthetic_location`` rather than being dropped silently. """ from __future__ import annotations import hashlib import json import logging import os import re from pathlib import Path, PurePosixPath from typing import Any, cast logger = logging.getLogger(__name__) SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" SARIF_VERSION = "2.1.0" TOOL_NAME = "Strix" TOOL_INFORMATION_URI = "https://strix.ai" # Synthetic anchor for findings that have no safe code location. SARIF # requires every result to carry at least one location, and GitHub # code-scanning's UI handles locationless results unreliably. Anchoring # to SECURITY.md keeps the result valid + visible while a # ``properties.synthetic_location: true`` flag lets downstream tooling # distinguish synthetic anchors from real source locations. Anchoring # also lets the partialFingerprints + class-hash code path cover these # findings instead of re-orphaning them on every run. _SYNTHETIC_LOCATION_URI = "SECURITY.md" # SARIF only has three result levels; Strix's five severities collapse here. # Original label survives in ``result.properties.strix.severity``. _SEVERITY_TO_LEVEL = { "critical": "error", "high": "error", "medium": "warning", "low": "note", "info": "note", "informational": "note", } # GitHub code-scanning reads ``rule.properties['security-severity']`` (a # 0.0-10.0 string) to rank alerts. We prefer CVSS from the finding; absent # that we fall back to a conservative label -> score map. _SEVERITY_TO_SCORE = { "critical": "9.5", "high": "8.0", "medium": "5.5", "low": "3.0", "info": "1.0", "informational": "1.0", } # 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 # --------------------------------------------------------------------------- def build_sarif_report( vulnerability_reports: list[dict[str, Any]], *, tool_version: str | None = None, repository_context: dict[str, Any] | None = None, ) -> dict[str, Any]: """Return a SARIF 2.1.0 document for findings. ``repository_context`` (optional) supplies VCS provenance for repo scans: ``repositoryUri``, ``repositoryFullName``, ``commitSha``, ``branch``, ``ref``. When present, the run carries ``versionControlProvenance`` + ``automationDetails`` so code-scanning can bind alerts to the scanned commit; it is omitted for URL / IP (DAST) targets that have no repository. Findings without safe source locations are anchored synthetically to SECURITY.md and flagged via ``properties.synthetic_location``. They're still emitted as proper SARIF results so they (a) flow through code-scanning normally rather than being shunted into a run-properties summary the UI can't render, and (b) carry the partialFingerprints + class hash so cross-run dismissal stickiness works for them. """ rules_by_id: dict[str, dict[str, Any]] = {} rule_index_by_id: dict[str, int] = {} results: list[dict[str, Any]] = [] synthetic_location_count = 0 dropped_unsafe_location_findings: list[dict[str, Any]] = [] for report in vulnerability_reports: locations, is_synthetic, dropped_location_count = _build_locations(report) if is_synthetic: synthetic_location_count += 1 if dropped_location_count: dropped_unsafe_location_findings.append( _dropped_location_summary(report, dropped_location_count) ) rule_id = _rule_id(report) if rule_id not in rules_by_id: rule_index_by_id[rule_id] = len(rules_by_id) rules_by_id[rule_id] = _build_rule(rule_id, report) results.append( _build_result( rule_id, rule_index_by_id[rule_id], report, locations, is_synthetic=is_synthetic, ) ) driver: dict[str, Any] = { "name": TOOL_NAME, "informationUri": TOOL_INFORMATION_URI, "rules": list(rules_by_id.values()), } if tool_version: driver["version"] = tool_version run: dict[str, Any] = { "tool": {"driver": driver}, "results": results, } run_properties: dict[str, Any] = {} if synthetic_location_count: # Surface the count for observability without duplicating the # findings themselves — they're already in `results[]` with # `properties.synthetic_location: true`. Having a top-level count # means CI logs / dashboards can bookkeep without parsing the # result list. run_properties["syntheticLocationCount"] = synthetic_location_count if dropped_unsafe_location_findings: run_properties["droppedUnsafeLocationCount"] = sum( finding["droppedLocationCount"] for finding in dropped_unsafe_location_findings ) run_properties["droppedUnsafeLocationFindings"] = dropped_unsafe_location_findings if run_properties: run["properties"] = run_properties if repository_context: _apply_repository_context(run, repository_context) return { "version": SARIF_VERSION, "$schema": SARIF_SCHEMA, "runs": [run], } def write_sarif_report( output_path: Path, vulnerability_reports: list[dict[str, Any]], *, tool_version: str | None = None, repository_context: dict[str, Any] | None = None, ) -> None: """Write a SARIF report to disk, creating parent directories first. Writes to a sibling temp file and atomically replaces the target, so a crash or serialization error mid-write can never leave a truncated ``findings.sarif`` for CI to upload in place of the last complete snapshot. """ output_path.parent.mkdir(parents=True, exist_ok=True) sarif = build_sarif_report( vulnerability_reports, tool_version=tool_version, repository_context=repository_context, ) tmp_path = output_path.with_name(f"{output_path.name}.{os.getpid()}.tmp") try: with tmp_path.open("w", encoding="utf-8") as sarif_file: json.dump(sarif, sarif_file, ensure_ascii=False, indent=2) sarif_file.write("\n") tmp_path.replace(output_path) # atomic on the same filesystem finally: tmp_path.unlink(missing_ok=True) def write_sarif( run_dir: Path, reports: list[dict[str, Any]], *, tool_version: str | None = None, repository_context: dict[str, Any] | None = None, filename: str = "findings.sarif", ) -> Path: """Write ``findings.sarif`` alongside existing outputs in ``run_dir``. Returns the output path. This is the ``ReportState`` entry point: SARIF writing must never break the CSV + markdown path, so the caller wraps it in try/except. """ out = run_dir / filename write_sarif_report( out, reports, tool_version=tool_version, repository_context=repository_context, ) logger.info( "Wrote SARIF 2.1.0 report: %s (%d results)", out, len(reports), ) return out # ``build_sarif_document`` is a convenience alias for callers that prefer a # name mirroring ``write_sarif_report``. def build_sarif_document( reports: list[dict[str, Any]], *, tool_version: str | None = None, repository_context: dict[str, Any] | None = None, ) -> dict[str, Any]: return build_sarif_report( reports, tool_version=tool_version, repository_context=repository_context, ) # --------------------------------------------------------------------------- # Repository provenance # --------------------------------------------------------------------------- def _apply_repository_context(run: dict[str, Any], context: dict[str, Any]) -> None: """Attach VCS provenance to a run for code-scanning alert binding. ``automationDetails.id`` categorises the run (``strix//``) so multiple Strix runs against the same repo reconcile rather than pile up. ``versionControlProvenance`` records the exact repo + commit + branch the findings came from. Both are omitted when the corresponding context fields are absent (e.g. DAST-only scans). """ full_name = _string_value(context.get("repositoryFullName")) uri = _string_value(context.get("repositoryUri")) commit = _string_value(context.get("commitSha")) branch = _string_value(context.get("branch")) ref = _string_value(context.get("ref")) if full_name: run["automationDetails"] = {"id": f"strix/{full_name}"} if uri: provenance: dict[str, Any] = {"repositoryUri": uri} if commit: provenance["revisionId"] = commit if branch: provenance["branch"] = branch run["versionControlProvenance"] = [provenance] properties = run.setdefault("properties", {}) if full_name: properties["repository"] = full_name if ref: properties["ref"] = ref if commit: properties["commit_sha"] = commit if not properties: run.pop("properties", None) # --------------------------------------------------------------------------- # Rule + result builders # --------------------------------------------------------------------------- def _build_rule(rule_id: str, report: dict[str, Any]) -> dict[str, Any]: """Build a SARIF rule descriptor from a Strix finding.""" title = _string_value(report.get("title")) or rule_id full_description = _string_value(report.get("description")) or title help_text = _help_text(report, full_description) rule: dict[str, Any] = { "id": rule_id, "name": _rule_name(rule_id, title), "shortDescription": {"text": title}, "fullDescription": {"text": full_description}, "defaultConfiguration": {"level": _sarif_level(report.get("severity"))}, "help": {"text": help_text, "markdown": help_text}, } properties: dict[str, Any] = { "security-severity": _security_severity(report), } tags = _rule_tags(rule_id, report) if tags: properties["tags"] = tags rule["properties"] = properties help_uri = _help_uri_for(rule_id) if help_uri: rule["helpUri"] = help_uri return rule def _build_result( rule_id: str, rule_index: int, report: dict[str, Any], locations: list[dict[str, Any]], *, is_synthetic: bool = False, ) -> dict[str, Any]: """Build one SARIF result using validated locations. ``is_synthetic`` flags results whose location is the SECURITY.md anchor rather than a real code location — surfaces as ``properties.synthetic_location: true`` so reviewers and downstream tooling can distinguish anchored-locationless findings from source-linked ones. """ title = _string_value(report.get("title")) or rule_id description = _string_value(report.get("description")) message_text = f"{title}\n\n{description}" if description else title result: dict[str, Any] = { "ruleId": rule_id, "ruleIndex": rule_index, "level": _sarif_level(report.get("severity")), "message": {"text": message_text}, } if locations: result["locations"] = locations fixes = _build_fixes(report) if fixes: result["fixes"] = fixes # Code-scanning auto-resolution + dismissal-stickiness key on # partialFingerprints. Computed from the deterministic primitives # this report already carries (CWE, primary code location, route # tuple) — NOT from the LLM-authored title or message body, which # vary cosmetically across runs of the same finding. fp = _primary_fingerprint(rule_id, report, locations, is_synthetic=is_synthetic) if fp: result["partialFingerprints"] = {"primaryLocationLineHash": fp} # File-independent class fingerprint as a sibling property: lets # downstream tooling carry "won't fix" / "false positive" # determinations across file rename refactors where the primary # fingerprint legitimately shifts but the underlying class is # unchanged. class_fp = _class_fingerprint(rule_id, report) result["properties"] = _result_properties(report, class_fp, is_synthetic=is_synthetic) return result def _result_properties( report: dict[str, Any], class_fingerprint: str | None = None, *, is_synthetic: bool = False, ) -> dict[str, Any]: """Strix-specific metadata for downstream consumers. The top-level ``security-severity`` matches GitHub code-scanning's expected property. Strix-specific fields are namespaced under ``strix`` so generic SARIF consumers don't see them by default. """ properties: dict[str, Any] = { "security-severity": _security_severity(report), } if class_fingerprint: # Surfaced at top level so cross-rename dismissal tooling can # filter alerts by it without parsing the nested strix.* tree. properties["strix_vuln_class_hash"] = class_fingerprint if is_synthetic: # Top-level so reviewers + downstream automation can filter # synthetic-anchored alerts without parsing the nested strix.* # tree. properties["synthetic_location"] = True strix: dict[str, Any] = {} for key in ( "id", "severity", "cvss", "timestamp", "target", "endpoint", "method", "cve", "cwe", "impact", "technical_analysis", "remediation_steps", ): value = report.get(key) if value not in (None, ""): strix[key] = value # SARIF is written for external upload (code-scanning / ASPM), so it must # NOT carry the weaponized exploit payload — that stays a local run # artifact (vulnerabilities.json / the finding MD). We surface the PoC # *description* (triage context) and a boolean flag that a script exists, # so consumers know to look at the local artifact, but never the script # body itself. poc_description = _string_value(report.get("poc_description")) poc_script = _string_value(report.get("poc_script_code")) if poc_description or poc_script: poc: dict[str, Any] = {} if poc_description: poc["description"] = poc_description if poc_script: poc["script_available"] = True strix["poc"] = poc if strix: properties["strix"] = strix return properties def _build_fixes(report: dict[str, Any]) -> list[dict[str, Any]] | None: """Build SARIF ``fixes`` from a finding's code-location fix pairs. Strix findings carry the suggested change inline on each code location as ``fix_before`` + ``fix_after``. We map every location that has both (and a safe repo-relative URI + start line) into a SARIF ``artifactChange``, replacing the finding's region with the fixed text. Returns None when no location carries a usable fix pair. """ raw_locations = report.get("code_locations") if not isinstance(raw_locations, list): return None artifact_changes: list[dict[str, Any]] = [] for location in raw_locations: if not isinstance(location, dict): continue file_path = _string_value(location.get("file")) fix_before = _string_value(location.get("fix_before")) fix_after = _string_value(location.get("fix_after")) start_line = location.get("start_line") if not (file_path and fix_before and fix_after): continue if type(start_line) is not int or start_line < 1: continue uri = _sarif_uri(file_path) if uri is None: continue deleted_region: dict[str, Any] = {"startLine": start_line} end_line = location.get("end_line") if type(end_line) is int and end_line >= start_line: deleted_region["endLine"] = end_line artifact_changes.append( { "artifactLocation": {"uri": uri}, "replacements": [ { "deletedRegion": deleted_region, "insertedContent": {"text": fix_after}, } ], } ) if not artifact_changes: return None fix: dict[str, Any] = {"artifactChanges": artifact_changes} remediation = _string_value(report.get("remediation_steps")) if remediation: fix["description"] = {"text": remediation, "markdown": remediation} return [fix] # --------------------------------------------------------------------------- # Location handling # --------------------------------------------------------------------------- def _synthetic_location() -> dict[str, Any]: """Synthetic anchor for findings with no safe code location. SARIF requires every result to carry at least one location, and code-scanning's UI handles locationless results unreliably. Anchoring to SECURITY.md gives the result a valid + visible location; the result's ``properties.synthetic_location: true`` flag lets reviewers + tooling distinguish synthetic from real. """ return { "physicalLocation": { "artifactLocation": {"uri": _SYNTHETIC_LOCATION_URI}, } } def _build_locations(report: dict[str, Any]) -> tuple[list[dict[str, Any]], bool, int]: """Return ``(locations, is_synthetic, dropped_count)`` for a finding. Physical locations come from validated ``code_locations``. When none are safe, the result is anchored to SECURITY.md (``is_synthetic``). An ``endpoint`` (typical of DAST findings) is added as a ``logicalLocations`` entry; a locationless, endpoint-less finding gets a ``resource`` logical location carrying the target so the finding keeps a human-meaningful anchor. """ physical, dropped_location_count = _build_physical_locations(report.get("code_locations")) is_synthetic = not physical locations: list[dict[str, Any]] = list(physical) if physical else [_synthetic_location()] endpoint = _string_value(report.get("endpoint")) if endpoint: locations.append( {"logicalLocations": [{"fullyQualifiedName": endpoint, "kind": "endpoint"}]} ) elif is_synthetic: resource = _string_value(report.get("target")) or _string_value(report.get("title")) if resource: locations.append( {"logicalLocations": [{"fullyQualifiedName": resource, "kind": "resource"}]} ) return locations, is_synthetic, dropped_location_count def _build_physical_locations(raw_locations: Any) -> tuple[list[dict[str, Any]], int]: """Return SARIF physical locations and a count of dropped unsafe locations.""" if not isinstance(raw_locations, list): return [], 0 locations: list[dict[str, Any]] = [] dropped_location_count = 0 for location in raw_locations: if not isinstance(location, dict): dropped_location_count += 1 continue file_path = _string_value(location.get("file")) start_line = location.get("start_line") end_line = location.get("end_line") if not file_path or type(start_line) is not int or start_line < 1: dropped_location_count += 1 continue uri = _sarif_uri(file_path) if uri is None: dropped_location_count += 1 continue region: dict[str, Any] = {"startLine": start_line} if type(end_line) is int and end_line >= start_line: region["endLine"] = end_line snippet = _string_value(location.get("snippet")) if snippet: region["snippet"] = {"text": snippet} physical_location: dict[str, Any] = { "artifactLocation": {"uri": uri}, "region": region, } entry: dict[str, Any] = {"physicalLocation": physical_location} label = _string_value(location.get("label")) if label: entry["message"] = {"text": label} locations.append(entry) return locations, dropped_location_count def _sarif_uri(file_path: str) -> str | None: """Return a safe repo-relative SARIF URI, or None for unsafe paths.""" uri = PurePosixPath(file_path.replace("\\", "/")).as_posix() parts = PurePosixPath(uri).parts if not uri or uri.startswith("/") or not parts: return None if ":" in parts[0] or any(part == ".." for part in parts): return None return uri # --------------------------------------------------------------------------- # Rule ID resolution + CWE normalisation # --------------------------------------------------------------------------- def _rule_id(report: dict[str, Any]) -> str: """Choose a stable SARIF rule id, preferring CWE → CVE → finding-id → slug. CWE values are normalised from Strix output variants (``CWE-306``, ``cwe: 306``, ``306``) to the canonical ``CWE-NNN`` form. Without normalisation, the same weakness across runs dedups to separate rules. """ cwe = _string_value(report.get("cwe")) if cwe: normalised = _normalise_cwe(cwe) if normalised: return normalised cve = _string_value(report.get("cve")) if cve: return cve finding_id = _string_value(report.get("id")) if finding_id: return finding_id title = _string_value(report.get("title")) or "strix-finding" return _slugify(title) def _normalise_cwe(value: str) -> str | None: """``CWE-306``, ``cwe:306``, ``306`` → ``CWE-306``.""" digits = "".join(c for c in value if c.isdigit()) if not digits: return None return f"CWE-{digits}" # Vulnerability-class keywords for the file-independent class # fingerprint. Order matters — first match wins, so precise terms # come before fuzzy ones (e.g. "broken access control" before # "access control"). Keep this list closed and curated; a future # maintainer adding sloppy entries could collapse distinct findings # to the same class hash. _VULN_CLASS_KEYWORDS = ( "missing authentication", "missing authorization", "broken access control", "incorrect authorization", "default credentials", "hardcoded credentials", "hardcoded secret", "hardcoded password", "default admin", "default password", "session fixation", "open redirect", "path traversal", "directory traversal", "command injection", "sql injection", "code injection", "template injection", "xpath injection", "ldap injection", "log injection", "header injection", "csv injection", "prompt injection", "deserialization", "ssrf", "xss", "csrf", "xxe", "race condition", "toctou", "information disclosure", "insecure direct object reference", "idor", "bola", "bfla", "cross-tenant", "cross-project", "tenant bypass", "auth bypass", "rate limiting", "rate limit", "weak cryptography", "weak hash", "weak random", "insecure random", "tls verification", "certificate verification", "denial of service", "regex denial of service", "redos", "supply chain", ) def _primary_fingerprint( rule_id: str, report: dict[str, Any], locations: list[dict[str, Any]], *, is_synthetic: bool = False, ) -> str | None: """Deterministic per-finding fingerprint for SARIF auto-resolution. Computed from primitives that don't depend on LLM prose stability: - rule_id (already CWE-normalised by ``_rule_id``) - first SARIF location's URI + startLine — these come from Strix's ``code_locations[].file`` and ``start_line`` which are sourced from the actual finding evidence, not synthesized - HTTP method + endpoint when present (BOLA/IDOR/missing-authz findings carry these explicitly in the report dict) Synthetic-anchored findings (``is_synthetic=True``) all share uri="SECURITY.md" and have no real start_line. Hashing by (rule_id, "SECURITY.md") alone would collapse every locationless finding of the same CWE into a single alert. To distinguish them, the synthetic path adds the class keyword extracted from the title (same logic ``_class_fingerprint`` uses). The class keyword catalogue is closed and stable, so cross-run identity holds — same vulnerability class on the same rule_id always lands on the same hash, and two different classes on the same rule_id don't collide. Returns None when no anchor is available AND not synthetic. """ primary_physical = _first_physical_location(locations) uri = "" start_line: int | None = None if primary_physical: uri = (primary_physical.get("artifactLocation") or {}).get("uri", "") or "" region = primary_physical.get("region") or {} sl = region.get("startLine") if isinstance(sl, int) and sl >= 1: start_line = sl method = _string_value(report.get("method")) or "" endpoint = _string_value(report.get("endpoint")) or "" route = f"{method.upper()} {endpoint}".strip() if (method or endpoint) else "" if not uri and not route: return None parts = [f"rule:{rule_id}"] if uri: parts.append(f"uri:{uri}") if start_line is not None: # startLine in fingerprint is debatable: line shifts in # surrounding code re-fingerprint. The alternative — drop # line — collides multiple findings per file. We include # line because Strix code_locations carry the SINK line, # which moves only when the vulnerable code itself moves; # lines surfacing from cosmetic edits in unrelated parts # of the file don't shift it. parts.append(f"line:{start_line}") if route: parts.append(f"route:{route}") if is_synthetic: # Augment with class keyword so locationless findings of # different vuln classes don't collide. rule_id is already in # the composite so we don't also need to stamp the CWE. title = _string_value(report.get("title")) or "" if title: parts.append(f"synth_class:{_class_keyword(title)}") composite = "|".join(parts) return hashlib.sha256(composite.encode("utf-8")).hexdigest() def _first_physical_location(locations: list[dict[str, Any]]) -> dict[str, Any] | None: """Return the first location's physicalLocation payload, if any.""" for location in locations: physical = location.get("physicalLocation") if physical: return cast("dict[str, Any]", physical) return None def _class_fingerprint(rule_id: str, report: dict[str, Any]) -> str | None: """File-independent fingerprint for cross-rename dismissal carryover. Lets downstream tooling apply prior dismissal determinations to a new alert that has the same vulnerability class but a different primary fingerprint (typical case: file rename, or a fix that moves the vulnerable code to a new module). Composite of (rule_id, vuln-class keyword extracted from title). Title is LLM-authored so it's stochastic at the prose level, but the class keyword extraction picks up the discrete vulnerability category, which is much more stable than the full title. Falls back to the first 5 lowercased words of the title when no curated keyword matches. Acceptable as a fallback because the class fingerprint is a tiebreaker, not a primary reconciliation key. """ title = _string_value(report.get("title")) or "" keyword = _class_keyword(title) if title else "" if not keyword: return None composite = f"rule:{rule_id}|class:{keyword}" return hashlib.sha256(composite.encode("utf-8")).hexdigest() def _class_keyword(title: str) -> str: """Pick the first matching curated keyword in ``title``, or fall back to the first 5 lowercased alpha-numeric words. Shared between ``_class_fingerprint`` (file-rename carryover) and ``_primary_fingerprint`` (synthetic-location distinguisher) so the two fingerprints stay aligned on what counts as "the same class". Returns empty string when title is empty or whitespace-only. """ if not title: return "" lower = title.lower() for kw in _VULN_CLASS_KEYWORDS: if kw in lower: return kw words = re.findall(r"[a-z0-9]+", lower)[:5] return " ".join(words) def _rule_name(rule_id: str, title: str) -> str: """SARIF rule.name must be a free-form string; prefer the finding title where available, fall back to a snake_case'd form of the rule id.""" return title or rule_id.replace("-", "_") def _rule_tags(rule_id: str, report: dict[str, Any]) -> list[str]: tags: list[str] = ["security"] if rule_id.startswith("CWE-"): tags.append(rule_id) 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 def _help_uri_for(rule_id: str) -> str | None: if rule_id.startswith("CWE-"): return f"https://cwe.mitre.org/data/definitions/{rule_id.removeprefix('CWE-')}.html" return None # --------------------------------------------------------------------------- # Severity + help text # --------------------------------------------------------------------------- def _sarif_level(severity: Any) -> str: """Map Strix severity labels to SARIF result levels.""" normalised = (_string_value(severity) or "").lower() return _SEVERITY_TO_LEVEL.get(normalised, "note") def _security_severity(report: dict[str, Any]) -> str: """GitHub-compatible ``security-severity`` string in 0.0-10.0. Uses CVSS when present, otherwise falls back to the severity label. """ cvss = report.get("cvss") if cvss is not None: try: return f"{float(cvss):.1f}" except (TypeError, ValueError): pass normalised = (_string_value(report.get("severity")) or "info").lower() return _SEVERITY_TO_SCORE.get(normalised, "1.0") def _help_text(report: dict[str, Any], fallback: str) -> str: """Assemble SARIF help text from finding details and remediation.""" sections = [ _string_value(report.get("description")), _string_value(report.get("impact")), _string_value(report.get("remediation_steps")), ] help_text = "\n\n".join(section for section in sections if section) return help_text or fallback # --------------------------------------------------------------------------- # Summaries for unsafe-location findings # --------------------------------------------------------------------------- def _dropped_location_summary( report: dict[str, Any], dropped_location_count: int, ) -> dict[str, Any]: """Summarize unsafe locations dropped from a partially emitted finding.""" summary: dict[str, Any] = {"droppedLocationCount": dropped_location_count} for key in ("id", "title"): value = report.get(key) if value not in (None, ""): summary[key] = value return summary # --------------------------------------------------------------------------- # Utility # --------------------------------------------------------------------------- def _string_value(value: Any) -> str | None: """Return a stripped non-empty string value, or None.""" if isinstance(value, str): stripped = value.strip() return stripped or None return None def _slugify(value: str) -> str: """Convert arbitrary finding text into a stable lowercase slug.""" chars = [char.lower() if char.isalnum() else "-" for char in value] slug = "-".join(part for part in "".join(chars).split("-") if part) return slug or "strix-finding"