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:
1111 steps:
1212 - uses: actions/checkout@v5
1313 - name: Install uv
14 uses: astral-sh/setup-uv@v6
14 uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
1515 - name: Build
1616 run: uv build
1717 - name: Check
18 run: uvx twine check dist/*
18 run: uvx twine@7.0.0 check dist/*
1919 - uses: actions/upload-artifact@v4
2020 with:
2121 name: dist
audit_report/diff.py +54 −43
@@ -185,6 +185,33 @@ def _md_table(rows: list[dict[str, str]]) -> list[str]:
185185 return out
186186
187187
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
188215def _render_md(diff: DiffReport) -> str:
189216 counts = diff.counts
190217 out: list[str] = []
@@ -213,27 +240,7 @@ def _render_md(diff: DiffReport) -> str:
213240 out.append(f"## {_CATEGORY_HEADING[category]}")
214241 out.append("")
215242 for delta in rows:
216 rule = delta.rule
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("")
243 out.extend(_render_delta_md(delta, category))
237244
238245 unchanged = diff.counts[UNCHANGED]
239246 if unchanged:
@@ -275,6 +282,31 @@ table.added caption { color: #c1272d; } table.removed caption { color: #1a7f37;
275282)
276283
277284
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
278310def _render_html(diff: DiffReport) -> str:
279311 counts = diff.counts
280312 parts: list[str] = []
@@ -307,28 +339,7 @@ def _render_html(diff: DiffReport) -> str:
307339 continue
308340 parts.append(f"<h2>{escape(_CATEGORY_HEADING[category])}</h2>")
309341 for delta in rows:
310 rule = delta.rule
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>")
342 parts.append(_render_delta_html(delta, category))
332343
333344 if counts[UNCHANGED]:
334345 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:
7171 return None
7272
7373
74def match(condition: dict, row: dict[str, str]) -> bool:
75 """Return True if *condition* holds for *row*.
76
77 Raises ``ValueError`` on a malformed condition so ruleset bugs surface
78 loudly rather than silently evaluating to False.
79 """
80 if "all" in condition:
81 return all(match(c, row) for c in condition["all"])
82 if "any" in condition:
83 return any(match(c, row) for c in condition["any"])
84 if "not" in condition:
85 return not match(condition["not"], row)
86
74def _compare_numeric(op: str, raw: str, value) -> bool:
75 left, right = _as_number(raw), _as_number(str(value))
76 if left is None or right is None:
77 return False
78 return {
79 "gt": left > right,
80 "gte": left >= right,
81 "lt": left < right,
82 "lte": left <= right,
83 }[op]
84
85
86def _match_leaf(condition: dict, row: dict[str, str]) -> bool:
8787 column = condition.get("column")
8888 op = condition.get("op")
8989 if column is None or op is None:
@@ -109,19 +109,26 @@ def match(condition: dict, row: dict[str, str]) -> bool:
109109 choices = {str(v).strip().lower() for v in (value or [])}
110110 return (norm in choices) if op == "in" else (norm not in choices)
111111 if op in ("gt", "gte", "lt", "lte"):
112 left, right = _as_number(raw), _as_number(str(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]
112 return _compare_numeric(op, raw, value)
121113
122114 raise ValueError(f"unknown operator: {op!r}")
123115
124116
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
125132def load_ruleset(path: str | Path) -> Ruleset:
126133 """Parse a ruleset YAML file into a :class:`Ruleset`, validating each rule."""
127134 text = Path(path).read_text(encoding="utf-8")
tests/test_diff.py +2 −1
@@ -67,7 +67,8 @@ def test_diff_render_markdown():
6767def test_diff_render_html_self_contained():
6868 html = diff.render(_build(), "html")
6969 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
7172 assert "Evidence Drift" in html
7273
7374
tests/test_reporters.py +4 −2
@@ -35,7 +35,8 @@ def test_html_render_is_self_contained():
3535 assert html.startswith("<!doctype html>")
3636 assert "<style>" in html
3737 # 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
3940 assert "src=" not in html
4041
4142
@@ -79,8 +80,9 @@ def test_html_escapes_evidence(tmp_path):
7980
8081
8182def test_unknown_format_raises():
83 report = _report()
8284 with pytest.raises(ValueError, match="unknown format"):
83 reporters.render(_report(), "pdf")
85 reporters.render(report, "pdf")
8486
8587
8688def test_cli_writes_files_and_exit_code(tmp_path):
tests/test_trend.py +2 −1
@@ -83,7 +83,8 @@ def test_trend_render_markdown():
8383def test_trend_render_html_self_contained():
8484 html = trend.render(_build(), "html")
8585 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
8788 assert "class='trend'" in html
8889
8990