audit-labs/gh-attest

GitHub Audit Evidence Extractor

clone: git clone https://gitbay.org/audit-labs/gh-attest.git

51c38a22797e07211db857bfe36bea2fc041b559

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-08-19T23:58:07Z

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
 docs/architecture.md      |  8 +++++++-
 docs/framework-mapping.md |  7 +++++++
 src/exporter.ts           | 32 ++++++++++++++++++++++++++++++--
 3 files changed, 44 insertions(+), 3 deletions(-)

diff --git a/docs/architecture.md b/docs/architecture.md
index 303a22f..47c7aae 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -227,7 +227,8 @@ flowchart TB
   L --> J{{"JOIN control_mappings<br/>on resource + status"}}
   CM[("control_mappings")] --> J
   J --> F["filter by framework<br/>(soc2 | iso27001 | all)"]
-  F --> E["evidence rows<br/>control · posture · rationale"]
+  F --> C["collapse branch_protection<br/>+ repository_ruleset to one row"]
+  C --> E["evidence rows<br/>control · posture · rationale"]
   E --> CSV["renderCsv"]
   E --> PDF["renderPdf"]
 ```
@@ -235,6 +236,11 @@ flowchart TB
 Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply
 produce no rows — no evidence in either direction.
 
+Classic branch protection and repository rulesets both attest the same control,
+so the two are collapsed to one row per (framework, control, repo) — enabled
+wins over disabled — rather than letting an unused mechanism report a gap the
+other one covers.
+
 ## Boundaries & isolation
 
 - **Multi-tenant scoping.** Every session route is scoped to the session's
diff --git a/docs/framework-mapping.md b/docs/framework-mapping.md
index 563908b..52f5aa7 100644
--- a/docs/framework-mapping.md
+++ b/docs/framework-mapping.md
@@ -151,6 +151,13 @@ the next poll settles the state. See [`extractFact`](../src/webhook.ts).
 change control in a Git workflow. Enabled → **positive**; disabled →
 **negative** ("direct pushes possible" is a concrete change-control gap).
 
+**One row per repo, not two.** The two are alternative implementations of the
+same control, so the evidence query collapses them to a single row per
+(framework, control, repo), keeping whichever mechanism is actually in force —
+a repo covered by an active ruleset is not a change-control gap merely because
+classic protection is off. Both snapshots are still recorded; the collapse
+happens at query time in [`collapseChangeControl`](../src/exporter.ts).
+
 **Fit assessment: strong, with the scope stated in the evidence itself.** Both
 frameworks name "change management" explicitly, and branch protection is the
 canonical GitHub-native implementation of it. What the evidence attests is that
diff --git a/src/exporter.ts b/src/exporter.ts
index 1d4341b..656abfa 100644
--- a/src/exporter.ts
+++ b/src/exporter.ts
@@ -57,12 +57,40 @@ export async function buildEvidenceRows(
            l.resource NOT IN ('org_member', 'team_member')
            OR l.captured_at = (SELECT t FROM access_latest)
          )
-       ORDER BY cm.framework, cm.control_id, l.repo`,
+       -- l.resource last so the change-control collapse below sees
+       -- branch_protection before repository_ruleset deterministically.
+       ORDER BY cm.framework, cm.control_id, l.repo, l.resource`,
     )
     .bind(installationId, framework)
     .all<EvidenceRow>();
 
-  return results;
+  return collapseChangeControl(results);
+}
+
+// Classic branch protection and repository rulesets are two implementations of
+// the same control (CC8.1 / A.8.32), and a repo can have either, both, or
+// neither. Reported separately, 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 that isn't one. Collapse the pair to one row
+// per (framework, control, repo), keeping the mechanism actually in force:
+// enabled beats disabled, and classic protection wins an otherwise-equal tie
+// because its rationale describes the repo's state without naming a mechanism
+// the reader may not use.
+const CHANGE_CONTROL_RESOURCES = new Set(["branch_protection", "repository_ruleset"]);
+
+function collapseChangeControl(rows: EvidenceRow[]): EvidenceRow[] {
+  const groupKey = (row: EvidenceRow) => `${row.framework}|${row.control_id}|${row.repo}`;
+  const winners = new Map<string, EvidenceRow>();
+
+  for (const row of rows) {
+    if (!CHANGE_CONTROL_RESOURCES.has(row.resource)) continue;
+    const incumbent = winners.get(groupKey(row));
+    if (!incumbent || (row.posture === "positive" && incumbent.posture !== "positive")) {
+      winners.set(groupKey(row), row);
+    }
+  }
+
+  return rows.filter((row) => !CHANGE_CONTROL_RESOURCES.has(row.resource) || winners.get(groupKey(row)) === row);
 }
 
 const CSV_COLUMNS: Array<keyof EvidenceRow> = [