mirror of
https://github.com/usestrix/strix.git
synced 2026-09-05 08:06:08 +00:00
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:
parent
3c767cdd47
commit
1df67c52e2
4 changed files with 200 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} {_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:
|
||||
|
|
|
|||
|
|
@ -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 * < &</font>'),
|
||||
(
|
||||
"",
|
||||
"",
|
||||
),
|
||||
("<https://example.invalid>", "<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*",
|
||||
"***__***__",
|
||||
"__***__***",
|
||||
"<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
2
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue