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 * < &'),
+ (
+ "",
+ "",
+ ),
+ ("", "<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" },