diff --git a/strix/config/settings.py b/strix/config/settings.py index 4db49d5d8..611e3b52e 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -170,6 +170,10 @@ class RuntimeSettings(BaseSettings): backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND") # 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/interface/tui/runtime.py b/strix/interface/tui/runtime.py index ae9832369..2e614c7db 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -389,6 +389,19 @@ class GoTuiRuntime: packaged = get_strix_resource_path("bin", tui_executable()) if packaged.is_file(): return [str(packaged)] + # Source checkout without Go on PATH: fall back to the sidecar that + # ``make build`` / ``scripts/build.sh`` compile into build/sidecar/. + # The path is pinned to this checkout, never looked up on PATH. + # tui_source_dir() is /strix/interface/tui. + checkout_build = source.parents[2] / "build" / "sidecar" / tui_executable() + if (source / "go.mod").is_file() and checkout_build.is_file(): + return [str(checkout_build)] + if (source / "go.mod").is_file(): + raise RuntimeError( + "Bubble Tea TUI binary not found. This is a source checkout: install Go 1.24+ " + "and make sure `go` is on PATH, or build the sidecar with " + f"`go build -o {checkout_build} ./cmd/strix-tui` from {source}." + ) raise RuntimeError( "Bubble Tea TUI binary not found. Reinstall Strix from an official platform wheel." ) diff --git a/strix/interface/viewer/server.py b/strix/interface/viewer/server.py index 9fb320ba9..900008c28 100644 --- a/strix/interface/viewer/server.py +++ b/strix/interface/viewer/server.py @@ -78,15 +78,17 @@ def run_list_entry(run_dir: Path) -> dict[str, Any]: } -def build_runs_payload(base_dir: Path, *, verified: bool) -> dict[str, Any]: - """The /api/runs payload. Gates the run list behind email verification. +def build_runs_payload(base_dir: Path, *, unlocked: bool) -> dict[str, Any]: + """The /api/runs payload. Gates the run list behind the caller's authorization. - The count is always advertised so the UI can tease the history, but the - entries only appear once the viewer is verified. + ``unlocked`` is the caller's access decision (today: holds this process's + session capability). When locked, the count is still advertised so the UI can + tease the history, but the entries only appear once unlocked. Email + verification is no longer part of this decision. """ run_dirs = _iter_run_dirs(base_dir) count = len(run_dirs) - if not verified: + if not unlocked: return {"locked": True, "count": count, "runs": []} return {"locked": False, "count": count, "runs": [run_list_entry(d) for d in run_dirs]} @@ -235,14 +237,18 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]: self.end_headers() def _handle_api(self, path: str, query: dict[str, list[str]]) -> None: - # The cross-run history list (/api/runs) unlocks its entries only for - # a caller that holds this process's session capability *and* is - # email verified, so merely reaching an exposed --host port never - # leaks the run list (the payload still advertises the count as a - # teaser). + # The launched run is always viewable with no verification. The + # cross-run history list (/api/runs) unlocks its entries for any + # caller that holds this process's session capability, so merely + # reaching an exposed --host port never leaks the run list (the + # cookie is handed only to the browser this process bootstrapped). + # Email verification is intentionally NOT required here: browsing + # local run history is a local convenience, and gating it behind an + # OTP was unnecessary friction. Verification still guards emailing an + # encrypted report (see _handle_report_send). if path == "/api/runs": - unlocked = self._has_session() and auth.is_verified() - payload = build_runs_payload(state.base_dir, verified=unlocked) + unlocked = self._has_session() + payload = build_runs_payload(state.base_dir, unlocked=unlocked) self._send_json(HTTPStatus.OK, payload) return if path == "/api/capabilities": @@ -268,11 +274,15 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]: self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"}) return - # Any run other than the one used to launch the viewer is part of the - # email-gated history. The session check above applies to both paths; - # verification adds a second gate for historical run data. - if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified(): - self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"}) + # The launched run is always viewable. Any *other* run's data is part + # of the history: it needs this process's session capability (so + # merely reaching an exposed --host port is not enough) -- knowing a + # run name is not sufficient to leak its metadata, vulnerabilities, + # report, or transcript. Email verification is intentionally NOT + # required, matching the /api/runs list: once the list is browsable, + # its entries must be openable without an OTP step. + if run_dir.resolve() != state.run_dir.resolve() and not self._has_session(): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"}) return if path == "/api/run": diff --git a/strix/report/html_report.py b/strix/report/html_report.py new file mode 100644 index 000000000..4bfdbcf45 --- /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 = [ + '
', + "

Strix · Security Penetration Test Report

", + f'

Target: {_esc(_target_label(run_record))} ' + f"· Run: {run_name} · Generated: {generated}

", + "
", + '
', + ] + 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"

    {label}

    {_esc(value)}

    ") + + 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( + "" + ) + 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 104e4af4c..71f9bb158 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -19,6 +19,7 @@ from strix.report.sarif import write_sarif from strix.report.writer import ( read_run_record, write_executive_report, + write_html_report, write_run_record, write_vulnerabilities, ) @@ -892,6 +893,15 @@ 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: + 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 cf858f7d7..556ad54ad 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -17,6 +17,7 @@ from pygments.lexers.special import TextLexer from pygments.util import ClassNotFound from strix.core.paths import run_record_path +from strix.report.html_report import render_html_report if TYPE_CHECKING: @@ -136,6 +137,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_go_tui_runtime.py b/tests/test_go_tui_runtime.py index 08cf1f4c6..e98aec80d 100644 --- a/tests/test_go_tui_runtime.py +++ b/tests/test_go_tui_runtime.py @@ -84,6 +84,58 @@ def test_binary_command_reports_missing_sidecar( GoTuiRuntime.binary_command() +def _checkout(tmp_path: Path) -> Path: + source = tmp_path / "strix" / "interface" / "tui" + source.mkdir(parents=True) + (source / "go.mod").write_text("module test\n") + return source + + +def test_binary_command_uses_checkout_build_without_go( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = _checkout(tmp_path) + built = tmp_path / "build" / "sidecar" / sidecar.tui_executable() + built.parent.mkdir(parents=True) + built.write_text("binary") + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: source) + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing") + monkeypatch.setattr(shutil, "which", lambda _name: None) + + assert GoTuiRuntime.binary_command() == [str(built)] + + +def test_binary_command_checkout_without_go_or_build_explains_fix( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = _checkout(tmp_path) + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: source) + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing") + monkeypatch.setattr(shutil, "which", lambda _name: None) + + with pytest.raises(RuntimeError, match="source checkout: install Go"): + GoTuiRuntime.binary_command() + + +def test_binary_command_ignores_checkout_build_outside_a_checkout( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "strix" / "interface" / "tui" # no go.mod: not a checkout + source.mkdir(parents=True) + built = tmp_path / "build" / "sidecar" / sidecar.tui_executable() + built.parent.mkdir(parents=True) + built.write_text("binary") + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: source) + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing") + monkeypatch.setattr(shutil, "which", lambda _name: None) + + with pytest.raises(RuntimeError, match="official platform wheel"): + GoTuiRuntime.binary_command() + + def test_binary_command_ignores_unconstrained_path_sidecar( monkeypatch: pytest.MonkeyPatch, tmp_path: Any, diff --git a/tests/test_html_report.py b/tests/test_html_report.py new file mode 100644 index 000000000..649e6193e --- /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 "