mirror of
https://github.com/usestrix/strix.git
synced 2026-11-14 09:01:25 +00:00
Merge 162b425a13 into 851743adcf
This commit is contained in:
commit
9a36fa9708
10 changed files with 489 additions and 39 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 <root>/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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
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}")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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
|
||||
|
|
@ -619,15 +619,16 @@ def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.Monkey
|
|||
httpd.server_close()
|
||||
|
||||
|
||||
def test_historical_run_data_requires_verification(
|
||||
def test_historical_run_data_requires_session_only(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
verified = {"value": False}
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: verified["value"])
|
||||
# Email verification is pinned False for the whole test to prove it is no
|
||||
# longer consulted for browsing history: only the session capability is.
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False)
|
||||
|
||||
httpd, url, token = serve(launched, open_browser=False)
|
||||
try:
|
||||
|
|
@ -636,31 +637,27 @@ def test_historical_run_data_requires_verification(
|
|||
cookie = _session_cookie(url, token)
|
||||
assert _get_status(f"{url}/api/run", cookie=cookie) == 200
|
||||
|
||||
# A different run needs the session capability first: a cookie-less
|
||||
# caller is forbidden even once the machine is verified.
|
||||
verified["value"] = True
|
||||
# A different run still needs the session capability first: a cookie-less
|
||||
# caller is forbidden.
|
||||
assert _get_status(f"{url}/api/run?run=other") == 403
|
||||
|
||||
# With the cookie but not verified, the history gate returns 401.
|
||||
verified["value"] = False
|
||||
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 401
|
||||
|
||||
# With both the cookie and verification, the historical run resolves.
|
||||
verified["value"] = True
|
||||
# With the cookie, the historical run resolves even though the machine is
|
||||
# NOT email-verified -- the email gate has been removed.
|
||||
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 200
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_runs_list_requires_session_and_verification(
|
||||
def test_runs_list_requires_session_only(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
|
||||
# Pinned unverified to prove the list unlocks on the session capability alone.
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False)
|
||||
|
||||
def _runs(cookie: str | None) -> dict[str, object]:
|
||||
headers = {"Cookie": cookie} if cookie else {}
|
||||
|
|
@ -670,14 +667,13 @@ def test_runs_list_requires_session_and_verification(
|
|||
|
||||
httpd, url, token = serve(launched, open_browser=False)
|
||||
try:
|
||||
# A cookie-less caller (even with the machine verified) only sees the
|
||||
# teaser count, never the run entries.
|
||||
# A cookie-less caller only sees the teaser count, never the run entries.
|
||||
payload = _runs(None)
|
||||
assert payload["locked"] is True
|
||||
assert payload["count"] == 2
|
||||
assert payload["runs"] == []
|
||||
|
||||
# With the session cookie and verification, the entries unlock.
|
||||
# With the session cookie the entries unlock -- no email verification.
|
||||
payload = _runs(_session_cookie(url, token))
|
||||
assert payload["locked"] is False
|
||||
assert {r["name"] for r in payload["runs"]} == {"launched", "other"} # type: ignore[attr-defined]
|
||||
|
|
|
|||
|
|
@ -30,23 +30,25 @@ def _make_run(base: Path, name: str, *, severity: str = "high") -> Path:
|
|||
return run_dir
|
||||
|
||||
|
||||
def test_runs_payload_locked_when_unverified(tmp_path: Path) -> None:
|
||||
def test_runs_payload_locked_when_no_session(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_make_run(tmp_path, "alpha")
|
||||
_make_run(tmp_path, "beta")
|
||||
|
||||
payload = build_runs_payload(base, verified=False)
|
||||
# No session capability -> locked, but the count is still advertised.
|
||||
payload = build_runs_payload(base, unlocked=False)
|
||||
assert payload["locked"] is True
|
||||
assert payload["count"] == 2
|
||||
assert payload["runs"] == []
|
||||
|
||||
|
||||
def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
|
||||
def test_runs_payload_lists_when_unlocked(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_make_run(tmp_path, "alpha", severity="critical")
|
||||
_make_run(tmp_path, "beta", severity="info")
|
||||
|
||||
payload = build_runs_payload(base, verified=True)
|
||||
# A session-holding caller unlocks the list -- no email verification needed.
|
||||
payload = build_runs_payload(base, unlocked=True)
|
||||
assert payload["locked"] is False
|
||||
assert payload["count"] == 2
|
||||
assert len(payload["runs"]) == 2
|
||||
|
|
@ -59,7 +61,7 @@ def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
|
|||
|
||||
|
||||
def test_runs_payload_empty_base(tmp_path: Path) -> None:
|
||||
payload = build_runs_payload(tmp_path / "strix_runs", verified=True)
|
||||
payload = build_runs_payload(tmp_path / "strix_runs", unlocked=True)
|
||||
assert payload == {"locked": False, "count": 0, "runs": []}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue