From 87d47eb3902f31b8373d3d6b58c2b09aede2b021 Mon Sep 17 00:00:00 2001 From: Adrian <1917353+apetcu@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:06 +0300 Subject: [PATCH 1/2] fix(viewer): keep unbalanced markdown emphasis literal in PDF reports A run of asterisks in a finding (e.g. a masked secret '******') was turned into by the emphasis regexes in _inline_md, and reportlab rejected the crossed tags, failing the whole /api/report/send request. Emphasis content can no longer contain its own delimiter or a tag, so the bold/italic passes cannot interleave, and _para() falls back to the plain text if reportlab still rejects the markup. Fixes #1171 --- strix/interface/viewer/report_pdf.py | 24 +++++++++---- tests/test_report_pdf.py | 53 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/strix/interface/viewer/report_pdf.py b/strix/interface/viewer/report_pdf.py index 951fb6687..358bd4245 100644 --- a/strix/interface/viewer/report_pdf.py +++ b/strix/interface/viewer/report_pdf.py @@ -417,10 +417,14 @@ def _stash(match: re.Match[str]) -> str: codes.append(match.group(1)) return f"\x00{len(codes) - 1}\x00" + # Emphasis content may not contain its own delimiter or a tag, so the three passes can + # never interleave into crossed markup (``..``), which reportlab rejects. + # A run of asterisks such as a masked secret (``******``) therefore stays literal. 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"\*\*\*([^*<>\n]+?)\*\*\*", r"\1", seg) + seg = re.sub(r"\*\*([^*<>\n]+?)\*\*", r"\1", seg) + seg = re.sub(r"__([^_<>\n]+?)__", r"\1", seg) + seg = re.sub(r"\*([^*<>\n]+?)\*", r"\1", seg) def _restore(match: re.Match[str]) -> str: inner = html.escape(codes[int(match.group(1))]) @@ -429,6 +433,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"]*>", "", 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 +459,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 +491,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) diff --git a/tests/test_report_pdf.py b/tests/test_report_pdf.py index 305ad33f2..c25b486b2 100644 --- a/tests/test_report_pdf.py +++ b/tests/test_report_pdf.py @@ -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, @@ -103,3 +109,50 @@ 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" From bc1b0b7ec2ecd68638899aa286666ec310af56b7 Mon Sep 17 00:00:00 2001 From: Adrian <1917353+apetcu@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:01:40 +0300 Subject: [PATCH 2/2] fix(viewer): keep balanced nested emphasis working Restricting emphasis content to non-delimiters made `**bold with *italic* inside**` render its outer delimiters literally. Bold spans now only require a non-delimiter at each end (so `******` still stays literal) and italic content may contain complete .. spans, which keeps nesting working while still ruling out crossed tags. --- strix/interface/viewer/report_pdf.py | 15 ++++++++------- tests/test_report_pdf.py | 6 ++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/strix/interface/viewer/report_pdf.py b/strix/interface/viewer/report_pdf.py index 358bd4245..91436095f 100644 --- a/strix/interface/viewer/report_pdf.py +++ b/strix/interface/viewer/report_pdf.py @@ -417,14 +417,15 @@ def _stash(match: re.Match[str]) -> str: codes.append(match.group(1)) return f"\x00{len(codes) - 1}\x00" - # Emphasis content may not contain its own delimiter or a tag, so the three passes can - # never interleave into crossed markup (``..``), which reportlab rejects. - # A run of asterisks such as a masked secret (``******``) therefore stays literal. + # 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"\*\*\*([^*<>\n]+?)\*\*\*", r"\1", seg) - seg = re.sub(r"\*\*([^*<>\n]+?)\*\*", r"\1", seg) - seg = re.sub(r"__([^_<>\n]+?)__", r"\1", seg) - seg = re.sub(r"\*([^*<>\n]+?)\*", 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))]) diff --git a/tests/test_report_pdf.py b/tests/test_report_pdf.py index c25b486b2..35b103b07 100644 --- a/tests/test_report_pdf.py +++ b/tests/test_report_pdf.py @@ -156,3 +156,9 @@ def test_generate_report_pdf_with_masked_secret_in_summary(tmp_path: Path) -> No 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