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
v0.1.0: audit_report/trend.py · raw
1"""Trend mode — track each rule across a series of dated packages.
2
3Given a folder of audit-tools packages for the same platform and subject
4(``aws_audit_prod_2026-01-01/``, ``…_2026-02-01/``, …), this evaluates every
5package with the same ruleset and lays the results out as a timeline: one row
6per rule, one column per package date, so you can see a control drift in and out
7of compliance over time.
8"""
9
10from __future__ import annotations
11
12from dataclasses import dataclass
13from datetime import datetime, timezone
14from html import escape
15from pathlib import Path
16
17from .engine import FAIL, NOT_APPLICABLE, PASS, Finding
18from .loader import Package, detect_platform, package_date
19from .reporters.html import CSS as _CSS
20
21_STATUS_SYMBOL = {PASS: "✓", FAIL: "✗", NOT_APPLICABLE: "·"}
22_STATUS_CLASS = {PASS: "pass", FAIL: "fail", NOT_APPLICABLE: "na"}
23_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
24
25
26def discover(parent: str | Path, subject: str | None = None) -> tuple[str, str, list[Path]]:
27 """Find a single series of packages under *parent*.
28
29 Returns ``(platform, subject, [paths sorted by date])``. Raises ``ValueError``
30 if no packages are found, if fewer than two share a platform/subject, or if
31 several distinct series are present and *subject* does not narrow it to one.
32 """
33 directory = Path(parent)
34 if not directory.is_dir():
35 raise ValueError(f"not a directory: {directory}")
36
37 groups: dict[tuple[str, str], list[Path]] = {}
38 for child in sorted(directory.iterdir()):
39 if not child.is_dir() or not any(child.glob("*.csv")):
40 continue
41 platform, subj = detect_platform(child.name)
42 if platform == "unknown":
43 continue
44 if subject and subj != subject:
45 continue
46 groups.setdefault((platform, subj), []).append(child)
47
48 if not groups:
49 raise ValueError(
50 f"no audit-tools packages found under {directory}"
51 + (f" for subject '{subject}'" if subject else "")
52 )
53 if len(groups) > 1:
54 listed = ", ".join(f"{p}/{s}" for p, s in sorted(groups))
55 raise ValueError(
56 f"multiple series found ({listed}); narrow with --subject and a "
57 "directory that holds one platform"
58 )
59
60 (platform, subj), paths = next(iter(groups.items()))
61 if len(paths) < 2:
62 raise ValueError("a trend needs at least two packages in the series")
63
64 paths.sort(key=lambda p: (package_date(p.name), p.name))
65 return platform, subj, paths
66
67
68@dataclass
69class TrendRow:
70 """One rule's status across the timeline."""
71
72 rule: object # audit_report.rules.Rule
73 statuses: list[str]
74
75 @property
76 def transitions(self) -> int:
77 """How many times the status changed along the timeline."""
78 return sum(1 for a, b in zip(self.statuses, self.statuses[1:]) if a != b)
79
80
81@dataclass
82class TrendReport:
83 """Rules-over-time view of a package series."""
84
85 platform: str
86 subject: str
87 dates: list[str] # column labels (package date or dir name)
88 rows: list[TrendRow]
89 generated_at: str
90
91 def fails_per_date(self) -> list[int]:
92 return [
93 sum(1 for row in self.rows if row.statuses[i] == FAIL)
94 for i in range(len(self.dates))
95 ]
96
97
98def build_trend(
99 packages: list[Package], findings_per_package: list[list[Finding]]
100) -> TrendReport:
101 """Assemble a :class:`TrendReport` from aligned packages and findings."""
102 dates = [pkg.date or pkg.path.name for pkg in packages]
103
104 # Preserve rule order from the first package; align by rule id across dates.
105 order = [f.rule for f in findings_per_package[0]]
106 by_date = [{f.rule.id: f for f in findings} for findings in findings_per_package]
107
108 rows = [
109 TrendRow(
110 rule=rule,
111 statuses=[
112 col.get(rule.id).status if col.get(rule.id) else NOT_APPLICABLE
113 for col in by_date
114 ],
115 )
116 for rule in order
117 ]
118 stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
119 return TrendReport(
120 platform=packages[0].platform,
121 subject=packages[0].subject,
122 dates=dates,
123 rows=rows,
124 generated_at=stamp,
125 )
126
127
128def latest_findings(findings_per_package: list[list[Finding]]) -> list[Finding]:
129 """The findings of the most recent package (for --fail-on gating)."""
130 return findings_per_package[-1]
131
132
133# --------------------------------------------------------------------------- #
134# Rendering
135# --------------------------------------------------------------------------- #
136
137
138def _render_md(trend: TrendReport) -> str:
139 out: list[str] = []
140 out.append(f"# Evidence Trend — {trend.subject} ({trend.platform})")
141 out.append("")
142 out.append(f"- **Packages:** {len(trend.dates)}")
143 out.append(f"- **Timeline:** {trend.dates[0]} → {trend.dates[-1]}")
144 out.append(f"- **Generated:** {trend.generated_at}")
145 out.append("")
146 out.append("Legend: ✓ pass · ✗ fail · · not applicable")
147 out.append("")
148
149 header = "| Rule | Sev | " + " | ".join(trend.dates) + " |"
150 sep = "| --- | --- | " + " | ".join("---" for _ in trend.dates) + " |"
151 out.append(header)
152 out.append(sep)
153 for row in sorted(trend.rows, key=lambda r: _SEVERITY_ORDER.get(r.rule.severity, 1)):
154 cells = " | ".join(_STATUS_SYMBOL.get(s, "?") for s in row.statuses)
155 out.append(f"| {row.rule.title} | {row.rule.severity} | {cells} |")
156
157 fails = trend.fails_per_date()
158 out.append("| **Failing total** | | " + " | ".join(str(n) for n in fails) + " |")
159 return "\n".join(out).rstrip() + "\n"
160
161
162_TREND_CSS = (
163 _CSS
164 + """
165.trend { border-collapse: collapse; }
166.trend th.date { font-size: .78rem; white-space: nowrap; }
167.trend td.cell { text-align: center; font-weight: 700; width: 2.4rem; }
168.trend td.cell.pass { background: #e5f6ea; color: #1a7f37; }
169.trend td.cell.fail { background: #fdeaea; color: #c1272d; }
170.trend td.cell.na { background: #f4f4f6; color: #999; }
171.trend tr.totals td { font-weight: 700; background: #fafafa; }
172.rulecol { max-width: 22rem; }
173.legend { font-size: .85rem; color: #555; margin: .25rem 0 1rem; }
174@media (prefers-color-scheme: dark) {
175 .trend td.cell.pass { background: #12321d; color: #4ac36a; }
176 .trend td.cell.fail { background: #3a1416; color: #ff6b70; }
177 .trend td.cell.na { background: #202126; color: #888; }
178 .trend tr.totals td { background: #1c1d21; }
179 .legend { color: #aaa; }
180}
181"""
182)
183
184
185def _render_html(trend: TrendReport) -> str:
186 date_heads = "".join(f"<th class='date'>{escape(d)}</th>" for d in trend.dates)
187 body_rows: list[str] = []
188 for row in sorted(trend.rows, key=lambda r: _SEVERITY_ORDER.get(r.rule.severity, 1)):
189 cells = "".join(
190 f"<td class='cell {_STATUS_CLASS.get(s, 'na')}' title='{escape(s)}'>"
191 f"{_STATUS_SYMBOL.get(s, '?')}</td>"
192 for s in row.statuses
193 )
194 body_rows.append(
195 f"<tr><td class='rulecol'>{escape(row.rule.title)}"
196 f"<br><code>{escape(row.rule.id)}</code></td>"
197 f"<td>{escape(row.rule.severity)}</td>{cells}</tr>"
198 )
199 totals = "".join(f"<td class='cell'>{n}</td>" for n in trend.fails_per_date())
200
201 table = (
202 "<table class='trend'><thead><tr><th class='rulecol'>Rule</th><th>Sev</th>"
203 f"{date_heads}</tr></thead><tbody>{''.join(body_rows)}"
204 f"<tr class='totals'><td>Failing total</td><td></td>{totals}</tr>"
205 "</tbody></table>"
206 )
207 return (
208 "<!doctype html><html lang='en'><head><meta charset='utf-8'>"
209 "<meta name='viewport' content='width=device-width, initial-scale=1'>"
210 f"<title>Evidence Trend — {escape(trend.subject)}</title>"
211 f"<style>{_TREND_CSS}</style></head><body><main>"
212 f"<h1>Evidence Trend — {escape(trend.subject)} ({escape(trend.platform)})</h1>"
213 f"<p class='meta'>{len(trend.dates)} packages · {escape(trend.dates[0])} → "
214 f"{escape(trend.dates[-1])} · Generated {escape(trend.generated_at)}</p>"
215 "<p class='legend'>✓ pass · ✗ fail · · not applicable — cell colour tracks "
216 "each control over time.</p>"
217 f"{table}"
218 "<footer>Generated by audit-report · Audit Labs · evidence, not a verdict.</footer>"
219 "</main></body></html>\n"
220 )
221
222
223def _render_json(trend: TrendReport) -> str:
224 import json as _json
225
226 payload = {
227 "subject": trend.subject,
228 "platform": trend.platform,
229 "dates": trend.dates,
230 "generated_at": trend.generated_at,
231 "fails_per_date": trend.fails_per_date(),
232 "rules": [
233 {
234 "id": row.rule.id,
235 "title": row.rule.title,
236 "severity": row.rule.severity,
237 "controls": row.rule.controls,
238 "statuses": row.statuses,
239 }
240 for row in trend.rows
241 ],
242 }
243 return _json.dumps(payload, indent=2) + "\n"
244
245
246_RENDERERS = {"md": _render_md, "html": _render_html, "json": _render_json}
247
248
249def render(trend: TrendReport, fmt: str) -> str:
250 """Render a trend in the named format ('md', 'html', or 'json')."""
251 try:
252 return _RENDERERS[fmt](trend)
253 except KeyError:
254 raise ValueError(f"unknown format: {fmt!r}") from None