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)