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 f3c12544e8

f3c12544e8bef2bf45f4aa68ce0dec2c44e531ca

parent: 250db17024

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-07 02:13 UTC

Stamp tool and ruleset provenance into reports

- Ruleset carries name, version, and a SHA-256 of its file
- JSON, Markdown, and HTML reports record the tool version and the ruleset
  identity + hash so findings are traceable and re-performable
- Version the aws/github/gitlab rulesets and add MAPPING.md (authorship,
  review status, framework revisions, disclaimers)
- Add ruff + pytest CI

Layout: unified · split

.github/workflows/ci.yml added +26
@@ -0,0 +1,26 @@
1name: CI
2
3on:
4 push:
5 pull_request:
6
7jobs:
8 test:
9 runs-on: ubuntu-latest
10 strategy:
11 matrix:
12 python-version: ["3.10", "3.12"]
13 steps:
14 - uses: actions/checkout@v5
15 - name: Set up Python ${{ matrix.python-version }}
16 uses: actions/setup-python@v6
17 with:
18 python-version: ${{ matrix.python-version }}
19 - name: Install
20 run: |
21 python -m pip install --upgrade pip
22 pip install -e ".[dev]"
23 - name: Ruff
24 run: ruff check .
25 - name: Tests
26 run: pytest -q
MAPPING.md added +40
@@ -0,0 +1,40 @@
1# Rulesets — provenance and control-mapping rationale
2
3An `audit-report` **ruleset** (`audit_report/rulesets/*.yaml`) turns collected
4evidence into control-relevant findings: each rule names a table, a check, and the
5control identifiers the signal is offered as evidence *for*. This document records
6where those mappings come from and their limits.
7
8## What a mapping claims — and does not
9
10- A rule's `controls` list says: "this signal is relevant to these controls."
11- A **fail** means a setting is in a state that does **not** support the control.
12 It is not a compliance verdict — the auditor still owns the conclusion.
13- The control identifiers (`SOC2:CC6.1`, `ISO:A.5.17`, `NIST:IA-2`, …) are
14 reproduced; the frameworks' normative control text is not. See the catalog
15 provenance in [control-coverage `MAPPING.md`](https://github.com/audit-labs/control-coverage/blob/main/MAPPING.md).
16- The mappings are the **maintainers' interpretation**, not reviewed or endorsed
17 by the AICPA, ISO/IEC, or NIST.
18
19## Framework revisions referenced
20
21- **SOC 2** — Trust Services Criteria 2017 (2022 revised points of focus).
22- **ISO/IEC 27001** — 27001:2022 Annex A.
23- **NIST SP 800-53** — Rev. 5.
24
25## Versioning and traceability
26
27- Each ruleset carries `name` and `version` fields.
28- Every report stamps the tool name + version and the ruleset `name`, `version`,
29 and a **SHA-256 of the ruleset file** into its output (`tool` / `ruleset` in
30 JSON; the header line in Markdown/HTML).
31- An auditor can therefore tie any finding back to the exact ruleset that produced
32 it, and re-perform against it. Bump `version` on any change to a rule's
33 controls, checks, or thresholds.
34
35## Authorship and review
36
37- **Author:** the audit-labs maintainer.
38- **Review status:** maintainer self-review; no independent professional review.
39 Validate a ruleset against your own control set before relying on it.
40- **Effective date:** 2026-08.
audit_report/cli.py +1 −1
@@ -181,7 +181,7 @@ def main(argv: list[str] | None = None) -> int:
181181 return _run_diff(args, package, ruleset, formats)
182182
183183 findings = evaluate(package, ruleset)
184 report = reporters.build_report(package, findings)
184 report = reporters.build_report(package, findings, ruleset)
185185 _emit(lambda fmt: reporters.render(report, fmt), formats, args.out, "report")
186186
187187 counts = report.counts
audit_report/reporters/__init__.py +23 −2
@@ -5,8 +5,10 @@ from __future__ import annotations
55from dataclasses import dataclass
66from datetime import datetime, timezone
77
8from .. import __version__
89from ..engine import Finding, control_coverage, summarize
910from ..loader import Package
11from ..rules import Ruleset
1012from . import html as _html
1113from . import json as _json
1214from . import markdown as _markdown
@@ -19,6 +21,7 @@ class Report:
1921 package: Package
2022 findings: list[Finding]
2123 generated_at: str
24 ruleset: Ruleset | None = None
2225
2326 @property
2427 def counts(self) -> dict[str, int]:
@@ -28,11 +31,29 @@ class Report:
2831 def coverage(self) -> dict[str, dict]:
2932 return control_coverage(self.findings)
3033
34 @property
35 def provenance(self) -> dict[str, dict]:
36 """Tool + ruleset identity, so a report can be tied to what produced it."""
37 rs = self.ruleset
38 return {
39 "tool": {"name": "audit-report", "version": __version__},
40 "ruleset": {
41 "name": rs.name if rs else "",
42 "platform": rs.platform if rs else "",
43 "version": rs.version if rs else "",
44 "sha256": rs.sha256 if rs else "",
45 },
46 }
47
3148
32def build_report(package: Package, findings: list[Finding]) -> Report:
49def build_report(
50 package: Package, findings: list[Finding], ruleset: Ruleset | None = None
51) -> Report:
3352 """Assemble a :class:`Report` with a UTC generation timestamp."""
3453 stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
35 return Report(package=package, findings=findings, generated_at=stamp)
54 return Report(
55 package=package, findings=findings, generated_at=stamp, ruleset=ruleset
56 )
3657
3758
3859RENDERERS = {
audit_report/reporters/html.py +12 −1
@@ -90,10 +90,21 @@ def render(report: Report) -> str:
9090 parts: list[str] = []
9191
9292 parts.append(f"<h1>Evidence Report — {escape(pkg.subject)}</h1>")
93 prov = report.provenance
94 tool, rs = prov["tool"], prov["ruleset"]
95 ruleset_meta = ""
96 if rs["sha256"]:
97 rs_ver = f" {escape(rs['version'])}" if rs["version"] else ""
98 ruleset_meta = (
99 f" · Ruleset <code>{escape(rs['name'])}{rs_ver}</code> "
100 f"<code>sha256:{escape(rs['sha256'][:12])}</code>"
101 )
93102 parts.append(
94103 f"<p class='meta'>Platform <code>{escape(pkg.platform)}</code> · "
95104 f"Source <code>{escape(pkg.path.name)}</code> · "
96 f"Generated {escape(report.generated_at)}</p>"
105 f"Generated {escape(report.generated_at)} · "
106 f"Tool <code>{escape(tool['name'])} {escape(tool['version'])}</code>"
107 f"{ruleset_meta}</p>"
97108 )
98109 parts.append(
99110 "<p class='summary-pills'>"
audit_report/reporters/json.py +2
@@ -21,6 +21,8 @@ def to_dict(report: Report) -> dict:
2121 "platform": pkg.platform,
2222 "source_package": pkg.path.name,
2323 "generated_at": report.generated_at,
24 "tool": report.provenance["tool"],
25 "ruleset": report.provenance["ruleset"],
2426 "summary": report.counts,
2527 "coverage": report.coverage,
2628 "findings": [
audit_report/reporters/markdown.py +9
@@ -42,6 +42,15 @@ def render(report: Report) -> str:
4242 out.append(f"- **Subject:** {pkg.subject}")
4343 out.append(f"- **Source package:** `{pkg.path.name}`")
4444 out.append(f"- **Generated:** {report.generated_at}")
45 prov = report.provenance
46 tool, rs = prov["tool"], prov["ruleset"]
47 out.append(f"- **Tool:** {tool['name']} {tool['version']}")
48 if rs["sha256"]:
49 rs_ver = f" {rs['version']}" if rs["version"] else ""
50 out.append(
51 f"- **Ruleset:** {rs['name']}{rs_ver} "
52 f"(`sha256:{rs['sha256'][:12]}`)"
53 )
4554 out.append(
4655 f"- **Result:** {counts[FAIL]} failing · {counts[PASS]} passing · "
4756 f"{counts[NOT_APPLICABLE]} not applicable"
audit_report/rules.py +19 −3
@@ -17,6 +17,7 @@ Operators (``op``): ``equals``, ``not_equals``, ``is_true``, ``is_false``,
1717
1818from __future__ import annotations
1919
20import hashlib
2021from dataclasses import dataclass, field
2122from pathlib import Path
2223
@@ -49,10 +50,18 @@ class Rule:
4950
5051@dataclass
5152class Ruleset:
52 """A named collection of rules for one platform."""
53 """A named collection of rules for one platform.
54
55 ``version`` and ``sha256`` identify *which* ruleset produced a report, so an
56 auditor can re-perform against the exact mapping used. ``sha256`` is the
57 digest of the ruleset file's bytes as loaded.
58 """
5359
5460 platform: str
5561 rules: list[Rule]
62 name: str = ""
63 version: str = ""
64 sha256: str = ""
5665
5766
5867def _as_number(value: str) -> float | None:
@@ -115,7 +124,8 @@ def match(condition: dict, row: dict[str, str]) -> bool:
115124
116125def load_ruleset(path: str | Path) -> Ruleset:
117126 """Parse a ruleset YAML file into a :class:`Ruleset`, validating each rule."""
118 data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
127 text = Path(path).read_text(encoding="utf-8")
128 data = yaml.safe_load(text) or {}
119129 platform = data.get("platform")
120130 if not platform:
121131 raise ValueError(f"{path}: ruleset is missing a 'platform'")
@@ -137,4 +147,10 @@ def load_ruleset(path: str | Path) -> Ruleset:
137147 raise ValueError(f"{rule.id}: unknown check type {check_type!r}")
138148 rules.append(rule)
139149
140 return Ruleset(platform=platform, rules=rules)
150 return Ruleset(
151 platform=platform,
152 rules=rules,
153 name=data.get("name", platform),
154 version=str(data.get("version", "")),
155 sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
156 )
audit_report/rulesets/aws.yaml +5
@@ -4,6 +4,11 @@
44# (aws_audit_<profile>_<date>/). Each rule names a CSV table, a check, and the
55# controls the signal is offered as evidence for. A "fail" means a setting is in
66# a state that does NOT support the control — an auditor still owns the verdict.
7#
8# Provenance and control-mapping rationale: see MAPPING.md. Bump `version` on any
9# change to a rule's controls, checks, or thresholds so reports stay traceable.
10name: AWS ITGC ruleset
11version: "2026.08.0"
712platform: aws
813
914rules:
audit_report/rulesets/github.yaml +5
@@ -3,6 +3,11 @@
33# Evaluated against an audit-tools GitHub evidence package
44# (github_audit_<org>_<date>/). Complements gh-attest: same spirit of mapping
55# GitHub signals to controls, applied offline to a captured CSV package.
6#
7# Provenance and control-mapping rationale: see MAPPING.md. Bump `version` on any
8# change to a rule's controls, checks, or thresholds so reports stay traceable.
9name: GitHub ITGC ruleset
10version: "2026.08.0"
611platform: github
712
813rules:
audit_report/rulesets/gitlab.yaml +5
@@ -10,6 +10,11 @@
1010# * audit-tools omits a CSV entirely when a collector returns no rows, so an
1111# absent table reports as "not applicable", not "pass". A rule can only
1212# speak to data that was actually collected.
13#
14# Provenance and control-mapping rationale: see MAPPING.md. Bump `version` on any
15# change to a rule's controls, checks, or thresholds so reports stay traceable.
16name: GitLab ITGC ruleset
17version: "2026.08.0"
1318platform: gitlab
1419
1520rules:
tests/test_reporters.py +20 −3
@@ -5,7 +5,7 @@ from pathlib import Path
55
66import pytest
77
8from audit_report import reporters
8from audit_report import __version__, reporters
99from audit_report.cli import main
1010from audit_report.engine import evaluate
1111from audit_report.loader import load_package
@@ -17,8 +17,9 @@ RULESETS = Path("audit_report/rulesets")
1717
1818def _report(pkg_name="aws_audit_acme_2026-01-01", ruleset="aws.yaml"):
1919 pkg = load_package(FIXTURES / pkg_name)
20 findings = evaluate(pkg, load_ruleset(RULESETS / ruleset))
21 return reporters.build_report(pkg, findings)
20 rs = load_ruleset(RULESETS / ruleset)
21 findings = evaluate(pkg, rs)
22 return reporters.build_report(pkg, findings, rs)
2223
2324
2425def test_markdown_render_contains_sections():
@@ -46,6 +47,22 @@ def test_json_render_roundtrips():
4647 assert ids["aws.iam.console-mfa"] == "fail"
4748
4849
50def test_json_stamps_tool_and_ruleset_provenance():
51 data = json.loads(reporters.render(_report(), "json"))
52 assert data["tool"] == {"name": "audit-report", "version": __version__}
53 rs = data["ruleset"]
54 assert rs["name"] == "AWS ITGC ruleset"
55 assert rs["platform"] == "aws"
56 assert rs["version"] == "2026.08.0"
57 assert len(rs["sha256"]) == 64 # full SHA-256 hex digest of the ruleset file
58
59
60def test_markdown_shows_ruleset_provenance():
61 md = reporters.render(_report(), "md")
62 assert "**Tool:** audit-report" in md
63 assert "sha256:" in md
64
65
4966def test_html_escapes_evidence(tmp_path):
5067 pkg_dir = tmp_path / "aws_audit_x_2026-01-01"
5168 pkg_dir.mkdir()