feat: export findings to a self-contained HTML report on scan completion

Adds report/html_report.py (render_html_report) and writer.write_html_report, invoked best-effort from ReportState._save_artifacts, to write a single self-contained report.html into strix_runs/<run-name>/ alongside the existing markdown/CSV/JSON/SARIF outputs.

The report renders parity with the markdown findings report (severity summary + per-finding detail) with all attacker-influenced content HTML-escaped, handles the zero-finding empty state, and is generated by default but disableable via STRIX_HTML_REPORT=0. Adds tests/test_html_report.py (incl. an escaping/XSS test).
This commit is contained in:
kent 2026-07-22 16:41:07 +07:00
parent c7c7096c69
commit 7c1e37c116
6 changed files with 380 additions and 1 deletions

View file

@ -233,7 +233,7 @@ ignore = [
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
# ReportState carries scan artifact/report fields and
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"]
"strix/report/usage.py" = ["PLC0415"]
"strix/config/models.py" = ["PLC0415"]
# Interface utility branches per scope-mode / target-type combination;

View file

@ -58,6 +58,10 @@ class RuntimeSettings(BaseSettings):
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
# Max screenshot/image tool outputs kept live per agent context (0 = none).
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
# Emit a self-contained HTML findings report (report.html) on scan
# completion, alongside the markdown/CSV/JSON/SARIF outputs. Set to false
# (STRIX_HTML_REPORT=0) to disable.
html_report: bool = Field(default=True, alias="STRIX_HTML_REPORT")
class TelemetrySettings(BaseSettings):

247
strix/report/html_report.py Normal file
View file

@ -0,0 +1,247 @@
"""Self-contained HTML findings report renderer.
Renders a Strix scan's findings (the same ``run_record`` + ``vulnerability_reports``
data used by :mod:`strix.report.writer`) into a single portable HTML document with
inlined CSS — no external assets, opens offline.
Named ``html_report`` (not ``html``) to avoid shadowing the stdlib :mod:`html`
module used here for escaping. All finding-derived content is escaped before
embedding, so attacker-influenced values (titles, PoC code, snippets) cannot
inject markup or script into the report.
"""
from __future__ import annotations
import html
from datetime import UTC, datetime
from typing import Any
# Mirrors ``strix.report.writer._SEVERITY_ORDER`` (kept local to avoid a cross-
# module import cycle; both derive from the same fixed severity vocabulary).
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
# Mirrors ``strix.interface.utils.get_severity_color`` for visual consistency.
_SEVERITY_COLOR = {
"critical": "#dc2626",
"high": "#ea580c",
"medium": "#d97706",
"low": "#65a30d",
"info": "#0284c7",
}
_DEFAULT_COLOR = "#6b7280"
_SEVERITIES = ("critical", "high", "medium", "low", "info")
def _esc(value: Any) -> str:
"""HTML-escape any value (attribute-safe). ``None`` becomes an empty string."""
if value is None:
return ""
return html.escape(str(value), quote=True)
def _severity_color(severity: str) -> str:
return _SEVERITY_COLOR.get(severity.lower(), _DEFAULT_COLOR)
def _sort_key(report: dict[str, Any]) -> tuple[int, str]:
severity = str(report.get("severity", "")).lower()
return (_SEVERITY_ORDER.get(severity, 5), str(report.get("timestamp", "")))
def _target_label(run_record: dict[str, Any]) -> str:
targets = run_record.get("targets_info") or []
if isinstance(targets, list) and targets:
first = targets[0] if isinstance(targets[0], dict) else {}
original = first.get("original")
if isinstance(original, str) and original:
if len(targets) > 1:
return f"{original} (+{len(targets) - 1} more)"
return original
return "unknown"
def _severity_counts(reports: list[dict[str, Any]]) -> dict[str, int]:
counts = dict.fromkeys(_SEVERITIES, 0)
for report in reports:
severity = str(report.get("severity", "")).lower()
if severity in counts:
counts[severity] += 1
return counts
def _render_summary(run_record: dict[str, Any], reports: list[dict[str, Any]]) -> str:
run_name = _esc(run_record.get("run_name") or run_record.get("run_id") or "scan")
generated = _esc(datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"))
parts = [
'<header class="report-header">',
"<h1>Strix &middot; Security Penetration Test Report</h1>",
f'<p class="meta">Target: {_esc(_target_label(run_record))} '
f"&middot; Run: {run_name} &middot; Generated: {generated}</p>",
"</header>",
'<section class="summary" aria-label="Summary">',
]
if reports:
counts = _severity_counts(reports)
chips = "".join(
f'<li class="chip {sev}" style="border-color:{_severity_color(sev)}">'
f'<span class="chip-label" style="color:{_severity_color(sev)}">'
f'{sev.upper()}</span> <span class="chip-count">{counts[sev]}</span></li>'
for sev in _SEVERITIES
)
parts.append(f'<ul class="severity-chips">{chips}</ul>')
parts.append(f'<p class="total">Total findings: {len(reports)}</p>')
else:
parts.append(
'<p class="empty">No exploitable vulnerabilities detected.</p>'
)
parts.append("</section>")
return "".join(parts)
def _render_code_block(label: str, code: str) -> str:
return (
f"<h3>{_esc(label)}</h3>"
f'<pre class="code"><code>{_esc(code)}</code></pre>'
)
def _render_meta_row(report: dict[str, Any]) -> str:
dep = report.get("dependency_metadata") or {}
pairs: list[tuple[str, Any]] = [
("ID", report.get("id")),
("Target", report.get("target")),
("Package", dep.get("package_name") if isinstance(dep, dict) else None),
("Installed", dep.get("installed_version") if isinstance(dep, dict) else None),
("Fixed", dep.get("fixed_version") if isinstance(dep, dict) else None),
("Endpoint", report.get("endpoint")),
("Method", report.get("method")),
("CVE", report.get("cve")),
("CWE", report.get("cwe")),
]
items = "".join(
f"<dt>{_esc(label)}</dt><dd>{_esc(value)}</dd>" for label, value in pairs if value
)
return f'<dl class="finding-meta">{items}</dl>' if items else ""
def _render_finding(report: dict[str, Any]) -> str:
severity = str(report.get("severity", "unknown")).lower()
color = _severity_color(severity)
title = _esc(report.get("title") or "Untitled Vulnerability")
cvss = report.get("cvss")
cvss_html = f'<span class="cvss">CVSS {_esc(cvss)}</span>' if cvss is not None else ""
parts = [
f'<article class="finding sev-{_esc(severity)}" id="vuln-{_esc(report.get("id"))}">',
f'<h2><span class="badge" style="background:{color}">{_esc(severity.upper())}</span> '
f"{title} {cvss_html}</h2>",
_render_meta_row(report),
]
for label, key in (
("Description", "description"),
("Impact", "impact"),
("Technical Analysis", "technical_analysis"),
):
value = report.get(key)
if value:
parts.append(f"<section><h3>{label}</h3><p>{_esc(value)}</p></section>")
if report.get("poc_description") or report.get("poc_script_code"):
parts.append("<section>")
if report.get("poc_description"):
parts.append(f"<h3>Proof of Concept</h3><p>{_esc(report['poc_description'])}</p>")
if report.get("poc_script_code"):
parts.append(_render_code_block("PoC Script", str(report["poc_script_code"])))
parts.append("</section>")
code_locations = report.get("code_locations")
if isinstance(code_locations, list) and code_locations:
parts.append("<section><h3>Code Locations</h3>")
for loc in code_locations:
if not isinstance(loc, dict):
continue
file_ref = _esc(loc.get("file") or "unknown")
line = loc.get("start_line")
line_ref = f":{_esc(line)}" if line is not None else ""
parts.append(f'<p class="loc"><code>{file_ref}{line_ref}</code></p>')
snippet = loc.get("snippet")
if snippet:
parts.append(f'<pre class="code"><code>{_esc(snippet)}</code></pre>')
parts.append("</section>")
if report.get("remediation_steps"):
parts.append(
f"<section><h3>Remediation</h3><p>{_esc(report['remediation_steps'])}</p></section>"
)
parts.append("</article>")
return "".join(parts)
_STYLE = """
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; padding: 0 1rem 3rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1f2937; background: #f9fafb; line-height: 1.5; }
.report-header, .summary, .findings, footer { max-width: 960px; margin: 0 auto; }
.report-header { padding: 2rem 0 1rem; border-bottom: 2px solid #e5e7eb; }
h1 { font-size: 1.5rem; margin: 0; }
.meta { color: #6b7280; font-size: .9rem; margin: .4rem 0 0; }
.summary { padding: 1.25rem 0; }
.severity-chips { list-style: none; display: flex; flex-wrap: wrap; gap: .5rem;
padding: 0; margin: 0 0 .75rem; }
.chip { border: 1px solid #e5e7eb; border-radius: 999px; padding: .25rem .75rem;
font-size: .85rem; background: #fff; }
.chip-label { font-weight: 700; }
.total { font-weight: 600; margin: 0; }
.empty { font-size: 1.1rem; color: #15803d; }
.finding { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 1rem 1.25rem; margin: 1rem auto; }
.finding h2 { font-size: 1.15rem; display: flex; align-items: center; gap: .5rem;
flex-wrap: wrap; margin: 0 0 .5rem; }
.badge { color: #fff; font-size: .72rem; font-weight: 700; padding: .15rem .5rem;
border-radius: 4px; letter-spacing: .03em; }
.cvss { color: #6b7280; font-size: .85rem; font-weight: 600; margin-left: auto; }
.finding-meta { display: grid; grid-template-columns: max-content 1fr; gap: .1rem .75rem;
font-size: .85rem; margin: .5rem 0; }
.finding-meta dt { color: #6b7280; }
.finding-meta dd { margin: 0; }
h3 { font-size: .95rem; margin: .75rem 0 .25rem; }
.code { background: #0f172a; color: #e2e8f0; padding: .75rem; border-radius: 6px;
overflow-x: auto; font-size: .8rem; }
.loc code { background: #f1f5f9; padding: .1rem .3rem; border-radius: 4px; }
footer { color: #9ca3af; font-size: .8rem; text-align: center; padding-top: 2rem; }
@media print { body { background: #fff; } .finding { break-inside: avoid; } }
"""
def _document(body: str, title: str) -> str:
return (
"<!doctype html>\n"
'<html lang="en">\n<head>\n'
'<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
f"<title>{_esc(title)}</title>\n"
f"<style>{_STYLE}</style>\n"
"</head>\n<body>\n"
f"{body}\n"
"</body>\n</html>\n"
)
def render_html_report(
run_record: dict[str, Any],
vulnerability_reports: list[dict[str, Any]],
) -> str:
"""Return a complete, self-contained HTML document for the scan's findings."""
reports = sorted(vulnerability_reports, key=_sort_key)
body_parts = [_render_summary(run_record, reports)]
if reports:
findings = "".join(_render_finding(r) for r in reports)
body_parts.append(f'<main class="findings">{findings}</main>')
body_parts.append(
"<footer>Strix &middot; generated locally &middot; open this file in a browser</footer>"
)
target = _target_label(run_record)
return _document("".join(body_parts), f"Strix Report — {target}")

View file

@ -16,6 +16,7 @@ from strix.report.usage import LLMUsageLedger
from strix.report.writer import (
read_run_record,
write_executive_report,
write_html_report,
write_run_record,
write_vulnerabilities,
)
@ -437,6 +438,17 @@ class ReportState:
except Exception:
logger.exception("SARIF emit failed (non-fatal; CSV/MD unaffected)")
# Self-contained HTML findings report (report.html). Best-effort and
# isolated: a render/write error must NEVER break the CSV/MD/SARIF/
# run-record path or fail the scan. Disable via STRIX_HTML_REPORT=0.
try:
from strix.config import load_settings
if load_settings().runtime.html_report:
write_html_report(run_dir, self.run_record, self.vulnerability_reports)
except Exception:
logger.exception("HTML report emit failed (non-fatal; other outputs unaffected)")
write_run_record(run_dir, self.run_record)
logger.info("Essential scan data saved to: %s", run_dir)

View file

@ -13,6 +13,7 @@ from pathlib import Path
from typing import Any
from strix.core.paths import run_record_path
from strix.report.html_report import render_html_report
logger = logging.getLogger(__name__)
@ -55,6 +56,17 @@ def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None:
)
def write_html_report(
run_dir: Path,
run_record: dict[str, Any],
vulnerability_reports: list[dict[str, Any]],
) -> None:
"""Render and atomically write a self-contained ``report.html`` into ``run_dir``."""
html = render_html_report(run_record, vulnerability_reports)
_atomic_write_text(run_dir / "report.html", html)
logger.info("Saved HTML findings report to: %s", run_dir / "report.html")
def write_executive_report(run_dir: Path, final_scan_result: str) -> None:
path = run_dir / "penetration_test_report.md"
with path.open("w", encoding="utf-8") as f:

104
tests/test_html_report.py Normal file
View file

@ -0,0 +1,104 @@
"""Tests for the self-contained HTML findings report renderer."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from strix.report.html_report import render_html_report
from strix.report.writer import write_html_report
if TYPE_CHECKING:
from pathlib import Path
def _run_record() -> dict[str, Any]:
return {
"run_name": "demo_ab12",
"targets_info": [{"type": "web_application", "original": "https://example.com"}],
"start_time": "2026-07-22T00:00:00Z",
}
def _finding(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "vuln-1",
"title": "SQL Injection in login",
"severity": "critical",
"timestamp": "2026-07-22T00:01:00Z",
"cvss": 9.8,
"description": "User input concatenated into a SQL query.",
"impact": "Full database read.",
"poc_script_code": "' OR '1'='1",
"remediation_steps": "Use parameterized queries.",
"code_locations": [{"file": "app/auth.py", "start_line": 42, "snippet": "query = ..."}],
}
base.update(overrides)
return base
def test_report_is_a_full_html_document() -> None:
html = render_html_report(_run_record(), [_finding()])
assert html.startswith("<!doctype html>")
assert '<html lang="en">' in html
assert "</html>" in html
# Self-contained: inline style, no external asset references.
assert "<style>" in html
assert "http://" not in html.split("<footer>")[0].replace("https://example.com", "")
assert "src=" not in html
assert "<link" not in html
def test_report_renders_findings_and_summary() -> None:
html = render_html_report(_run_record(), [_finding()])
assert "SQL Injection in login" in html
assert "CRITICAL" in html
assert "CVSS 9.8" in html
assert "Total findings: 1" in html
assert "Remediation" in html
assert "app/auth.py" in html
def test_report_escapes_malicious_finding_content() -> None:
# Bug/abuse guard (FR4/SEC1): attacker-influenced content must be escaped.
payload = "<script>alert(1)</script>"
html = render_html_report(
_run_record(),
[_finding(title=payload, poc_script_code=payload, description=payload)],
)
# The raw executable tag must NOT appear; only its escaped form.
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
def test_report_empty_state() -> None:
html = render_html_report(_run_record(), [])
assert "No exploitable vulnerabilities detected." in html
assert html.startswith("<!doctype html>")
assert "<article" not in html # no finding cards
def test_report_orders_by_severity() -> None:
reports = [
_finding(id="a", title="low one", severity="low", timestamp="2026-07-22T00:00:00Z"),
_finding(id="b", title="crit one", severity="critical", timestamp="2026-07-22T00:00:01Z"),
]
html = render_html_report(_run_record(), reports)
assert html.index("crit one") < html.index("low one")
def test_report_omits_absent_fields() -> None:
minimal = {"id": "x", "title": "Minimal", "severity": "low", "timestamp": "t"}
html = render_html_report(_run_record(), [minimal])
assert "Minimal" in html
assert "Remediation" not in html
assert "Proof of Concept" not in html
def test_write_html_report_creates_file(tmp_path: Path) -> None:
write_html_report(tmp_path, _run_record(), [_finding()])
out = tmp_path / "report.html"
assert out.exists()
content = out.read_text(encoding="utf-8")
assert content.startswith("<!doctype html>")
assert "SQL Injection in login" in content