Skip to content
Open
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
25 changes: 19 additions & 6 deletions strix/interface/viewer/report_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,15 @@ def _stash(match: re.Match[str]) -> str:
codes.append(match.group(1))
return f"\x00{len(codes) - 1}\x00"

# Bold spans must start and end with a non-delimiter, so a run of asterisks such as a masked
# secret (``******``) stays literal. Italic content is plain text or complete ``<b>..</b>``
# spans, so the passes can nest (``**a *b* c**``, ``*a **b** c*``) but can never interleave
# into crossed markup (``<b><i>..</b></i>``), which reportlab rejects.
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)
seg = re.sub(r"\*\*\*(?=[^*])(.+?)(?<=[^*])\*\*\*", r"<b><i>\1</i></b>", seg)
seg = re.sub(r"\*\*(?=[^*])(.+?)(?<=[^*])\*\*", r"<b>\1</b>", seg)
seg = re.sub(r"__(?=[^_])(.+?)(?<=[^_])__", r"<b>\1</b>", seg)
seg = re.sub(r"\*((?:[^*<>\n]|<b>[^<>*\n]*</b>)+?)\*", r"<i>\1</i>", seg)

def _restore(match: re.Match[str]) -> str:
inner = html.escape(codes[int(match.group(1))])
Expand All @@ -429,6 +434,14 @@ def _restore(match: re.Match[str]) -> str:
return re.sub(r"\x00(\d+)\x00", _restore, seg)


def _para(markup: str, style: ParagraphStyle) -> Paragraph:
"""Build a Paragraph; if reportlab rejects the markup, drop our tags and keep the text."""
try:
return Paragraph(markup, style)
except ValueError:
return Paragraph(re.sub(r"</?(?:b|i|font)\b[^>]*>", "", markup), style)


def _strip_leading_heading(md: str) -> str:
"""Drop a single leading markdown heading (each section adds its own title)."""
lines = md.lstrip("\n").split("\n")
Expand All @@ -447,12 +460,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(_para(_inline_md(" ".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(_para(f"{marker}&nbsp;{_inline_md(item)}", styles["bullet"]))
bullets.clear()

lines = md.replace("\r\n", "\n").split("\n")
Expand All @@ -479,7 +492,7 @@ def flush_bullets() -> None:
if heading:
flush_para()
flush_bullets()
flow.append(Paragraph(_inline_md(heading.group(2)), styles["md_heading"]))
flow.append(_para(_inline_md(heading.group(2)), styles["md_heading"]))
i += 1
continue
ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped)
Expand Down
59 changes: 59 additions & 0 deletions tests/test_report_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@
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 (
_inline_md,
_markdown_flowables,
_para,
_styles,
build_encrypted_report,
encrypt_pdf,
generate_password,
Expand Down Expand Up @@ -103,3 +109,56 @@ 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",
[
"(observed as '******')", # masked secret: a run of asterisks
"***x***",
"**a *b** c*",
"* * *",
"__a *b__ c*",
],
)
def test_inline_md_never_emits_crossed_markup(text: str) -> None:
Paragraph(_inline_md(text), ParagraphStyle("t")) # reportlab raises ValueError on crossed tags


def test_markdown_flowables_survive_unbalanced_emphasis() -> None:
md = (
"Spring Boot masks secrets in /actuator/env (observed as '******').\n\n"
"- bullet with ***three*** stars\n"
"# heading with *unbalanced\n"
)
assert len(_markdown_flowables(md, _styles())) == 3


def test_generate_report_pdf_with_masked_secret_in_summary(tmp_path: Path) -> None:
run_dir = tmp_path / "strix_runs" / "masked"
run_dir.mkdir(parents=True)
record = {
"run_name": "masked",
"targets_info": [{"original": "https://example.com"}],
"scan_mode": "deep",
"status": "completed",
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T01:02:03Z",
"scan_results": {
"executive_summary": "Password keys are masked (observed as '******').",
"recommendations": "Nothing.",
},
}
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
assert generate_report_pdf(run_dir).startswith(b"%PDF")


def test_para_falls_back_to_plain_text_on_crossed_markup() -> None:
para = _para("<b><i>x</b></i> &amp; y", ParagraphStyle("t"))
assert para.getPlainText() == "x & y"


def test_inline_md_keeps_balanced_nested_emphasis() -> None:
assert _inline_md("**bold with *italic* inside**") == "<b>bold with <i>italic</i> inside</b>"
assert _inline_md("*outer **bold** inner*") == "<i>outer <b>bold</b> inner</i>"
assert "******" in _inline_md("(observed as '******')") # a masked secret stays literal