audit-labs/gh-attest

GitHub Audit Evidence Extractor

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

7e090f3e86ce3cb53bc21197244e63b1c2c0214a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-26T17:33:42Z

docs: document framework mappings, close coverage gaps, enforce doc/SQL sync

Add docs/framework-mapping.md explaining, for every GitHub signal, why it is
offered as evidence for each SOC 2 / ISO 27001 control, with plain-language
control meanings and honest fit assessments. Written to be the basis for adding
future frameworks (includes a mapping methodology and checklist).

Close three asymmetric-coverage gaps found during the review:
- migration 0006: secret scanning -> SOC 2 CC6.1 (alongside existing CC6.6)
- migration 0007: Dependabot -> ISO A.8.8, code scanning -> SOC 2 CC7.1,
  secret scanning -> ISO A.5.17

Add scripts/check-mappings.mjs (npm run test:mappings): applies every migration
to an in-memory SQLite DB and diffs control_mappings against the doc's reference
table, so the two can't drift. No dependencies (built-in node:sqlite).

Update the README evidence table to match the new mappings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 README.md                                 |  10 +-
 docs/framework-mapping.md                 | 600 ++++++++++++++++++++++++++++++
 migrations/0006_secret_scanning_cc6_1.sql |  12 +
 migrations/0007_close_coverage_gaps.sql   |  30 ++
 package.json                              |   1 +
 scripts/check-mappings.mjs                |  89 +++++
 6 files changed, 738 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 55aafb8..a71f575 100644
--- a/README.md
+++ b/README.md
@@ -20,14 +20,16 @@ Read-only — it never modifies your repositories, permissions, or membership.
 | Signal | Evidence for |
 | --- | --- |
 | Branch protection, repository rulesets | SOC 2 CC8.1 · ISO 27001 A.8.32 |
-| Secret scanning alerts | SOC 2 CC6.6 |
-| Dependabot alerts | SOC 2 CC7.1, CC7.2 |
-| Code scanning alerts | ISO 27001 A.8.28, A.8.29 |
+| Secret scanning alerts | SOC 2 CC6.1, CC6.6 · ISO 27001 A.5.17 |
+| Dependabot alerts | SOC 2 CC7.1, CC7.2 · ISO 27001 A.8.8 |
+| Code scanning alerts | SOC 2 CC7.1 · ISO 27001 A.8.28, A.8.29 |
 | Organization / team membership | SOC 2 CC6.2, CC6.3 · ISO 27001 A.5.18 |
 | Repository inventory | ISO 27001 A.5.9 |
 
 Mappings live in `migrations/` and are applied as a join at query time, so a
-mapping can be corrected without re-ingesting history.
+mapping can be corrected without re-ingesting history. Every mapping — and the
+plain-language rationale for why each signal is evidence for its control — is
+documented in [docs/framework-mapping.md](docs/framework-mapping.md).
 
 ## How it works
 
