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    )