From c7c7096c69cbee698240262ad45eb3f99bfe794d Mon Sep 17 00:00:00 2001 From: kent Date: Wed, 22 Jul 2026 11:04:10 +0700 Subject: [PATCH 1/3] fix: bind-mount local --target by default to avoid slow file-by-file import (#725) A local directory passed via --target was handed to the SDK LocalDir manifest entry, which copies the tree into the sandbox file-by-file at session.start(). On large repositories this stalls for a very long time with no report produced (issue #725). Default local_code targets to a read-only bind mount (applied at container-create time, effectively instant) instead of the file-by-file copy. An explicit mount: False still forces the copy. Also soften the STRIX_MAX_LOCAL_COPY_MB oversize guard from a hard error to a warning so a large --target reaches the fast path instead of being rejected at parse time. Preserves --mount, cloned-repository, and explicit-copy behavior. Adds tests/test_fast_local_import.py (incl. a bug-condition regression test) and updates tests/test_local_sources.py for the new default. --- strix/interface/main.py | 14 +++-- strix/interface/utils.py | 7 ++- tests/test_fast_local_import.py | 90 +++++++++++++++++++++++++++++++++ tests/test_local_sources.py | 23 +++++++-- 4 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 tests/test_fast_local_import.py diff --git a/strix/interface/main.py b/strix/interface/main.py index 2403200df..b75ddf632 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -644,11 +644,15 @@ def parse_arguments() -> argparse.Namespace: details = "; ".join( f"{path} ({size / (1024 * 1024):.0f} MB)" for path, size in oversized ) - parser.error( - f"Local target too large to stream into the sandbox: {details}. " - f"The limit is {max_local_copy_mb} MB " - "(set STRIX_MAX_LOCAL_COPY_MB to change it). Re-run with " - "--mount to bind-mount the directory instead of copying it." + # Issue #725: do NOT refuse large local targets. They are + # bind-mounted read-only into the sandbox by default (applied at + # container-create time, no file-by-file copy), so a large tree no + # longer stalls the scan. Warn for visibility and proceed. + logger.warning( + "Large local target(s) detected: %s. Bind-mounting read-only " + "into the sandbox (no file-by-file copy); the scan starts " + "immediately. Set STRIX_MAX_LOCAL_COPY_MB to tune this check.", + details, ) return args diff --git a/strix/interface/utils.py b/strix/interface/utils.py index 27c10a468..b9abdfaec 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -1225,11 +1225,16 @@ def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, workspace_subdir = details.get("workspace_subdir") if target_info["type"] == "local_code" and "target_path" in details: + # Local directory targets bind-mount read-only by default (issue + # #725): the SDK LocalDir entry copies a tree into the sandbox + # file-by-file, which stalls for hours on large repos. A bind mount + # is applied at container-create time and is effectively instant. + # An explicit ``mount: False`` still forces the file-by-file copy. local_sources.append( { "source_path": details["target_path"], "workspace_subdir": workspace_subdir, - "mount": bool(details.get("mount", False)), + "mount": bool(details.get("mount", True)), } ) diff --git a/tests/test_fast_local_import.py b/tests/test_fast_local_import.py new file mode 100644 index 000000000..cb6a91ea0 --- /dev/null +++ b/tests/test_fast_local_import.py @@ -0,0 +1,90 @@ +"""Regression tests for issue #725: local --target hangs on file-by-file import. + +Root cause: a local directory ``--target`` was handed to the SDK ``LocalDir`` +manifest entry, which copies the tree into the sandbox file-by-file at +``session.start()`` — hours-long on large repos. The fix bind-mounts local +targets read-only by default (fast, applied at container-create time). + +The bug-condition exploration test asserts the *negation* of the bug condition +C(X): a local_code target must resolve to a bind mount, not a copied LocalDir +entry. It fails on the unfixed code (confirming the bug) and passes after the +fix. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agents.sandbox.entries import LocalDir + +from strix.interface.utils import collect_local_sources +from strix.runtime.session_manager import build_session_entries + + +if TYPE_CHECKING: + from pathlib import Path + + +def _local_target(target_path: str, workspace_subdir: str = "repo") -> dict[str, Any]: + """A plain ``--target `` target_info (no explicit --mount).""" + return { + "type": "local_code", + "details": {"target_path": target_path, "workspace_subdir": workspace_subdir}, + "original": target_path, + } + + +def test_bug_condition_local_target_is_bind_mounted_not_copied(tmp_path: Path) -> None: + """BUG-CONDITION EXPLORATION (issue #725). + + A local directory target must be configured as a bind mount, NOT a + file-by-file LocalDir copy. Expected to FAIL on unfixed code, where a plain + --target defaults to a LocalDir copy. + """ + (tmp_path / "app.py").write_text("print('hi')\n", encoding="utf-8") + + sources = collect_local_sources([_local_target(str(tmp_path))]) + entries, bind_mounts, _staged = build_session_entries(sources) + + # Negation of C(X): bind-mounted, and NOT a copied LocalDir entry. + assert entries == {}, "local --target must not be a file-by-file LocalDir copy" + assert [m["target"] for m in bind_mounts] == ["/workspace/repo"] + assert all(not isinstance(v, LocalDir) for v in entries.values()) + + +def test_local_target_bind_mount_is_read_only(tmp_path: Path) -> None: + sources = collect_local_sources([_local_target(str(tmp_path))]) + _entries, bind_mounts, _staged = build_session_entries(sources) + + assert bind_mounts == [ + { + "source": str(tmp_path.resolve()), + "target": "/workspace/repo", + "read_only": True, + } + ] + + +def test_explicit_copy_still_supported(tmp_path: Path) -> None: + """An explicit mount=False local source is still copied (escape hatch preserved).""" + target = _local_target(str(tmp_path)) + target["details"]["mount"] = False + + sources = collect_local_sources([target]) + entries, bind_mounts, _staged = build_session_entries(sources) + + assert bind_mounts == [] + assert isinstance(entries["repo"], LocalDir) + + +def test_repository_source_is_unaffected(tmp_path: Path) -> None: + """FR2: cloned repositories retain their copy behavior (not bind-mounted).""" + repo = { + "type": "repository", + "details": {"cloned_repo_path": str(tmp_path), "workspace_subdir": "clone"}, + } + sources = collect_local_sources([repo]) + entries, bind_mounts, _staged = build_session_entries(sources) + + assert bind_mounts == [] + assert isinstance(entries["clone"], LocalDir) diff --git a/tests/test_local_sources.py b/tests/test_local_sources.py index 22ed4b9bf..378e888e2 100644 --- a/tests/test_local_sources.py +++ b/tests/test_local_sources.py @@ -106,19 +106,32 @@ def test_find_oversized_disabled_for_non_positive_limit(tmp_path: Path, disabled assert find_oversized_local_targets(targets, max_bytes=disabled) == [] -def test_collect_local_sources_propagates_mount_flag() -> None: - copied = _local_target("/copied") - copied["details"]["workspace_subdir"] = "copied" +def test_collect_local_sources_defaults_local_code_to_mount() -> None: + # Issue #725: a local_code target with no explicit flag now bind-mounts by + # default (fast) rather than copying file-by-file. + default_target = _local_target("/default") + default_target["details"]["workspace_subdir"] = "default" mounted = _local_target("/mounted", mount=True) mounted["details"]["workspace_subdir"] = "mounted" - sources = collect_local_sources([copied, mounted]) + sources = collect_local_sources([default_target, mounted]) by_path = {s["source_path"]: s for s in sources} - assert by_path["/copied"]["mount"] is False + assert by_path["/default"]["mount"] is True assert by_path["/mounted"]["mount"] is True +def test_collect_local_sources_respects_explicit_copy() -> None: + # An explicit ``mount: False`` preserves the file-by-file copy escape hatch. + copied = _local_target("/copied") + copied["details"]["workspace_subdir"] = "copied" + copied["details"]["mount"] = False + + sources = collect_local_sources([copied]) + + assert sources[0]["mount"] is False + + def test_collect_local_sources_repository_is_never_mounted() -> None: repo = { "type": "repository", From 7c1e37c116787e99ee4b0da7c547d819fcbf090b Mon Sep 17 00:00:00 2001 From: kent Date: Wed, 22 Jul 2026 16:41:07 +0700 Subject: [PATCH 2/3] 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// 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). --- pyproject.toml | 2 +- strix/config/settings.py | 4 + strix/report/html_report.py | 247 ++++++++++++++++++++++++++++++++++++ strix/report/state.py | 12 ++ strix/report/writer.py | 12 ++ tests/test_html_report.py | 104 +++++++++++++++ 6 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 strix/report/html_report.py create mode 100644 tests/test_html_report.py diff --git a/pyproject.toml b/pyproject.toml index 384773f42..539cabd18 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 84339218e..e1fdcd2f5 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 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'
      {chips}
    ') + 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( + "
    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 217b266d5..c6684978e 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -16,6 +16,7 @@ from strix.report.writer import ( read_run_record, write_executive_report, + write_html_report, write_run_record, write_vulnerabilities, ) @@ -437,6 +438,17 @@ def _save_artifacts(self) -> None: 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 686792633..41def8b8d 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -13,6 +13,7 @@ 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 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 "