audit-labs/control-coverage

Control coverage and blind-spot analysis for audit evidence.

clone: git clone https://gitbay.org/audit-labs/control-coverage.git

v1.0.0: 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)]