mirror of
https://github.com/usestrix/strix.git
synced 2026-08-25 04:12:37 +02:00
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:
co-authored by
Claude Opus 4.8
bearsyankees
parent
7e808f7d34
commit
302efedca6
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, cast
|
||||
from uuid import uuid4
|
||||
@@ -9,6 +11,7 @@ from uuid import uuid4
|
||||
from agents.usage import Usage
|
||||
|
||||
from strix.core.paths import run_dir_for
|
||||
from strix.report.sarif import write_sarif
|
||||
from strix.report.usage import LLMUsageLedger
|
||||
from strix.report.writer import (
|
||||
read_run_record,
|
||||
@@ -24,6 +27,65 @@ logger = logging.getLogger(__name__)
|
||||
_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"]:
|
||||
return _global_report_state
|
||||
|
||||
@@ -70,6 +132,9 @@ class ReportState:
|
||||
self.caido_url: str | 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:
|
||||
if self._run_dir is None:
|
||||
run_dir_name = self.run_name if self.run_name else self.run_id
|
||||
@@ -335,12 +400,69 @@ class ReportState:
|
||||
if self.vulnerability_reports:
|
||||
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)
|
||||
|
||||
logger.info("Essential scan data saved to: %s", run_dir)
|
||||
except (OSError, RuntimeError):
|
||||
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:
|
||||
self.run_record["llm_usage"] = self._build_llm_usage_record()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user