Defense-in-depth: neutralize raw HTML in md_to_html.py itself

The previous fix escaped AD data at generation time in both scripts,
but the converter still blindly trusted its input -- a report from
before that fix, from a hand edit, or from a third-party tool would
still render live HTML unmodified.

Normalize the whole Markdown source before parsing: unescape any
existing entities, then re-escape &, <, > uniformly. The round trip
keeps already-escaped (freshly generated) reports single-escaped
instead of doubling up, while raw/legacy unescaped HTML gets
neutralized for the first time. Neither script intentionally emits
raw HTML, so this is safe across all normal report content.

Verified: a freshly-escaped report stays single-escaped (AT&T reads
as AT&amp;T, not AT&amp;amp;T), a hand-written report with a live
<script> tag gets neutralized, and a full report with badges/TOC/
timestamps/tables still renders identically to before.
This commit is contained in:
2026-08-21 17:15:11 -04:00
parent d2ff9d2617
commit dba3ee1815
+20 -1
View File
@@ -392,11 +392,30 @@ def find_latest_report(reports_dir):
return candidates[-1]
def neutralize_raw_html(md_text):
"""Defense-in-depth: strip any live HTML out of the Markdown source
before parsing, regardless of whether the report generator already
escaped it.
The generator scripts (ad_audit.py / Invoke-ADAudit.ps1) escape AD data
before writing it into table cells, but this converter shouldn't have to
trust that: a report could have been generated by an older version, by
a third-party tool, or hand-edited. Neither of our reports intentionally
emits raw HTML, so it's safe to normalize the whole source first --
unescape any existing entities, then re-escape uniformly. That round
trip keeps already-escaped content single-escaped instead of doubling
up (which naive blanket-escaping would do to freshly generated reports).
"""
unescaped = html.unescape(md_text)
return unescaped.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def convert(md_path, out_path):
with open(md_path, "r", encoding="utf-8") as f:
md_text = f.read()
body_html = markdown.markdown(md_text, extensions=["tables", "fenced_code", "toc"])
safe_md_text = neutralize_raw_html(md_text)
body_html = markdown.markdown(safe_md_text, extensions=["tables", "fenced_code", "toc"])
body_html = badge_findings_table(body_html)
body_html = wrap_timestamps(body_html)
toc_html = build_toc(body_html)