fix(viewer): harden PDF report rendering (#1192)

* fix(viewer): harden PDF report rendering

* test(viewer): cover crossed markdown emphasis

---------

Co-authored-by: oyasumi <oyasumi@kantilabs.xyz>
This commit is contained in:
oyasumi
2026-08-31 20:22:33 -04:00
committed by GitHub
parent 3c767cdd47
commit 1df67c52e2
4 changed files with 200 additions and 25 deletions

View File

@@ -43,6 +43,7 @@ dependencies = [
"requests>=2.32.0", "requests>=2.32.0",
"cvss>=3.2", "cvss>=3.2",
"caido-sdk-client>=0.2.0", "caido-sdk-client>=0.2.0",
"markdown-it-py>=3.0.0",
"reportlab>=4.0", "reportlab>=4.0",
"pypdf>=5.0", "pypdf>=5.0",
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks # Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks

View File

@@ -20,6 +20,7 @@ from datetime import datetime
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from markdown_it import MarkdownIt
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER from reportlab.lib.enums import TA_CENTER
@@ -49,6 +50,8 @@ from strix.interface.viewer.transcript import (
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from markdown_it.token import Token
# Palette lifted from the cloud report theme (styles/base.ts, docx/theme.ts). # Palette lifted from the cloud report theme (styles/base.ts, docx/theme.ts).
_INK = colors.HexColor("#000000") _INK = colors.HexColor("#000000")
@@ -72,11 +75,21 @@ _SANS_BOLD = "Helvetica-Bold"
_MONO = "Courier" _MONO = "Courier"
_PAGE_W, _PAGE_H = A4 _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: def _esc(value: Any) -> str:
"""Escape a value for reportlab's Paragraph markup.""" """Escape a value for reportlab's Paragraph markup."""
return html.escape(str(value)).replace("\n", "<br/>") return html.escape(_normalize_text(value)).replace("\n", "<br/>")
class _NumberedCanvas(pdfcanvas.Canvas): # type: ignore[misc] # reportlab base is untyped 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) end_dt = _parse_time(end)
if not start_dt or not end_dt: if not start_dt or not end_dt:
return "n/a" 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: if seconds < 0:
return "n/a" return "n/a"
hours, remainder = divmod(seconds, 3600) hours, remainder = divmod(seconds, 3600)
@@ -265,10 +281,18 @@ def _duration(start: Any, end: Any) -> str:
return f"{secs}s" 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.""" """A colored pill matching .severity-badge in the cloud report."""
severity = _normalize_severity(severity)
color = _SEVERITY_COLORS.get(severity, _MUTED) 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 = Table([[cell]], colWidths=[len(severity) * 6.5 + 20])
table.setStyle( table.setStyle(
TableStyle( TableStyle(
@@ -406,27 +430,36 @@ def _cover(
def _inline_md(text: str) -> str: def _inline_md(text: str) -> str:
"""Convert inline markdown (bold, italic, `code`) to reportlab markup. """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)
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: def _inline_token_markup(token: Token) -> str:
codes.append(match.group(1)) fixed_markup = {
return f"\x00{len(codes) - 1}\x00" "strong_open": "<b>",
"strong_close": "</b>",
"em_open": "<i>",
"em_close": "</i>",
"hardbreak": "<br/>",
"softbreak": " ",
}.get(token.type)
if fixed_markup is not None:
return fixed_markup
if token.type == "code_inline":
return f'<font face="{_MONO}" color="#b31d28">{html.escape(token.content)}</font>'
# Unsupported token content remains escaped so parser extensions cannot
# expose ReportLab tags.
return html.escape(token.content)
seg = html.escape(re.sub(r"`([^`]+)`", _stash, text))
seg = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", seg)
seg = re.sub(r"__(.+?)__", r"<b>\1</b>", seg)
seg = re.sub(r"\*(.+?)\*", r"<i>\1</i>", seg)
def _restore(match: re.Match[str]) -> str: def _markdown_paragraph(text: str, style: ParagraphStyle) -> Paragraph:
inner = html.escape(codes[int(match.group(1))]) """Build a Markdown paragraph, falling back to escaped source text."""
return f'<font face="{_MONO}" color="#b31d28">{inner}</font>' source = _normalize_text(text)
try:
return re.sub(r"\x00(\d+)\x00", _restore, seg) return Paragraph(_inline_md(source), style)
except ValueError:
return Paragraph(_esc(source), style)
def _strip_leading_heading(md: str) -> str: 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: def flush_para() -> None:
if para: if para:
flow.append(Paragraph(_inline_md(" ".join(para)), styles["body"])) flow.append(_markdown_paragraph(" ".join(para), styles["body"]))
para.clear() para.clear()
def flush_bullets() -> None: def flush_bullets() -> None:
for marker, item in bullets: for marker, item in bullets:
flow.append(Paragraph(f"{marker}&nbsp;{_inline_md(item)}", styles["bullet"])) flow.append(_markdown_paragraph(f"{marker}\u00a0{item}", styles["bullet"]))
bullets.clear() bullets.clear()
lines = md.replace("\r\n", "\n").split("\n") lines = md.replace("\r\n", "\n").split("\n")
@@ -479,7 +512,7 @@ def _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hur
if heading: if heading:
flush_para() flush_para()
flush_bullets() 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 i += 1
continue continue
ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped) ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped)
@@ -532,7 +565,7 @@ def _finding_flowables(
styles: dict[str, ParagraphStyle], index: int, vuln: dict[str, Any] styles: dict[str, ParagraphStyle], index: int, vuln: dict[str, Any]
) -> list[Flowable]: ) -> list[Flowable]:
title = vuln.get("title") or "Untitled finding" 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 = [] meta_bits = []
if vuln.get("cvss") is not None: if vuln.get("cvss") is not None:

View File

@@ -4,13 +4,19 @@ from __future__ import annotations
import json import json
from io import BytesIO from io import BytesIO
from itertools import product
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
from pypdf import PdfReader from pypdf import PdfReader
from pypdf.errors import WrongPasswordError from pypdf.errors import WrongPasswordError
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import Paragraph
from strix.interface.viewer.report_pdf import ( from strix.interface.viewer.report_pdf import (
_duration,
_inline_md,
_normalize_severity,
build_encrypted_report, build_encrypted_report,
encrypt_pdf, encrypt_pdf,
generate_password, generate_password,
@@ -60,6 +66,10 @@ def _make_run(base: Path, name: str = "sample") -> Path:
return run_dir 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: def test_generate_report_pdf_has_pdf_header(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path) run_dir = _make_run(tmp_path)
pdf = generate_report_pdf(run_dir) pdf = generate_report_pdf(run_dir)
@@ -103,3 +113,132 @@ def test_build_encrypted_report(tmp_path: Path) -> None:
reader = PdfReader(BytesIO(pdf_bytes)) reader = PdfReader(BytesIO(pdf_bytes))
assert reader.is_encrypted assert reader.is_encrypted
assert reader.decrypt(password) assert reader.decrypt(password)
@pytest.mark.parametrize(
("text", "expected"),
[
("**bold**", "<b>bold</b>"),
("__bold__", "<b>bold</b>"),
("*italic*", "<i>italic</i>"),
("***both***", "<i><b>both</b></i>"),
("**bold with *italic* inside**", "<b>bold with <i>italic</i> inside</b>"),
("*outer **bold** inner*", "<i>outer <b>bold</b> inner</i>"),
(r"\*literal\*", "*literal*"),
("******", "******"),
("`a * < &`", '<font face="Courier" color="#b31d28">a * &lt; &amp;</font>'),
(
"![alt](https://example.invalid/image.png)",
"![alt](https://example.invalid/image.png)",
),
("<https://example.invalid>", "&lt;https://example.invalid&gt;"),
],
)
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*",
"***__***__",
"__***__***",
"<b><i></b></i>",
"<font size='999'>x</font>",
"<img src='/definitely/missing.png'/>",
"\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 = "****** <b><i></b></i> <img src='/definitely/missing.png'/> \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 "<b><i></b></i>" in text
assert "<img src='/definitely/missing.png'/>" in text
def test_generate_report_pdf_survives_hostile_finding_fields(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path)
hostile = "****** <b><i></b></i> <img src='/definitely/missing.png'/> \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 "<b><i></b></i>" in text
assert "<img src='/definitely/missing.png'/>" 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"),
("<b><i></b></i>", "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"

2
uv.lock generated
View File

@@ -2386,6 +2386,7 @@ dependencies = [
{ name = "cvss" }, { name = "cvss" },
{ name = "docker" }, { name = "docker" },
{ name = "litellm" }, { name = "litellm" },
{ name = "markdown-it-py" },
{ name = "openai" }, { name = "openai" },
{ name = "openai-agents", extra = ["litellm"] }, { name = "openai-agents", extra = ["litellm"] },
{ name = "pydantic" }, { name = "pydantic" },
@@ -2427,6 +2428,7 @@ requires-dist = [
{ name = "docker", specifier = ">=7.1.0" }, { name = "docker", specifier = ">=7.1.0" },
{ name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" }, { name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" },
{ name = "litellm" }, { name = "litellm" },
{ name = "markdown-it-py", specifier = ">=3.0.0" },
{ name = "openai", specifier = ">=2.45.0,<3" }, { name = "openai", specifier = ">=2.45.0,<3" },
{ name = "openai-agents", extras = ["litellm"], specifier = ">=0.19.0,<0.20" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.19.0,<0.20" },
{ name = "pydantic", specifier = ">=2.11.3" }, { name = "pydantic", specifier = ">=2.11.3" },