Files
strix/tests/test_report_writer.py

182 lines
6.4 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![x](https://evil.example/beacon.png)"
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