audit-labs/control-coverage

Control coverage and blind-spot analysis for audit evidence.

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

v0.1.0: control_coverage/scope.py · raw

  1"""Scope — which controls are in play, and which are excluded with justification.
  2
  3Not every control applies to every organization. ISO 27001 formalizes this as the
  4**Statement of Applicability (SoA)**: for each Annex A control, a decision to apply
  5it or not, and the reason. This module reads a small YAML scope file expressing
  6exactly that, so coverage is computed over *in-scope* controls and exclusions are
  7recorded rather than silently counted as gaps::
  8
  9    subject: Acme Production
 10    frameworks: [SOC2, ISO]
 11    exclusions:
 12      - {control: ISO:A.5.7, reason: "No formal threat-intel program; risk accepted 2026-Q1."}
 13      - {control: ISO:A.7.1, reason: "Fully cloud-hosted; no physical premises in scope."}
 14    exclude_families:
 15      - {framework: SOC2, family: Privacy, reason: "Privacy category not in the SOC 2 audit scope."}
 16    owners:
 17      SOC2:CC6.1: platform-team
 18
 19``exclude_families`` removes a whole category at once — a SOC 2 Trust Services
 20category, an ISO Annex A theme, a NIST family — which is how audit scope is actually
 21decided (a SOC 2 report covers Security and maybe Availability, rarely Privacy).
 22
 23Every exclusion, per-control or per-family, must carry a reason — an exclusion without
 24justification is the single most common SoA audit finding, so we reject it rather than
 25accept it.
 26"""
 27
 28from __future__ import annotations
 29
 30from dataclasses import dataclass, field
 31from pathlib import Path
 32
 33import yaml
 34
 35
 36@dataclass
 37class Scope:
 38    """A parsed scope / Statement of Applicability."""
 39
 40    subject: str = ""
 41    frameworks: list[str] = field(default_factory=list)
 42    # control code -> justification for excluding it
 43    exclusions: dict[str, str] = field(default_factory=dict)
 44    # (framework, family) -> justification for excluding a whole family/category
 45    family_exclusions: dict[tuple[str, str], str] = field(default_factory=dict)
 46    # control code -> owning team/person (optional metadata)
 47    owners: dict[str, str] = field(default_factory=dict)
 48
 49    def excluded(self, code: str) -> bool:
 50        return code in self.exclusions
 51
 52    def reason(self, code: str) -> str:
 53        return self.exclusions.get(code, "")
 54
 55    def family_excluded(self, framework: str, family: str) -> bool:
 56        return (framework, family) in self.family_exclusions
 57
 58    def family_reason(self, framework: str, family: str) -> str:
 59        return self.family_exclusions.get((framework, family), "")
 60
 61    def owner(self, code: str) -> str:
 62        return self.owners.get(code, "")
 63
 64
 65def empty() -> Scope:
 66    """A scope that excludes nothing — every catalog control is in scope."""
 67    return Scope()
 68
 69
 70def load(path: str | Path) -> Scope:
 71    """Load a scope file, validating that every exclusion carries a reason."""
 72    raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
 73
 74    exclusions: dict[str, str] = {}
 75    for i, item in enumerate(raw.get("exclusions", [])):
 76        if not isinstance(item, dict) or "control" not in item:
 77            raise ValueError(f"exclusion #{i + 1} must be a mapping with a 'control' key")
 78        code = str(item["control"]).strip()
 79        reason = str(item.get("reason", "")).strip()
 80        if not reason:
 81            raise ValueError(f"exclusion for '{code}' needs a non-empty 'reason'")
 82        exclusions[code] = reason
 83
 84    family_exclusions: dict[tuple[str, str], str] = {}
 85    for i, item in enumerate(raw.get("exclude_families", [])):
 86        if not isinstance(item, dict) or "framework" not in item or "family" not in item:
 87            raise ValueError(
 88                f"exclude_families #{i + 1} must be a mapping with 'framework' and 'family' keys"
 89            )
 90        framework = str(item["framework"]).strip()
 91        family = str(item["family"]).strip()
 92        reason = str(item.get("reason", "")).strip()
 93        if not reason:
 94            raise ValueError(f"family exclusion for '{framework}:{family}' needs a non-empty 'reason'")
 95        family_exclusions[(framework, family)] = reason
 96
 97    owners = {str(k): str(v) for k, v in (raw.get("owners") or {}).items()}
 98    frameworks = [str(f) for f in (raw.get("frameworks") or [])]
 99
100    return Scope(
101        subject=str(raw.get("subject", "")),
102        frameworks=frameworks,
103        exclusions=exclusions,
104        family_exclusions=family_exclusions,
105        owners=owners,
106    )