Skip to content

[high] fix(report): escape finding titles and restore the executive summary's markup - #128

Draft
elhoim wants to merge 1 commit into
calebevans:mainfrom
elhoim:fix/report-exec-summary-escaping
Draft

[high] fix(report): escape finding titles and restore the executive summary's markup#128
elhoim wants to merge 1 commit into
calebevans:mainfrom
elhoim:fix/report-exec-summary-escaping

Conversation

@elhoim

@elhoim elhoim commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: high.
  • The HTML report's Executive Summary — the first block an analyst reads — currently renders as escaped text, showing <div class="exec-threats"> instead of a formatted summary.
  • Underneath it sits a stored XSS: four finding titles are interpolated into that pre-built HTML string with no escaping, and titles come straight off the evidence (filenames, registry values, carved strings).
  • The two are coupled. Autoescaping currently neutralises the injection by accident; the obvious fix for the display bug (| safe) makes the XSS live. Neither half is correct alone.
  • Fix: html.escape() on the four title interpolations, plus | safe on the single template use site. 9 lines in renderer.py, 1 token in the template.
  • Scope: src/mulder/report/renderer.py and report.html.j2 only.

The bug

_build_executive_summary builds raw HTML with f-strings and returns it as a plain str:

    if critical_findings:
        items = "".join(f"<li>{f.title}</li>" for f in critical_findings[:5])
        sections.append(
            f'<div class="exec-threats"><strong>Key Threats</strong><ul>{items}</ul></div>'
        )

Three more titles go in via the timeline narrative (first_event, mid_titles, last_event). html.escape appears nowhere in the module.

The template interpolates the result without | safe:

    <p>{{ executive_summary }}</p>

Autoescaping is on for .html.j2, and exactly three values are marked trusted there (narrative_html | safe, f.description_html | safe ×2). executive_summary is not one of them, so its own markup is escaped and displayed as text.

The fix

            first_event = html.escape(tl[0].title)
                mid_titles = [html.escape(f.title) for f in crit_tl[1:4]]
            last_event = html.escape(tl[-1].title)
        items = "".join(f"<li>{html.escape(f.title)}</li>" for f in critical_findings[:5])
    <p>{{ executive_summary | safe }}</p>

The last_event != first_event comparison still holds: both sides are escaped identically.

Why both halves ship together

  • Escaping alone → the summary still displays as literal tags.
  • | safe alone → <script> in a finding title becomes live markup in the report.

A reviewer applying only one half gets either no improvement or a regression, so they are one change.

Deliberately out of scope

  • The other {{ f.title }} sites in the template (finding list, timeline, MITRE panels) need no change: autoescaping already covers them because they are interpolated as values, not as pre-built HTML. A test pins that this PR did not weaken that.
  • _build_executive_summary_md feeds the markdown report, which is deliberately not autoescaped. Different output, different concern.

Verification

  • uvx pre-commit run --all-files (new test staged first) → ruff, ruff-format, mypy all pass.
  • uv run --locked --extra dev pytest tests/ -q865 passed, nothing deselected.
  • Discriminating check — with renderer.py and report.html.j2 restored to origin/main and the new tests kept, 7 of 10 fail:
FAILED test_a_malicious_finding_title_cannot_inject_markup - assert '<script>' not in '<div class=...></ul></div>'
FAILED test_timeline_titles_are_escaped_too[first]  - assert '<script>' not in '<div class=...-01-04).</p>'
FAILED test_timeline_titles_are_escaped_too[middle] - assert '<script>' not in '<div class=...-01-04).</p>'
FAILED test_timeline_titles_are_escaped_too[last]   - assert '<script>' not in '<div class=...-01-04).</p>'
FAILED test_a_title_is_not_double_escaped           - assert 'Rock &amp; Roll' in '<div class="exec-meta">...
FAILED test_the_executive_summary_is_interpolated_as_markup - assert '{{ executive_summary | safe }}' in ...
FAILED test_only_the_intended_values_are_marked_safe - AssertionError: unexpected `| safe` uses: [...]

The three that pass on both trees are the narrowness tests — they pin the fix, not the bug.

Context

Recovered from closed PR #42 (codex/pr-3.1-evidence-envelope), which carried this fix underneath a large evidence-envelope refactor. Only the fix is taken: no outcome framework, no envelope types, no shared helper — the maintainer's objection in #32 was to that architecture, and none of it appears here.

The display half is a regression introduced by my own merged #73, which turned autoescaping on for .html.j2. #73 marked the three genuinely pre-rendered values | safe and missed this fourth one, which is pre-rendered HTML built in Python rather than by markdown.markdown.

Branched fresh from current main (2e5432c).

🤖 Generated with Claude Code

…s markup

Two coupled defects on one path from evidence to the rendered HTML report.

_build_executive_summary returns a string of pre-built HTML. The template
interpolated it as `{{ executive_summary }}` with no `| safe`, and autoescaping
is on for .html.j2, so the first block an analyst reads showed raw
`&lt;div class=...&gt;` tags as literal text instead of a formatted summary.

Marking it `| safe` is only correct once the finding titles baked into that
string are escaped. Four of them are -- the first, middle and last timeline
events and the Key Threats list -- and titles come straight off the evidence,
so an attacker-chosen filename or registry value would otherwise reach the
report as live markup. html.escape appeared nowhere in the module. Fixing
either half alone is wrong: escaping without `| safe` leaves the display bug,
and `| safe` without escaping turns a display bug into stored XSS.

Recovered from closed PR calebevans#42, which carried this fix under a large
evidence-envelope refactor. Only the fix is taken; no framework comes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant