audit-labs/control-coverage
Control coverage and blind-spot analysis for audit evidence.
clone: git clone https://gitbay.org/audit-labs/control-coverage.git
v0.1.0: control_coverage/reporters/html.py · raw
1"""HTML renderer — a self-contained, printable coverage 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 __version__
13from ..coverage import (
14 ASSERTED,
15 FAILING,
16 OUT_OF_SCOPE,
17 SUPPORTED,
18 UNADDRESSED,
19)
20
21if TYPE_CHECKING:
22 from ..coverage import CoverageReport, FrameworkCoverage
23
24_STATE_LABEL = {
25 SUPPORTED: "supported",
26 FAILING: "failing",
27 ASSERTED: "asserted",
28 UNADDRESSED: "unaddressed",
29 OUT_OF_SCOPE: "out of scope",
30}
31_STATE_CLASS = {
32 SUPPORTED: "supported",
33 FAILING: "failing",
34 ASSERTED: "asserted",
35 UNADDRESSED: "unaddressed",
36 OUT_OF_SCOPE: "oos",
37}
38
39CSS = """
40:root { color-scheme: light dark; }
41* { box-sizing: border-box; }
42body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
43 margin: 0; padding: 2rem; line-height: 1.5; color: #1a1a1a; background: #fff; }
44main { max-width: 64rem; margin: 0 auto; }
45h1 { margin: 0 0 .25rem; font-size: 1.6rem; }
46h2 { margin: 2rem 0 .75rem; font-size: 1.25rem; border-bottom: 2px solid #e5e5e5; padding-bottom: .25rem; }
47h3 { margin: 1.4rem 0 .5rem; font-size: 1.02rem; }
48.meta { color: #555; font-size: .9rem; margin: 0 0 1rem; }
49.meta code { background: #f2f2f2; padding: .05rem .3rem; border-radius: 3px; }
50.note { background: #f7f7f9; border-left: 3px solid #b9b9c6; padding: .6rem .9rem;
51 font-size: .9rem; color: #444; border-radius: 0 4px 4px 0; }
52table { border-collapse: collapse; width: 100%; font-size: .85rem; margin: .5rem 0; }
53th, td { border: 1px solid #e0e0e0; padding: .35rem .5rem; text-align: left; vertical-align: top; }
54th { background: #f5f5f7; }
55td.num, th.num { text-align: right; }
56.badge { display: inline-block; font-weight: 700; font-size: .72rem; letter-spacing: .02em;
57 padding: .12rem .5rem; border-radius: 999px; white-space: nowrap; }
58.badge.supported { background: #e5f6ea; color: #1a7f37; }
59.badge.failing { background: #fdeaea; color: #c1272d; }
60.badge.asserted { background: #fff4e0; color: #a8620a; }
61.badge.unaddressed { background: #eceaf6; color: #5b4bb0; }
62.badge.oos { background: #eee; color: #666; }
63.bar { display: flex; height: 1.1rem; border-radius: 4px; overflow: hidden; margin: .4rem 0 .2rem;
64 border: 1px solid #ddd; }
65.bar > span { display: block; }
66.bar .supported { background: #35b866; }
67.bar .failing { background: #e2565b; }
68.bar .asserted { background: #eaa53c; }
69.bar .unaddressed { background: #8877d8; }
70.bar .oos { background: #cfcfcf; }
71.headline { font-size: 1.5rem; font-weight: 700; }
72.headline small { font-size: .85rem; font-weight: 500; color: #666; }
73.legend { font-size: .78rem; color: #666; display: flex; flex-wrap: wrap; gap: .8rem; margin: .2rem 0 1rem; }
74.legend i { display: inline-block; width: .8rem; height: .8rem; border-radius: 2px; vertical-align: -1px; margin-right: .25rem; }
75footer { margin-top: 3rem; font-size: .8rem; color: #888; border-top: 1px solid #eee; padding-top: .75rem; }
76@media (prefers-color-scheme: dark) {
77 body { color: #e6e6e6; background: #16171a; }
78 h2 { border-color: #333; }
79 .meta { color: #aaa; } .meta code { background: #26272b; }
80 .note { background: #1e1f24; border-color: #444; color: #bbb; }
81 th, td { border-color: #333; } th { background: #202126; }
82 .headline small, .legend { color: #999; }
83 .badge.supported { background: #12321d; color: #4ac36a; }
84 .badge.failing { background: #3a1416; color: #ff6b70; }
85 .badge.asserted { background: #33260f; color: #e6a94e; }
86 .badge.unaddressed { background: #211d3a; color: #9d8ef0; }
87 .badge.oos { background: #26272b; color: #999; }
88 .bar { border-color: #333; }
89 footer { border-color: #2a2b30; }
90}
91"""
92
93_LEGEND_COLORS = {
94 SUPPORTED: "#35b866",
95 FAILING: "#e2565b",
96 ASSERTED: "#eaa53c",
97 UNADDRESSED: "#8877d8",
98 OUT_OF_SCOPE: "#cfcfcf",
99}
100
101
102def _badge(state: str) -> str:
103 return f'<span class="badge {_STATE_CLASS[state]}">{_STATE_LABEL[state]}</span>'
104
105
106def _bar(fc: FrameworkCoverage) -> str:
107 counts = fc.counts
108 total = sum(counts.values()) or 1
109 segments = []
110 for state in [SUPPORTED, FAILING, ASSERTED, UNADDRESSED, OUT_OF_SCOPE]:
111 n = counts[state]
112 if not n:
113 continue
114 pct = 100 * n / total
115 segments.append(
116 f'<span class="{_STATE_CLASS[state]}" style="width:{pct:.2f}%" '
117 f'title="{n} {_STATE_LABEL[state]}"></span>'
118 )
119 return '<div class="bar">' + "".join(segments) + "</div>"
120
121
122def _legend() -> str:
123 items = []
124 for state in [SUPPORTED, FAILING, ASSERTED, UNADDRESSED, OUT_OF_SCOPE]:
125 items.append(
126 f'<span><i style="background:{_LEGEND_COLORS[state]}"></i>{_STATE_LABEL[state]}</span>'
127 )
128 return '<div class="legend">' + "".join(items) + "</div>"
129
130
131def _checked_by(result) -> str:
132 if result.state == OUT_OF_SCOPE:
133 return f"<em>excluded: {escape(result.exclusion_reason)}</em>"
134 rules = sorted({o.rule_id for o in result.observations if o.rule_id})
135 return ", ".join(f"<code>{escape(r)}</code>" for r in rules)
136
137
138def _framework_section(fc: FrameworkCoverage) -> str:
139 cat = fc.catalog
140 partial = "" if cat.complete else (
141 ' <small>(partial catalog — coverage is of the shipped subset)</small>'
142 )
143 rows = []
144 for r in fc.results:
145 rows.append(
146 "<tr>"
147 f"<td><strong>{escape(r.control.id)}</strong></td>"
148 f"<td>{_badge(r.state)}</td>"
149 f"<td>{escape(r.control.title)}</td>"
150 f"<td>{_checked_by(r)}</td>"
151 "</tr>"
152 )
153 return (
154 f"<h2>{escape(cat.name)}{partial}</h2>"
155 f'<p class="headline">{fc.coverage_pct}% <small>coverage · {fc.addressed}/{fc.in_scope} '
156 f"in-scope controls addressed · {fc.assured_pct}% assured</small></p>"
157 f"{_bar(fc)}{_legend()}"
158 "<table><thead><tr><th>Control</th><th>Status</th><th>Description</th>"
159 "<th>Checked by</th></tr></thead><tbody>"
160 + "".join(rows)
161 + "</tbody></table>"
162 )
163
164
165def _summary_table(report: CoverageReport) -> str:
166 rows = []
167 for fc in report.frameworks:
168 c = fc.counts
169 rows.append(
170 "<tr>"
171 f"<td>{escape(fc.catalog.name)}</td>"
172 f'<td class="num">{fc.in_scope}</td>'
173 f'<td class="num">{fc.addressed}</td>'
174 f'<td class="num">{fc.supported}</td>'
175 f'<td class="num">{c[FAILING]}</td>'
176 f'<td class="num">{len(fc.blind_spots)}</td>'
177 f'<td class="num">{fc.coverage_pct}%</td>'
178 f'<td class="num">{fc.assured_pct}%</td>'
179 "</tr>"
180 )
181 return (
182 "<table><thead><tr><th>Framework</th><th class='num'>In scope</th>"
183 "<th class='num'>Addressed</th><th class='num'>Supported</th>"
184 "<th class='num'>Failing</th><th class='num'>Blind spots</th>"
185 "<th class='num'>Coverage</th><th class='num'>Assured</th></tr></thead><tbody>"
186 + "".join(rows)
187 + "</tbody></table>"
188 )
189
190
191def _blind_spots(report: CoverageReport) -> str:
192 total = sum(len(fc.blind_spots) for fc in report.frameworks)
193 if total == 0:
194 return "<h2>Blind spots</h2><p>No in-scope control is left unaddressed by the corpus.</p>"
195 parts = [
196 "<h2>Blind spots</h2>",
197 (
198 f"<p>{total} in-scope control(s) are <strong>unaddressed</strong> — no finding "
199 "in the corpus maps to them.</p>"
200 ),
201 ]
202 for fc in report.frameworks:
203 spots = fc.blind_spots
204 if not spots:
205 continue
206 parts.append(f"<h3>{escape(fc.catalog.name)} ({len(spots)})</h3><ul>")
207 for r in spots:
208 fam = f" <em>· {escape(r.control.family)}</em>" if r.control.family else ""
209 parts.append(
210 f"<li><strong>{escape(r.control.id)}</strong> — {escape(r.control.title)}{fam}</li>"
211 )
212 parts.append("</ul>")
213 return "".join(parts)
214
215
216def render(report: CoverageReport) -> str:
217 title = report.subject or "Evidence corpus"
218 frameworks = ", ".join(
219 f"{fc.catalog.framework} {fc.catalog.version} "
220 f"(sha256:{fc.catalog.sha256[:12]})"
221 for fc in report.frameworks
222 )
223 body = [
224 "<!doctype html><html lang='en'><head><meta charset='utf-8'>",
225 "<meta name='viewport' content='width=device-width, initial-scale=1'>",
226 f"<title>Control Coverage — {escape(title)}</title>",
227 f"<style>{CSS}</style></head><body><main>",
228 f"<h1>Control Coverage — {escape(title)}</h1>",
229 (
230 f'<p class="meta">Generated {escape(report.generated_at)} · '
231 f"Tool control-coverage {escape(__version__)} · "
232 f"{report.source_count} evidence source(s) · frameworks: {escape(frameworks)}</p>"
233 ),
234 (
235 '<p class="note">Coverage measures how much of a framework the evidence corpus '
236 "addresses — not whether the organization is compliant. An unaddressed control is "
237 "a gap in <em>evidence</em>, which may reflect a real control gap or simply a signal "
238 "not yet collected. The final judgment belongs to the organization and its auditor.</p>"
239 ),
240 "<h2>Summary</h2>",
241 _summary_table(report),
242 _blind_spots(report),
243 ]
244 for fc in report.frameworks:
245 body.append(_framework_section(fc))
246
247 if report.orphan_codes:
248 codes = "".join(f"<li><code>{escape(c)}</code></li>" for c in report.orphan_codes)
249 body.append(
250 "<h2>Unmatched control codes</h2><p>The corpus cites these codes, but no loaded "
251 f"catalog defines them (typos, renamed, or out-of-catalog):</p><ul>{codes}</ul>"
252 )
253
254 body.append(
255 "<footer>Generated by control-coverage · Audit Labs. Evidence, not a verdict.</footer>"
256 )
257 body.append("</main></body></html>")
258 return "".join(body)