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,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