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