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.
This commit is contained in:
2026-08-21 16:27:10 -04:00
parent 1a2a9b8d16
commit 163204d81c
2 changed files with 238 additions and 0 deletions
+1
View File
@@ -1 +1,2 @@
ldap3>=2.9
markdown>=3.5
+237
View File
@@ -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"(<td>){severity}(</td>)",
rf'\1<span class="badge {css_class}">{severity}</span>\2',
cell_html,
)
cell_html = re.sub(
rf"(<h4>)\[{severity}\]",
rf'\1<span class="badge {css_class}">{severity}</span> ',
cell_html,
)
return cell_html
html_text = re.sub(r"<td>\w+</td>", replace_cell, html_text)
html_text = re.sub(r"<h4>\[\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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>{CSS}</style>
</head>
<body>
<div class="wrap">
{body_html}
</div>
</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()