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

main: 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())