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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 200 additions and 25 deletions

View file

@ -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

View file

@ -20,6 +20,7 @@ from datetime import datetime
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 @@ from strix.interface.viewer.transcript import (
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 @@ _SANS_BOLD = "Helvetica-Bold"
_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", "<br/>")
return html.escape(_normalize_text(value)).replace("\n", "<br/>")
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.
"""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:
codes.append(match.group(1))
return f"\x00{len(codes) - 1}\x00"
def _inline_token_markup(token: Token) -> str:
fixed_markup = {
"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:
inner = html.escape(codes[int(match.group(1))])
return f'<font face="{_MONO}" color="#b31d28">{inner}</font>'
return re.sub(r"\x00(\d+)\x00", _restore, seg)
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}&nbsp;{_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 _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hur
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:

View file

@ -4,13 +4,19 @@ from __future__ import annotations
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**", "<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 = "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" },