diff --git a/strix/report/writer.py b/strix/report/writer.py index 6fefefdb..68679263 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -6,6 +6,7 @@ import csv import io import json import logging +import re import tempfile from datetime import UTC, datetime from pathlib import Path @@ -18,6 +19,21 @@ logger = logging.getLogger(__name__) _SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} +_BACKTICK_RUN = re.compile(r"`+") + + +def _safe_fence(content: str) -> str: + """Return a backtick fence that ``content`` cannot break out of. + + Per CommonMark a fenced code block is closed only by a run of backticks at + least as long as the opening fence. LLM-authored, attacker-influenced values + (PoC scripts, code snippets) may contain their own ``` runs, so we open with + a fence one backtick longer than the longest run inside ``content`` (never + fewer than three). Everything in ``content`` then renders verbatim. + """ + longest = max((len(m.group()) for m in _BACKTICK_RUN.finditer(content)), default=0) + return "`" * max(3, longest + 1) + def read_run_record(run_dir: Path) -> dict[str, Any]: path = run_record_path(run_dir) @@ -171,9 +187,11 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL lines.append(str(report["poc_description"])) lines.append("") if report.get("poc_script_code"): - lines.append("```") - lines.append(str(report["poc_script_code"])) - lines.append("```") + code = str(report["poc_script_code"]) + fence = _safe_fence(code) + lines.append(fence) + lines.append(code) + lines.append(fence) lines.append("") if report.get("code_locations"): @@ -190,7 +208,11 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL if loc.get("label"): lines.append(f" {loc['label']}") if loc.get("snippet"): - lines.append(f" ```\n {loc['snippet']}\n ```") + snippet = str(loc["snippet"]) + fence = _safe_fence(snippet) + lines.append(f" {fence}") + lines.extend(f" {ln}" for ln in snippet.splitlines()) + lines.append(f" {fence}") if loc.get("fix_before") or loc.get("fix_after"): lines.append("\n **Suggested Fix:**") lines.append("```diff") diff --git a/tests/test_report_writer.py b/tests/test_report_writer.py index 3ef16ccf..b9d855a0 100644 --- a/tests/test_report_writer.py +++ b/tests/test_report_writer.py @@ -113,6 +113,28 @@ def test_render_vulnerability_md_includes_dependency_fields() -> None: assert "## Assumptions" in md +def test_render_vulnerability_md_poc_code_cannot_break_out_of_fence() -> None: + # LLM/target-authored PoC content containing its own ``` must not close the + # fence early and turn the injected markdown into live headings/images. + injected = "curl x\n```\n\n## Injected Heading\n![x](https://evil.example/beacon.png)" + md = render_vulnerability_md(_sample_report(poc_script_code=injected)) + lines = md.split("\n") + fence = next(ln for ln in lines[lines.index("## Proof of Concept") + 1 :] if ln.strip()) + assert set(fence) == {"`"} + assert len(fence) >= 4 # wider than the payload's 3-backtick run + assert injected in md # the payload survives verbatim, inside the fence + + +def test_render_vulnerability_md_snippet_cannot_break_out_of_fence() -> None: + snippet = "row = q()\n```\n## Injected" + md = render_vulnerability_md( + _sample_report(code_locations=[{"file": "app.py", "snippet": snippet}]), + ) + assert ( + " ````\n row = q()\n ```\n ## Injected\n ````" + ) in md # indented fence widened past the payload's ``` run + + def test_write_vulnerabilities_creates_markdown_csv_and_json(tmp_path: Path) -> None: reports = [ _sample_report(id="vuln-0001", severity="medium", timestamp="2026-07-02 11:00:00 UTC"),