diff --git a/pyproject.toml b/pyproject.toml index 1a8e646f0..ab4127e71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "requests>=2.32.0", "cvss>=3.2", "caido-sdk-client>=0.2.0", + "markdown-it-py>=3.0.0", "reportlab>=4.0", "pypdf>=5.0", # Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks diff --git a/strix/interface/viewer/report_pdf.py b/strix/interface/viewer/report_pdf.py index 951fb6687..b749f2706 100644 --- a/strix/interface/viewer/report_pdf.py +++ b/strix/interface/viewer/report_pdf.py @@ -20,6 +20,7 @@ from io import BytesIO from typing import TYPE_CHECKING, Any +from markdown_it import MarkdownIt from pypdf import PdfReader, PdfWriter from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER @@ -49,6 +50,8 @@ if TYPE_CHECKING: from pathlib import Path + from markdown_it.token import Token + # Palette lifted from the cloud report theme (styles/base.ts, docx/theme.ts). _INK = colors.HexColor("#000000") @@ -72,11 +75,21 @@ _MONO = "Courier" _PAGE_W, _PAGE_H = A4 +_INLINE_MD = MarkdownIt("commonmark", {"html": False, "linkify": False}).disable( + ["autolink", "image", "link"] +) +_UNSAFE_TEXT_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ud800-\udfff\ufffe\uffff]") + + +def _normalize_text(value: Any) -> str: + """Normalize characters that ReportLab cannot safely serialize.""" + text = str(value).replace("\r\n", "\n").replace("\r", "\n") + return _UNSAFE_TEXT_RE.sub("\ufffd", text) def _esc(value: Any) -> str: """Escape a value for reportlab's Paragraph markup.""" - return html.escape(str(value)).replace("\n", "
") + return html.escape(_normalize_text(value)).replace("\n", "
") class _NumberedCanvas(pdfcanvas.Canvas): # type: ignore[misc] # reportlab base is untyped @@ -253,7 +266,10 @@ def _duration(start: Any, end: Any) -> str: end_dt = _parse_time(end) if not start_dt or not end_dt: return "n/a" - seconds = int((end_dt - start_dt).total_seconds()) + try: + seconds = int((end_dt - start_dt).total_seconds()) + except (OverflowError, TypeError): + return "n/a" if seconds < 0: return "n/a" hours, remainder = divmod(seconds, 3600) @@ -265,10 +281,18 @@ def _duration(start: Any, end: Any) -> str: return f"{secs}s" -def _severity_badge(styles: dict[str, ParagraphStyle], severity: str) -> Table: +def _normalize_severity(value: Any) -> str: + severity = str(value or "").lower().strip() + if severity == "informational": + return "info" + return severity if severity in {*_SEVERITY_COLORS, "info"} else "low" + + +def _severity_badge(styles: dict[str, ParagraphStyle], severity: Any) -> Table: """A colored pill matching .severity-badge in the cloud report.""" + severity = _normalize_severity(severity) color = _SEVERITY_COLORS.get(severity, _MUTED) - cell = Paragraph(severity.upper(), styles["badge"]) + cell = Paragraph(_esc(severity.upper()), styles["badge"]) table = Table([[cell]], colWidths=[len(severity) * 6.5 + 20]) table.setStyle( TableStyle( @@ -406,27 +430,36 @@ def _cover( def _inline_md(text: str) -> str: - """Convert inline markdown (bold, italic, `code`) to reportlab markup. - - Code spans are stashed as placeholders before bold/italic run, so bold that - wraps a code span (``**`x`**``) works and code contents are never mangled. - """ - codes: list[str] = [] - - def _stash(match: re.Match[str]) -> str: - codes.append(match.group(1)) - return f"\x00{len(codes) - 1}\x00" - - seg = html.escape(re.sub(r"`([^`]+)`", _stash, text)) - seg = re.sub(r"\*\*(.+?)\*\*", r"\1", seg) - seg = re.sub(r"__(.+?)__", r"\1", seg) - seg = re.sub(r"\*(.+?)\*", r"\1", seg) - - def _restore(match: re.Match[str]) -> str: - inner = html.escape(codes[int(match.group(1))]) - return f'{inner}' - - return re.sub(r"\x00(\d+)\x00", _restore, seg) + """Render a safe subset of inline Markdown as ReportLab markup.""" + tokens = _INLINE_MD.parseInline(_normalize_text(text))[0].children or [] + return "".join(_inline_token_markup(token) for token in tokens) + + +def _inline_token_markup(token: Token) -> str: + fixed_markup = { + "strong_open": "", + "strong_close": "", + "em_open": "", + "em_close": "", + "hardbreak": "
", + "softbreak": " ", + }.get(token.type) + if fixed_markup is not None: + return fixed_markup + if token.type == "code_inline": + return f'{html.escape(token.content)}' + # Unsupported token content remains escaped so parser extensions cannot + # expose ReportLab tags. + return html.escape(token.content) + + +def _markdown_paragraph(text: str, style: ParagraphStyle) -> Paragraph: + """Build a Markdown paragraph, falling back to escaped source text.""" + source = _normalize_text(text) + try: + return Paragraph(_inline_md(source), style) + except ValueError: + return Paragraph(_esc(source), style) def _strip_leading_heading(md: str) -> str: @@ -447,12 +480,12 @@ def _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hur def flush_para() -> None: if para: - flow.append(Paragraph(_inline_md(" ".join(para)), styles["body"])) + flow.append(_markdown_paragraph(" ".join(para), styles["body"])) para.clear() def flush_bullets() -> None: for marker, item in bullets: - flow.append(Paragraph(f"{marker} {_inline_md(item)}", styles["bullet"])) + flow.append(_markdown_paragraph(f"{marker}\u00a0{item}", styles["bullet"])) bullets.clear() lines = md.replace("\r\n", "\n").split("\n") @@ -479,7 +512,7 @@ def flush_bullets() -> None: if heading: flush_para() flush_bullets() - flow.append(Paragraph(_inline_md(heading.group(2)), styles["md_heading"])) + flow.append(_markdown_paragraph(heading.group(2), styles["md_heading"])) i += 1 continue ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped) @@ -532,7 +565,7 @@ def _finding_flowables( styles: dict[str, ParagraphStyle], index: int, vuln: dict[str, Any] ) -> list[Flowable]: title = vuln.get("title") or "Untitled finding" - severity = str(vuln.get("severity") or "").lower().strip() or "low" + severity = _normalize_severity(vuln.get("severity")) meta_bits = [] if vuln.get("cvss") is not None: diff --git a/tests/test_report_pdf.py b/tests/test_report_pdf.py index 305ad33f2..61d1f2058 100644 --- a/tests/test_report_pdf.py +++ b/tests/test_report_pdf.py @@ -4,13 +4,19 @@ import json from io import BytesIO +from itertools import product from typing import TYPE_CHECKING import pytest from pypdf import PdfReader from pypdf.errors import WrongPasswordError +from reportlab.lib.styles import ParagraphStyle +from reportlab.platypus import Paragraph from strix.interface.viewer.report_pdf import ( + _duration, + _inline_md, + _normalize_severity, build_encrypted_report, encrypt_pdf, generate_password, @@ -60,6 +66,10 @@ def _make_run(base: Path, name: str = "sample") -> Path: return run_dir +def _pdf_text(pdf: bytes) -> str: + return "\n".join(page.extract_text() or "" for page in PdfReader(BytesIO(pdf)).pages) + + def test_generate_report_pdf_has_pdf_header(tmp_path: Path) -> None: run_dir = _make_run(tmp_path) pdf = generate_report_pdf(run_dir) @@ -103,3 +113,132 @@ def test_build_encrypted_report(tmp_path: Path) -> None: reader = PdfReader(BytesIO(pdf_bytes)) assert reader.is_encrypted assert reader.decrypt(password) + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("**bold**", "bold"), + ("__bold__", "bold"), + ("*italic*", "italic"), + ("***both***", "both"), + ("**bold with *italic* inside**", "bold with italic inside"), + ("*outer **bold** inner*", "outer bold inner"), + (r"\*literal\*", "*literal*"), + ("******", "******"), + ("`a * < &`", 'a * < &'), + ( + "![alt](https://example.invalid/image.png)", + "![alt](https://example.invalid/image.png)", + ), + ("", "<https://example.invalid>"), + ], +) +def test_inline_md_emits_only_safe_balanced_markup(text: str, expected: str) -> None: + markup = _inline_md(text) + assert markup == expected + Paragraph(markup, ParagraphStyle("test")) + + +@pytest.mark.parametrize( + "text", + [ + "*a **b* c**", + "**a *b** c*", + "*outer **inner* end**", + "__a *b__ c*", + "***__***__", + "__***__***", + "", + "x", + "", + "\x000\x00 `code` \x0099\x00", + "\ud800", + ], +) +def test_inline_md_survives_malformed_external_text(text: str) -> None: + markup = _inline_md(text) + assert "\x00" not in markup + assert "\ud800" not in markup + Paragraph(markup, ParagraphStyle("test")) + + +def test_inline_md_generated_corpus_never_breaks_reportlab() -> None: + style = ParagraphStyle("test") + for length in range(1, 6): + for chars in product("*_`a ", repeat=length): + Paragraph(_inline_md("".join(chars)), style) + + +def test_generate_report_pdf_survives_hostile_run_fields(tmp_path: Path) -> None: + run_dir = _make_run(tmp_path) + hostile = "****** \x000\x00 \ud800" + record = json.loads((run_dir / "run.json").read_text(encoding="utf-8")) + record.update( + { + "run_name": hostile, + "targets_info": [{"original": hostile}], + "scan_mode": hostile, + "status": hostile, + "start_time": hostile, + "end_time": hostile, + "scan_results": { + "executive_summary": hostile, + "methodology": hostile, + "technical_analysis": hostile, + "recommendations": hostile, + }, + } + ) + (run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8") + + text = _pdf_text(generate_report_pdf(run_dir)) + assert "******" in text + assert "" in text + assert "" in text + + +def test_generate_report_pdf_survives_hostile_finding_fields(tmp_path: Path) -> None: + run_dir = _make_run(tmp_path) + hostile = "****** \x000\x00 \ud800" + vulnerability = { + "title": hostile, + "severity": hostile, + "cvss": hostile, + "description": hostile, + "impact": hostile, + "technical_analysis": hostile, + "poc_description": hostile, + "poc_script_code": hostile, + "evidence": hostile, + "remediation_steps": [hostile], + "target": hostile, + "endpoint": hostile, + "method": hostile, + } + (run_dir / "vulnerabilities.json").write_text(json.dumps([vulnerability]), encoding="utf-8") + + text = _pdf_text(generate_report_pdf(run_dir)) + assert "******" in text + assert "" in text + assert "" in text + assert text.count("LOW") == 2 # severity grid label plus canonicalized finding badge + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("CRITICAL", "critical"), + (" info ", "info"), + ("informational", "info"), + ("", "low"), + ({"severity": "critical"}, "low"), + (None, "low"), + ], +) +def test_normalize_severity_restricts_badge_markup(value: object, expected: str) -> None: + assert _normalize_severity(value) == expected + + +def test_duration_rejects_mixed_timezone_awareness() -> None: + assert _duration("2026-01-01T00:00:00", "2026-01-01T01:00:00Z") == "n/a" diff --git a/uv.lock b/uv.lock index 523e7c646..ecd1b6965 100644 --- a/uv.lock +++ b/uv.lock @@ -2386,6 +2386,7 @@ dependencies = [ { name = "cvss" }, { name = "docker" }, { name = "litellm" }, + { name = "markdown-it-py" }, { name = "openai" }, { name = "openai-agents", extra = ["litellm"] }, { name = "pydantic" }, @@ -2427,6 +2428,7 @@ requires-dist = [ { name = "docker", specifier = ">=7.1.0" }, { name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" }, { name = "litellm" }, + { name = "markdown-it-py", specifier = ">=3.0.0" }, { name = "openai", specifier = ">=2.45.0,<3" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.19.0,<0.20" }, { name = "pydantic", specifier = ">=2.11.3" },