Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 62 additions & 29 deletions strix/interface/viewer/report_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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", "<br/>")
return html.escape(_normalize_text(value)).replace("\n", "<br/>")


class _NumberedCanvas(pdfcanvas.Canvas): # type: ignore[misc] # reportlab base is untyped
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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"<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)
"""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": "<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)


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:
Expand All @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
139 changes: 139 additions & 0 deletions tests/test_report_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.