From 163204d81c35a8e8726d2864fd3fadb772142e37 Mon Sep 17 00:00:00 2001 From: ergosteur Date: Fri, 21 Aug 2026 16:27:10 -0400 Subject: [PATCH] Add markdown-to-HTML report renderer Converts an ad_audit_report_*.md (from either audit script) into a self-contained, styled HTML page: severity badges on findings, sorted tables, responsive layout, light/dark theme via prefers-color-scheme. No network access needed to view -- all CSS is inlined. --- requirements.txt | 1 + src/md_to_html.py | 237 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 src/md_to_html.py diff --git a/requirements.txt b/requirements.txt index 9b5a6ef..07de80e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ ldap3>=2.9 +markdown>=3.5 diff --git a/src/md_to_html.py b/src/md_to_html.py new file mode 100644 index 0000000..b913323 --- /dev/null +++ b/src/md_to_html.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Render an ad_audit_report_*.md file (from either audit script) as a styled, +self-contained HTML page. No network access required at view time -- all CSS +is inlined. +""" + +import argparse +import glob +import html +import os +import re + +import markdown + +CSS = """ +:root { + --bg: #0b0e14; + --panel: #131720; + --border: #232837; + --text: #e6e9ef; + --text-dim: #9aa4b8; + --accent: #5b9dfa; + --critical: #f0466a; + --high: #f2884d; + --medium: #e6c14c; + --low: #63c5b3; + --info: #8a93a6; +} +@media (prefers-color-scheme: light) { + :root { + --bg: #f6f7fb; + --panel: #ffffff; + --border: #dde1ea; + --text: #1b1f2a; + --text-dim: #5b6472; + --accent: #2563eb; + --critical: #d7264d; + --high: #d9711a; + --medium: #a9820c; + --low: #147a68; + --info: #6b7280; + } +} +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.55; +} +.wrap { + max-width: 1080px; + margin: 0 auto; + padding: 2.5rem 1.5rem 5rem; +} +h1 { + font-size: 1.9rem; + margin-bottom: 0.25rem; +} +h1 + ul { + color: var(--text-dim); + font-size: 0.92rem; + margin-top: 0.75rem; + padding-left: 1.1rem; +} +h1 + ul code { + color: var(--text); +} +h2 { + font-size: 1.35rem; + margin-top: 2.5rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border); +} +h3 { + font-size: 1.1rem; + margin-top: 1.75rem; + color: var(--text); +} +h4 { + font-size: 0.98rem; + margin-top: 1.4rem; + margin-bottom: 0.5rem; +} +code { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.1rem 0.35rem; + font-size: 0.85em; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +strong { color: var(--text); } +table { + width: 100%; + border-collapse: collapse; + margin: 0.75rem 0 1.25rem; + font-size: 0.88rem; + display: block; + overflow-x: auto; + white-space: nowrap; +} +thead { display: table-header-group; } +tbody { display: table-row-group; } +tr { display: table-row; } +th, td { + display: table-cell; + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} +th { + color: var(--text-dim); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; + background: var(--panel); + position: sticky; + top: 0; +} +tbody tr:hover { background: var(--panel); } +ul { padding-left: 1.3rem; } +li { margin: 0.2rem 0; } +.badge { + display: inline-block; + padding: 0.12rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #0b0e14; +} +.badge-critical { background: var(--critical); color: #fff; } +.badge-high { background: var(--high); color: #1b1200; } +.badge-medium { background: var(--medium); color: #1b1200; } +.badge-low { background: var(--low); color: #04231d; } +.badge-info { background: var(--info); color: #fff; } +.meta { + color: var(--text-dim); + font-size: 0.85rem; +} +""" + +SEVERITY_CLASS = { + "Critical": "badge-critical", + "High": "badge-high", + "Medium": "badge-medium", + "Low": "badge-low", + "Info": "badge-info", +} + + +def badge_findings_table(html_text): + """Wrap bare 'Critical'/'High'/... cell text with colored badge spans, + but only inside table cells (so words like "Critical" in prose stay plain).""" + + def replace_cell(match): + cell_html = match.group(0) + for severity, css_class in SEVERITY_CLASS.items(): + cell_html = re.sub( + rf"(){severity}()", + rf'\1{severity}\2', + cell_html, + ) + cell_html = re.sub( + rf"(

)\[{severity}\]", + rf'\1{severity} ', + cell_html, + ) + return cell_html + + html_text = re.sub(r"\w+", replace_cell, html_text) + html_text = re.sub(r"

\[\w+\][^<]*", replace_cell, html_text) + return html_text + + +def find_latest_report(reports_dir): + candidates = sorted(glob.glob(os.path.join(reports_dir, "ad_audit_report_*.md"))) + if not candidates: + raise SystemExit(f"No ad_audit_report_*.md files found in {reports_dir}") + return candidates[-1] + + +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"]) + body_html = badge_findings_table(body_html) + + title = "Active Directory Audit Report" + first_heading = re.search(r"^#\s+(.+)$", md_text, re.MULTILINE) + if first_heading: + title = first_heading.group(1).strip() + + page = f""" + + + + +{html.escape(title)} + + + +
+{body_html} +
+ + +""" + + with open(out_path, "w", encoding="utf-8") as f: + f.write(page) + + +def parse_args(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("input", nargs="?", help="Path to an ad_audit_report_*.md file. Defaults to the newest one in --reports-dir.") + p.add_argument("-o", "--output", help="Output HTML path. Defaults alongside the input file with a .html extension.") + p.add_argument("--reports-dir", default="reports", help="Where to look for the newest report when --input is omitted.") + return p.parse_args() + + +def main(): + args = parse_args() + md_path = args.input or find_latest_report(args.reports_dir) + out_path = args.output or os.path.splitext(md_path)[0] + ".html" + + convert(md_path, out_path) + print(f"Wrote {out_path}") + + +if __name__ == "__main__": + main()