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:
@@ -1 +1,2 @@
|
||||
ldap3>=2.9
|
||||
markdown>=3.5
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user