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 <b><i></b></i> 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
This commit is contained in:
Adrian 2026-08-26 21:48:06 +03:00
parent a5856108a7
commit 87d47eb390
2 changed files with 71 additions and 6 deletions

View file

@ -417,10 +417,14 @@ def _inline_md(text: 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 (``<b><i>..</b></i>``), 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"<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"\*\*\*([^*<>\n]+?)\*\*\*", r"<b><i>\1</i></b>", seg)
seg = re.sub(r"\*\*([^*<>\n]+?)\*\*", r"<b>\1</b>", seg)
seg = re.sub(r"__([^_<>\n]+?)__", r"<b>\1</b>", seg)
seg = re.sub(r"\*([^*<>\n]+?)\*", r"<i>\1</i>", seg)
def _restore(match: re.Match[str]) -> str:
inner = html.escape(codes[int(match.group(1))])
@ -429,6 +433,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 +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}&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")
@ -479,7 +491,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)

View file

@ -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,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("<b><i>x</b></i> &amp; y", ParagraphStyle("t"))
assert para.getPlainText() == "x & y"