diff --git a/docs/framework-mapping.md b/docs/framework-mapping.md
new file mode 100644
index 0000000..c914c2a
--- /dev/null
+++ b/docs/framework-mapping.md
@@ -0,0 +1,600 @@
+# Framework Mapping
+
+This document is the rationale behind every control mapping in gh-attest: what
+each GitHub signal is, which compliance control it is offered as evidence for,
+and **why that mapping is defensible to an auditor**. It also explains what each
+control code (`A.8.32`, `CC8.1`, …) actually means in plain language, and how to
+add a new framework.
+
+It is the human-readable companion to the machine-readable mappings in
+[`migrations/`](../migrations). The two must agree — see
+[Keeping this document in sync](#keeping-this-document-in-sync).
+
+> **Scope of the claim.** gh-attest produces *evidence*, not a compliance
+> verdict. A "positive" row means a GitHub setting is in a state that supports a
+> control; it does not mean the control is satisfied — that judgment belongs to
+> the organization and its auditor. This document explains why each signal is
+> *relevant* to a control, and is honest about where a mapping is a strong fit
+> versus a defensible-but-debatable one.
+
+---
+
+## Contents
+
+- [How mapping works mechanically](#how-mapping-works-mechanically)
+- [The frameworks in one paragraph each](#the-frameworks-in-one-paragraph-each)
+- [The signals and their mappings](#the-signals-and-their-mappings)
+  - [Branch protection & repository rulesets](#1-branch-protection--repository-rulesets)
+  - [Dependabot alerts](#2-dependabot-alerts)
+  - [Code scanning alerts](#3-code-scanning-alerts)
+  - [Secret scanning alerts](#4-secret-scanning-alerts)
+  - [Membership changes (webhook trail)](#5-membership-changes-webhook-trail)
+  - [Membership & team inventory (polled)](#6-membership--team-inventory-polled)
+  - [Repository inventory](#7-repository-inventory)
+- [Complete mapping reference](#complete-mapping-reference)
+- [Adding a new framework](#adding-a-new-framework)
+- [Keeping this document in sync](#keeping-this-document-in-sync)
+- [Sources](#sources)
+
+---
+
+## How mapping works mechanically
+
+Understanding the evidence output requires understanding four rules in the
+mapping engine. All four live in [`control_mappings`](../migrations/0002_control_mappings.sql)
+and [`buildEvidenceRows`](../src/exporter.ts).
+
+**1. A snapshot is a `(resource, status)` pair; a mapping is a row that attaches
+a control to one.** The poller and webhook handler both normalize GitHub events
+into a small vocabulary — `resource` (e.g. `branch_protection`, `dependabot_alert`)
+and `status` (e.g. `enabled`, `open`, `fixed`). See [`extractFact`](../src/webhook.ts)
+and [`poller.ts`](../src/poller.ts). Mapping happens as a **join at export time**,
+never at ingest, so a mapping can be corrected without re-ingesting history.
+
+**2. `status = NULL` in a mapping matches *any* status for that resource.** The
+join condition is `cm.status IS NULL OR cm.status = l.status`. This is how a
+"the tooling exists and is producing signal" fact is expressed independently of
+any individual finding's state.
+
+**3. Consequently, one snapshot can emit multiple evidence rows.** A single
+Dependabot alert with `status = 'open'` matches *both* the `NULL` mapping
+(CC7.1, "detection tooling is active", **positive**) *and* the `'open'` mapping
+(CC7.2, "unremediated vulnerability", **negative**). This is intentional: the
+existence of the scanner and the existence of an open finding are two different
+facts about two different control expectations. This behavior is called out
+per-signal below wherever it applies.
+
+**4. `posture` is the auditor-facing verdict on a row**, one of:
+
+| Posture | Meaning | Example |
+| --- | --- | --- |
+| `positive` | State supports the control | Branch protection enabled |
+| `negative` | State is a gap against the control | Branch protection disabled; open secret |
+| `informational` | Neither pass nor fail — an audit-trail / inventory fact | A member was added; a repo exists |
+
+Two more rules affect *which* snapshots become evidence at all:
+
+- **Unmapped states produce no evidence, in either direction.** A
+  `branch_protection` status of `unavailable` (GitHub returned 403 — the feature
+  isn't on the repo's plan; see [`fetchBranchProtection`](../src/poller.ts)) has
+  no mapping row, so it never counts as a pass *or* a fail. Same for raw
+  `push` events.
+- **"Latest row wins" per `(repo, subject, resource)`** gives point-in-time
+  current posture from an append-only table. Access facts are special-cased so a
+  member who lost access stops being attested — only the most recent poll batch
+  counts. See the CTE in [`buildEvidenceRows`](../src/exporter.ts).
+
+---
+
+## The frameworks in one paragraph each
+
+**SOC 2** is an attestation report defined by the AICPA's *Trust Services
+Criteria* (TSC). The criteria we map to are all in the **Common Criteria (CC)**
+series, which every SOC 2 report shares regardless of which trust categories are
+in scope. A code like `CC8.1` reads as *Common Criteria, category 8 (Change
+Management), criterion 1*. The CC categories used here: **CC6** — logical &
+physical access; **CC7** — system operations (detection & monitoring); **CC8** —
+change management.
+
+**ISO/IEC 27001:2022** is a certifiable ISMS standard. Its **Annex A** lists 93
+controls grouped into four themes: **A.5** organizational (37), **A.6** people
+(8), **A.7** physical (14), **A.8** technological (34). A code like `A.8.32`
+reads as *Annex A, theme 8 (Technological), control 32*. Note this is the
+**2022** numbering — the 2013 edition used different numbers, so a mapping must
+state which edition it targets.
+
+---
+
+## The signals and their mappings
+
+Each section below states: what GitHub thing we read and how, the `(resource,
+status)` vocabulary we normalize it to, the control(s) we map it to with a
+plain-language explanation, the evidentiary argument, and an honest fit
+assessment.
+
+### 1. Branch protection & repository rulesets
+
+**What we collect.** For each repo's default branch, whether merge controls are
+in force — via the `branch_protection_rule` and `repository_ruleset` webhooks
+(change events) and an hourly poll of the branch-protection and rulesets APIs
+(baseline, for protection that predates the install). Normalized to
+`resource ∈ {branch_protection, repository_ruleset}`, `status ∈ {enabled,
+disabled}`. A ruleset in `evaluate` (monitor-only) mode counts as **not**
+enabled because it does not actually block anything — see
+[`fetchRulesets`](../src/poller.ts).
+
+**Maps to:**
+
+| Framework | Control | Title / plain meaning |
+| --- | --- | --- |
+| SOC 2 | **CC8.1** | *Change management.* Changes to software/infrastructure must go through an authorized, controlled process — designed, tested, approved, and implemented — and unauthorized changes must be prevented. |
+| ISO 27001 | **A.8.32** | *Change management.* Changes to information systems must follow formal change-management procedures to prevent unauthorized or destabilizing changes. |
+
+**Why this holds.** Branch protection / rulesets are the technical enforcement of
+change control in a Git workflow: requiring pull-request review before merge,
+blocking direct pushes to the default branch, and requiring status checks to
+pass. That is exactly the "controlled process… stops unauthorized changes"
+language of both controls. Enabled → **positive**; disabled → **negative**
+("direct pushes now possible" is a concrete change-control gap).
+
+**Fit assessment: strong.** This is the least ambiguous mapping in the system —
+both frameworks name "change management" explicitly, and branch protection is
+the canonical GitHub-native implementation of it. The one nuance an auditor will
+probe is *scope*: we check the **default branch** only, and "enabled" does not
+verify that the *specific* rules (required reviewers, etc.) match the
+organization's policy. The evidence attests that a change-control gate exists,
+not that its configuration is sufficient.
+
+### 2. Dependabot alerts
+
+**What we collect.** `dependabot_alert` webhook events — known-vulnerability
+alerts against the repo's dependencies. `status` is the alert state: `open`,
+`fixed`, `dismissed`, `auto_dismissed`.
+
+**Maps to:**
+
+| Framework | Control | When | Posture |
+| --- | --- | --- | --- |
+| SOC 2 | **CC7.1** | any alert (`status = NULL`) | positive — "detection tooling is active" |
+| SOC 2 | **CC7.2** | `open` | negative — "unremediated known vulnerability" |
+| SOC 2 | **CC7.2** | `fixed` / `dismissed` / `auto_dismissed` | positive — "remediated" |
+| ISO 27001 | **A.8.8** | any alert (`status = NULL`) | positive — "technical vulnerability management active" |
+| ISO 27001 | **A.8.8** | `open` | negative — "unremediated known vulnerability" |
+| ISO 27001 | **A.8.8** | `fixed` / `dismissed` / `auto_dismissed` | positive — "remediated" |
+
+**Control meanings.**
+- **CC7.1** — *Detection & monitoring.* The entity uses detection procedures to
+  identify configuration changes that introduce new vulnerabilities, and
+  susceptibilities to *newly discovered* vulnerabilities. Dependabot is a
+  textbook example: it continuously matches your dependency tree against newly
+  published CVEs.
+- **CC7.2** — *Anomaly monitoring.* The entity monitors system components for
+  anomalies and analyzes them to determine whether they are security events.
+- **A.8.8** — *Management of technical vulnerabilities.* Information about
+  technical vulnerabilities must be obtained, exposure evaluated, and
+  appropriate measures taken. This is a single control spanning the whole
+  vulnerability lifecycle — detect, evaluate, remediate.
+
+**Why this holds.** The *presence* of Dependabot alerts proves the detection
+capability required by CC7.1 exists and is running — hence the `NULL` mapping
+fires positive on any alert regardless of state. Each *individual* alert's
+lifecycle (open vs. remediated) is then evidence for CC7.2: an open alert is an
+unresolved condition, a fixed/dismissed one is a closed one. On the **ISO** side
+the entire story lands on a *single* control, A.8.8, because A.8.8 explicitly
+covers the full lifecycle — so every status maps to A.8.8 (open → negative,
+remediated → positive, tooling-active → positive). See
+[migration 0007](../migrations/0007_close_coverage_gaps.sql).
+
+**Fit assessment: CC7.1 strong; CC7.2 defensible but the weakest link in the
+system.** CC7.2's formal text is about anomalies "indicative of malicious acts,
+natural disasters, and errors" — i.e. runtime security events. An unpatched
+dependency is a *known vulnerability*, which sits more naturally in CC7.1's
+"susceptibility to newly discovered vulnerabilities" language than in CC7.2's
+anomaly-detection language. Many auditors keep the **entire** dependency story
+(detection *and* remediation tracking) under CC7.1. **Recommendation:** before
+you present this to an auditor, decide whether open/remediated Dependabot state
+belongs under CC7.1 or CC7.2 in your control narrative, and align the mapping to
+that decision. Both are defensible; the current split is a design choice, not a
+requirement. The **ISO A.8.8** mapping, by contrast, is a strong, clean fit —
+A.8.8 is purpose-built for technical-vulnerability management and absorbs the
+whole lifecycle without the CC7.1/CC7.2 ambiguity. It was added in migration 0007
+to close a gap: before it, Dependabot produced no evidence at all in an ISO
+export.
+
+### 3. Code scanning alerts
+
+**What we collect.** `code_scanning_alert` webhook events — SAST findings from
+CodeQL or a third-party analyzer. `status ∈ {open, fixed, dismissed}`.
+
+**Maps to:**
+
+| Framework | Control | When | Posture |
+| --- | --- | --- | --- |
+| ISO 27001 | **A.8.29** | any alert (`status = NULL`) | positive — "security testing in development is active" |
+| ISO 27001 | **A.8.28** | `open` | negative — "unremediated finding" |
+| ISO 27001 | **A.8.28** | `fixed` / `dismissed` | positive — "remediated" |
+| SOC 2 | **CC7.1** | any alert (`status = NULL`) | positive — "detection tooling is active" |
+
+**Control meanings.**
+- **A.8.29** — *Security testing in development and acceptance.* Security testing
+  processes must be defined and run within the development lifecycle so
+  vulnerabilities are found before production. The existence of code scanning
+  *is* that testing process.
+- **A.8.28** — *Secure coding.* Secure coding principles must be applied during
+  development. An open finding is evidence of a secure-coding gap in the source;
+  a remediated one is evidence the gap was closed.
+- **CC7.1** — *Detection & monitoring.* (Same control as Dependabot's SOC 2
+  mapping.) Code scanning is detection tooling that surfaces vulnerabilities, so
+  its presence satisfies the "detection procedures exist and run" expectation.
+
+**Why this holds.** On the **ISO** side this splits cleanly across two controls
+that map to two facts: *"a testing process exists"* (A.8.29, from the `NULL`
+mapping) versus *"the code itself is/ isn't secure"* (A.8.28, from each finding's
+state). On the **SOC 2** side (added in [migration 0007](../migrations/0007_close_coverage_gaps.sql))
+only the tooling-active fact is mapped, to CC7.1 — mirroring how Dependabot's
+tooling-active fact maps to CC7.1.
+
+**Fit assessment: strong on ISO; SOC 2 intentionally partial.** The
+A.8.29-vs-A.8.28 split is clean — one control is about *having* the testing
+process, the other about the *code quality* it reveals — and both titles match
+the signal directly. The new SOC 2 CC7.1 mapping covers only detection-active,
+**not** finding-level state: code-scanning `open`/`fixed` rows are deliberately
+*not* routed to CC7.2, because whether the vulnerability lifecycle belongs under
+CC7.1 or CC7.2 is still an open decision (see the Dependabot fit assessment). Once
+that is settled, finding-level SOC 2 rows for code scanning can be added to match
+Dependabot. Until then a SOC 2 export shows code scanning as "detection active"
+only — which under-claims rather than over-claims, the safe direction.
+
+### 4. Secret scanning alerts
+
+**What we collect.** `secret_scanning_alert` webhook events — detected
+credentials/tokens committed to the repo. `status ∈ {open, resolved}`.
+
+**Maps to:**
+
+| Framework | Control | When | Posture |
+| --- | --- | --- | --- |
+| SOC 2 | **CC6.6** | any alert (`status = NULL`) | positive — "leaked-credential detection is active" |
+| SOC 2 | **CC6.6** | `open` | negative — "live credential exposure" |
+| SOC 2 | **CC6.6** | `resolved` | positive — "exposure remediated" |
+| SOC 2 | **CC6.1** | any alert (`status = NULL`) | positive — "logical-access credential protection active" |
+| SOC 2 | **CC6.1** | `open` | negative — "exposed credential undermines logical access controls" |
+| SOC 2 | **CC6.1** | `resolved` | positive — "logical access control restored" |
+| ISO 27001 | **A.5.17** | any alert (`status = NULL`) | positive — "authentication-information protection active" |
+| ISO 27001 | **A.5.17** | `open` | negative — "exposed authentication information" |
+| ISO 27001 | **A.5.17** | `resolved` | positive — "exposure remediated" |
+
+**Control meanings.**
+- **CC6.6** — *Protection against external threats.* The entity implements
+  logical-access security measures to protect against threats from **outside**
+  its system boundaries.
+- **CC6.1** — *Logical access controls over protected assets.* The entity
+  implements logical-access security software, infrastructure, and architectures
+  over protected information assets to protect them from security events. A
+  credential *is* such a control; its exposure is a failure of that control.
+- **A.5.17** — *Authentication information.* Allocation and management of
+  authentication information (passwords, keys, tokens) must be controlled. A
+  credential committed to a repository is exposed authentication information —
+  exactly what this control governs.
+
+**Why this holds.** A committed credential is relevant to all three controls at
+once. For **CC6.6**, it is a direct path for an *external* attacker to cross the
+system boundary. For **CC6.1**, the credential is itself one of the logical-access
+keys the control is meant to safeguard, so a leak is a compromise of the access
+controls themselves. For **A.5.17**, the credential is authentication information
+whose confidentiality the control requires. In every case: scanning active →
+**positive** (a protective measure exists); open alert → **negative** (a live
+gap); resolved → **positive** (gap closed). See
+[migration 0006](../migrations/0006_secret_scanning_cc6_1.sql) (CC6.1) and
+[migration 0007](../migrations/0007_close_coverage_gaps.sql) (A.5.17).
+
+**Fit assessment: all three defensible.** CC6.6 is the external-threat framing,
+CC6.1 the logical-access framing, A.5.17 the ISO authentication-information
+framing (added in migration 0007 to close a gap — before it, secret scanning
+produced no ISO evidence). Mapping to all three means the export satisfies
+whichever control the organization's narrative uses. A further SOC 2 framing,
+**CC6.7** (restricting the transmission/movement of information), also touches
+this and could be added if an auditor prefers it. Note the multiplicity: one
+`open` secret now emits **six** rows — a positive ("scanner running") and a
+negative ("open exposure") under *each* of CC6.6, CC6.1 (SOC 2 export) and A.5.17
+(ISO export). That is intended and reads correctly, but expect the row counts to
+scale accordingly.
+
+### 5. Membership changes (webhook trail)
+
+**What we collect.** `member`, `team`, and `repository` webhook events — the
+*change* events, recording that an access-related mutation happened. Normalized
+to `resource ∈ {member_access, team, repository}` with the GitHub action as
+status.
+
+**Maps to:**
+
+| Framework | Control | When | Posture |
+| --- | --- | --- | --- |
+| SOC 2 | **CC6.2** | `member_access` `added` | informational — "access grant, logged for review" |
+| SOC 2 | **CC6.3** | `member_access` `removed` | positive — "timely access removal" |
+| SOC 2 | **CC6.3** | `member_access` `edited` | informational — "access-level change, logged" |
+| ISO 27001 | **A.5.18** | `team` (any) | informational — "access-rights change, audit trail" |
+
+**Control meanings.**
+- **CC6.2** — *Registration & authorization of new users.* Before credentials are
+  issued, new users are registered and authorized; credentials are removed when
+  access is no longer authorized. A member being *added* is the provisioning
+  event this criterion governs.
+- **CC6.3** — *Authorize / modify / remove access.* Access is authorized,
+  modified, or removed based on roles, least privilege, and segregation of
+  duties. Member *removal* and *role change* are the modify/remove events here.
+- **A.5.18** — *Access rights.* Access rights are provisioned, reviewed,
+  modified, and removed per the access-control policy. A team membership change
+  is an access-rights mutation on that trail.
+
+**Why this holds & posture logic.** These are the *audit trail* of access
+administration — evidence that grants/changes are captured, which is what an
+auditor samples. Most are **informational** (an add or a role change is neither
+inherently good nor bad — it needs human review). The one exception is
+`removed` → **positive**, because timely de-provisioning is itself a control
+objective (CC6.3), so a captured removal is affirmative evidence.
+
+**Fit assessment: strong on the CC6.2/CC6.3 split** (it follows the criteria's
+own provisioning-vs-modification language). The informational posture is the
+right call — this data feeds the access review, it does not pass/fail on its own.
+
+### 6. Membership & team inventory (polled)
+
+**What we collect.** The hourly poll writes the **full current set** of org
+members and team members (see [`pollOrgAccess`](../src/poller.ts)), distinct from
+the webhook change-trail above. `resource ∈ {org_member, team_member}`,
+`status` = the role. Each poll shares one `captured_at` so a batch is a coherent
+point-in-time snapshot — this is what powers the [access-review diff](../src/access-review.ts).
+
+**Maps to:**
+
+| Framework | Control | Resource | Posture |
+| --- | --- | --- | --- |
+| SOC 2 | **CC6.2** | `org_member` | informational — "org access inventory, subject to periodic review" |
+| SOC 2 | **CC6.3** | `team_member` | informational — "team-based access inventory" |
+| ISO 27001 | **A.5.18** | `org_member`, `team_member` | informational — "access-rights inventory" |
+
+**Why this holds.** A point-in-time roster of who has access is the raw material
+of a periodic access review — the recurring auditor ask that CC6.2/CC6.3 and
+A.5.18 all expect. Org membership maps to CC6.2 (who is registered/authorized in
+the org); team membership maps to CC6.3 (role-/least-privilege-based access).
+A.5.18 explicitly names "reviewed" among its verbs, so both feed it. All rows
+are **informational**: an inventory does not pass or fail, it *enables* the
+review.
+
+**Fit assessment: strong, with one nuance.** The org→CC6.2 / team→CC6.3 split is
+reasonable but not the only defensible cut — the *review* of org membership is
+arguably as much CC6.3 (appropriateness of access) as CC6.2 (registration). Since
+these are informational inventory rows feeding a review, the exact CC6.2/CC6.3
+attribution is low-stakes; A.5.18 is unambiguous. Note the two resource families
+(`member_access`/`team` webhook trail vs. `org_member`/`team_member` polled
+inventory) are deliberately separate resource names so the change-trail and the
+current-state inventory don't collide.
+
+### 7. Repository inventory
+
+**What we collect.** `repository` webhook events — repos created/deleted/renamed
+within the installation. `resource = repository`.
+
+**Maps to:**
+
+| Framework | Control | Posture |
+| --- | --- | --- |
+| ISO 27001 | **A.5.9** | informational — "asset inventory trail" |
+
+**Control meaning.**
+- **A.5.9** — *Inventory of information and other associated assets.* A complete,
+  maintained inventory of information assets and their owners must exist.
+
+**Why this holds.** Repositories are information assets. The trail of repo
+create/delete/rename events is evidence that the asset inventory is maintained as
+it changes — exactly A.5.9's requirement. **Informational**: it is inventory, not
+a pass/fail condition.
+
+**Fit assessment: strong for what it claims.** The honest caveat is completeness:
+this is a *change trail*, so it evidences that inventory changes are captured, not
+that a full, owner-annotated asset register exists. If a future need is to attest
+a complete inventory, the polled repo list (already fetched in
+[`listInstallationRepos`](../src/poller.ts)) would be the better source than the
+webhook trail.
+
+---
+
+## Complete mapping reference
+
+This table is the authoritative human-readable copy of every row in
+[`control_mappings`](../migrations/0002_control_mappings.sql) after all migrations
+(0002 seeds most; 0003 replaces branch-protection/ruleset with the
+enabled/disabled vocabulary; 0005 adds the polled access inventory; 0006 adds
+the secret-scanning CC6.1 rows; 0007 closes the cross-framework coverage gaps —
+Dependabot→A.8.8, code scanning→CC7.1, secret scanning→A.5.17). **A "·" in
+Status means the mapping's `status` is `NULL` — it matches any status.**
+
+| Resource | Status | Framework | Control | Posture | Rationale |
+| --- | --- | --- | --- | --- | --- |
+| `branch_protection` | `enabled` | SOC 2 | CC8.1 | positive | Change management — review before merge |
+| `branch_protection` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — direct pushes possible |
+| `branch_protection` | `enabled` | ISO 27001 | A.8.32 | positive | Change management |
+| `branch_protection` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — direct pushes possible |
+| `repository_ruleset` | `enabled` | SOC 2 | CC8.1 | positive | Change management — review before merge |
+| `repository_ruleset` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — direct pushes possible |
+| `repository_ruleset` | `enabled` | ISO 27001 | A.8.32 | positive | Change management |
+| `repository_ruleset` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — direct pushes possible |
+| `dependabot_alert` | · | SOC 2 | CC7.1 | positive | Detection tooling is active |
+| `dependabot_alert` | `open` | SOC 2 | CC7.2 | negative | Unremediated known vulnerability |
+| `dependabot_alert` | `fixed` | SOC 2 | CC7.2 | positive | Remediated |
+| `dependabot_alert` | `dismissed` | SOC 2 | CC7.2 | positive | Remediated (risk accepted) |
+| `dependabot_alert` | `auto_dismissed` | SOC 2 | CC7.2 | positive | Remediated (e.g. dependency removed) |
+| `dependabot_alert` | · | ISO 27001 | A.8.8 | positive | Technical vulnerability management — detection active |
+| `dependabot_alert` | `open` | ISO 27001 | A.8.8 | negative | Unremediated known technical vulnerability |
+| `dependabot_alert` | `fixed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated |
+| `dependabot_alert` | `dismissed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated (risk accepted) |
+| `dependabot_alert` | `auto_dismissed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated (e.g. dependency removed) |
+| `code_scanning_alert` | · | ISO 27001 | A.8.29 | positive | Security testing in development is active |
+| `code_scanning_alert` | `open` | ISO 27001 | A.8.28 | negative | Unremediated finding |
+| `code_scanning_alert` | `fixed` | ISO 27001 | A.8.28 | positive | Remediated |
+| `code_scanning_alert` | `dismissed` | ISO 27001 | A.8.28 | positive | Remediated (risk accepted) |
+| `code_scanning_alert` | · | SOC 2 | CC7.1 | positive | Detection tooling is active (findings unmapped in SOC 2) |
+| `secret_scanning_alert` | · | SOC 2 | CC6.6 | positive | Leaked-credential detection is active |
+| `secret_scanning_alert` | `open` | SOC 2 | CC6.6 | negative | Live credential exposure |
+| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.6 | positive | Exposure remediated |
+| `secret_scanning_alert` | · | SOC 2 | CC6.1 | positive | Logical-access credential protection — detection active |
+| `secret_scanning_alert` | `open` | SOC 2 | CC6.1 | negative | Exposed credential undermines logical access controls |
+| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.1 | positive | Logical access control restored — exposure remediated |
+| `secret_scanning_alert` | · | ISO 27001 | A.5.17 | positive | Authentication-information protection — detection active |
+| `secret_scanning_alert` | `open` | ISO 27001 | A.5.17 | negative | Exposed authentication information |
+| `secret_scanning_alert` | `resolved` | ISO 27001 | A.5.17 | positive | Authentication-information exposure remediated |
+| `member_access` | `added` | SOC 2 | CC6.2 | informational | Access grant — logged for review |
+| `member_access` | `removed` | SOC 2 | CC6.3 | positive | Timely access removal |
+| `member_access` | `edited` | SOC 2 | CC6.3 | informational | Access-level change — logged for review |
+| `team` | · | ISO 27001 | A.5.18 | informational | Access-rights change, audit trail |
+| `repository` | · | ISO 27001 | A.5.9 | informational | Asset inventory trail |
+| `org_member` | · | SOC 2 | CC6.2 | informational | Org access inventory — subject to periodic review |
+| `org_member` | · | ISO 27001 | A.5.18 | informational | Access-rights inventory |
+| `team_member` | · | SOC 2 | CC6.3 | informational | Team-based access inventory |
+| `team_member` | · | ISO 27001 | A.5.18 | informational | Access-rights inventory |
+
+### Control glossary
+
+| Code | Title (plain language) |
+| --- | --- |
+| **CC6.1** | Implement logical-access controls over protected information assets |
+| **CC6.2** | Register & authorize new users before granting access; remove credentials when access ends |
+| **CC6.3** | Authorize, modify, and remove access by role, with least privilege and segregation of duties |
+| **CC6.6** | Protect against threats originating outside the system boundary |
+| **CC7.1** | Detect configuration changes that introduce vulnerabilities, and susceptibility to newly discovered ones |
+| **CC7.2** | Monitor components for anomalies and analyze them as potential security events |
+| **CC8.1** | Put changes through an authorized, controlled process; block unauthorized changes |
+| **A.5.9** | Maintain an inventory of information and associated assets, with owners |
+| **A.5.17** | Control the allocation and management of authentication information (passwords, keys, tokens) |
+| **A.5.18** | Provision, review, modify, and remove access rights per policy |
+| **A.8.8** | Obtain, evaluate, and act on information about technical vulnerabilities |
+| **A.8.28** | Apply secure coding principles throughout development |
+| **A.8.29** | Run security testing within the development and acceptance lifecycle |
+| **A.8.32** | Subject system changes to formal change-management procedures |
+
+---
+
+## Adding a new framework
+
+The join engine is framework-agnostic — a new framework is **data, not code**.
+Adding one (e.g. NIST CSF 2.0, PCI DSS 4.0, CIS Controls) is a new migration that
+inserts `control_mappings` rows with a new `framework` value, plus a section in
+this document. No changes to the poller, exporter query, or webhook handler are
+needed. The only code touchpoints are the `Framework` type and the
+`normalizeFramework` allow-list — see [What the code needs](#what-the-code-needs).
+
+### Methodology — how to map a signal to a control accurately
+
+Do this per `(resource, status)` you want to attest, and write the reasoning into
+this document as you go. The goal the user cares about is **100% defensibility**,
+so bias toward under-claiming.
+
+1. **Start from the signal, not the control.** Name exactly what the GitHub state
+   proves ("a merge gate exists on the default branch"), in one sentence, without
+   reference to any framework.
+2. **Find the control whose *intent* that sentence satisfies** — read the actual
+   control text, not a blog summary. If the signal only partially satisfies the
+   control, say so in the fit assessment; do not round up.
+3. **Prefer one strong control over several weak ones.** A single defensible
+   mapping is worth more to an auditor than three tenuous ones. Tenuous mappings
+   erode trust in the whole evidence pack.
+4. **Assign posture from the control's expectation, not the signal's sentiment:**
+   - `positive` — the state is what the control wants.
+   - `negative` — the state is a concrete gap the control would flag.
+   - `informational` — the state is audit-trail/inventory that feeds a review but
+     is not itself pass/fail. When in doubt, use `informational`.
+5. **Decide detection-vs-finding.** If the signal is a scanner/alert stream, you
+   usually want two mapping kinds: a `status = NULL` row for "the control's
+   *tooling* exists" (positive), and per-status rows for individual findings.
+   Remember rule 3 in [How mapping works](#how-mapping-works-mechanically): both
+   fire on the same snapshot.
+6. **Pin the edition.** State which version of the framework you mapped (e.g.
+   "PCI DSS v4.0.1", "NIST CSF 2.0") — control numbers move between editions.
+7. **Write the rationale** in the `rationale` column *and* the fit assessment
+   here. The `rationale` is what an auditor reads in the export; make it a
+   complete thought, not a keyword.
+
+### What the code needs
+
+Three touchpoints, all small:
+
+- **`src/exporter.ts`** — add the new value to the `Framework` type
+  (`"soc2" | "iso27001" | ...`).
+- **`src/index.ts`** — add it to `normalizeFramework` so `?framework=` and the
+  export form accept it.
+- **`src/dashboard.ts`** — add it to the framework selector if it should be
+  user-selectable.
+
+### Checklist for a new framework
+
+- [ ] New migration `migrations/000N_<framework>_mappings.sql` inserting
+      `control_mappings` rows with the new `framework` value.
+- [ ] Every mapping uses a `resource`/`status` the pipeline already produces (see
+      [`extractFact`](../src/webhook.ts) and [`poller.ts`](../src/poller.ts)). If
+      you need a signal that isn't collected yet, that is a collection change
+      first — a mapping to a resource that is never written produces no evidence.
+- [ ] `rationale` on each row is a complete, auditor-readable sentence.
+- [ ] A new `### <Framework>` subsection here, or per-signal rows added to the
+      existing sections, plus reference-table and glossary entries.
+- [ ] The framework edition/version is stated.
+- [ ] `Framework` type + `normalizeFramework` updated.
+- [ ] `npm run typecheck` passes; `npm run db:migrate:local` applies cleanly.
+
+### Worked micro-example
+
+To map branch protection to **NIST CSF 2.0**, whose `PR.PS-06` covers a secure
+software development lifecycle:
+
+```sql
+INSERT INTO control_mappings (resource, status, framework, control_id, posture, rationale) VALUES
+  ('branch_protection', 'enabled',  'nistcsf', 'PR.PS-06', 'positive', 'SDLC change control — review required before merge to the default branch'),
+  ('branch_protection', 'disabled', 'nistcsf', 'PR.PS-06', 'negative', 'SDLC change-control gap — direct pushes to the default branch possible');
+```
+
+Then add `"nistcsf"` to the `Framework` type and `normalizeFramework`, and add a
+`### NIST CSF 2.0` subsection here documenting the reasoning and fit.
+
+---
+
+## Keeping this document in sync
+
+The mappings live in two places that must agree: the SQL seed rows in
+`migrations/` (the source of truth the engine reads) and the
+[reference table](#complete-mapping-reference) and per-signal sections here.
+**When you change a mapping, change both in the same PR** — the same discipline
+the README applies to retention periods. A mapping row with no rationale here, or
+a row here with no SQL, is a bug.
+
+This is enforced. [`scripts/check-mappings.mjs`](../scripts/check-mappings.mjs)
+applies every migration to an in-memory SQLite database, reads back
+`control_mappings`, and diffs the `(resource, status, framework, control_id,
+posture)` tuples against the rows parsed out of the
+[reference table](#complete-mapping-reference) above. It fails with a row-level
+diff if the two drift. Run it with:
+
+```sh
+npm run test:mappings
+```
+
+It has no dependencies (Node's built-in `node:sqlite`) and is a good CI gate. It
+checks the **reference table** specifically — the per-signal tables and glossary
+are prose and are not parsed, so keep those consistent by hand.
+
+---
+
+## Sources
+
+Control *numbers and titles* are cited from the published standards; exact
+criterion text is paraphrased (the standards themselves are copyrighted).
+
+- ISO/IEC 27001:2022, Annex A — control titles confirmed via
+  [ISMS.online, "ISO 27001:2022 Annex A Explained"](https://www.isms.online/iso-27001/annex-a-2022/).
+- AICPA *Trust Services Criteria* (TSP Section 100, 2017 criteria with 2022
+  revised points of focus) — CC category scope confirmed via
+  [Linford & Co., "Trust Services Criteria"](https://linfordco.com/blog/trust-services-critieria-principles-soc-2/)
+  and [Secureframe, "SOC 2 Common Criteria"](https://secureframe.com/hub/soc-2/common-criteria).
+- The authoritative text for both is the source standard: purchase ISO/IEC
+  27001:2022 from ISO, and the AICPA Trust Services Criteria from the AICPA.
+  Verify any mapping against those before an audit.
diff --git a/migrations/0006_secret_scanning_cc6_1.sql b/migrations/0006_secret_scanning_cc6_1.sql
new file mode 100644
index 0000000..5f7ca3d
--- /dev/null
+++ b/migrations/0006_secret_scanning_cc6_1.sql
@@ -0,0 +1,12 @@
+-- Secret scanning now maps to BOTH CC6.6 and CC6.1. A leaked credential is
+-- simultaneously an external-access vector (CC6.6 — protection against threats
+-- outside the system boundary, already mapped in 0002) and a compromise of the
+-- logical-access controls themselves (CC6.1 — logical access security over
+-- protected assets), since the credential is itself a logical-access key.
+-- Auditors differ on which is the primary home; attesting both lets the export
+-- satisfy whichever the control narrative uses. Each secret-scanning snapshot
+-- therefore emits the CC6.1 rows below in addition to its CC6.6 rows.
+INSERT INTO control_mappings (resource, status, framework, control_id, posture, rationale) VALUES
+  ('secret_scanning_alert', NULL, 'soc2', 'CC6.1', 'positive', 'Logical-access credential protection — leaked-credential detection is active'),
+  ('secret_scanning_alert', 'open', 'soc2', 'CC6.1', 'negative', 'Exposed credential undermines logical access controls'),
+  ('secret_scanning_alert', 'resolved', 'soc2', 'CC6.1', 'positive', 'Logical access control restored — exposure remediated');
diff --git a/migrations/0007_close_coverage_gaps.sql b/migrations/0007_close_coverage_gaps.sql
new file mode 100644
index 0000000..0746c8b
--- /dev/null
+++ b/migrations/0007_close_coverage_gaps.sql
@@ -0,0 +1,30 @@
+-- Migration 0007: close the asymmetric-coverage gaps from the framework-mapping
+-- review. Three signals were mapped in only one framework although the
+-- equivalent control clearly exists in the other. See docs/framework-mapping.md.
+--
+-- 1. Dependabot -> ISO A.8.8 (Management of technical vulnerabilities). A.8.8 is
+--    one control covering the whole vulnerability lifecycle (identify -> evaluate
+--    -> remediate), so — unlike the SOC 2 split across CC7.1/CC7.2 — every status
+--    maps to this single control.
+-- 2. Code scanning -> SOC 2 CC7.1 (detection & monitoring), mirroring how
+--    Dependabot's tooling-active fact already maps to CC7.1. Only the tooling-
+--    active (NULL) row is added; finding-level rows are deliberately NOT routed
+--    to CC7.2 here, pending the CC7.1-vs-CC7.2 decision noted in the doc.
+-- 3. Secret scanning -> ISO A.5.17 (Authentication information). A leaked
+--    credential is exposed authentication information; mirrors the SOC 2
+--    CC6.6/CC6.1 rows into ISO.
+INSERT INTO control_mappings (resource, status, framework, control_id, posture, rationale) VALUES
+  -- Dependabot: ISO technical-vulnerability management (full lifecycle, one control)
+  ('dependabot_alert', NULL, 'iso27001', 'A.8.8', 'positive', 'Technical vulnerability management — detection tooling is active'),
+  ('dependabot_alert', 'open', 'iso27001', 'A.8.8', 'negative', 'Unremediated known technical vulnerability'),
+  ('dependabot_alert', 'fixed', 'iso27001', 'A.8.8', 'positive', 'Vulnerability remediated'),
+  ('dependabot_alert', 'dismissed', 'iso27001', 'A.8.8', 'positive', 'Vulnerability remediated (risk accepted)'),
+  ('dependabot_alert', 'auto_dismissed', 'iso27001', 'A.8.8', 'positive', 'Vulnerability remediated (e.g. dependency removed)'),
+
+  -- Code scanning: SOC 2 detection tooling active (findings intentionally unmapped here)
+  ('code_scanning_alert', NULL, 'soc2', 'CC7.1', 'positive', 'Detection tooling is active — SAST runs in the development pipeline'),
+
+  -- Secret scanning: ISO authentication-information protection
+  ('secret_scanning_alert', NULL, 'iso27001', 'A.5.17', 'positive', 'Authentication information protection — leaked-credential detection is active'),
+  ('secret_scanning_alert', 'open', 'iso27001', 'A.5.17', 'negative', 'Exposed authentication information'),
+  ('secret_scanning_alert', 'resolved', 'iso27001', 'A.5.17', 'positive', 'Authentication information exposure remediated');
diff --git a/package.json b/package.json
index 97f64ae..f1cf1c6 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
     "deploy": "wrangler deploy",
     "types": "wrangler types",
     "typecheck": "tsc --noEmit",
+    "test:mappings": "node scripts/check-mappings.mjs",
     "db:migrate:local": "wrangler d1 migrations apply DB --local",
     "db:migrate:remote": "wrangler d1 migrations apply DB --remote"
   },
diff --git a/scripts/check-mappings.mjs b/scripts/check-mappings.mjs
new file mode 100644
index 0000000..2f19336
--- /dev/null
+++ b/scripts/check-mappings.mjs
@@ -0,0 +1,89 @@
+// Sync check: assert that the control-framework mappings in migrations/ exactly
+// match the reference table in docs/framework-mapping.md. The migrations are the
+// source of truth the engine reads; the doc is the human-readable copy an auditor
+// relies on. If they drift, the doc is lying — so this fails CI.
+//
+// It works by actually applying every migration to an in-memory SQLite database
+// (so migration 0003's delete-and-reinsert is handled exactly as production D1
+// would), reading back control_mappings, and diffing against the rows parsed out
+// of the doc's "Complete mapping reference" table.
+//
+// Run: npm run test:mappings   (no dependencies — uses Node's built-in sqlite)
+
+import { DatabaseSync } from "node:sqlite";
+import { readFileSync, readdirSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
+const migrationsDir = join(repoRoot, "migrations");
+const docPath = join(repoRoot, "docs", "framework-mapping.md");
+
+// A "·" in the doc's Status column means the mapping's status is NULL (matches
+// any status); normalize both sides to this sentinel so they compare equal.
+const NULL_STATUS = "·";
+const key = (resource, status, framework, control, posture) =>
+  `${resource}|${status ?? NULL_STATUS}|${framework}|${control}|${posture}`;
+
+// --- 1. Source of truth: apply migrations, read control_mappings. ---
+function rowsFromMigrations() {
+  const db = new DatabaseSync(":memory:");
+  const files = readdirSync(migrationsDir)
+    .filter((f) => f.endsWith(".sql"))
+    .sort();
+  for (const file of files) {
+    db.exec(readFileSync(join(migrationsDir, file), "utf8"));
+  }
+  const rows = db
+    .prepare("SELECT resource, status, framework, control_id, posture FROM control_mappings")
+    .all();
+  db.close();
+  return new Set(rows.map((r) => key(r.resource, r.status, r.framework, r.control_id, r.posture)));
+}
+
+// --- 2. Human-readable copy: parse the doc's reference table. ---
+// Matches only rows of the reference table, which are shaped:
+//   | `resource` | status | SOC 2 | CC8.1 | positive | rationale |
+// The framework name in cell 3 and the bare posture word in cell 5 are what
+// distinguish these from the per-section tables (framework-first, prose posture)
+// and the glossary (bolded control in cell 1), so those are not matched.
+const FRAMEWORK_LABELS = { "SOC 2": "soc2", "ISO 27001": "iso27001" };
+const ROW_RE =
+  /^\|\s*`([^`]+)`\s*\|\s*(.+?)\s*\|\s*(SOC 2|ISO 27001)\s*\|\s*([\w.]+)\s*\|\s*(positive|negative|informational)\s*\|/;
+
+function rowsFromDoc() {
+  const set = new Set();
+  for (const line of readFileSync(docPath, "utf8").split("\n")) {
+    const m = ROW_RE.exec(line);
+    if (!m) continue;
+    const [, resource, statusCell, frameworkLabel, control, posture] = m;
+    const status = statusCell.replace(/`/g, "").trim(); // already "·" for NULL
+    set.add(key(resource, status, FRAMEWORK_LABELS[frameworkLabel], control, posture));
+  }
+  return set;
+}
+
+// --- 3. Diff. ---
+const db = rowsFromMigrations();
+const doc = rowsFromDoc();
+
+const onlyInDb = [...db].filter((k) => !doc.has(k)).sort();
+const onlyInDoc = [...doc].filter((k) => !db.has(k)).sort();
+
+if (onlyInDb.length === 0 && onlyInDoc.length === 0) {
+  console.log(`✓ mappings in sync: ${db.size} rows match between migrations/ and docs/framework-mapping.md`);
+  process.exit(0);
+}
+
+console.error("✗ control-mapping drift between migrations/ and docs/framework-mapping.md\n");
+console.error("  columns: resource | status | framework | control | posture\n");
+if (onlyInDb.length) {
+  console.error(`  In migrations but MISSING from the doc (${onlyInDb.length}):`);
+  for (const k of onlyInDb) console.error(`    + ${k}`);
+}
+if (onlyInDoc.length) {
+  console.error(`  In the doc but MISSING from migrations (${onlyInDoc.length}):`);
+  for (const k of onlyInDoc) console.error(`    - ${k}`);
+}
+console.error("\n  Fix: update whichever is wrong so migrations/ and the doc's reference table agree.");
+process.exit(1);