Commit cadec30c7d
cadec30c7d7c510c0eb48b6abc45913e3f4fc93e
parent: 3d273a7bf8
Unsigned
cmc <hello@cleberg.net> · 2026-08-19 23:58 UTC
Collapse branch protection and rulesets into one evidence row
Classic branch protection and repository rulesets are two implementations
of the same control (CC8.1 / A.8.32), but were reported as independent
signals: a repo whose default branch is covered by an active ruleset still
emitted a branch_protection/disabled row and counted as a change-control
gap the ruleset already addresses.
buildEvidenceRows now keeps one row per (framework, control, repo) for the
pair, preferring the mechanism actually in force. Both snapshots are still
recorded — the collapse is query-time, like the mapping join itself.
Closes #27
Layout: unified · split
docs/architecture.md
+7 −1
| @@ -227,7 +227,8 @@ flowchart TB |
| 227 | 227 | L --> J{{"JOIN control_mappings<br/>on resource + status"}} |
| 228 | 228 | CM[("control_mappings")] --> J |
| 229 | 229 | J --> F["filter by framework<br/>(soc2 | iso27001 | all)"] |
| 230 | | F --> E["evidence rows<br/>control · posture · rationale"] |
| 230 | F --> C["collapse branch_protection<br/>+ repository_ruleset to one row"] |
| 231 | C --> E["evidence rows<br/>control · posture · rationale"] |
| 231 | 232 | E --> CSV["renderCsv"] |
| 232 | 233 | E --> PDF["renderPdf"] |
| 233 | 234 | ``` |
| @@ -235,6 +236,11 @@ flowchart TB |
| 235 | 236 | Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply |
| 236 | 237 | produce no rows — no evidence in either direction. |
| 237 | 238 | |
| 239 | Classic branch protection and repository rulesets both attest the same control, |
| 240 | so the two are collapsed to one row per (framework, control, repo) — enabled |
| 241 | wins over disabled — rather than letting an unused mechanism report a gap the |
| 242 | other one covers. |
| 243 | |
| 238 | 244 | ## Boundaries & isolation |
| 239 | 245 | |
| 240 | 246 | - **Multi-tenant scoping.** Every session route is scoped to the session's |
docs/framework-mapping.md
+7
| @@ -151,6 +151,13 @@ the next poll settles the state. See [`extractFact`](../src/webhook.ts). |
| 151 | 151 | change control in a Git workflow. Enabled → **positive**; disabled → |
| 152 | 152 | **negative** ("direct pushes possible" is a concrete change-control gap). |
| 153 | 153 | |
| 154 | **One row per repo, not two.** The two are alternative implementations of the |
| 155 | same control, so the evidence query collapses them to a single row per |
| 156 | (framework, control, repo), keeping whichever mechanism is actually in force — |
| 157 | a repo covered by an active ruleset is not a change-control gap merely because |
| 158 | classic protection is off. Both snapshots are still recorded; the collapse |
| 159 | happens at query time in [`collapseChangeControl`](../src/exporter.ts). |
| 160 | |
| 154 | 161 | **Fit assessment: strong, with the scope stated in the evidence itself.** Both |
| 155 | 162 | frameworks name "change management" explicitly, and branch protection is the |
| 156 | 163 | canonical GitHub-native implementation of it. What the evidence attests is that |
src/exporter.ts
+30 −2
| @@ -57,12 +57,40 @@ export async function buildEvidenceRows( |
| 57 | 57 | l.resource NOT IN ('org_member', 'team_member') |
| 58 | 58 | OR l.captured_at = (SELECT t FROM access_latest) |
| 59 | 59 | ) |
| 60 | | ORDER BY cm.framework, cm.control_id, l.repo`, |
| 60 | -- l.resource last so the change-control collapse below sees |
| 61 | -- branch_protection before repository_ruleset deterministically. |
| 62 | ORDER BY cm.framework, cm.control_id, l.repo, l.resource`, |
| 61 | 63 | ) |
| 62 | 64 | .bind(installationId, framework) |
| 63 | 65 | .all<EvidenceRow>(); |
| 64 | 66 | |
| 65 | | return results; |
| 67 | return collapseChangeControl(results); |
| 68 | } |
| 69 | |
| 70 | // Classic branch protection and repository rulesets are two implementations of |
| 71 | // the same control (CC8.1 / A.8.32), and a repo can have either, both, or |
| 72 | // neither. Reported separately, a repo whose default branch is covered by an |
| 73 | // active ruleset still emitted a `branch_protection` / `disabled` row and |
| 74 | // counted as a change-control gap that isn't one. Collapse the pair to one row |
| 75 | // per (framework, control, repo), keeping the mechanism actually in force: |
| 76 | // enabled beats disabled, and classic protection wins an otherwise-equal tie |
| 77 | // because its rationale describes the repo's state without naming a mechanism |
| 78 | // the reader may not use. |
| 79 | const CHANGE_CONTROL_RESOURCES = new Set(["branch_protection", "repository_ruleset"]); |
| 80 | |
| 81 | function collapseChangeControl(rows: EvidenceRow[]): EvidenceRow[] { |
| 82 | const groupKey = (row: EvidenceRow) => `${row.framework}|${row.control_id}|${row.repo}`; |
| 83 | const winners = new Map<string, EvidenceRow>(); |
| 84 | |
| 85 | for (const row of rows) { |
| 86 | if (!CHANGE_CONTROL_RESOURCES.has(row.resource)) continue; |
| 87 | const incumbent = winners.get(groupKey(row)); |
| 88 | if (!incumbent || (row.posture === "positive" && incumbent.posture !== "positive")) { |
| 89 | winners.set(groupKey(row), row); |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | return rows.filter((row) => !CHANGE_CONTROL_RESOURCES.has(row.resource) || winners.get(groupKey(row)) === row); |
| 66 | 94 | } |
| 67 | 95 | |
| 68 | 96 | const CSV_COLUMNS: Array<keyof EvidenceRow> = [ |