diff --git a/pyproject.toml b/pyproject.toml
index 384773f4..539cabd1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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;
diff --git a/strix/config/settings.py b/strix/config/settings.py
index 84339218..e1fdcd2f 100644
--- a/strix/config/settings.py
+++ b/strix/config/settings.py
@@ -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):
diff --git a/strix/report/html_report.py b/strix/report/html_report.py
new file mode 100644
index 00000000..4bfdbcf4
--- /dev/null
+++ b/strix/report/html_report.py
@@ -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 = [
+ '",
+ '',
+ ]
+ if reports:
+ counts = _severity_counts(reports)
+ chips = "".join(
+ f''
+ f''
+ f'{sev.upper()} {counts[sev]} '
+ for sev in _SEVERITIES
+ )
+ parts.append(f'')
+ parts.append(f'Total findings: {len(reports)}
')
+ else:
+ parts.append(
+ 'No exploitable vulnerabilities detected.
'
+ )
+ parts.append(" ")
+ return "".join(parts)
+
+
+def _render_code_block(label: str, code: str) -> str:
+ return (
+ f"
{_esc(label)} "
+ f'{_esc(code)} '
+ )
+
+
+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"{_esc(label)} {_esc(value)} " for label, value in pairs if value
+ )
+ return f'{items} ' 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'CVSS {_esc(cvss)} ' if cvss is not None else ""
+ parts = [
+ f'',
+ f'{_esc(severity.upper())} '
+ f"{title} {cvss_html} ",
+ _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"")
+
+ if report.get("poc_description") or report.get("poc_script_code"):
+ parts.append("")
+ if report.get("poc_description"):
+ parts.append(f"Proof of Concept {_esc(report['poc_description'])}
")
+ if report.get("poc_script_code"):
+ parts.append(_render_code_block("PoC Script", str(report["poc_script_code"])))
+ parts.append(" ")
+
+ code_locations = report.get("code_locations")
+ if isinstance(code_locations, list) and code_locations:
+ parts.append("Code Locations ")
+ 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'{file_ref}{line_ref}
')
+ snippet = loc.get("snippet")
+ if snippet:
+ parts.append(f'{_esc(snippet)} ')
+ parts.append(" ")
+
+ if report.get("remediation_steps"):
+ parts.append(
+ f"Remediation {_esc(report['remediation_steps'])}
"
+ )
+ parts.append(" ")
+ 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 (
+ "\n"
+ '\n\n'
+ ' \n'
+ ' \n'
+ f"{_esc(title)} \n"
+ f"\n"
+ "\n\n"
+ f"{body}\n"
+ "\n\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'{findings} ')
+ body_parts.append(
+ "Strix · generated locally · open this file in a browser "
+ )
+ target = _target_label(run_record)
+ return _document("".join(body_parts), f"Strix Report — {target}")
diff --git a/strix/report/state.py b/strix/report/state.py
index 217b266d..c6684978 100644
--- a/strix/report/state.py
+++ b/strix/report/state.py
@@ -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)
diff --git a/strix/report/writer.py b/strix/report/writer.py
index 68679263..41def8b8 100644
--- a/strix/report/writer.py
+++ b/strix/report/writer.py
@@ -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:
diff --git a/tests/test_html_report.py b/tests/test_html_report.py
new file mode 100644
index 00000000..649e6193
--- /dev/null
+++ b/tests/test_html_report.py
@@ -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("")
+ assert '' in html
+ assert "" in html
+ # Self-contained: inline style, no external asset references.
+ assert "