diff --git a/strix/interface/viewer/report_pdf.py b/strix/interface/viewer/report_pdf.py
index 951fb668..91436095 100644
--- a/strix/interface/viewer/report_pdf.py
+++ b/strix/interface/viewer/report_pdf.py
@@ -417,10 +417,15 @@ def _inline_md(text: 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 ``..``
+ # spans, so the passes can nest (``**a *b* c**``, ``*a **b** c*``) but can never interleave
+ # into crossed markup (``..``), which reportlab rejects.
seg = html.escape(re.sub(r"`([^`]+)`", _stash, text))
- seg = re.sub(r"\*\*(.+?)\*\*", r"\1", seg)
- seg = re.sub(r"__(.+?)__", r"\1", seg)
- seg = re.sub(r"\*(.+?)\*", r"\1", seg)
+ seg = re.sub(r"\*\*\*(?=[^*])(.+?)(?<=[^*])\*\*\*", r"\1", seg)
+ seg = re.sub(r"\*\*(?=[^*])(.+?)(?<=[^*])\*\*", r"\1", seg)
+ seg = re.sub(r"__(?=[^_])(.+?)(?<=[^_])__", r"\1", seg)
+ seg = re.sub(r"\*((?:[^*<>\n]|[^<>*\n]*)+?)\*", r"\1", seg)
def _restore(match: re.Match[str]) -> str:
inner = html.escape(codes[int(match.group(1))])
@@ -429,6 +434,14 @@ def _inline_md(text: 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")
@@ -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} {_inline_md(item)}", styles["bullet"]))
+ flow.append(_para(f"{marker} {_inline_md(item)}", styles["bullet"]))
bullets.clear()
lines = md.replace("\r\n", "\n").split("\n")
@@ -479,7 +492,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(_para(_inline_md(heading.group(2)), styles["md_heading"]))
i += 1
continue
ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped)
diff --git a/tests/test_report_pdf.py b/tests/test_report_pdf.py
index 305ad33f..35b103b0 100644
--- a/tests/test_report_pdf.py
+++ b/tests/test_report_pdf.py
@@ -9,8 +9,14 @@ 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 (
+ _inline_md,
+ _markdown_flowables,
+ _para,
+ _styles,
build_encrypted_report,
encrypt_pdf,
generate_password,
@@ -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("x & y", ParagraphStyle("t"))
+ assert para.getPlainText() == "x & y"
+
+
+def test_inline_md_keeps_balanced_nested_emphasis() -> None:
+ assert _inline_md("**bold with *italic* inside**") == "bold with italic inside"
+ assert _inline_md("*outer **bold** inner*") == "outer bold inner"
+ assert "******" in _inline_md("(observed as '******')") # a masked secret stays literal