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 e1665106ac

e1665106acf579481649d470be9d413338ad4875

parent: f14a20bc6c

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-06 07:23 UTC

feat: add diff mode to compare two evidence packages

Passing --baseline evaluates both packages with the same ruleset and reports
drift: each rule is classified as regressed, fixed, drifted (an ongoing failure
whose evidence rows changed), changed, or unchanged, with the exact rows that
appeared or disappeared. Renders md/html/json as diff.*; --fail-on gates on
regressions for CI. Adds a baseline fixture and diff tests.

Layout: unified · split

README.md +24 −1
@@ -41,6 +41,28 @@ audit-report ./output/github_audit_acme_2026-07-29 --format md,html,json --out r
41audit-report ./output/aws_audit_prod_2026-07-29 --fail-on high --out report/ 41audit-report ./output/aws_audit_prod_2026-07-29 --fail-on high --out report/
42``` 42```
43 43
44### Diff mode — compare two packages
45
46Pass `--baseline` to report how a package **drifted** from an earlier one. Both
47are evaluated with the same ruleset; the report classifies each rule as
48regressed, fixed, drifted (an ongoing failure whose evidence rows changed),
49changed, or unchanged — and shows exactly which evidence rows appeared or
50disappeared.
51
52```bash
53# How did prod change between two audit runs?
54audit-report ./output/aws_audit_prod_2026-07-29 \
55 --baseline ./output/aws_audit_prod_2026-06-29 \
56 --format md,html --out drift/
57
58# Fail CI if this change regressed any high-severity control
59audit-report ./output/aws_audit_prod_2026-07-29 \
60 --baseline ./output/aws_audit_prod_2026-06-29 --fail-on high
61```
62
63In diff mode `--fail-on` gates on **regressions** at or above the given
64severity, and output files are named `diff.*` instead of `report.*`.
65
44You can also run it without installing: 66You can also run it without installing:
45 67
46```bash 68```bash
@@ -51,10 +73,11 @@ python -m audit_report ./output/aws_audit_default_2026-07-29
51 73
52| Flag | Description | 74| Flag | Description |
53| --- | --- | 75| --- | --- |
76| `--baseline PATH` | Diff mode: report how `PACKAGE` drifted from this earlier package. |
54| `--ruleset PATH` | Use a specific ruleset instead of the bundled one for the detected platform. | 77| `--ruleset PATH` | Use a specific ruleset instead of the bundled one for the detected platform. |
55| `--format md,html,json` | One or more output formats (default: `md`). | 78| `--format md,html,json` | One or more output formats (default: `md`). |
56| `--out DIR` | Write `report.<ext>` files into `DIR`. Without it, the first format prints to stdout. | 79| `--out DIR` | Write `report.<ext>` files into `DIR`. Without it, the first format prints to stdout. |
57| `--fail-on low\|medium\|high\|none` | Exit non-zero when a failing finding meets this severity (default: `none`). | 80| `--fail-on low\|medium\|high\|none` | Exit non-zero when a failing finding — or, in diff mode, a regression — meets this severity (default: `none`). |
58 81
59## What a report contains 82## What a report contains
60 83
audit_report/cli.py +62 −15
@@ -6,7 +6,7 @@ import argparse
6import sys 6import sys
7from pathlib import Path 7from pathlib import Path
8 8
9from . import __version__, reporters 9from . import __version__, diff, reporters
10from .engine import FAIL, evaluate 10from .engine import FAIL, evaluate
11from .loader import load_package 11from .loader import load_package
12from .rules import load_ruleset 12from .rules import load_ruleset
@@ -34,6 +34,10 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
34 description="Turn an audit-tools evidence package into a control-mapped report.", 34 description="Turn an audit-tools evidence package into a control-mapped report.",
35 ) 35 )
36 parser.add_argument("package", help="path to an audit-tools output package directory") 36 parser.add_argument("package", help="path to an audit-tools output package directory")
37 parser.add_argument(
38 "--baseline",
39 help="path to an earlier package; diff mode reports how PACKAGE drifted from it",
40 )
37 parser.add_argument( 41 parser.add_argument(
38 "--ruleset", 42 "--ruleset",
39 help="path to a ruleset YAML (default: bundled ruleset for the detected platform)", 43 help="path to a ruleset YAML (default: bundled ruleset for the detected platform)",
@@ -51,7 +55,10 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
51 "--fail-on", 55 "--fail-on",
52 choices=["low", "medium", "high", "none"], 56 choices=["low", "medium", "high", "none"],
53 default="none", 57 default="none",
54 help="exit non-zero if any finding fails at or above this severity (default: none)", 58 help=(
59 "exit non-zero if any finding fails (or, in diff mode, regresses) at or "
60 "above this severity (default: none)"
61 ),
55 ) 62 )
56 parser.add_argument("--version", action="version", version=f"audit-report {__version__}") 63 parser.add_argument("--version", action="version", version=f"audit-report {__version__}")
57 return parser.parse_args(argv) 64 return parser.parse_args(argv)
@@ -68,9 +75,58 @@ def _exit_code(findings, threshold: str) -> int:
68 return 1 if breached else 0 75 return 1 if breached else 0
69 76
70 77
78def _emit(render_one, formats: list[str], out: str | None, basename: str) -> None:
79 """Write one file per format into *out*, or print the first to stdout."""
80 if out:
81 out_dir = Path(out)
82 out_dir.mkdir(parents=True, exist_ok=True)
83 for fmt in formats:
84 dest = out_dir / f"{basename}.{reporters.EXTENSIONS[fmt]}"
85 dest.write_text(render_one(fmt), encoding="utf-8")
86 print(f"wrote {dest}", file=sys.stderr)
87 else:
88 print(render_one(formats[0]), end="")
89
90
91def _run_diff(args, package, ruleset, formats: list[str]) -> int:
92 try:
93 baseline = load_package(args.baseline)
94 except (FileNotFoundError, ValueError) as exc:
95 print(f"error: {exc}", file=sys.stderr)
96 return 2
97 if baseline.platform != package.platform:
98 print(
99 f"error: cannot diff a {baseline.platform} package against a "
100 f"{package.platform} package",
101 file=sys.stderr,
102 )
103 return 2
104
105 old = evaluate(baseline, ruleset)
106 new = evaluate(package, ruleset)
107 report = diff.build_diff(baseline, package, old, new)
108
109 _emit(lambda fmt: diff.render(report, fmt), formats, args.out, "diff")
110
111 counts = report.counts
112 print(
113 f"{package.platform}/{package.subject}: "
114 f"{counts[diff.REGRESSED]} regressed, {counts[diff.FIXED]} fixed, "
115 f"{counts[diff.DRIFTED]} drifted",
116 file=sys.stderr,
117 )
118 return 1 if diff.has_regression(report, args.fail_on) else 0
119
120
71def main(argv: list[str] | None = None) -> int: 121def main(argv: list[str] | None = None) -> int:
72 args = _parse_args(argv if argv is not None else sys.argv[1:]) 122 args = _parse_args(argv if argv is not None else sys.argv[1:])
73 123
124 formats = [f.strip() for f in args.format.split(",") if f.strip()]
125 unknown = [f for f in formats if f not in reporters.EXTENSIONS]
126 if not formats or unknown:
127 print(f"error: unknown format(s): {', '.join(unknown) or '(none given)'}", file=sys.stderr)
128 return 2
129
74 try: 130 try:
75 package = load_package(args.package) 131 package = load_package(args.package)
76 except (FileNotFoundError, ValueError) as exc: 132 except (FileNotFoundError, ValueError) as exc:
@@ -80,21 +136,12 @@ def main(argv: list[str] | None = None) -> int:
80 ruleset_path = Path(args.ruleset) if args.ruleset else _default_ruleset(package.platform) 136 ruleset_path = Path(args.ruleset) if args.ruleset else _default_ruleset(package.platform)
81 ruleset = load_ruleset(ruleset_path) 137 ruleset = load_ruleset(ruleset_path)
82 138
139 if args.baseline:
140 return _run_diff(args, package, ruleset, formats)
141
83 findings = evaluate(package, ruleset) 142 findings = evaluate(package, ruleset)
84 report = reporters.build_report(package, findings) 143 report = reporters.build_report(package, findings)
85 formats = [f.strip() for f in args.format.split(",") if f.strip()] 144 _emit(lambda fmt: reporters.render(report, fmt), formats, args.out, "report")
86
87 if args.out:
88 out_dir = Path(args.out)
89 out_dir.mkdir(parents=True, exist_ok=True)
90 for fmt in formats:
91 content = reporters.render(report, fmt)
92 dest = out_dir / f"report.{reporters.EXTENSIONS[fmt]}"
93 dest.write_text(content, encoding="utf-8")
94 print(f"wrote {dest}", file=sys.stderr)
95 else:
96 # No --out: emit the first requested format to stdout.
97 print(reporters.render(report, formats[0]), end="")
98 145
99 counts = report.counts 146 counts = report.counts
100 print( 147 print(
audit_report/diff.py added +381
@@ -0,0 +1,381 @@
1"""Diff mode — compare two evidence packages and report drift.
2
3Both packages are evaluated with the same ruleset; this module compares the two
4sets of findings and classifies each rule's change:
5
6* **regressed** — a control that was supported (or not yet observed) now fails
7* **fixed** — a control that failed now passes (or is no longer observed)
8* **drifted** — an ongoing failure whose failing evidence rows changed
9* **changed** — a non-failure status change (e.g. a table stopped being collected)
10* **unchanged** — same status, same evidence
11
12Evidence rows are compared as whole rows, so drift shows exactly which items
13appeared or disappeared (a new MFA-less user, a security group that was closed).
14"""
15
16from __future__ import annotations
17
18from dataclasses import dataclass, field
19from datetime import datetime, timezone
20from html import escape
21
22from .engine import FAIL, Finding
23from .loader import Package
24from .reporters.html import CSS as _CSS
25
26ABSENT = "absent" # rule present in only one of the two packages
27
28REGRESSED = "regressed"
29FIXED = "fixed"
30DRIFTED = "drifted"
31CHANGED = "changed"
32UNCHANGED = "unchanged"
33
34# Order categories appear in a report and how they roll up in the summary.
35CATEGORY_ORDER = [REGRESSED, FIXED, DRIFTED, CHANGED, UNCHANGED]
36_STATUS_LABEL = {FAIL: "fail", "pass": "pass", "not_applicable": "n/a", ABSENT: "absent"}
37_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
38
39
40def _row_key(row: dict[str, str]) -> tuple:
41 return tuple(sorted(row.items()))
42
43
44@dataclass
45class RuleDelta:
46 """How one rule's finding changed between the two packages."""
47
48 rule: object # audit_report.rules.Rule
49 old_status: str
50 new_status: str
51 category: str
52 new_reason: str = ""
53 evidence_added: list[dict[str, str]] = field(default_factory=list)
54 evidence_removed: list[dict[str, str]] = field(default_factory=list)
55
56 @property
57 def controls(self) -> list[str]:
58 return self.rule.controls
59
60
61def _classify(old_status, new_status, added, removed) -> str:
62 if old_status == ABSENT:
63 return REGRESSED if new_status == FAIL else CHANGED
64 if new_status == ABSENT:
65 return CHANGED # rule dropped from the current ruleset
66 if new_status == FAIL and old_status != FAIL:
67 return REGRESSED
68 if old_status == FAIL and new_status != FAIL:
69 return FIXED
70 if old_status == FAIL and new_status == FAIL:
71 return DRIFTED if (added or removed) else UNCHANGED
72 return CHANGED if old_status != new_status else UNCHANGED
73
74
75def diff_findings(old: list[Finding], new: list[Finding]) -> list[RuleDelta]:
76 """Compare two finding lists (same ruleset) into a list of deltas."""
77 old_by_id = {f.rule.id: f for f in old}
78 new_by_id = {f.rule.id: f for f in new}
79
80 # New order first (ruleset order), then any rules only the baseline had.
81 ordered_ids = [f.rule.id for f in new]
82 ordered_ids += [f.rule.id for f in old if f.rule.id not in new_by_id]
83
84 deltas: list[RuleDelta] = []
85 for rid in ordered_ids:
86 of, nf = old_by_id.get(rid), new_by_id.get(rid)
87 rule = (nf or of).rule
88 old_status = of.status if of else ABSENT
89 new_status = nf.status if nf else ABSENT
90
91 old_ev = {_row_key(r): r for r in (of.evidence if of else [])}
92 new_ev = {_row_key(r): r for r in (nf.evidence if nf else [])}
93 added = [r for k, r in new_ev.items() if k not in old_ev]
94 removed = [r for k, r in old_ev.items() if k not in new_ev]
95
96 category = _classify(old_status, new_status, added, removed)
97 deltas.append(
98 RuleDelta(
99 rule=rule,
100 old_status=old_status,
101 new_status=new_status,
102 category=category,
103 new_reason=nf.reason if nf else "",
104 evidence_added=added,
105 evidence_removed=removed,
106 )
107 )
108 return deltas
109
110
111@dataclass
112class DiffReport:
113 """A computed comparison of two packages, ready to render."""
114
115 baseline: Package
116 current: Package
117 deltas: list[RuleDelta]
118 generated_at: str
119
120 def by_category(self, category: str) -> list[RuleDelta]:
121 rows = [d for d in self.deltas if d.category == category]
122 return sorted(rows, key=lambda d: _SEVERITY_ORDER.get(d.rule.severity, 1))
123
124 @property
125 def counts(self) -> dict[str, int]:
126 return {cat: len(self.by_category(cat)) for cat in CATEGORY_ORDER}
127
128
129def build_diff(
130 baseline: Package,
131 current: Package,
132 old_findings: list[Finding],
133 new_findings: list[Finding],
134) -> DiffReport:
135 """Assemble a :class:`DiffReport` with a UTC timestamp."""
136 stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
137 return DiffReport(
138 baseline=baseline,
139 current=current,
140 deltas=diff_findings(old_findings, new_findings),
141 generated_at=stamp,
142 )
143
144
145def has_regression(diff: DiffReport, threshold: str) -> bool:
146 """True if any regressed rule meets the severity *threshold* ('none' = off)."""
147 if threshold == "none":
148 return False
149 floor = _SEVERITY_ORDER[threshold]
150 return any(
151 _SEVERITY_ORDER.get(d.rule.severity, 1) <= floor
152 for d in diff.by_category(REGRESSED)
153 )
154
155
156# --------------------------------------------------------------------------- #
157# Rendering
158# --------------------------------------------------------------------------- #
159
160_CATEGORY_HEADING = {
161 REGRESSED: "Regressions — a control is no longer supported",
162 FIXED: "Fixed — a previously failing control now passes",
163 DRIFTED: "Ongoing failures with changed evidence",
164 CHANGED: "Other status changes",
165 UNCHANGED: "Unchanged",
166}
167
168
169def _transition(delta: RuleDelta) -> str:
170 return f"{_STATUS_LABEL.get(delta.old_status, delta.old_status)} → {_STATUS_LABEL.get(delta.new_status, delta.new_status)}"
171
172
173def _md_table(rows: list[dict[str, str]]) -> list[str]:
174 if not rows:
175 return []
176 shown = rows[:10]
177 headers = list(shown[0].keys())
178 out = [
179 "| " + " | ".join(headers) + " |",
180 "| " + " | ".join("---" for _ in headers) + " |",
181 ]
182 out += ["| " + " | ".join(str(r.get(h, "")) for h in headers) + " |" for r in shown]
183 if len(rows) > len(shown):
184 out.append(f"\n_+{len(rows) - len(shown)} more row(s) omitted._")
185 return out
186
187
188def _render_md(diff: DiffReport) -> str:
189 counts = diff.counts
190 out: list[str] = []
191 out.append(f"# Evidence Drift — {diff.current.subject} ({diff.current.platform})")
192 out.append("")
193 out.append(f"- **Baseline:** `{diff.baseline.path.name}`")
194 out.append(f"- **Current:** `{diff.current.path.name}`")
195 out.append(f"- **Generated:** {diff.generated_at}")
196 out.append(
197 f"- **Drift:** {counts[REGRESSED]} regressed · {counts[FIXED]} fixed · "
198 f"{counts[DRIFTED]} drifted · {counts[CHANGED]} changed · "
199 f"{counts[UNCHANGED]} unchanged"
200 )
201 out.append("")
202 out.append(
203 "> A *regression* means a setting moved into a state that no longer "
204 "supports a control since the baseline. As always this is evidence, not "
205 "a verdict."
206 )
207 out.append("")
208
209 for category in CATEGORY_ORDER:
210 rows = diff.by_category(category)
211 if not rows or category == UNCHANGED:
212 continue
213 out.append(f"## {_CATEGORY_HEADING[category]}")
214 out.append("")
215 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("")
237
238 unchanged = diff.counts[UNCHANGED]
239 if unchanged:
240 out.append(f"_{unchanged} rule(s) unchanged._")
241 return "\n".join(out).rstrip() + "\n"
242
243
244def _html_table(rows: list[dict[str, str]], caption: str, cls: str) -> str:
245 shown = rows[:10]
246 headers = list(shown[0].keys())
247 head = "".join(f"<th>{escape(h)}</th>" for h in headers)
248 body = "".join(
249 "<tr>" + "".join(f"<td>{escape(str(r.get(h, '')))}</td>" for h in headers) + "</tr>"
250 for r in shown
251 )
252 return (
253 f"<table class='{cls}'><caption>{escape(caption)}</caption>"
254 f"<thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
255 )
256
257
258_DIFF_CSS = (
259 _CSS
260 + """
261.delta { border: 1px solid #e5e5e5; border-radius: 6px; padding: 1rem 1.1rem; margin: .8rem 0; }
262.delta.regressed { border-left: 4px solid #c1272d; }
263.delta.fixed { border-left: 4px solid #1a7f37; }
264.delta.drifted { border-left: 4px solid #d08700; }
265.delta.changed { border-left: 4px solid #bbb; }
266.delta h3 { margin: 0 0 .4rem; font-size: 1.05rem; }
267.transition { font-weight: 700; }
268table.added caption, table.removed caption { text-align: left; font-weight: 600; font-size: .85rem; padding: .2rem 0; }
269table.added caption { color: #c1272d; } table.removed caption { color: #1a7f37; }
270@media (prefers-color-scheme: dark) {
271 .delta { border-color: #2d2e33; }
272 table.added caption { color: #ff6b70; } table.removed caption { color: #4ac36a; }
273}
274"""
275)
276
277
278def _render_html(diff: DiffReport) -> str:
279 counts = diff.counts
280 parts: list[str] = []
281 parts.append(
282 f"<h1>Evidence Drift — {escape(diff.current.subject)} "
283 f"({escape(diff.current.platform)})</h1>"
284 )
285 parts.append(
286 f"<p class='meta'>Baseline <code>{escape(diff.baseline.path.name)}</code> → "
287 f"Current <code>{escape(diff.current.path.name)}</code> · "
288 f"Generated {escape(diff.generated_at)}</p>"
289 )
290 parts.append(
291 "<p class='summary-pills'>"
292 f"<span>{counts[REGRESSED]} regressed</span>"
293 f"<span>{counts[FIXED]} fixed</span>"
294 f"<span>{counts[DRIFTED]} drifted</span>"
295 f"<span>{counts[CHANGED]} changed</span>"
296 f"<span>{counts[UNCHANGED]} unchanged</span></p>"
297 )
298 parts.append(
299 "<p class='note'>A <strong>regression</strong> means a setting moved into "
300 "a state that no longer supports a control since the baseline. As always "
301 "this is evidence, not a verdict.</p>"
302 )
303
304 for category in CATEGORY_ORDER:
305 rows = diff.by_category(category)
306 if not rows or category == UNCHANGED:
307 continue
308 parts.append(f"<h2>{escape(_CATEGORY_HEADING[category])}</h2>")
309 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>")
332
333 if counts[UNCHANGED]:
334 parts.append(f"<p class='meta'>{counts[UNCHANGED]} rule(s) unchanged.</p>")
335 parts.append("<footer>Generated by audit-report · Audit Labs · evidence, not a verdict.</footer>")
336
337 return (
338 "<!doctype html><html lang='en'><head><meta charset='utf-8'>"
339 "<meta name='viewport' content='width=device-width, initial-scale=1'>"
340 f"<title>Evidence Drift — {escape(diff.current.subject)}</title>"
341 f"<style>{_DIFF_CSS}</style></head><body><main>{''.join(parts)}</main></body></html>\n"
342 )
343
344
345def _render_json(diff: DiffReport) -> str:
346 import json as _json
347
348 payload = {
349 "subject": diff.current.subject,
350 "platform": diff.current.platform,
351 "baseline_package": diff.baseline.path.name,
352 "current_package": diff.current.path.name,
353 "generated_at": diff.generated_at,
354 "summary": diff.counts,
355 "deltas": [
356 {
357 "id": d.rule.id,
358 "title": d.rule.title,
359 "severity": d.rule.severity,
360 "controls": d.rule.controls,
361 "category": d.category,
362 "old_status": d.old_status,
363 "new_status": d.new_status,
364 "evidence_added": d.evidence_added,
365 "evidence_removed": d.evidence_removed,
366 }
367 for d in diff.deltas
368 ],
369 }
370 return _json.dumps(payload, indent=2) + "\n"
371
372
373_RENDERERS = {"md": _render_md, "html": _render_html, "json": _render_json}
374
375
376def render(diff: DiffReport, fmt: str) -> str:
377 """Render a diff in the named format ('md', 'html', or 'json')."""
378 try:
379 return _RENDERERS[fmt](diff)
380 except KeyError:
381 raise ValueError(f"unknown format: {fmt!r}") from None
audit_report/reporters/html.py +2 −2
@@ -19,7 +19,7 @@ _STATUS_LABEL = {PASS: "PASS", FAIL: "FAIL", NOT_APPLICABLE: "N/A"}
19_STATUS_CLASS = {PASS: "pass", FAIL: "fail", NOT_APPLICABLE: "na"} 19_STATUS_CLASS = {PASS: "pass", FAIL: "fail", NOT_APPLICABLE: "na"}
20_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} 20_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
21 21
22_CSS = """ 22CSS = """
23:root { color-scheme: light dark; } 23:root { color-scheme: light dark; }
24* { box-sizing: border-box; } 24* { box-sizing: border-box; }
25body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; 25body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
@@ -161,5 +161,5 @@ def render(report: Report) -> str:
161 "<!doctype html><html lang='en'><head><meta charset='utf-8'>" 161 "<!doctype html><html lang='en'><head><meta charset='utf-8'>"
162 "<meta name='viewport' content='width=device-width, initial-scale=1'>" 162 "<meta name='viewport' content='width=device-width, initial-scale=1'>"
163 f"<title>Evidence Report — {escape(pkg.subject)}</title>" 163 f"<title>Evidence Report — {escape(pkg.subject)}</title>"
164 f"<style>{_CSS}</style></head><body><main>{body}</main></body></html>\n" 164 f"<style>{CSS}</style></head><body><main>{body}</main></body></html>\n"
165 ) 165 )
tests/fixtures/aws_audit_acme_2025-12-01/account_security.csv added +2
@@ -0,0 +1,2 @@
1root_mfa_enabled,root_access_keys_present,root_signing_certs_present,mfa_devices,users,groups,roles,policies
2False,False,False,1,2,0,5,0
tests/fixtures/aws_audit_acme_2025-12-01/cloudtrail.csv added +2
@@ -0,0 +1,2 @@
1name,home_region,multi_region,log_file_validation,is_logging,s3_bucket
2main,us-east-1,True,True,True,acme-logs
tests/fixtures/aws_audit_acme_2025-12-01/iam_users.csv added +3
@@ -0,0 +1,3 @@
1user,mfa_enabled,access_keys,oldest_key_age_days,console_password,password_last_used,created
2alice,True,1,30,True,2025-12-01T00:00:00+00:00,2025-01-01T00:00:00+00:00
3bob,False,1,400,True,2025-11-01T00:00:00+00:00,2025-02-01T00:00:00+00:00
tests/fixtures/aws_audit_acme_2025-12-01/open_security_groups.csv added +2
@@ -0,0 +1,2 @@
1region,group_id,group_name,protocol,from_port,to_port,open_to
2us-east-1,sg-old,legacy,tcp,22,22,0.0.0.0/0
tests/fixtures/aws_audit_acme_2025-12-01/password_policy.csv added +2
@@ -0,0 +1,2 @@
1minimum_length,require_symbols,require_numbers,require_uppercase,require_lowercase,allow_users_to_change,max_age_days,reuse_prevention,hard_expiry
214,True,True,True,True,True,90,24,False
tests/fixtures/aws_audit_acme_2025-12-01/s3_public_access.csv added
tests/test_diff.py added +113
@@ -0,0 +1,113 @@
1"""Tests for diff mode: status transitions, evidence drift, and the CLI."""
2
3import json
4from pathlib import Path
5
6from audit_report import diff
7from audit_report.cli import main
8from audit_report.engine import evaluate
9from audit_report.loader import load_package
10from audit_report.rules import load_ruleset
11
12FIXTURES = Path(__file__).parent / "fixtures"
13RULESETS = Path("audit_report/rulesets")
14
15BASELINE = FIXTURES / "aws_audit_acme_2025-12-01"
16CURRENT = FIXTURES / "aws_audit_acme_2026-01-01"
17
18
19def _build():
20 ruleset = load_ruleset(RULESETS / "aws.yaml")
21 old = load_package(BASELINE)
22 new = load_package(CURRENT)
23 return diff.build_diff(
24 old, new, evaluate(old, ruleset), evaluate(new, ruleset)
25 )
26
27
28def test_diff_categories():
29 report = _build()
30 by_id = {d.rule.id: d for d in report.deltas}
31
32 # Password policy was strong in the baseline, weak now -> regressed.
33 assert by_id["aws.iam.password-policy"].category == diff.REGRESSED
34 # Root MFA was off in the baseline, on now -> fixed.
35 assert by_id["aws.root.mfa"].category == diff.FIXED
36 # Open SSH fails in both, but on a different security group -> drifted.
37 ssh = by_id["aws.network.no-open-ssh"]
38 assert ssh.category == diff.DRIFTED
39 assert ssh.evidence_added[0]["group_name"] == "web"
40 assert ssh.evidence_removed[0]["group_name"] == "legacy"
41 # Bob still lacks MFA with the same evidence in both -> unchanged.
42 assert by_id["aws.iam.console-mfa"].category == diff.UNCHANGED
43
44
45def test_diff_counts():
46 counts = _build().counts
47 assert counts[diff.REGRESSED] == 1
48 assert counts[diff.FIXED] == 1
49 assert counts[diff.DRIFTED] == 1
50
51
52def test_has_regression_respects_threshold():
53 report = _build()
54 # The regression (password policy) is medium severity.
55 assert diff.has_regression(report, "none") is False
56 assert diff.has_regression(report, "medium") is True
57 assert diff.has_regression(report, "high") is False # nothing high regressed
58
59
60def test_diff_render_markdown():
61 md = diff.render(_build(), "md")
62 assert "# Evidence Drift — acme" in md
63 assert "Regressions" in md
64 assert "Newly failing rows" in md
65
66
67def test_diff_render_html_self_contained():
68 html = diff.render(_build(), "html")
69 assert html.startswith("<!doctype html>")
70 assert "http://" not in html and "https://" not in html
71 assert "Evidence Drift" in html
72
73
74def test_diff_render_json():
75 data = json.loads(diff.render(_build(), "json"))
76 cats = {d["id"]: d["category"] for d in data["deltas"]}
77 assert cats["aws.root.mfa"] == "fixed"
78 assert data["baseline_package"] == "aws_audit_acme_2025-12-01"
79
80
81def test_cli_diff_mode(tmp_path):
82 out = tmp_path / "out"
83 code = main(
84 [
85 str(CURRENT),
86 "--baseline",
87 str(BASELINE),
88 "--format",
89 "md,html,json",
90 "--out",
91 str(out),
92 "--fail-on",
93 "medium",
94 ]
95 )
96 assert (out / "diff.md").exists()
97 assert (out / "diff.html").exists()
98 assert (out / "diff.json").exists()
99 # A medium-severity regression is present -> non-zero exit.
100 assert code == 1
101
102
103def test_cli_diff_platform_mismatch_returns_2():
104 code = main(
105 [
106 str(FIXTURES / "github_audit_acme_2026-01-01"),
107 "--baseline",
108 str(BASELINE),
109 "--format",
110 "json",
111 ]
112 )
113 assert code == 2