audit-labs/audit-report
Turn audit-tools evidence packages into control-mapped, auditor-ready reports.
clone: git clone https://gitbay.org/audit-labs/audit-report.git
main: audit_report/reporters/html.py · raw
1"""HTML renderer — a self-contained, printable evidence report.
2
3No external assets: all CSS is inlined so the file can be attached to an audit
4workpaper and opened anywhere, including offline.
5"""
6
7from __future__ import annotations
8
9from html import escape
10from typing import TYPE_CHECKING
11
12from .. import catalog
13from ..engine import FAIL, NOT_APPLICABLE, PASS
14
15if TYPE_CHECKING:
16 from . import Report
17
18_STATUS_LABEL = {PASS: "PASS", FAIL: "FAIL", NOT_APPLICABLE: "N/A"}
19_STATUS_CLASS = {PASS: "pass", FAIL: "fail", NOT_APPLICABLE: "na"}
20_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
21
22CSS = """
23:root { color-scheme: light dark; }
24* { box-sizing: border-box; }
25body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
26 margin: 0; padding: 2rem; line-height: 1.5; color: #1a1a1a; background: #fff; }
27main { max-width: 60rem; margin: 0 auto; }
28h1 { margin: 0 0 .25rem; font-size: 1.6rem; }
29h2 { margin: 2rem 0 .75rem; font-size: 1.25rem; border-bottom: 2px solid #e5e5e5; padding-bottom: .25rem; }
30.meta { color: #555; font-size: .9rem; margin: 0 0 1rem; }
31.meta code { background: #f2f2f2; padding: .05rem .3rem; border-radius: 3px; }
32.note { background: #f7f7f9; border-left: 3px solid #b9b9c6; padding: .6rem .9rem;
33 font-size: .9rem; color: #444; border-radius: 0 4px 4px 0; }
34table { border-collapse: collapse; width: 100%; font-size: .85rem; margin: .5rem 0; }
35th, td { border: 1px solid #e0e0e0; padding: .35rem .5rem; text-align: left; vertical-align: top; }
36th { background: #f5f5f7; }
37.badge { display: inline-block; font-weight: 700; font-size: .72rem; letter-spacing: .03em;
38 padding: .12rem .5rem; border-radius: 999px; }
39.badge.pass { background: #e5f6ea; color: #1a7f37; }
40.badge.fail { background: #fdeaea; color: #c1272d; }
41.badge.na { background: #eee; color: #666; }
42.finding { border: 1px solid #e5e5e5; border-radius: 6px; padding: 1rem 1.1rem; margin: .8rem 0; }
43.finding.fail { border-left: 4px solid #c1272d; }
44.finding.pass { border-left: 4px solid #1a7f37; }
45.finding.na { border-left: 4px solid #bbb; }
46.finding h3 { margin: 0 0 .5rem; font-size: 1.05rem; display: flex; gap: .5rem; align-items: center; }
47.finding dl { display: grid; grid-template-columns: max-content 1fr; gap: .2rem .8rem; margin: .4rem 0 0; font-size: .9rem; }
48.finding dt { color: #666; font-weight: 600; }
49.finding dd { margin: 0; }
50.summary-pills span { display: inline-block; margin-right: .5rem; font-weight: 600; }
51footer { margin-top: 3rem; font-size: .8rem; color: #888; border-top: 1px solid #eee; padding-top: .75rem; }
52@media (prefers-color-scheme: dark) {
53 body { color: #e6e6e6; background: #16171a; }
54 h2 { border-color: #333; }
55 .meta { color: #aaa; } .meta code { background: #26272b; }
56 .note { background: #1e1f24; border-color: #444; color: #bbb; }
57 th, td { border-color: #333; } th { background: #202126; }
58 .finding { border-color: #2d2e33; }
59 .badge.pass { background: #12321d; color: #4ac36a; }
60 .badge.fail { background: #3a1416; color: #ff6b70; }
61 .badge.na { background: #26272b; color: #999; }
62 footer { border-color: #2a2b30; }
63}
64"""
65
66
67def _badge(status: str) -> str:
68 return f'<span class="badge {_STATUS_CLASS[status]}">{_STATUS_LABEL[status]}</span>'
69
70
71def _evidence_table(rows: list[dict[str, str]]) -> str:
72 shown = rows[:10]
73 headers = list(shown[0].keys())
74 head = "".join(f"<th>{escape(h)}</th>" for h in headers)
75 body = "".join(
76 "<tr>" + "".join(f"<td>{escape(str(r.get(h, '')))}</td>" for h in headers) + "</tr>"
77 for r in shown
78 )
79 extra = (
80 f"<p class='meta'>+{len(rows) - len(shown)} more row(s) omitted.</p>"
81 if len(rows) > len(shown)
82 else ""
83 )
84 return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>{extra}"
85
86
87def render(report: Report) -> str:
88 pkg = report.package
89 counts = report.counts
90 parts: list[str] = []
91
92 parts.append(f"<h1>Evidence Report — {escape(pkg.subject)}</h1>")
93 prov = report.provenance
94 tool, rs = prov["tool"], prov["ruleset"]
95 ruleset_meta = ""
96 if rs["sha256"]:
97 rs_ver = f" {escape(rs['version'])}" if rs["version"] else ""
98 ruleset_meta = (
99 f" · Ruleset <code>{escape(rs['name'])}{rs_ver}</code> "
100 f"<code>sha256:{escape(rs['sha256'][:12])}</code>"
101 )
102 parts.append(
103 f"<p class='meta'>Platform <code>{escape(pkg.platform)}</code> · "
104 f"Source <code>{escape(pkg.path.name)}</code> · "
105 f"Generated {escape(report.generated_at)} · "
106 f"Tool <code>{escape(tool['name'])} {escape(tool['version'])}</code>"
107 f"{ruleset_meta}</p>"
108 )
109 parts.append(
110 "<p class='summary-pills'>"
111 f"<span>{_badge(FAIL)} {counts[FAIL]} failing</span>"
112 f"<span>{_badge(PASS)} {counts[PASS]} passing</span>"
113 f"<span>{_badge(NOT_APPLICABLE)} {counts[NOT_APPLICABLE]} not applicable</span>"
114 "</p>"
115 )
116 parts.append(
117 "<p class='note'>This report presents <strong>evidence</strong>, not a "
118 "compliance verdict. A failing row means a setting is in a state that does "
119 "not support a control; the final judgment belongs to the organization and "
120 "its auditor.</p>"
121 )
122
123 # Coverage matrix.
124 parts.append("<h2>Control coverage</h2>")
125 cov_rows = "".join(
126 f"<tr><td>{escape(control)}</td><td>{escape(catalog.framework_of(control))}</td>"
127 f"<td>{_badge(entry['status'])}</td>"
128 f"<td><code>{escape(', '.join(entry['rules']))}</code></td>"
129 f"<td>{escape(catalog.describe(control))}</td></tr>"
130 for control, entry in report.coverage.items()
131 )
132 parts.append(
133 "<table><thead><tr><th>Control</th><th>Framework</th><th>Status</th>"
134 f"<th>Checked by</th><th>Description</th></tr></thead><tbody>{cov_rows}</tbody></table>"
135 )
136
137 # Findings.
138 parts.append("<h2>Findings</h2>")
139 ordered = sorted(
140 report.findings,
141 key=lambda f: (f.status != FAIL, _SEVERITY_ORDER.get(f.rule.severity, 1)),
142 )
143 for finding in ordered:
144 rule = finding.rule
145 rows = [
146 f"<dt>Rule</dt><dd><code>{escape(rule.id)}</code> · {escape(rule.severity)}</dd>",
147 f"<dt>Controls</dt><dd>{escape(', '.join(rule.controls) or '—')}</dd>",
148 f"<dt>Result</dt><dd>{escape(finding.reason)}</dd>",
149 ]
150 if rule.rationale:
151 rows.append(f"<dt>Why it matters</dt><dd>{escape(rule.rationale.strip())}</dd>")
152 if finding.status == FAIL and rule.remediation:
153 rows.append(f"<dt>Remediation</dt><dd>{escape(rule.remediation.strip())}</dd>")
154 evidence = (
155 _evidence_table(finding.evidence)
156 if finding.status == FAIL and finding.evidence
157 else ""
158 )
159 parts.append(
160 f"<div class='finding {_STATUS_CLASS[finding.status]}'>"
161 f"<h3>{_badge(finding.status)} {escape(rule.title)}</h3>"
162 f"<dl>{''.join(rows)}</dl>{evidence}</div>"
163 )
164
165 parts.append(
166 "<footer>Generated by audit-report · Audit Labs · "
167 "evidence, not a verdict.</footer>"
168 )
169
170 body = "\n".join(parts)
171 return (
172 "<!doctype html><html lang='en'><head><meta charset='utf-8'>"
173 "<meta name='viewport' content='width=device-width, initial-scale=1'>"
174 f"<title>Evidence Report — {escape(pkg.subject)}</title>"
175 f"<style>{CSS}</style></head><body><main>{body}</main></body></html>\n"
176 )