1048 lines
39 KiB
Python
1048 lines
39 KiB
Python
"""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:<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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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/<owner>/<repo>``)
|
|
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"
|