diff --git a/src/md_to_html.py b/src/md_to_html.py
index bb87a8d..008607d 100644
--- a/src/md_to_html.py
+++ b/src/md_to_html.py
@@ -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("&", "&").replace("<", "<").replace(">", ">")
+
+
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)