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 )