audit-labs/audit-report
Turn audit-tools evidence packages into control-mapped, auditor-ready reports.
clone: git clone https://gitbay.org/audit-labs/audit-report.git
v0.1.0: audit_report/cli.py · raw
1"""Command-line entry point for audit-report."""
2
3from __future__ import annotations
4
5import argparse
6import sys
7from pathlib import Path
8
9from . import __version__, diff, reporters, trend
10from .engine import FAIL, evaluate
11from .loader import load_package
12from .rules import load_ruleset
13
14# Bundled rulesets ship inside the package, one per platform.
15_RULESET_DIR = Path(__file__).resolve().parent / "rulesets"
16
17_SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2}
18
19
20def _default_ruleset(platform: str) -> Path:
21 candidate = _RULESET_DIR / f"{platform}.yaml"
22 if not candidate.exists():
23 raise SystemExit(
24 f"error: no bundled ruleset for platform '{platform}'. "
25 f"Pass one with --ruleset. Available: "
26 f"{', '.join(p.stem for p in sorted(_RULESET_DIR.glob('*.yaml'))) or 'none'}"
27 )
28 return candidate
29
30
31def _parse_args(argv: list[str]) -> argparse.Namespace:
32 parser = argparse.ArgumentParser(
33 prog="audit-report",
34 description="Turn an audit-tools evidence package into a control-mapped report.",
35 )
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 )
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 )
50 parser.add_argument(
51 "--ruleset",
52 help="path to a ruleset YAML (default: bundled ruleset for the detected platform)",
53 )
54 parser.add_argument(
55 "--format",
56 default="md",
57 help="comma-separated output formats: md, html, json (default: md)",
58 )
59 parser.add_argument(
60 "--out",
61 help="directory to write report files into (default: print Markdown to stdout)",
62 )
63 parser.add_argument(
64 "--fail-on",
65 choices=["low", "medium", "high", "none"],
66 default="none",
67 help=(
68 "exit non-zero if any finding fails (or, in diff mode, regresses) at or "
69 "above this severity (default: none)"
70 ),
71 )
72 parser.add_argument("--version", action="version", version=f"audit-report {__version__}")
73 return parser.parse_args(argv)
74
75
76def _exit_code(findings, threshold: str) -> int:
77 if threshold == "none":
78 return 0
79 floor = _SEVERITY_ORDER[threshold]
80 breached = any(
81 f.status == FAIL and _SEVERITY_ORDER.get(f.rule.severity, 1) >= floor
82 for f in findings
83 )
84 return 1 if breached else 0
85
86
87def _emit(render_one, formats: list[str], out: str | None, basename: str) -> None:
88 """Write one file per format into *out*, or print the first to stdout."""
89 if out:
90 out_dir = Path(out)
91 out_dir.mkdir(parents=True, exist_ok=True)
92 for fmt in formats:
93 dest = out_dir / f"{basename}.{reporters.EXTENSIONS[fmt]}"
94 dest.write_text(render_one(fmt), encoding="utf-8")
95 print(f"wrote {dest}", file=sys.stderr)
96 else:
97 print(render_one(formats[0]), end="")
98
99
100def _run_diff(args, package, ruleset, formats: list[str]) -> int:
101 try:
102 baseline = load_package(args.baseline)
103 except (FileNotFoundError, ValueError) as exc:
104 print(f"error: {exc}", file=sys.stderr)
105 return 2
106 if baseline.platform != package.platform:
107 print(
108 f"error: cannot diff a {baseline.platform} package against a "
109 f"{package.platform} package",
110 file=sys.stderr,
111 )
112 return 2
113
114 old = evaluate(baseline, ruleset)
115 new = evaluate(package, ruleset)
116 report = diff.build_diff(baseline, package, old, new)
117
118 _emit(lambda fmt: diff.render(report, fmt), formats, args.out, "diff")
119
120 counts = report.counts
121 print(
122 f"{package.platform}/{package.subject}: "
123 f"{counts[diff.REGRESSED]} regressed, {counts[diff.FIXED]} fixed, "
124 f"{counts[diff.DRIFTED]} drifted",
125 file=sys.stderr,
126 )
127 return 1 if diff.has_regression(report, args.fail_on) else 0
128
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
155def main(argv: list[str] | None = None) -> int:
156 args = _parse_args(argv if argv is not None else sys.argv[1:])
157
158 formats = [f.strip() for f in args.format.split(",") if f.strip()]
159 unknown = [f for f in formats if f not in reporters.EXTENSIONS]
160 if not formats or unknown:
161 print(f"error: unknown format(s): {', '.join(unknown) or '(none given)'}", file=sys.stderr)
162 return 2
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
171 try:
172 package = load_package(args.package)
173 except (FileNotFoundError, ValueError) as exc:
174 print(f"error: {exc}", file=sys.stderr)
175 return 2
176
177 ruleset_path = Path(args.ruleset) if args.ruleset else _default_ruleset(package.platform)
178 ruleset = load_ruleset(ruleset_path)
179
180 if args.baseline:
181 return _run_diff(args, package, ruleset, formats)
182
183 findings = evaluate(package, ruleset)
184 report = reporters.build_report(package, findings, ruleset)
185 _emit(lambda fmt: reporters.render(report, fmt), formats, args.out, "report")
186
187 counts = report.counts
188 print(
189 f"{package.platform}/{package.subject}: "
190 f"{counts[FAIL]} failing, {counts['pass']} passing, "
191 f"{counts['not_applicable']} n/a",
192 file=sys.stderr,
193 )
194 return _exit_code(findings, args.fail_on)
195
196
197if __name__ == "__main__":
198 raise SystemExit(main())