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 92ba96f545

92ba96f5451fca75c3e2544c4b0c52a3c5abfba9

parent: e1665106ac

Verified · cmc

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

feat: add trend mode and CI examples

Trend mode (--trend) discovers a series of dated packages under a folder,
evaluates each with the same ruleset, and renders a rule-over-time heatmap
(md/html/json) with a per-date failing total. Discovery groups by platform and
subject, orders by the date in the dir name, and requires at least two
packages; --fail-on reflects the latest snapshot.

Adds GitHub Actions and GitLab CI example workflows plus docs/ci.md covering the
collect/report/gate pattern and snapshot vs regression gating. loader now
captures each package's date. 58 tests, ruff clean.

Layout: unified · split

README.md +33
@@ -63,6 +63,25 @@ audit-report ./output/aws_audit_prod_2026-07-29 \
63In diff mode `--fail-on` gates on **regressions** at or above the given 63In diff mode `--fail-on` gates on **regressions** at or above the given
64severity, and output files are named `diff.*` instead of `report.*`. 64severity, and output files are named `diff.*` instead of `report.*`.
65 65
66### Trend mode — track controls over time
67
68Pass `--trend` and point at a **folder of dated packages** (for example
69audit-tools' `output/`). Every package in the series is evaluated with the same
70ruleset and laid out as a timeline — one row per rule, one column per date — so
71you can watch a control drift in and out of compliance.
72
73```bash
74# Heatmap of every control across all retained prod packages
75audit-report ./output --trend --format html,json --out trend/
76
77# Disambiguate when the folder holds more than one series
78audit-report ./output --trend --subject prod --out trend/
79```
80
81The series must share one platform and subject (use `--subject` to pick one) and
82contain at least two packages. In trend mode `--fail-on` reflects the **latest**
83package, so it can double as a snapshot gate. Output files are named `trend.*`.
84
66You can also run it without installing: 85You can also run it without installing:
67 86
68```bash 87```bash
@@ -74,6 +93,8 @@ python -m audit_report ./output/aws_audit_default_2026-07-29
74| Flag | Description | 93| Flag | Description |
75| --- | --- | 94| --- | --- |
76| `--baseline PATH` | Diff mode: report how `PACKAGE` drifted from this earlier package. | 95| `--baseline PATH` | Diff mode: report how `PACKAGE` drifted from this earlier package. |
96| `--trend` | Trend mode: treat `PACKAGE` as a folder of dated packages and chart each rule over time. |
97| `--subject NAME` | In trend mode, pick one subject when the folder holds several series. |
77| `--ruleset PATH` | Use a specific ruleset instead of the bundled one for the detected platform. | 98| `--ruleset PATH` | Use a specific ruleset instead of the bundled one for the detected platform. |
78| `--format md,html,json` | One or more output formats (default: `md`). | 99| `--format md,html,json` | One or more output formats (default: `md`). |
79| `--out DIR` | Write `report.<ext>` files into `DIR`. Without it, the first format prints to stdout. | 100| `--out DIR` | Write `report.<ext>` files into `DIR`. Without it, the first format prints to stdout. |
@@ -135,6 +156,18 @@ rules:
135`not_empty`. Control codes referenced by a rule must exist in 156`not_empty`. Control codes referenced by a rule must exist in
136[`audit_report/catalog.py`](audit_report/catalog.py). 157[`audit_report/catalog.py`](audit_report/catalog.py).
137 158
159## Continuous integration
160
161Run it on a schedule or in a pipeline to keep evidence current and gate on
162regressions. See [docs/ci.md](docs/ci.md) and the ready-to-copy examples:
163
164- [`examples/github-actions-audit.yml`](examples/github-actions-audit.yml)
165- [`examples/gitlab-ci-audit.yml`](examples/gitlab-ci-audit.yml)
166
167The `--fail-on` exit code (`1` = a finding/regression met the threshold, `2` =
168usage error) lets a workflow separate "the audit found a problem" from "the job
169is misconfigured".
170
138## Development 171## Development
139 172
140```bash 173```bash
audit_report/cli.py +42 −1
@@ -6,7 +6,7 @@ import argparse
6import sys 6import sys
7from pathlib import Path 7from pathlib import Path
8 8
9from . import __version__, diff, reporters 9from . import __version__, diff, reporters, trend
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
@@ -38,6 +38,15 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
38 "--baseline", 38 "--baseline",
39 help="path to an earlier package; diff mode reports how PACKAGE drifted from it", 39 help="path to an earlier package; diff mode reports how PACKAGE drifted from it",
40 ) 40 )
41 parser.add_argument(
42 "--trend",
43 action="store_true",
44 help="trend mode: treat PACKAGE as a folder of dated packages and chart each rule over time",
45 )
46 parser.add_argument(
47 "--subject",
48 help="in trend mode, pick one subject when the folder holds several series",
49 )
41 parser.add_argument( 50 parser.add_argument(
42 "--ruleset", 51 "--ruleset",
43 help="path to a ruleset YAML (default: bundled ruleset for the detected platform)", 52 help="path to a ruleset YAML (default: bundled ruleset for the detected platform)",
@@ -118,6 +127,31 @@ def _run_diff(args, package, ruleset, formats: list[str]) -> int:
118 return 1 if diff.has_regression(report, args.fail_on) else 0 127 return 1 if diff.has_regression(report, args.fail_on) else 0
119 128
120 129
130def _run_trend(args, formats: list[str]) -> int:
131 try:
132 platform, subject, paths = trend.discover(args.package, args.subject)
133 except ValueError as exc:
134 print(f"error: {exc}", file=sys.stderr)
135 return 2
136
137 ruleset_path = Path(args.ruleset) if args.ruleset else _default_ruleset(platform)
138 ruleset = load_ruleset(ruleset_path)
139
140 packages = [load_package(p) for p in paths]
141 findings_per = [evaluate(pkg, ruleset) for pkg in packages]
142 report = trend.build_trend(packages, findings_per)
143
144 _emit(lambda fmt: trend.render(report, fmt), formats, args.out, "trend")
145
146 fails = report.fails_per_date()
147 print(
148 f"{platform}/{subject}: {len(paths)} packages, "
149 f"failing {fails[0]} → {fails[-1]}",
150 file=sys.stderr,
151 )
152 return _exit_code(trend.latest_findings(findings_per), args.fail_on)
153
154
121def main(argv: list[str] | None = None) -> int: 155def main(argv: list[str] | None = None) -> int:
122 args = _parse_args(argv if argv is not None else sys.argv[1:]) 156 args = _parse_args(argv if argv is not None else sys.argv[1:])
123 157
@@ -127,6 +161,13 @@ def main(argv: list[str] | None = None) -> int:
127 print(f"error: unknown format(s): {', '.join(unknown) or '(none given)'}", file=sys.stderr) 161 print(f"error: unknown format(s): {', '.join(unknown) or '(none given)'}", file=sys.stderr)
128 return 2 162 return 2
129 163
164 if args.trend and args.baseline:
165 print("error: --trend and --baseline cannot be combined", file=sys.stderr)
166 return 2
167
168 if args.trend:
169 return _run_trend(args, formats)
170
130 try: 171 try:
131 package = load_package(args.package) 172 package = load_package(args.package)
132 except (FileNotFoundError, ValueError) as exc: 173 except (FileNotFoundError, ValueError) as exc:
audit_report/loader.py +14 −1
@@ -29,6 +29,7 @@ class Package:
29 path: Path 29 path: Path
30 platform: str 30 platform: str
31 subject: str # the org / profile the audit was run against 31 subject: str # the org / profile the audit was run against
32 date: str = "" # trailing YYYY-MM-DD from the dir name, if present
32 tables: dict[str, Table] = field(default_factory=dict) 33 tables: dict[str, Table] = field(default_factory=dict)
33 34
34 def table(self, name: str) -> Table: 35 def table(self, name: str) -> Table:
@@ -61,6 +62,12 @@ def _looks_like_date(token: str) -> bool:
61 return len(bits) == 3 and all(b.isdigit() for b in bits) 62 return len(bits) == 3 and all(b.isdigit() for b in bits)
62 63
63 64
65def package_date(dir_name: str) -> str:
66 """Return the trailing ``YYYY-MM-DD`` in a package dir name, or ''."""
67 tail = dir_name.rsplit("_", 1)[-1]
68 return tail if _looks_like_date(tail) else ""
69
70
64def load_package(path: str | Path) -> Package: 71def load_package(path: str | Path) -> Package:
65 """Load every ``*.csv`` in *path* into a :class:`Package`. 72 """Load every ``*.csv`` in *path* into a :class:`Package`.
66 73
@@ -80,4 +87,10 @@ def load_package(path: str | Path) -> Package:
80 if not tables: 87 if not tables:
81 raise ValueError(f"no CSV files found in {directory}") 88 raise ValueError(f"no CSV files found in {directory}")
82 89
83 return Package(path=directory, platform=platform, subject=subject, tables=tables) 90 return Package(
91 path=directory,
92 platform=platform,
93 subject=subject,
94 date=package_date(directory.name),
95 tables=tables,
96 )
audit_report/trend.py added +254
@@ -0,0 +1,254 @@
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
docs/ci.md added +67
@@ -0,0 +1,67 @@
1# Running audit-report in CI
2
3The pattern is always the same three steps:
4
51. **Collect** an evidence package with `audit-tools` (per-platform CLI).
62. **Report** on it with `audit-report`, writing `md`/`html`/`json` artifacts.
73. **Gate** the pipeline with `--fail-on` so a control regression can block a
8 merge or page a scheduled run.
9
10Ready-to-copy starting points:
11
12- [`examples/github-actions-audit.yml`](../examples/github-actions-audit.yml)
13- [`examples/gitlab-ci-audit.yml`](../examples/gitlab-ci-audit.yml)
14
15> The collection step in the examples shows both a module entrypoint
16> (`python -m audit_tools.github`) and a script fallback (`python audit.py`).
17> Use whichever your installed `audit-tools` exposes; everything downstream only
18> needs the `./output/<platform>_audit_<subject>_<date>/` directory it writes.
19
20## Gating strategies
21
22**Snapshot gate — the current state must be clean.**
23
24```bash
25audit-report "$PKG" --fail-on high
26```
27
28Exits non-zero if any high-severity control is unsupported in the newest
29package. Simple and strict; good for a scheduled run that should stay green.
30
31**Regression gate — this change must not make things worse.**
32
33Keep the previous package in the repo (or restore it from an artifact) and diff
34against it. The build fails only when a control that used to pass now fails,
35which avoids blocking on pre-existing debt.
36
37```bash
38audit-report "$PKG" --baseline ./baseline/"$LAST_PKG" --fail-on high
39```
40
41**Trend artifact — show direction over time.**
42
43Point trend mode at a folder of retained packages to publish a heatmap of every
44control across dates. Its `--fail-on` reflects the latest package, so it can
45double as a snapshot gate while producing the timeline artifact.
46
47```bash
48audit-report ./history --trend --format html,json --out ./trend
49```
50
51## Exit codes
52
53| Code | Meaning |
54| --- | --- |
55| `0` | Ran successfully; no `--fail-on` threshold was breached. |
56| `1` | A finding (or, in diff mode, a regression) met the `--fail-on` severity. |
57| `2` | Usage or input error (missing package, unknown format, mismatched platforms). |
58
59Distinguishing `1` from `2` lets a workflow tell "the audit found a problem"
60(expected, actionable) from "the job is misconfigured" (fix the pipeline).
61
62## Retaining history
63
64`audit-report` never writes back to the package — it only reads. To build a
65trend or a regression baseline, archive each run's package directory (a CI
66artifact, a committed `history/` folder, or object storage) and feed the
67collection back in on the next run.
examples/github-actions-audit.yml added +66
@@ -0,0 +1,66 @@
1# Example GitHub Actions workflow: collect evidence, then report on it.
2#
3# Copy into .github/workflows/audit.yml in the repository you want to audit and
4# adjust the collection step to your platform. It runs on a schedule and on
5# demand, produces a control-mapped report, and fails the run if a high-severity
6# control regresses against the previous package committed to the repo.
7#
8# Requires two org/repo secrets for the GitHub collector: AUDIT_GITHUB_TOKEN
9# (a read-only token for the org you audit) and the org name in AUDIT_ORG.
10
11name: compliance-evidence
12
13on:
14 schedule:
15 - cron: "0 6 * * 1" # every Monday 06:00 UTC
16 workflow_dispatch: {}
17
18permissions:
19 contents: read
20
21jobs:
22 audit:
23 runs-on: ubuntu-latest
24 steps:
25 - uses: actions/checkout@v4
26
27 - uses: actions/setup-python@v5
28 with:
29 python-version: "3.12"
30
31 - name: Install tools
32 run: |
33 python -m pip install --upgrade pip
34 # The reporter:
35 pip install "audit-report @ git+https://github.com/audit-labs/audit-report"
36 # The collector (audit-tools ships CLIs per platform):
37 pip install "audit-tools @ git+https://github.com/audit-labs/audit-tools"
38
39 - name: Collect evidence (GitHub example)
40 env:
41 GITHUB_TOKEN: ${{ secrets.AUDIT_GITHUB_TOKEN }}
42 GITHUB_ORG: ${{ secrets.AUDIT_ORG }}
43 run: |
44 # Produces ./output/github_audit_<org>_<date>/
45 python -m audit_tools.github --out ./output || \
46 python audit.py --out ./output # fall back to the script entrypoint
47
48 - name: Locate the newest package
49 id: pkg
50 run: echo "dir=$(ls -d ./output/*_audit_* | sort | tail -n1)" >> "$GITHUB_OUTPUT"
51
52 - name: Generate evidence report
53 run: |
54 audit-report "${{ steps.pkg.outputs.dir }}" \
55 --format md,html,json --out ./report
56
57 - name: Fail on any high-severity finding
58 run: audit-report "${{ steps.pkg.outputs.dir }}" --fail-on high
59
60 - name: Publish the report as a build artifact
61 if: always()
62 uses: actions/upload-artifact@v4
63 with:
64 name: evidence-report
65 path: report/
66 retention-days: 90
examples/gitlab-ci-audit.yml added +33
@@ -0,0 +1,33 @@
1# Example GitLab CI configuration: collect evidence, then report on it.
2#
3# Copy into .gitlab-ci.yml (or include it) in the project you want to audit.
4# It produces a control-mapped report as a job artifact and fails the pipeline
5# if any high-severity control is not supported.
6#
7# Set CI/CD variables GITLAB_TOKEN (read-only) and GITLAB_GROUP for the group
8# you audit.
9
10stages: [audit]
11
12compliance-evidence:
13 stage: audit
14 image: python:3.12-slim
15 rules:
16 - if: $CI_PIPELINE_SOURCE == "schedule"
17 - if: $CI_PIPELINE_SOURCE == "web" # manual "Run pipeline"
18 variables:
19 PIP_DISABLE_PIP_VERSION_CHECK: "1"
20 before_script:
21 - pip install "audit-report @ git+https://github.com/audit-labs/audit-report"
22 - pip install "audit-tools @ git+https://github.com/audit-labs/audit-tools"
23 script:
24 # Produces ./output/gitlab_audit_<group>_<date>/
25 - python -m audit_tools.gitlab --out ./output || python audit.py --out ./output
26 - PKG=$(ls -d ./output/*_audit_* | sort | tail -n1)
27 - audit-report "$PKG" --format md,html,json --out ./report
28 - audit-report "$PKG" --fail-on high
29 artifacts:
30 when: always
31 paths:
32 - report/
33 expire_in: 90 days
tests/fixtures/series/aws_audit_prod_2026-01-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,True,False,0,1,0,3,0
tests/fixtures/series/aws_audit_prod_2026-01-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,False,False,False,
tests/fixtures/series/aws_audit_prod_2026-01-01/iam_users.csv added +2
@@ -0,0 +1,2 @@
1user,mfa_enabled,access_keys,oldest_key_age_days,console_password,password_last_used,created
2bob,False,1,400,True,2025-11-01T00:00:00+00:00,2025-02-01T00:00:00+00:00
tests/fixtures/series/aws_audit_prod_2026-01-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-web,web,tcp,22,22,0.0.0.0/0
tests/fixtures/series/aws_audit_prod_2026-01-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
28,False,True,True,True,True,365,0,False
tests/fixtures/series/aws_audit_prod_2026-01-01/s3_public_access.csv added +2
@@ -0,0 +1,2 @@
1bucket,region,public_access_block,policy_public,acl_public
2acme-assets,us-east-1,False,True,False
tests/fixtures/series/aws_audit_prod_2026-02-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
2True,True,False,1,1,0,3,0
tests/fixtures/series/aws_audit_prod_2026-02-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/series/aws_audit_prod_2026-02-01/iam_users.csv added +2
@@ -0,0 +1,2 @@
1user,mfa_enabled,access_keys,oldest_key_age_days,console_password,password_last_used,created
2bob,True,1,400,True,2026-01-15T00:00:00+00:00,2025-02-01T00:00:00+00:00
tests/fixtures/series/aws_audit_prod_2026-02-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-web,web,tcp,22,22,0.0.0.0/0
tests/fixtures/series/aws_audit_prod_2026-02-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/series/aws_audit_prod_2026-02-01/s3_public_access.csv added +1
@@ -0,0 +1 @@
1bucket,region,public_access_block,policy_public,acl_public
tests/fixtures/series/aws_audit_prod_2026-03-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
2True,False,False,1,1,0,3,0
tests/fixtures/series/aws_audit_prod_2026-03-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/series/aws_audit_prod_2026-03-01/iam_users.csv added +2
@@ -0,0 +1,2 @@
1user,mfa_enabled,access_keys,oldest_key_age_days,console_password,password_last_used,created
2bob,True,1,30,True,2026-02-20T00:00:00+00:00,2025-02-01T00:00:00+00:00
tests/fixtures/series/aws_audit_prod_2026-03-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-db,db,tcp,443,443,10.0.0.0/8
tests/fixtures/series/aws_audit_prod_2026-03-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/series/aws_audit_prod_2026-03-01/s3_public_access.csv added +1
@@ -0,0 +1 @@
1bucket,region,public_access_block,policy_public,acl_public
tests/test_trend.py added +115
@@ -0,0 +1,115 @@
1"""Tests for trend mode: discovery, timeline building, rendering, and the CLI."""
2
3import json
4from pathlib import Path
5
6import pytest
7
8from audit_report import trend
9from audit_report.cli import main
10from audit_report.engine import FAIL, PASS, evaluate
11from audit_report.loader import load_package
12from audit_report.rules import load_ruleset
13
14FIXTURES = Path(__file__).parent / "fixtures"
15SERIES = FIXTURES / "series"
16RULESETS = Path("audit_report/rulesets")
17
18
19def _build():
20 platform, _subject, paths = trend.discover(SERIES)
21 ruleset = load_ruleset(RULESETS / f"{platform}.yaml")
22 packages = [load_package(p) for p in paths]
23 findings = [evaluate(pkg, ruleset) for pkg in packages]
24 return trend.build_trend(packages, findings)
25
26
27def test_discover_orders_by_date():
28 platform, subject, paths = trend.discover(SERIES)
29 assert (platform, subject) == ("aws", "prod")
30 assert [p.name for p in paths] == [
31 "aws_audit_prod_2026-01-01",
32 "aws_audit_prod_2026-02-01",
33 "aws_audit_prod_2026-03-01",
34 ]
35
36
37def test_discover_rejects_mixed_series():
38 # The fixtures root holds aws/github/gitlab packages for several subjects.
39 with pytest.raises(ValueError, match="multiple series"):
40 trend.discover(FIXTURES)
41
42
43def test_discover_requires_two(tmp_path):
44 # A parent directory holding a single package is not a trend.
45 pkg = tmp_path / "aws_audit_solo_2026-01-01"
46 pkg.mkdir()
47 (pkg / "account_security.csv").write_text("root_mfa_enabled\nTrue\n", encoding="utf-8")
48 with pytest.raises(ValueError, match="at least two"):
49 trend.discover(tmp_path)
50
51
52def test_discover_no_packages(tmp_path):
53 with pytest.raises(ValueError, match="no audit-tools packages"):
54 trend.discover(tmp_path)
55
56
57def test_trend_timeline_and_totals():
58 report = _build()
59 assert report.dates == ["2026-01-01", "2026-02-01", "2026-03-01"]
60 assert report.fails_per_date() == [8, 3, 0]
61
62 by_id = {row.rule.id: row for row in report.rows}
63 # Root MFA: fail, then fixed and stays fixed.
64 assert by_id["aws.root.mfa"].statuses == [FAIL, PASS, PASS]
65 # Access-key rotation lags: fixed only in the final package.
66 assert by_id["aws.iam.key-rotation"].statuses == [FAIL, FAIL, PASS]
67
68
69def test_trend_row_transitions():
70 report = _build()
71 by_id = {row.rule.id: row for row in report.rows}
72 assert by_id["aws.root.mfa"].transitions == 1 # one fail->pass change
73 assert by_id["aws.s3.no-public-access"].transitions == 1
74
75
76def test_trend_render_markdown():
77 md = trend.render(_build(), "md")
78 assert "# Evidence Trend — prod (aws)" in md
79 assert "Failing total" in md
80 assert "2026-03-01" in md
81
82
83def test_trend_render_html_self_contained():
84 html = trend.render(_build(), "html")
85 assert html.startswith("<!doctype html>")
86 assert "http://" not in html and "https://" not in html
87 assert "class='trend'" in html
88
89
90def test_trend_render_json():
91 data = json.loads(trend.render(_build(), "json"))
92 assert data["dates"] == ["2026-01-01", "2026-02-01", "2026-03-01"]
93 assert data["fails_per_date"] == [8, 3, 0]
94 rules = {r["id"]: r["statuses"] for r in data["rules"]}
95 assert rules["aws.root.mfa"] == ["fail", "pass", "pass"]
96
97
98def test_cli_trend_mode(tmp_path):
99 out = tmp_path / "out"
100 code = main([str(SERIES), "--trend", "--format", "md,html,json", "--out", str(out)])
101 assert (out / "trend.md").exists()
102 assert (out / "trend.html").exists()
103 assert (out / "trend.json").exists()
104 # The latest package is clean, so default --fail-on none exits 0.
105 assert code == 0
106
107
108def test_cli_trend_fail_on_uses_latest(tmp_path):
109 # Latest package (2026-03) has no failures, so even --fail-on low passes.
110 code = main([str(SERIES), "--trend", "--format", "json", "--out", str(tmp_path), "--fail-on", "low"])
111 assert code == 0
112
113
114def test_cli_trend_and_baseline_conflict():
115 assert main([str(SERIES), "--trend", "--baseline", str(SERIES), "--format", "json"]) == 2