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/catalog.py · raw
1"""Framework catalogs — the complete list of controls a framework defines.
2
3This is the piece the rest of the audit-labs ecosystem does not have. Tools like
4audit-report are *evidence-first*: they start from what you collected and map each
5finding onto whatever controls it touches. That can never tell you what you are
6**not** looking at, because it has no list of everything a framework requires.
7
8A catalog is that list — the denominator. Loading ``soc2`` gives every Trust
9Services Criterion; loading ``iso`` gives all 93 Annex A controls. Coverage is
10then simply: of these, how many does the evidence corpus actually address?
11
12Control codes are written ``FRAMEWORK:ID`` (for example ``SOC2:CC6.1``,
13``ISO:A.5.17``), matching the codes audit-report rulesets cite.
14"""
15
16from __future__ import annotations
17
18import hashlib
19from dataclasses import dataclass
20from pathlib import Path
21
22import yaml
23
24_CATALOG_DIR = Path(__file__).resolve().parent / "catalogs"
25
26# User-facing framework name -> catalog file stem. Aliases keep the CLI forgiving.
27_ALIASES = {
28 "soc2": "soc2",
29 "soc 2": "soc2",
30 "iso": "iso27001",
31 "iso27001": "iso27001",
32 "iso 27001": "iso27001",
33 "nist": "nist80053",
34 "nist80053": "nist80053",
35 "800-53": "nist80053",
36}
37
38
39@dataclass(frozen=True)
40class Control:
41 """One control in a framework catalog."""
42
43 framework: str
44 id: str
45 title: str
46 family: str = ""
47
48 @property
49 def code(self) -> str:
50 """The full ``FRAMEWORK:ID`` code used to join against evidence."""
51 return f"{self.framework}:{self.id}"
52
53
54@dataclass
55class Catalog:
56 """A framework's complete (or explicitly partial) set of controls."""
57
58 framework: str
59 name: str
60 version: str
61 coverage: str # "complete" or "partial"
62 source: str
63 controls: list[Control]
64 sha256: str = "" # digest of the catalog file, so coverage ties to a mapping
65
66 @property
67 def complete(self) -> bool:
68 return self.coverage == "complete"
69
70 def codes(self) -> set[str]:
71 return {c.code for c in self.controls}
72
73
74def available() -> list[str]:
75 """Framework short codes with a bundled catalog (e.g. ``["ISO", "NIST", "SOC2"]``)."""
76 return sorted(load(p.stem).framework for p in _CATALOG_DIR.glob("*.yaml"))
77
78
79def _resolve(name: str) -> Path:
80 stem = _ALIASES.get(name.strip().lower(), name.strip().lower())
81 path = _CATALOG_DIR / f"{stem}.yaml"
82 if not path.exists():
83 known = ", ".join(sorted(p.stem for p in _CATALOG_DIR.glob("*.yaml")))
84 raise ValueError(f"unknown framework '{name}'. Bundled catalogs: {known}")
85 return path
86
87
88def load(name: str) -> Catalog:
89 """Load a bundled catalog by framework name, short code, or alias."""
90 path = _resolve(name)
91 text = path.read_text(encoding="utf-8")
92 raw = yaml.safe_load(text)
93 framework = raw["framework"]
94 controls = [
95 Control(
96 framework=framework,
97 id=str(c["id"]),
98 title=str(c["title"]),
99 family=str(c.get("family", "")),
100 )
101 for c in raw.get("controls", [])
102 ]
103 return Catalog(
104 framework=framework,
105 name=raw.get("name", framework),
106 version=str(raw.get("version", "")),
107 coverage=raw.get("coverage", "partial"),
108 source=raw.get("source", ""),
109 controls=controls,
110 sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
111 )
112
113
114def load_frameworks(names: list[str]) -> list[Catalog]:
115 """Load several catalogs, de-duplicated by framework, in a stable order."""
116 seen: dict[str, Catalog] = {}
117 for name in names:
118 cat = load(name)
119 seen[cat.framework] = cat
120 return [seen[k] for k in sorted(seen)]