audit-labs/control-coverage
Control coverage and blind-spot analysis for audit evidence.
clone: git clone https://gitbay.org/audit-labs/control-coverage.git
main: control_coverage/cli.py · raw
1"""Command-line entry point for control-coverage."""
2
3from __future__ import annotations
4
5import argparse
6import sys
7from datetime import datetime, timezone
8from pathlib import Path
9
10from . import __version__, catalog, corpus, reporters, scope
11from .coverage import evaluate
12
13
14def _parse_args(argv: list[str]) -> argparse.Namespace:
15 parser = argparse.ArgumentParser(
16 prog="control-coverage",
17 description=(
18 "Score an evidence corpus against complete framework catalogs: what "
19 "share of each framework does the evidence address, and which controls "
20 "are blind spots no finding touches?"
21 ),
22 )
23 parser.add_argument(
24 "reports",
25 nargs="+",
26 help="audit-report JSON files, and/or directories containing them",
27 )
28 parser.add_argument(
29 "--framework",
30 help=(
31 "comma-separated frameworks to evaluate (e.g. SOC2,ISO,NIST). "
32 "Default: every framework the corpus cites, or the scope file's list."
33 ),
34 )
35 parser.add_argument(
36 "--scope",
37 help="path to a scope / Statement of Applicability YAML (marks exclusions)",
38 )
39 parser.add_argument(
40 "--subject",
41 help="name for the subject of this corpus (overrides the scope file)",
42 )
43 parser.add_argument(
44 "--format",
45 default="md",
46 help="comma-separated output formats: md, html, json, soa (default: md)",
47 )
48 parser.add_argument(
49 "--out",
50 help="directory to write reports into (default: print the first format to stdout)",
51 )
52 parser.add_argument(
53 "--blind-spots",
54 action="store_true",
55 help="print only the unaddressed in-scope controls, then exit",
56 )
57 parser.add_argument(
58 "--baseline",
59 metavar="PATH",
60 help=(
61 "trend mode: an earlier corpus (file or directory) to compare against. "
62 "Reports how coverage moved — what improved, regressed, was gained or lost."
63 ),
64 )
65 parser.add_argument(
66 "--crosswalk",
67 action="store_true",
68 help=(
69 "crosswalk mode: show which controls each piece of evidence supports "
70 "across frameworks, and the minimal evidence set that covers them all"
71 ),
72 )
73 parser.add_argument(
74 "--fail-under",
75 type=float,
76 metavar="PCT",
77 help="exit non-zero if any framework's coverage %% is below PCT (CI gate)",
78 )
79 parser.add_argument(
80 "--fail-on-regression",
81 action="store_true",
82 help="in trend mode, exit non-zero if any control regressed or lost coverage",
83 )
84 parser.add_argument("--version", action="version", version=f"control-coverage {__version__}")
85 return parser.parse_args(argv)
86
87
88def _select_frameworks(args, observations, scp) -> list[str]:
89 """Decide which framework catalogs to load, in priority order."""
90 if args.framework:
91 return [f.strip() for f in args.framework.split(",") if f.strip()]
92 if scp.frameworks:
93 return scp.frameworks
94 # Infer from the corpus: every framework prefix the observations cite.
95 cited = sorted({o.control.split(":", 1)[0] for o in observations if ":" in o.control})
96 if not cited:
97 raise SystemExit(
98 "error: could not infer frameworks from the corpus. Pass --framework."
99 )
100 return cited
101
102
103def _print_blind_spots(report) -> None:
104 total = 0
105 for fc in report.frameworks:
106 spots = fc.blind_spots
107 if not spots:
108 continue
109 print(f"{fc.catalog.name} — {len(spots)} unaddressed:")
110 for r in spots:
111 print(f" {r.control.code} {r.control.title}")
112 total += len(spots)
113 print(f"\n{total} in-scope control(s) unaddressed across {len(report.frameworks)} framework(s).")
114
115
116def _now() -> str:
117 return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
118
119
120def _build_report(paths, scp, names, subject):
121 """Load a corpus from *paths* and evaluate it into a CoverageReport."""
122 try:
123 observations = corpus.load_corpus(paths)
124 except (ValueError, OSError) as exc:
125 raise SystemExit(f"error: {exc}") from None
126 catalogs = catalog.load_frameworks(names)
127 return evaluate(catalogs, observations, scope=scp, subject=subject, generated_at=_now())
128
129
130def main(argv: list[str] | None = None) -> int:
131 args = _parse_args(sys.argv[1:] if argv is None else argv)
132
133 if args.crosswalk and args.baseline:
134 raise SystemExit("error: --crosswalk and --baseline cannot be combined")
135
136 try:
137 observations = corpus.load_corpus(args.reports)
138 except (ValueError, OSError) as exc:
139 raise SystemExit(f"error: {exc}") from None
140
141 scp = scope.load(args.scope) if args.scope else scope.empty()
142
143 try:
144 names = _select_frameworks(args, observations, scp)
145 catalogs = catalog.load_frameworks(names)
146 except ValueError as exc:
147 raise SystemExit(f"error: {exc}") from None
148
149 subject = args.subject or scp.subject
150 report = evaluate(catalogs, observations, scope=scp, subject=subject, generated_at=_now())
151
152 if args.baseline:
153 return _trend_mode(args, scp, names, subject, report)
154
155 if args.crosswalk:
156 return _crosswalk_mode(args, report)
157
158 if args.blind_spots:
159 _print_blind_spots(report)
160 return _exit_code(report, args.fail_under)
161
162 return _default_mode(args, report, subject)
163
164
165def _default_mode(args, report, subject) -> int:
166 formats = [f.strip() for f in args.format.split(",") if f.strip()]
167 if args.out:
168 out_dir = Path(args.out)
169 out_dir.mkdir(parents=True, exist_ok=True)
170 stem = _slug(subject) or "coverage"
171 for fmt in formats:
172 ext = reporters.EXTENSIONS.get(fmt, fmt)
173 name = "soa" if fmt == "soa" else "coverage"
174 path = out_dir / f"{name}.{ext}" if fmt == "soa" else out_dir / f"{stem}.{ext}"
175 path.write_text(reporters.render(report, fmt), encoding="utf-8")
176 print(f"wrote {path}")
177 else:
178 # Print the first requested format to stdout.
179 print(reporters.render(report, formats[0]), end="")
180
181 return _exit_code(report, args.fail_under)
182
183
184def _trend_mode(args, scp, names, subject, current) -> int:
185 from . import trend
186
187 baseline = _build_report([args.baseline], scp, names, subject)
188 tr = trend.compare(baseline, current)
189
190 formats = [f.strip() for f in args.format.split(",") if f.strip()]
191 renderers = {
192 "md": trend.render_markdown,
193 "html": trend.render_html,
194 "json": trend.render_json,
195 }
196 unknown = [f for f in formats if f not in renderers]
197 if unknown:
198 raise SystemExit(f"error: trend mode supports md, html, and json, not: {', '.join(unknown)}")
199
200 if args.out:
201 out_dir = Path(args.out)
202 out_dir.mkdir(parents=True, exist_ok=True)
203 for fmt in formats:
204 path = out_dir / f"trend.{fmt}"
205 path.write_text(renderers[fmt](tr), encoding="utf-8")
206 print(f"wrote {path}")
207 else:
208 print(renderers[formats[0]](tr), end="")
209
210 if args.fail_on_regression and tr.total_regressions:
211 print(
212 f"trend gate: {tr.total_regressions} control(s) regressed or lost coverage",
213 file=sys.stderr,
214 )
215 return 1
216 return 0
217
218
219def _crosswalk_mode(args, report) -> int:
220 from . import crosswalk
221
222 xw = crosswalk.build(report)
223 formats = [f.strip() for f in args.format.split(",") if f.strip()]
224 renderers = {
225 "md": crosswalk.render_markdown,
226 "html": crosswalk.render_html,
227 "json": crosswalk.render_json,
228 }
229 unknown = [f for f in formats if f not in renderers]
230 if unknown:
231 raise SystemExit(
232 f"error: crosswalk mode supports md, html, and json, not: {', '.join(unknown)}"
233 )
234
235 if args.out:
236 out_dir = Path(args.out)
237 out_dir.mkdir(parents=True, exist_ok=True)
238 for fmt in formats:
239 path = out_dir / f"crosswalk.{fmt}"
240 path.write_text(renderers[fmt](xw), encoding="utf-8")
241 print(f"wrote {path}")
242 else:
243 print(renderers[formats[0]](xw), end="")
244 return 0
245
246
247def _exit_code(report, fail_under: float | None) -> int:
248 if fail_under is None:
249 return 0
250 below = [fc for fc in report.frameworks if fc.coverage_pct < fail_under]
251 if below:
252 for fc in below:
253 print(
254 f"coverage gate: {fc.catalog.framework} at {fc.coverage_pct}% "
255 f"is below {fail_under}%",
256 file=sys.stderr,
257 )
258 return 1
259 return 0
260
261
262def _slug(text: str) -> str:
263 return "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
264
265
266if __name__ == "__main__":
267 raise SystemExit(main())