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/crosswalk.py · raw

  1"""Crosswalk — which controls each piece of evidence supports, across frameworks.
  2
  3One check is rarely worth one control. Enforced 2FA is evidence for SOC 2 CC6.1,
  4ISO A.5.17, and NIST IA-2 at once. This module inverts the coverage result to show
  5that leverage: for every check (rule) in the corpus, the set of controls it
  6addresses and the frameworks it spans.
  7
  8It then answers a practical question auditors and evidence-owners both ask — *what
  9is the smallest set of checks that still covers everything?* — with a greedy
 10set-cover over the addressed controls. The result is an ordered "minimal evidence
 11set": collect these few checks and you have touched every control the full corpus
 12touches, which is what you want when scoping a walkthrough or a sample.
 13
 14"Addressed" here matches the coverage engine: a control any finding maps to,
 15whatever the finding's outcome.
 16"""
 17
 18from __future__ import annotations
 19
 20from dataclasses import dataclass, field
 21
 22from .coverage import CoverageReport
 23
 24
 25@dataclass
 26class EvidenceItem:
 27    """One check and the controls it supports across frameworks."""
 28
 29    rule_id: str
 30    title: str
 31    controls: list[str]  # full FRAMEWORK:ID codes, sorted
 32    frameworks: list[str]  # framework short codes it spans, sorted
 33
 34    @property
 35    def count(self) -> int:
 36        return len(self.controls)
 37
 38
 39@dataclass
 40class CoverStep:
 41    """One pick in the greedy minimal-evidence set."""
 42
 43    rule_id: str
 44    new_controls: int  # controls this pick added that were not yet covered
 45    cumulative: int
 46    cumulative_pct: float
 47
 48
 49@dataclass
 50class Crosswalk:
 51    subject: str
 52    generated_at: str
 53    items: list[EvidenceItem] = field(default_factory=list)
 54    cover: list[CoverStep] = field(default_factory=list)
 55    universe_size: int = 0
 56
 57    @property
 58    def multi_framework(self) -> list[EvidenceItem]:
 59        """Checks that earn coverage in more than one framework at once."""
 60        return [i for i in self.items if len(i.frameworks) > 1]
 61
 62
 63def build(report: CoverageReport) -> Crosswalk:
 64    """Invert a coverage report into a crosswalk and a minimal evidence set."""
 65    rule_controls: dict[str, set[str]] = {}
 66    rule_title: dict[str, str] = {}
 67    rule_frameworks: dict[str, set[str]] = {}
 68    universe: set[str] = set()
 69
 70    for fc in report.frameworks:
 71        for r in fc.results:
 72            if not r.addressed:
 73                continue
 74            code = r.control.code
 75            universe.add(code)
 76            for obs in r.observations:
 77                if not obs.rule_id:
 78                    continue
 79                rule_controls.setdefault(obs.rule_id, set()).add(code)
 80                rule_title.setdefault(obs.rule_id, obs.title)
 81                rule_frameworks.setdefault(obs.rule_id, set()).add(fc.catalog.framework)
 82
 83    items = [
 84        EvidenceItem(
 85            rule_id=rid,
 86            title=rule_title.get(rid, ""),
 87            controls=sorted(codes),
 88            frameworks=sorted(rule_frameworks.get(rid, set())),
 89        )
 90        for rid, codes in rule_controls.items()
 91    ]
 92    # Most leverage first; rule id breaks ties for stable output.
 93    items.sort(key=lambda i: (-i.count, i.rule_id))
 94
 95    cover = _greedy_cover(rule_controls, universe)
 96    return Crosswalk(
 97        subject=report.subject,
 98        generated_at=report.generated_at,
 99        items=items,
100        cover=cover,
101        universe_size=len(universe),
102    )
103
104
105def _greedy_cover(rule_controls: dict[str, set[str]], universe: set[str]) -> list[CoverStep]:
106    remaining = set(universe)
107    pool = {rid: set(codes) for rid, codes in rule_controls.items()}
108    total = len(universe) or 1
109    steps: list[CoverStep] = []
110
111    while remaining:
112        best_rule, best_gain = None, 0
113        for rid in sorted(pool):
114            gain = len(pool[rid] & remaining)
115            if gain > best_gain:
116                best_rule, best_gain = rid, gain
117        if not best_rule:  # nothing left can cover the remainder
118            break
119        remaining -= pool[best_rule]
120        del pool[best_rule]
121        cumulative = len(universe) - len(remaining)
122        steps.append(
123            CoverStep(
124                rule_id=best_rule,
125                new_controls=best_gain,
126                cumulative=cumulative,
127                cumulative_pct=round(100 * cumulative / total, 1),
128            )
129        )
130    return steps
131
132
133# --- renderers -------------------------------------------------------------
134
135
136def to_dict(xw: Crosswalk) -> dict:
137    return {
138        "subject": xw.subject,
139        "generated_at": xw.generated_at,
140        "universe_size": xw.universe_size,
141        "minimal_evidence_set": [
142            {
143                "rule_id": s.rule_id,
144                "new_controls": s.new_controls,
145                "cumulative": s.cumulative,
146                "cumulative_pct": s.cumulative_pct,
147            }
148            for s in xw.cover
149        ],
150        "evidence": [
151            {
152                "rule_id": i.rule_id,
153                "title": i.title,
154                "frameworks": i.frameworks,
155                "controls": i.controls,
156                "count": i.count,
157            }
158            for i in xw.items
159        ],
160    }
161
162
163def render_json(xw: Crosswalk) -> str:
164    import json
165
166    return json.dumps(to_dict(xw), indent=2, sort_keys=False) + "\n"
167
168
169def render_markdown(xw: Crosswalk) -> str:
170    out: list[str] = []
171    out.append(f"# Evidence Crosswalk — {xw.subject or 'Evidence corpus'}")
172    out.append("")
173    out.append(f"- **Generated:** {xw.generated_at}")
174    out.append(f"- **Addressed controls:** {xw.universe_size}")
175    out.append(f"- **Checks in corpus:** {len(xw.items)}")
176    out.append(f"- **Checks spanning multiple frameworks:** {len(xw.multi_framework)}")
177    out.append("")
178
179    out.append("## Minimal evidence set")
180    out.append("")
181    if xw.cover:
182        out.append(
183            f"The {len(xw.cover)} check(s) below cover all {xw.universe_size} addressed "
184            "controls — the smallest set that touches everything the full corpus does."
185        )
186        out.append("")
187        out.append("| # | Check | New controls | Cumulative | % of addressed |")
188        out.append("| ---: | --- | ---: | ---: | ---: |")
189        for n, s in enumerate(xw.cover, 1):
190            out.append(
191                f"| {n} | `{s.rule_id}` | +{s.new_controls} | {s.cumulative} | {s.cumulative_pct}% |"
192            )
193    else:
194        out.append("_No addressed controls to cover._")
195    out.append("")
196
197    out.append("## Evidence leverage")
198    out.append("")
199    out.append("Each check and the controls it supports, most leverage first.")
200    out.append("")
201    out.append("| Check | Frameworks | # | Controls |")
202    out.append("| --- | --- | ---: | --- |")
203    for i in xw.items:
204        codes = ", ".join(f"`{c}`" for c in i.controls)
205        fws = ", ".join(i.frameworks)
206        out.append(f"| `{i.rule_id}` | {fws} | {i.count} | {codes} |")
207    out.append("")
208
209    return "\n".join(out).rstrip() + "\n"
210
211
212_XW_CSS = """
213.fw { display: inline-block; font-size: .7rem; font-weight: 700; letter-spacing: .02em;
214  padding: .08rem .4rem; border-radius: 4px; margin-right: .25rem; background: #eef; color: #33488c; }
215.track { position: relative; background: #eee; border-radius: 4px; height: 1rem; min-width: 5rem; }
216.track > span { position: absolute; left: 0; top: 0; bottom: 0; background: #35b866; border-radius: 4px; }
217.codes code { font-size: .78rem; }
218@media (prefers-color-scheme: dark) {
219  .fw { background: #22243a; color: #9fb0f0; }
220  .track { background: #26272b; }
221}
222"""
223
224
225def render_html(xw: Crosswalk) -> str:
226    from html import escape
227
228    from .reporters.html import CSS
229
230    title = xw.subject or "Evidence corpus"
231    body = [
232        "<!doctype html><html lang='en'><head><meta charset='utf-8'>",
233        "<meta name='viewport' content='width=device-width, initial-scale=1'>",
234        f"<title>Evidence Crosswalk — {escape(title)}</title>",
235        f"<style>{CSS}{_XW_CSS}</style></head><body><main>",
236        f"<h1>Evidence Crosswalk — {escape(title)}</h1>",
237        (
238            f'<p class="meta">Generated {escape(xw.generated_at)} · '
239            f"{xw.universe_size} addressed control(s) · {len(xw.items)} check(s) · "
240            f"{len(xw.multi_framework)} spanning multiple frameworks</p>"
241        ),
242        "<h2>Minimal evidence set</h2>",
243    ]
244    if xw.cover:
245        body.append(
246            f"<p>The {len(xw.cover)} check(s) below cover all {xw.universe_size} addressed "
247            "controls — the smallest set that touches everything the full corpus does.</p>"
248        )
249        body.append(
250            "<table><thead><tr><th class='num'>#</th><th>Check</th>"
251            "<th class='num'>New</th><th class='num'>Cumulative</th>"
252            "<th>% of addressed</th></tr></thead><tbody>"
253        )
254        for n, s in enumerate(xw.cover, 1):
255            body.append(
256                "<tr>"
257                f'<td class="num">{n}</td>'
258                f"<td><code>{escape(s.rule_id)}</code></td>"
259                f'<td class="num">+{s.new_controls}</td>'
260                f'<td class="num">{s.cumulative}</td>'
261                f'<td><div class="track" title="{s.cumulative_pct}%">'
262                f'<span style="width:{s.cumulative_pct:.1f}%"></span></div> {s.cumulative_pct}%</td>'
263                "</tr>"
264            )
265        body.append("</tbody></table>")
266    else:
267        body.append("<p>No addressed controls to cover.</p>")
268
269    body.append("<h2>Evidence leverage</h2>")
270    body.append("<p>Each check and the controls it supports, most leverage first.</p>")
271    body.append(
272        "<table><thead><tr><th>Check</th><th>Frameworks</th><th class='num'>#</th>"
273        "<th>Controls</th></tr></thead><tbody>"
274    )
275    for i in xw.items:
276        fws = "".join(f'<span class="fw">{escape(f)}</span>' for f in i.frameworks)
277        codes = ", ".join(f"<code>{escape(c)}</code>" for c in i.controls)
278        body.append(
279            "<tr>"
280            f"<td><code>{escape(i.rule_id)}</code></td>"
281            f"<td>{fws}</td>"
282            f'<td class="num">{i.count}</td>'
283            f'<td class="codes">{codes}</td>'
284            "</tr>"
285        )
286    body.append("</tbody></table>")
287    body.append(
288        "<footer>Generated by control-coverage · Audit Labs. Evidence, not a verdict.</footer>"
289    )
290    body.append("</main></body></html>")
291    return "".join(body)