mirror of
https://github.com/usestrix/strix.git
synced 2026-08-20 18:38:57 +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.
217 lines
7.9 KiB
Python
217 lines
7.9 KiB
Python
"""Tests for strix.report.writer artifact helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
from strix.report.writer import (
|
|
read_run_record,
|
|
render_vulnerability_md,
|
|
write_executive_report,
|
|
write_run_record,
|
|
write_vulnerabilities,
|
|
)
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def _sample_report(**overrides: Any) -> dict[str, Any]:
|
|
base: dict[str, Any] = {
|
|
"id": "vuln-0001",
|
|
"title": "SQL Injection",
|
|
"severity": "high",
|
|
"timestamp": "2026-07-02 10:00:00 UTC",
|
|
"description": "User input reaches SQL query unsanitized.",
|
|
"impact": "Database read access.",
|
|
"target": "https://app.example.com",
|
|
"endpoint": "/api/login",
|
|
"method": "POST",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def test_read_run_record_missing_returns_empty(tmp_path: Path) -> None:
|
|
assert read_run_record(tmp_path) == {}
|
|
|
|
|
|
def test_read_run_record_corrupt_raises(tmp_path: Path) -> None:
|
|
record = tmp_path / "run.json"
|
|
record.write_text("{not json", encoding="utf-8")
|
|
with pytest.raises(RuntimeError, match="unreadable"):
|
|
read_run_record(tmp_path)
|
|
|
|
|
|
def test_read_run_record_non_object_raises(tmp_path: Path) -> None:
|
|
record = tmp_path / "run.json"
|
|
record.write_text(json.dumps(["array"]), encoding="utf-8")
|
|
with pytest.raises(TypeError, match="not an object"):
|
|
read_run_record(tmp_path)
|
|
|
|
|
|
def test_write_and_read_run_record_round_trip(tmp_path: Path) -> None:
|
|
payload = {"scan_id": "scan-abc", "status": "completed"}
|
|
write_run_record(tmp_path, payload)
|
|
assert read_run_record(tmp_path) == payload
|
|
|
|
|
|
def test_render_vulnerability_md_includes_core_sections() -> None:
|
|
md = render_vulnerability_md(
|
|
_sample_report(
|
|
technical_analysis="Root cause in UserDAO.",
|
|
poc_description="Send ' OR 1=1 --",
|
|
remediation_steps="Use parameterized queries.",
|
|
),
|
|
)
|
|
assert "# SQL Injection" in md
|
|
assert "**Severity:** HIGH" in md
|
|
assert "## Description" in md
|
|
assert "## Impact" in md
|
|
assert "## Technical Analysis" in md
|
|
assert "## Proof of Concept" in md
|
|
assert "## Remediation" in md
|
|
assert "**Endpoint:** /api/login" in md
|
|
|
|
|
|
def test_render_vulnerability_md_includes_dependency_fields() -> None:
|
|
md = render_vulnerability_md(
|
|
_sample_report(
|
|
title="CVE-2021-23337 in lodash 4.17.20",
|
|
severity="high",
|
|
target="repo/package.json",
|
|
endpoint=None,
|
|
method=None,
|
|
cve="CVE-2021-23337",
|
|
cwe="CWE-94",
|
|
cvss=7.2,
|
|
fix_effort="trivial",
|
|
finding_class="dependency_cve",
|
|
evidence="**Advisory evidence:** `CVE-2021-23337` applies to `lodash`.",
|
|
assumptions="Assumes lodash ships in deployed builds.",
|
|
dependency_metadata={
|
|
"package_name": "lodash",
|
|
"package_ecosystem": "npm",
|
|
"installed_version": "4.17.20",
|
|
"fixed_version": "4.17.21",
|
|
},
|
|
remediation_steps="Upgrade to 4.17.21.",
|
|
),
|
|
)
|
|
assert "**Package:** lodash" in md
|
|
assert "**Ecosystem:** npm" in md
|
|
assert "**Installed Version:** 4.17.20" in md
|
|
assert "**Fixed Version:** 4.17.21" in md
|
|
assert "**CWE:** CWE-94" in md
|
|
assert "**Fix Effort:** Trivial" in md
|
|
assert "## Evidence" in md
|
|
assert "## Assumptions" in md
|
|
|
|
|
|
def test_render_vulnerability_md_poc_code_cannot_break_out_of_fence() -> None:
|
|
# LLM/target-authored PoC content containing its own ``` must not close the
|
|
# fence early and turn the injected markdown into live headings/images.
|
|
injected = "curl x\n```\n\n## Injected Heading\n"
|
|
md = render_vulnerability_md(_sample_report(poc_script_code=injected))
|
|
lines = md.split("\n")
|
|
opening = next(ln for ln in lines[lines.index("## Proof of Concept") + 1 :] if ln.strip())
|
|
ticks = opening[: len(opening) - len(opening.lstrip("`"))]
|
|
assert len(ticks) >= 4 # wider than the payload's 3-backtick run
|
|
assert "`" not in opening.removeprefix(ticks) # backtick run + language tag only
|
|
assert f"\n{ticks}\n" in md # pure-backtick closing fence of the same width
|
|
assert injected in md # the payload survives verbatim, inside the fence
|
|
|
|
|
|
def test_render_vulnerability_md_snippet_cannot_break_out_of_fence() -> None:
|
|
snippet = "row = q()\n```\n## Injected"
|
|
md = render_vulnerability_md(
|
|
_sample_report(code_locations=[{"file": "app.py", "snippet": snippet}]),
|
|
)
|
|
assert (
|
|
" ````\n row = q()\n ```\n ## Injected\n ````"
|
|
) in md # indented fence widened past the payload's ``` run
|
|
|
|
|
|
def test_write_vulnerabilities_creates_markdown_csv_and_json(tmp_path: Path) -> None:
|
|
reports = [
|
|
_sample_report(id="vuln-0001", severity="medium", timestamp="2026-07-02 11:00:00 UTC"),
|
|
_sample_report(
|
|
id="vuln-0002",
|
|
title="Critical RCE",
|
|
severity="critical",
|
|
timestamp="2026-07-02 09:00:00 UTC",
|
|
),
|
|
]
|
|
saved: set[str] = set()
|
|
|
|
new_count = write_vulnerabilities(tmp_path, reports, saved)
|
|
|
|
assert new_count == 2
|
|
assert (tmp_path / "vulnerabilities" / "vuln-0001.md").exists()
|
|
assert (tmp_path / "vulnerabilities" / "vuln-0002.md").exists()
|
|
assert json.loads((tmp_path / "vulnerabilities.json").read_text(encoding="utf-8")) == reports
|
|
|
|
csv_rows = list(
|
|
csv.DictReader((tmp_path / "vulnerabilities.csv").read_text(encoding="utf-8").splitlines()),
|
|
)
|
|
assert [row["id"] for row in csv_rows] == ["vuln-0002", "vuln-0001"]
|
|
assert csv_rows[0]["severity"] == "CRITICAL"
|
|
|
|
|
|
def test_write_vulnerabilities_skips_already_saved_ids(tmp_path: Path) -> None:
|
|
reports = [_sample_report(id="vuln-0001")]
|
|
saved: set[str] = {"vuln-0001"}
|
|
|
|
new_count = write_vulnerabilities(tmp_path, reports, saved)
|
|
|
|
assert new_count == 0
|
|
assert not (tmp_path / "vulnerabilities" / "vuln-0001.md").exists()
|
|
assert (tmp_path / "vulnerabilities.csv").exists()
|
|
|
|
|
|
def test_write_executive_report_writes_markdown(tmp_path: Path) -> None:
|
|
write_executive_report(tmp_path, "Scan complete. No critical issues.")
|
|
content = (tmp_path / "penetration_test_report.md").read_text(encoding="utf-8")
|
|
assert "# Security Penetration Test Report" in content
|
|
assert "Scan complete. No critical issues." in content
|
|
|
|
|
|
def test_render_vulnerability_md_surfaces_calibration_metadata() -> None:
|
|
"""Confidence, the case against the finding, and retest status are part of
|
|
the deliverable — storing them without rendering hides the reasoning."""
|
|
md = render_vulnerability_md(
|
|
{
|
|
"id": "vuln-0009",
|
|
"title": "SSRF in URL preview",
|
|
"severity": "high",
|
|
"timestamp": "2026-07-02 10:00:00 UTC",
|
|
"description": "Fetches user-supplied URLs.",
|
|
"confidence": "medium",
|
|
"counterevidence": "Egress appears filtered at the network layer.",
|
|
"confidence_rationale": "Reproduced once out of three attempts.",
|
|
"severity_change_conditions": "Critical if egress filtering is removed.",
|
|
"remediation_steps": "Allowlist destinations.",
|
|
"fix_verification": "Not retested.",
|
|
}
|
|
)
|
|
|
|
assert "**Confidence:** Medium" in md
|
|
assert "## Counterevidence" in md
|
|
assert "Egress appears filtered at the network layer." in md
|
|
assert "## Confidence Rationale" in md
|
|
assert "## What Would Change This Severity" in md
|
|
assert "## Fix Verification" in md
|
|
|
|
|
|
def test_write_executive_report_appends_the_coverage_section(tmp_path: Path) -> None:
|
|
write_executive_report(tmp_path, "Scan complete.", "# Coverage\n\nNothing tested.\n")
|
|
content = (tmp_path / "penetration_test_report.md").read_text(encoding="utf-8")
|
|
|
|
assert content.index("Scan complete.") < content.index("# Coverage")
|
|
assert "Nothing tested." in content
|