audit-labs/audit-report

Turn audit-tools evidence packages into control-mapped, auditor-ready reports. audit compliance evidence reporting https://audit-labs.dev/audit-report/

Commit b94aafccf9

b94aafccf922b8730bd7e5bad28d49dc8408b794

parent: 073020727e

Unsigned

cmc <hello@cleberg.net> · 2026-08-09 01:32 UTC

Pin CI actions to SHA, refactor diff/rules complexity, tidy tests

Layout: unified · split

.github/workflows/release.yml +2 −2
@@ -11,11 +11,11 @@ jobs:
11 steps: 11 steps:
12 - uses: actions/checkout@v5 12 - uses: actions/checkout@v5
13 - name: Install uv 13 - name: Install uv
14 uses: astral-sh/setup-uv@v6 14 uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
15 - name: Build 15 - name: Build
16 run: uv build 16 run: uv build
17 - name: Check 17 - name: Check
18 run: uvx twine check dist/* 18 run: uvx twine@7.0.0 check dist/*
19 - uses: actions/upload-artifact@v4 19 - uses: actions/upload-artifact@v4
20 with: 20 with:
21 name: dist 21 name: dist
audit_report/diff.py +54 −43
@@ -185,6 +185,33 @@ def _md_table(rows: list[dict[str, str]]) -> list[str]:
185 return out 185 return out
186 186
187 187
188def _render_delta_md(delta, category: str) -> list[str]:
189 rule = delta.rule
190 out = [
191 f"### {rule.title}",
192 "",
193 f"- **Rule:** `{rule.id}` · **Severity:** {rule.severity}",
194 f"- **Controls:** {', '.join(rule.controls) or '—'}",
195 f"- **Change:** {_transition(delta)}",
196 ]
197 if delta.new_reason:
198 out.append(f"- **Now:** {delta.new_reason}")
199 if category == REGRESSED and rule.remediation:
200 out.append(f"- **Remediation:** {rule.remediation.strip()}")
201 out.append("")
202 if delta.evidence_added:
203 out.append("**Newly failing rows:**")
204 out.append("")
205 out.extend(_md_table(delta.evidence_added))
206 out.append("")
207 if delta.evidence_removed:
208 out.append("**No longer failing rows:**")
209 out.append("")
210 out.extend(_md_table(delta.evidence_removed))
211 out.append("")
212 return out
213
214
188def _render_md(diff: DiffReport) -> str: 215def _render_md(diff: DiffReport) -> str:
189 counts = diff.counts 216 counts = diff.counts
190 out: list[str] = [] 217 out: list[str] = []
@@ -213,27 +240,7 @@ def _render_md(diff: DiffReport) -> str:
213 out.append(f"## {_CATEGORY_HEADING[category]}") 240 out.append(f"## {_CATEGORY_HEADING[category]}")
214 out.append("") 241 out.append("")
215 for delta in rows: 242 for delta in rows:
216 rule = delta.rule 243 out.extend(_render_delta_md(delta, category))
217 out.append(f"### {rule.title}")
218 out.append("")
219 out.append(f"- **Rule:** `{rule.id}` · **Severity:** {rule.severity}")
220 out.append(f"- **Controls:** {', '.join(rule.controls) or '—'}")
221 out.append(f"- **Change:** {_transition(delta)}")
222 if delta.new_reason:
223 out.append(f"- **Now:** {delta.new_reason}")
224 if category == REGRESSED and rule.remediation:
225 out.append(f"- **Remediation:** {rule.remediation.strip()}")
226 out.append("")
227 if delta.evidence_added:
228 out.append("**Newly failing rows:**")
229 out.append("")
230 out.extend(_md_table(delta.evidence_added))
231 out.append("")
232 if delta.evidence_removed:
233 out.append("**No longer failing rows:**")
234 out.append("")
235 out.extend(_md_table(delta.evidence_removed))
236 out.append("")
237 244
238 unchanged = diff.counts[UNCHANGED] 245 unchanged = diff.counts[UNCHANGED]
239 if unchanged: 246 if unchanged:
@@ -275,6 +282,31 @@ table.added caption { color: #c1272d; } table.removed caption { color: #1a7f37;
275) 282)
276 283
277 284
285def _render_delta_html(delta, category: str) -> str:
286 rule = delta.rule
287 body = [
288 f"<h3>{escape(rule.title)}</h3>",
289 (
290 f"<dl><dt>Rule</dt><dd><code>{escape(rule.id)}</code> · "
291 f"{escape(rule.severity)}</dd>"
292 ),
293 f"<dt>Controls</dt><dd>{escape(', '.join(rule.controls) or '—')}</dd>",
294 f"<dt>Change</dt><dd class='transition'>{escape(_transition(delta))}</dd>",
295 ]
296 if delta.new_reason:
297 body.append(f"<dt>Now</dt><dd>{escape(delta.new_reason)}</dd>")
298 if category == REGRESSED and rule.remediation:
299 body.append(f"<dt>Remediation</dt><dd>{escape(rule.remediation.strip())}</dd>")
300 body.append("</dl>")
301 if delta.evidence_added:
302 body.append(_html_table(delta.evidence_added, "Newly failing rows", "added"))
303 if delta.evidence_removed:
304 body.append(
305 _html_table(delta.evidence_removed, "No longer failing rows", "removed")
306 )
307 return f"<div class='delta {category}'>{''.join(body)}</div>"
308
309
278def _render_html(diff: DiffReport) -> str: 310def _render_html(diff: DiffReport) -> str:
279 counts = diff.counts 311 counts = diff.counts
280 parts: list[str] = [] 312 parts: list[str] = []
@@ -307,28 +339,7 @@ def _render_html(diff: DiffReport) -> str:
307 continue 339 continue
308 parts.append(f"<h2>{escape(_CATEGORY_HEADING[category])}</h2>") 340 parts.append(f"<h2>{escape(_CATEGORY_HEADING[category])}</h2>")
309 for delta in rows: 341 for delta in rows:
310 rule = delta.rule 342 parts.append(_render_delta_html(delta, category))
311 body = [
312 f"<h3>{escape(rule.title)}</h3>",
313 (
314 f"<dl><dt>Rule</dt><dd><code>{escape(rule.id)}</code> · "
315 f"{escape(rule.severity)}</dd>"
316 ),
317 f"<dt>Controls</dt><dd>{escape(', '.join(rule.controls) or '—')}</dd>",
318 f"<dt>Change</dt><dd class='transition'>{escape(_transition(delta))}</dd>",
319 ]
320 if delta.new_reason:
321 body.append(f"<dt>Now</dt><dd>{escape(delta.new_reason)}</dd>")
322 if category == REGRESSED and rule.remediation:
323 body.append(f"<dt>Remediation</dt><dd>{escape(rule.remediation.strip())}</dd>")
324 body.append("</dl>")
325 if delta.evidence_added:
326 body.append(_html_table(delta.evidence_added, "Newly failing rows", "added"))
327 if delta.evidence_removed:
328 body.append(
329 _html_table(delta.evidence_removed, "No longer failing rows", "removed")
330 )
331 parts.append(f"<div class='delta {category}'>{''.join(body)}</div>")
332 343
333 if counts[UNCHANGED]: 344 if counts[UNCHANGED]:
334 parts.append(f"<p class='meta'>{counts[UNCHANGED]} rule(s) unchanged.</p>") 345 parts.append(f"<p class='meta'>{counts[UNCHANGED]} rule(s) unchanged.</p>")
audit_report/rules.py +29 −22
@@ -71,19 +71,19 @@ def _as_number(value: str) -> float | None:
71 return None 71 return None
72 72
73 73
74def match(condition: dict, row: dict[str, str]) -> bool: 74def _compare_numeric(op: str, raw: str, value) -> bool:
75 """Return True if *condition* holds for *row*. 75 left, right = _as_number(raw), _as_number(str(value))
76 76 if left is None or right is None:
77 Raises ``ValueError`` on a malformed condition so ruleset bugs surface 77 return False
78 loudly rather than silently evaluating to False. 78 return {
79 """ 79 "gt": left > right,
80 if "all" in condition: 80 "gte": left >= right,
81 return all(match(c, row) for c in condition["all"]) 81 "lt": left < right,
82 if "any" in condition: 82 "lte": left <= right,
83 return any(match(c, row) for c in condition["any"]) 83 }[op]
84 if "not" in condition: 84
85 return not match(condition["not"], row) 85
86 86def _match_leaf(condition: dict, row: dict[str, str]) -> bool:
87 column = condition.get("column") 87 column = condition.get("column")
88 op = condition.get("op") 88 op = condition.get("op")
89 if column is None or op is None: 89 if column is None or op is None:
@@ -109,19 +109,26 @@ def match(condition: dict, row: dict[str, str]) -> bool:
109 choices = {str(v).strip().lower() for v in (value or [])} 109 choices = {str(v).strip().lower() for v in (value or [])}
110 return (norm in choices) if op == "in" else (norm not in choices) 110 return (norm in choices) if op == "in" else (norm not in choices)
111 if op in ("gt", "gte", "lt", "lte"): 111 if op in ("gt", "gte", "lt", "lte"):
112 left, right = _as_number(raw), _as_number(str(value)) 112 return _compare_numeric(op, raw, value)
113 if left is None or right is None:
114 return False
115 return {
116 "gt": left > right,
117 "gte": left >= right,
118 "lt": left < right,
119 "lte": left <= right,
120 }[op]
121 113
122 raise ValueError(f"unknown operator: {op!r}") 114 raise ValueError(f"unknown operator: {op!r}")
123 115
124 116
117def match(condition: dict, row: dict[str, str]) -> bool:
118 """Return True if *condition* holds for *row*.
119
120 Raises ``ValueError`` on a malformed condition so ruleset bugs surface
121 loudly rather than silently evaluating to False.
122 """
123 if "all" in condition:
124 return all(match(c, row) for c in condition["all"])
125 if "any" in condition:
126 return any(match(c, row) for c in condition["any"])
127 if "not" in condition:
128 return not match(condition["not"], row)
129 return _match_leaf(condition, row)
130
131
125def load_ruleset(path: str | Path) -> Ruleset: 132def load_ruleset(path: str | Path) -> Ruleset:
126 """Parse a ruleset YAML file into a :class:`Ruleset`, validating each rule.""" 133 """Parse a ruleset YAML file into a :class:`Ruleset`, validating each rule."""
127 text = Path(path).read_text(encoding="utf-8") 134 text = Path(path).read_text(encoding="utf-8")
tests/test_diff.py +2 −1
@@ -67,7 +67,8 @@ def test_diff_render_markdown():
67def test_diff_render_html_self_contained(): 67def test_diff_render_html_self_contained():
68 html = diff.render(_build(), "html") 68 html = diff.render(_build(), "html")
69 assert html.startswith("<!doctype html>") 69 assert html.startswith("<!doctype html>")
70 assert "http://" not in html and "https://" not in html 70 assert "http://" not in html
71 assert "https://" not in html
71 assert "Evidence Drift" in html 72 assert "Evidence Drift" in html
72 73
73 74
tests/test_reporters.py +4 −2
@@ -35,7 +35,8 @@ def test_html_render_is_self_contained():
35 assert html.startswith("<!doctype html>") 35 assert html.startswith("<!doctype html>")
36 assert "<style>" in html 36 assert "<style>" in html
37 # No external resource references. 37 # No external resource references.
38 assert "http://" not in html and "https://" not in html 38 assert "http://" not in html
39 assert "https://" not in html
39 assert "src=" not in html 40 assert "src=" not in html
40 41
41 42
@@ -79,8 +80,9 @@ def test_html_escapes_evidence(tmp_path):
79 80
80 81
81def test_unknown_format_raises(): 82def test_unknown_format_raises():
83 report = _report()
82 with pytest.raises(ValueError, match="unknown format"): 84 with pytest.raises(ValueError, match="unknown format"):
83 reporters.render(_report(), "pdf") 85 reporters.render(report, "pdf")
84 86
85 87
86def test_cli_writes_files_and_exit_code(tmp_path): 88def test_cli_writes_files_and_exit_code(tmp_path):
tests/test_trend.py +2 −1
@@ -83,7 +83,8 @@ def test_trend_render_markdown():
83def test_trend_render_html_self_contained(): 83def test_trend_render_html_self_contained():
84 html = trend.render(_build(), "html") 84 html = trend.render(_build(), "html")
85 assert html.startswith("<!doctype html>") 85 assert html.startswith("<!doctype html>")
86 assert "http://" not in html and "https://" not in html 86 assert "http://" not in html
87 assert "https://" not in html
87 assert "class='trend'" in html 88 assert "class='trend'" in html
88 89
89 90