mirror of
https://github.com/usestrix/strix.git
synced 2026-10-22 05:23:43 +00:00
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:
parent
c7c7096c69
commit
7c1e37c116
6 changed files with 380 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
247
strix/report/html_report.py
Normal 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 · Security Penetration Test Report</h1>",
|
||||
f'<p class="meta">Target: {_esc(_target_label(run_record))} '
|
||||
f"· Run: {run_name} · 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 · generated locally · open this file in a browser</footer>"
|
||||
)
|
||||
target = _target_label(run_record)
|
||||
return _document("".join(body_parts), f"Strix Report — {target}")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
104
tests/test_html_report.py
Normal 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 "<script>alert(1)</script>" 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
|
||||
Loading…
Add table
Reference in a new issue