Commit 0fb7c24d1a

0fb7c24d1a52ef0cefbd660f3ee1a9eea7e4a652

parent: 4576aedec5

Unsigned

cmc <hello@cleberg.net> · 2026-08-11 00:43 UTC

Fix evidence-to-control mapping defects from mapping review

- Derive secret-scanning open/resolved from alert.resolution: the webhook
  payload has no alert.state, so the 'open' (negative) mappings never
  matched and a live leak exported only positive rows. Backfill history.
- Store per-entity subjects (alert number, member login, team slug) so
  latest-row-wins works per alert/person instead of collapsing every
  alert of a type in a repo into one row. Backfill from raw_payload.
- Poll the three alert streams hourly: baselines alerts that predate the
  install and keeps open alerts from aging out of the retention window.
  The list endpoint doubles as a truthful tooling-enabled signal; the
  status-NULL "detection tooling is active" mappings (which persisted
  after a scanner was disabled and never fired for clean repos) are
  re-homed to new dependabot/code_scanning/secret_scanning resources.
- Consolidate the SOC 2 vulnerability lifecycle under CC7.1 and add the
  code-scanning finding-level SOC 2 rows deferred on that decision.
- Downgrade human dismissals/resolutions (incl. wont_fix) from positive
  "remediated" to informational; machine-verified outcomes stay positive.
- Check rulesets via /rules/branches/{default-branch} so only active
  rulesets actually covering the default branch count; reword branch-
  protection rationales to what is verified (rule contents are not).
- Gate branch_protection_rule webhooks to the default branch; ruleset
  webhooks become trail-only events (the poll is authoritative).
- Map the organization webhook (org member add/remove/invite), scope
  member_access rationales to repository collaborators, drop the
  unverifiable "timely" removal claim, add missing A.5.18 rows, and flag
  repository 'publicized' to CC6.1.
- check-mappings.mjs now also diffs the auditor-facing rationale text
  between migrations and the doc's reference table.

Layout: unified · split

README.md +3 −3
@@ -21,7 +21,7 @@ Read-only — it never modifies your repositories, permissions, or membership.
21| --- | --- | 21| --- | --- |
22| Branch protection, repository rulesets | SOC 2 CC8.1 · ISO 27001 A.8.32 | 22| Branch protection, repository rulesets | SOC 2 CC8.1 · ISO 27001 A.8.32 |
23| Secret scanning alerts | SOC 2 CC6.1, CC6.6 · ISO 27001 A.5.17 | 23| Secret scanning alerts | SOC 2 CC6.1, CC6.6 · ISO 27001 A.5.17 |
24| Dependabot alerts | SOC 2 CC7.1, CC7.2 · ISO 27001 A.8.8 | 24| Dependabot alerts | SOC 2 CC7.1 · ISO 27001 A.8.8 |
25| Code scanning alerts | SOC 2 CC7.1 · ISO 27001 A.8.28, A.8.29 | 25| Code scanning alerts | SOC 2 CC7.1 · ISO 27001 A.8.28, A.8.29 |
26| Organization / team membership | SOC 2 CC6.2, CC6.3 · ISO 27001 A.5.18 | 26| Organization / team membership | SOC 2 CC6.2, CC6.3 · ISO 27001 A.5.18 |
27| Repository inventory | ISO 27001 A.5.9 | 27| Repository inventory | ISO 27001 A.5.9 |
@@ -34,8 +34,8 @@ documented in [docs/framework-mapping.md](docs/framework-mapping.md).
34## How it works 34## How it works
35 35
36Webhooks capture changes as they happen; an hourly cron polls for state that 36Webhooks capture changes as they happen; an hourly cron polls for state that
37webhooks never announce (protection that existed before install, and current 37webhooks never announce (protection and open alerts that predate the install,
38membership). Each observation is stored as a timestamped snapshot in D1. 38whether each scanner is enabled, and current membership). Each observation is stored as a timestamped snapshot in D1.
39Exports render off the request path via a queue, into R2. 39Exports render off the request path via a queue, into R2.
40 40
41**Stack:** Cloudflare Workers · D1 · R2 · Queues. All storage is provisioned 41**Stack:** Cloudflare Workers · D1 · R2 · Queues. All storage is provisioned
docs/framework-mapping.md +280 −230
@@ -25,9 +25,9 @@ It is the human-readable companion to the machine-readable mappings in
25- [The frameworks in one paragraph each](#the-frameworks-in-one-paragraph-each) 25- [The frameworks in one paragraph each](#the-frameworks-in-one-paragraph-each)
26- [The signals and their mappings](#the-signals-and-their-mappings) 26- [The signals and their mappings](#the-signals-and-their-mappings)
27 - [Branch protection & repository rulesets](#1-branch-protection--repository-rulesets) 27 - [Branch protection & repository rulesets](#1-branch-protection--repository-rulesets)
28 - [Dependabot alerts](#2-dependabot-alerts) 28 - [Dependabot](#2-dependabot)
29 - [Code scanning alerts](#3-code-scanning-alerts) 29 - [Code scanning](#3-code-scanning)
30 - [Secret scanning alerts](#4-secret-scanning-alerts) 30 - [Secret scanning](#4-secret-scanning)
31 - [Membership changes (webhook trail)](#5-membership-changes-webhook-trail) 31 - [Membership changes (webhook trail)](#5-membership-changes-webhook-trail)
32 - [Membership & team inventory (polled)](#6-membership--team-inventory-polled) 32 - [Membership & team inventory (polled)](#6-membership--team-inventory-polled)
33 - [Repository inventory](#7-repository-inventory) 33 - [Repository inventory](#7-repository-inventory)
@@ -44,25 +44,27 @@ Understanding the evidence output requires understanding four rules in the
44mapping engine. All four live in [`control_mappings`](../migrations/0002_control_mappings.sql) 44mapping engine. All four live in [`control_mappings`](../migrations/0002_control_mappings.sql)
45and [`buildEvidenceRows`](../src/exporter.ts). 45and [`buildEvidenceRows`](../src/exporter.ts).
46 46
47**1. A snapshot is a `(resource, status)` pair; a mapping is a row that attaches 47**1. A snapshot is a `(resource, status)` pair about a `subject`; a mapping is a
48a control to one.** The poller and webhook handler both normalize GitHub events 48row that attaches a control to a `(resource, status)`.** The poller and webhook
49into a small vocabulary — `resource` (e.g. `branch_protection`, `dependabot_alert`) 49handler both normalize GitHub events into a small vocabulary — `resource`
50and `status` (e.g. `enabled`, `open`, `fixed`). See [`extractFact`](../src/webhook.ts) 50(e.g. `branch_protection`, `dependabot_alert`) and `status` (e.g. `enabled`,
51and [`poller.ts`](../src/poller.ts). Mapping happens as a **join at export time**, 51`open`, `fixed`) — plus a `subject` identifying which entity within the repo or
52never at ingest, so a mapping can be corrected without re-ingesting history. 52org the fact is about (an alert number, a member login, a team slug). See
53[`extractFact`](../src/webhook.ts) and [`poller.ts`](../src/poller.ts). Mapping
54happens as a **join at export time**, never at ingest, so a mapping can be
55corrected without re-ingesting history.
53 56
54**2. `status = NULL` in a mapping matches *any* status for that resource.** The 57**2. `status = NULL` in a mapping matches *any* status for that resource.** The
55join condition is `cm.status IS NULL OR cm.status = l.status`. This is how a 58join condition is `cm.status IS NULL OR cm.status = l.status`. This is used for
56"the tooling exists and is producing signal" fact is expressed independently of 59trail/inventory resources (`team`, `repository`, `org_member`, `team_member`)
57any individual finding's state. 60where every state is the same kind of informational fact.
58 61
59**3. Consequently, one snapshot can emit multiple evidence rows.** A single 62**3. One snapshot can emit multiple evidence rows.** A single secret-scanning
60Dependabot alert with `status = 'open'` matches *both* the `NULL` mapping 63alert with `status = 'open'` matches the `open` mapping under **each** of
61(CC7.1, "detection tooling is active", **positive**) *and* the `'open'` mapping 64CC6.6, CC6.1 (SOC 2) and A.5.17 (ISO). This is intentional: the same fact is
62(CC7.2, "unremediated vulnerability", **negative**). This is intentional: the 65legitimate evidence for more than one control expectation, and attesting all of
63existence of the scanner and the existence of an open finding are two different 66them lets the export serve whichever control the organization's narrative uses.
64facts about two different control expectations. This behavior is called out 67This behavior is called out per-signal below wherever it applies.
65per-signal below wherever it applies.
66 68
67**4. `posture` is the auditor-facing verdict on a row**, one of: 69**4. `posture` is the auditor-facing verdict on a row**, one of:
68 70
@@ -70,19 +72,26 @@ per-signal below wherever it applies.
70| --- | --- | --- | 72| --- | --- | --- |
71| `positive` | State supports the control | Branch protection enabled | 73| `positive` | State supports the control | Branch protection enabled |
72| `negative` | State is a gap against the control | Branch protection disabled; open secret | 74| `negative` | State is a gap against the control | Branch protection disabled; open secret |
73| `informational` | Neither pass nor fail — an audit-trail / inventory fact | A member was added; a repo exists | 75| `informational` | Neither pass nor fail — an audit-trail / inventory fact | A member was added; a finding was dismissed by a user |
74 76
75Two more rules affect *which* snapshots become evidence at all: 77Two more rules affect *which* snapshots become evidence at all:
76 78
77- **Unmapped states produce no evidence, in either direction.** A 79- **Unmapped states produce no evidence, in either direction.** A
78 `branch_protection` status of `unavailable` (GitHub returned 403 — the feature 80 `branch_protection` status of `unavailable` (GitHub returned 403 — the feature
79 isn't on the repo's plan; see [`fetchBranchProtection`](../src/poller.ts)) has 81 isn't on the repo's plan; see [`fetchBranchProtection`](../src/poller.ts)) has
80 no mapping row, so it never counts as a pass *or* a fail. Same for raw 82 no mapping row, so it never counts as a pass *or* a fail. The same applies to
81 `push` events. 83 the `disabled`/`unavailable` states of the detection-tooling resources
84 (`dependabot`, `code_scanning`, `secret_scanning`), to the trail-only
85 resources `branch_protection_rule_event` and `repository_ruleset_event`
86 (rule-scoped webhook events that can't be attributed to the default branch —
87 the poll is authoritative for state), and to raw `push` events.
82- **"Latest row wins" per `(repo, subject, resource)`** gives point-in-time 88- **"Latest row wins" per `(repo, subject, resource)`** gives point-in-time
83 current posture from an append-only table. Access facts are special-cased so a 89 current posture from an append-only table. Because `subject` carries the
84 member who lost access stops being attested — only the most recent poll batch 90 alert number / login / slug, this operates **per entity**: one alert being
85 counts. See the CTE in [`buildEvidenceRows`](../src/exporter.ts). 91 fixed does not mask another alert that is still open in the same repo. Access
92 facts are further special-cased so a member who lost access stops being
93 attested — only the most recent poll batch counts. See the CTE in
94 [`buildEvidenceRows`](../src/exporter.ts).
86 95
87--- 96---
88 97
@@ -114,14 +123,22 @@ assessment.
114 123
115### 1. Branch protection & repository rulesets 124### 1. Branch protection & repository rulesets
116 125
117**What we collect.** For each repo's default branch, whether merge controls are 126**What we collect.** For each repo's **default branch**, whether merge controls
118in force — via the `branch_protection_rule` and `repository_ruleset` webhooks 127are in force. The hourly poll is authoritative: the branch-protection API for
119(change events) and an hourly poll of the branch-protection and rulesets APIs 128classic protection, and the `rules/branches/{default-branch}` API for rulesets —
120(baseline, for protection that predates the install). Normalized to 129which aggregates the rules from every *active* ruleset (repo- and org-level)
121`resource ∈ {branch_protection, repository_ruleset}`, `status ∈ {enabled, 130that actually applies to that branch, so evaluate-mode (monitor-only) rulesets
122disabled}`. A ruleset in `evaluate` (monitor-only) mode counts as **not** 131and rulesets targeting other branches correctly count as **not** enabled.
123enabled because it does not actually block anything — see 132Normalized to `resource ∈ {branch_protection, repository_ruleset}`, `status ∈
124[`fetchRulesets`](../src/poller.ts). 133{enabled, disabled}`.
134
135Webhooks supplement the poll only where they are unambiguous: a
136`branch_protection_rule` event whose rule pattern is exactly the default branch
137updates state immediately. Any other rule event — and *every*
138`repository_ruleset` event, since one ruleset's deletion says nothing about
139whether other rulesets still cover the branch — is recorded as an unmapped
140trail event (`branch_protection_rule_event`, `repository_ruleset_event`) and
141the next poll settles the state. See [`extractFact`](../src/webhook.ts).
125 142
126**Maps to:** 143**Maps to:**
127 144
@@ -131,36 +148,46 @@ enabled because it does not actually block anything — see
131| ISO 27001 | **A.8.32** | *Change management.* Changes to information systems must follow formal change-management procedures to prevent unauthorized or destabilizing changes. | 148| ISO 27001 | **A.8.32** | *Change management.* Changes to information systems must follow formal change-management procedures to prevent unauthorized or destabilizing changes. |
132 149
133**Why this holds.** Branch protection / rulesets are the technical enforcement of 150**Why this holds.** Branch protection / rulesets are the technical enforcement of
134change control in a Git workflow: requiring pull-request review before merge, 151change control in a Git workflow. Enabled → **positive**; disabled →
135blocking direct pushes to the default branch, and requiring status checks to 152**negative** ("direct pushes possible" is a concrete change-control gap).
136pass. That is exactly the "controlled process… stops unauthorized changes" 153
137language of both controls. Enabled → **positive**; disabled → **negative** 154**Fit assessment: strong, with the scope stated in the evidence itself.** Both
138("direct pushes now possible" is a concrete change-control gap). 155frameworks name "change management" explicitly, and branch protection is the
139 156canonical GitHub-native implementation of it. What the evidence attests is that
140**Fit assessment: strong.** This is the least ambiguous mapping in the system — 157a change-control gate **exists on the default branch** — it does not verify that
141both frameworks name "change management" explicitly, and branch protection is 158the *specific* rules (required reviewers, status checks, …) match the
142the canonical GitHub-native implementation of it. The one nuance an auditor will 159organization's policy, and the exported rationale says so explicitly ("rule
143probe is *scope*: we check the **default branch** only, and "enabled" does not 160contents not verified") rather than claiming review is required.
144verify that the *specific* rules (required reviewers, etc.) match the 161
145organization's policy. The evidence attests that a change-control gate exists, 162### 2. Dependabot
146not that its configuration is sufficient. 163
147 164**What we collect.** Two distinct facts:
148### 2. Dependabot alerts 165
149 166- **Tooling state** (`resource = dependabot`, `status ∈ {enabled, disabled,
150**What we collect.** `dependabot_alert` webhook events — known-vulnerability 167 unavailable}`) — the hourly poll checks whether Dependabot alerts are enabled
151alerts against the repo's dependencies. `status` is the alert state: `open`, 168 on each repo (the alert-list API answering at all is the signal; see
152`fixed`, `dismissed`, `auto_dismissed`. 169 [`pollRepoAlerts`](../src/poller.ts)). Only `enabled` is mapped; a disabled or
170 unavailable scanner is recorded but deliberately produces no evidence either
171 way, matching the branch-protection `unavailable` precedent.
172- **Findings** (`resource = dependabot_alert`, `status ∈ {open, fixed,
173 dismissed, auto_dismissed}`, `subject` = alert number) — from
174 `dependabot_alert` webhooks, plus the same hourly poll re-recording every
175 *open* alert. The poll matters twice: alerts already open before the App was
176 installed never sent a webhook, and an open alert with no events for the
177 whole retention window would otherwise age out of evidence.
153 178
154**Maps to:** 179**Maps to:**
155 180
156| Framework | Control | When | Posture | 181| Framework | Control | When | Posture |
157| --- | --- | --- | --- | 182| --- | --- | --- | --- |
158| SOC 2 | **CC7.1** | any alert (`status = NULL`) | positive — "detection tooling is active" | 183| SOC 2 | **CC7.1** | tooling `enabled` | positive — detection tooling is on |
159| SOC 2 | **CC7.2** | `open` | negative — "unremediated known vulnerability" | 184| SOC 2 | **CC7.1** | `open` | negative — unremediated known vulnerability |
160| SOC 2 | **CC7.2** | `fixed` / `dismissed` / `auto_dismissed` | positive — "remediated" | 185| SOC 2 | **CC7.1** | `fixed` / `auto_dismissed` | positive — remediated (machine-verified) |
161| ISO 27001 | **A.8.8** | any alert (`status = NULL`) | positive — "technical vulnerability management active" | 186| SOC 2 | **CC7.1** | `dismissed` | informational — human risk-acceptance, justification subject to review |
162| ISO 27001 | **A.8.8** | `open` | negative — "unremediated known vulnerability" | 187| ISO 27001 | **A.8.8** | tooling `enabled` | positive — vulnerability management active |
163| ISO 27001 | **A.8.8** | `fixed` / `dismissed` / `auto_dismissed` | positive — "remediated" | 188| ISO 27001 | **A.8.8** | `open` | negative — unremediated known vulnerability |
189| ISO 27001 | **A.8.8** | `fixed` / `auto_dismissed` | positive — remediated (machine-verified) |
190| ISO 27001 | **A.8.8** | `dismissed` | informational — human risk-acceptance, justification subject to review |
164 191
165**Control meanings.** 192**Control meanings.**
166- **CC7.1** — *Detection & monitoring.* The entity uses detection procedures to 193- **CC7.1** — *Detection & monitoring.* The entity uses detection procedures to
@@ -168,101 +195,97 @@ alerts against the repo's dependencies. `status` is the alert state: `open`,
168 susceptibilities to *newly discovered* vulnerabilities. Dependabot is a 195 susceptibilities to *newly discovered* vulnerabilities. Dependabot is a
169 textbook example: it continuously matches your dependency tree against newly 196 textbook example: it continuously matches your dependency tree against newly
170 published CVEs. 197 published CVEs.
171- **CC7.2** — *Anomaly monitoring.* The entity monitors system components for
172 anomalies and analyzes them to determine whether they are security events.
173- **A.8.8** — *Management of technical vulnerabilities.* Information about 198- **A.8.8** — *Management of technical vulnerabilities.* Information about
174 technical vulnerabilities must be obtained, exposure evaluated, and 199 technical vulnerabilities must be obtained, exposure evaluated, and
175 appropriate measures taken. This is a single control spanning the whole 200 appropriate measures taken — one control spanning the whole lifecycle.
176 vulnerability lifecycle — detect, evaluate, remediate. 201
177 202**Why this holds.** The tooling-`enabled` fact proves the detection capability
178**Why this holds.** The *presence* of Dependabot alerts proves the detection 203required by CC7.1/A.8.8 exists and is on *right now* — it comes from the
179capability required by CC7.1 exists and is running — hence the `NULL` mapping 204feature's own state, not (as in earlier versions) from inference off the latest
180fires positive on any alert regardless of state. Each *individual* alert's 205alert event, which kept attesting after a scanner was switched off and never
181lifecycle (open vs. remediated) is then evidence for CC7.2: an open alert is an 206fired for a clean repo. Each individual alert's lifecycle is then finding-level
182unresolved condition, a fixed/dismissed one is a closed one. On the **ISO** side 207evidence under the same controls: an open alert is an unresolved known
183the entire story lands on a *single* control, A.8.8, because A.8.8 explicitly 208vulnerability; a `fixed` (patched) or `auto_dismissed` (e.g. dependency
184covers the full lifecycle — so every status maps to A.8.8 (open → negative, 209removed) alert is a machine-verified closure; a `dismissed` alert is a **human
185remediated → positive, tooling-active → positive). See 210decision** — it may be sound risk acceptance or may be rubber-stamping, and the
186[migration 0007](../migrations/0007_close_coverage_gaps.sql). 211justification is exactly what an auditor samples, so it is recorded as
187 212informational rather than claimed as remediation.
188**Fit assessment: CC7.1 strong; CC7.2 defensible but the weakest link in the 213
189system.** CC7.2's formal text is about anomalies "indicative of malicious acts, 214**Fit assessment: strong on both frameworks.** The whole SOC 2 lifecycle sits
190natural disasters, and errors" — i.e. runtime security events. An unpatched 215under CC7.1, whose "susceptibility to newly discovered vulnerabilities"
191dependency is a *known vulnerability*, which sits more naturally in CC7.1's 216language covers known-CVE management directly. (Earlier versions split
192"susceptibility to newly discovered vulnerabilities" language than in CC7.2's 217open/remediated state to CC7.2; CC7.2's anomaly-monitoring text is about
193anomaly-detection language. Many auditors keep the **entire** dependency story 218runtime security events, which made it the weakest link in the system — that
194(detection *and* remediation tracking) under CC7.1. **Recommendation:** before 219split has been removed.) A.8.8 is purpose-built for this signal and absorbs the
195you present this to an auditor, decide whether open/remediated Dependabot state 220whole lifecycle.
196belongs under CC7.1 or CC7.2 in your control narrative, and align the mapping to 221
197that decision. Both are defensible; the current split is a design choice, not a 222### 3. Code scanning
198requirement. The **ISO A.8.8** mapping, by contrast, is a strong, clean fit — 223
199A.8.8 is purpose-built for technical-vulnerability management and absorbs the 224**What we collect.** Same two-fact shape as Dependabot: tooling state
200whole lifecycle without the CC7.1/CC7.2 ambiguity. It was added in migration 0007 225(`resource = code_scanning`, from the hourly poll; only `enabled` mapped) and
201to close a gap: before it, Dependabot produced no evidence at all in an ISO 226findings (`resource = code_scanning_alert`, `status ∈ {open, fixed,
202export. 227dismissed}`, `subject` = alert number) from `code_scanning_alert` webhooks plus
203 228the hourly re-record of open alerts. Findings are SAST results from CodeQL or a
204### 3. Code scanning alerts 229third-party analyzer.
205
206**What we collect.** `code_scanning_alert` webhook events — SAST findings from
207CodeQL or a third-party analyzer. `status ∈ {open, fixed, dismissed}`.
208 230
209**Maps to:** 231**Maps to:**
210 232
211| Framework | Control | When | Posture | 233| Framework | Control | When | Posture |
212| --- | --- | --- | --- | 234| --- | --- | --- | --- |
213| ISO 27001 | **A.8.29** | any alert (`status = NULL`) | positive — "security testing in development is active" | 235| ISO 27001 | **A.8.29** | tooling `enabled` | positive — security testing in development is on |
214| ISO 27001 | **A.8.28** | `open` | negative — "unremediated finding" | 236| ISO 27001 | **A.8.28** | `open` | negative — unremediated finding |
215| ISO 27001 | **A.8.28** | `fixed` / `dismissed` | positive — "remediated" | 237| ISO 27001 | **A.8.28** | `fixed` | positive — remediated |
216| SOC 2 | **CC7.1** | any alert (`status = NULL`) | positive — "detection tooling is active" | 238| ISO 27001 | **A.8.28** | `dismissed` | informational — human risk-acceptance, justification subject to review |
239| SOC 2 | **CC7.1** | tooling `enabled` | positive — detection tooling is on |
240| SOC 2 | **CC7.1** | `open` | negative — unremediated finding |
241| SOC 2 | **CC7.1** | `fixed` | positive — remediated |
242| SOC 2 | **CC7.1** | `dismissed` | informational — human risk-acceptance, justification subject to review |
217 243
218**Control meanings.** 244**Control meanings.**
219- **A.8.29** — *Security testing in development and acceptance.* Security testing 245- **A.8.29** — *Security testing in development and acceptance.* Security testing
220 processes must be defined and run within the development lifecycle so 246 processes must be defined and run within the development lifecycle. The
221 vulnerabilities are found before production. The existence of code scanning 247 existence of code scanning *is* that testing process.
222 *is* that testing process.
223- **A.8.28** — *Secure coding.* Secure coding principles must be applied during 248- **A.8.28** — *Secure coding.* Secure coding principles must be applied during
224 development. An open finding is evidence of a secure-coding gap in the source; 249 development. An open finding is evidence of a secure-coding gap in the source;
225 a remediated one is evidence the gap was closed. 250 a remediated one is evidence the gap was closed.
226- **CC7.1** — *Detection & monitoring.* (Same control as Dependabot's SOC 2 251- **CC7.1** — *Detection & monitoring.* (Same control as Dependabot's SOC 2
227 mapping.) Code scanning is detection tooling that surfaces vulnerabilities, so 252 mapping.)
228 its presence satisfies the "detection procedures exist and run" expectation.
229 253
230**Why this holds.** On the **ISO** side this splits cleanly across two controls 254**Why this holds.** On the **ISO** side this splits cleanly across two controls
231that map to two facts: *"a testing process exists"* (A.8.29, from the `NULL` 255that map to two facts: *"a testing process exists"* (A.8.29, from tooling
232mapping) versus *"the code itself is/ isn't secure"* (A.8.28, from each finding's 256state) versus *"the code itself is / isn't secure"* (A.8.28, from each
233state). On the **SOC 2** side (added in [migration 0007](../migrations/0007_close_coverage_gaps.sql)) 257finding's state). On the **SOC 2** side the full lifecycle mirrors Dependabot
234only the tooling-active fact is mapped, to CC7.1 — mirroring how Dependabot's 258under CC7.1 — the finding-level rows were previously deferred pending the
235tooling-active fact maps to CC7.1. 259CC7.1-vs-CC7.2 decision, which is now settled in CC7.1's favor.
236 260
237**Fit assessment: strong on ISO; SOC 2 intentionally partial.** The 261**Fit assessment: strong on both.** The A.8.29-vs-A.8.28 split follows the
238A.8.29-vs-A.8.28 split is clean — one control is about *having* the testing 262controls' own having-a-process vs. code-quality distinction, and the SOC 2 side
239process, the other about the *code quality* it reveals — and both titles match 263is now symmetric with Dependabot rather than intentionally partial.
240the signal directly. The new SOC 2 CC7.1 mapping covers only detection-active, 264
241**not** finding-level state: code-scanning `open`/`fixed` rows are deliberately 265### 4. Secret scanning
242*not* routed to CC7.2, because whether the vulnerability lifecycle belongs under 266
243CC7.1 or CC7.2 is still an open decision (see the Dependabot fit assessment). Once 267**What we collect.** Tooling state (`resource = secret_scanning`, from the
244that is settled, finding-level SOC 2 rows for code scanning can be added to match 268hourly poll; only `enabled` mapped) and findings (`resource =
245Dependabot. Until then a SOC 2 export shows code scanning as "detection active" 269secret_scanning_alert`, `status ∈ {open, resolved}`, `subject` = alert number)
246only — which under-claims rather than over-claims, the safe direction. 270from `secret_scanning_alert` webhooks plus the hourly re-record of open alerts.
247 271One payload quirk matters: unlike the other two alert payloads, the
248### 4. Secret scanning alerts 272secret-scanning webhook alert carries **no `state` field** — ingest derives
249 273open/resolved from `alert.resolution`, which is set iff the alert is resolved
250**What we collect.** `secret_scanning_alert` webhook events — detected 274(see [`extractFact`](../src/webhook.ts)).
251credentials/tokens committed to the repo. `status ∈ {open, resolved}`.
252 275
253**Maps to:** 276**Maps to:**
254 277
255| Framework | Control | When | Posture | 278| Framework | Control | When | Posture |
256| --- | --- | --- | --- | 279| --- | --- | --- | --- |
257| SOC 2 | **CC6.6** | any alert (`status = NULL`) | positive — "leaked-credential detection is active" | 280| SOC 2 | **CC6.6** | tooling `enabled` | positive — leaked-credential detection is on |
258| SOC 2 | **CC6.6** | `open` | negative — "live credential exposure" | 281| SOC 2 | **CC6.6** | `open` | negative — live credential exposure |
259| SOC 2 | **CC6.6** | `resolved` | positive — "exposure remediated" | 282| SOC 2 | **CC6.6** | `resolved` | informational — resolution reason subject to review |
260| SOC 2 | **CC6.1** | any alert (`status = NULL`) | positive — "logical-access credential protection active" | 283| SOC 2 | **CC6.1** | tooling `enabled` | positive — logical-access credential protection is on |
261| SOC 2 | **CC6.1** | `open` | negative — "exposed credential undermines logical access controls" | 284| SOC 2 | **CC6.1** | `open` | negative — exposed credential undermines logical access controls |
262| SOC 2 | **CC6.1** | `resolved` | positive — "logical access control restored" | 285| SOC 2 | **CC6.1** | `resolved` | informational — resolution reason subject to review |
263| ISO 27001 | **A.5.17** | any alert (`status = NULL`) | positive — "authentication-information protection active" | 286| ISO 27001 | **A.5.17** | tooling `enabled` | positive — authentication-information protection is on |
264| ISO 27001 | **A.5.17** | `open` | negative — "exposed authentication information" | 287| ISO 27001 | **A.5.17** | `open` | negative — exposed authentication information |
265| ISO 27001 | **A.5.17** | `resolved` | positive — "exposure remediated" | 288| ISO 27001 | **A.5.17** | `resolved` | informational — resolution reason subject to review |
266 289
267**Control meanings.** 290**Control meanings.**
268- **CC6.6** — *Protection against external threats.* The entity implements 291- **CC6.6** — *Protection against external threats.* The entity implements
@@ -279,65 +302,70 @@ credentials/tokens committed to the repo. `status ∈ {open, resolved}`.
279 302
280**Why this holds.** A committed credential is relevant to all three controls at 303**Why this holds.** A committed credential is relevant to all three controls at
281once. For **CC6.6**, it is a direct path for an *external* attacker to cross the 304once. For **CC6.6**, it is a direct path for an *external* attacker to cross the
282system boundary. For **CC6.1**, the credential is itself one of the logical-access 305system boundary. For **CC6.1**, the credential is itself one of the
283keys the control is meant to safeguard, so a leak is a compromise of the access 306logical-access keys the control is meant to safeguard. For **A.5.17**, the
284controls themselves. For **A.5.17**, the credential is authentication information 307credential is authentication information whose confidentiality the control
285whose confidentiality the control requires. In every case: scanning active → 308requires. Scanning enabled → **positive**; open alert → **negative**. A
286**positive** (a protective measure exists); open alert → **negative** (a live 309`resolved` alert is **informational**, not positive: GitHub's resolution
287gap); resolved → **positive** (gap closed). See 310reasons include `wont_fix`, so "resolved" may mean the credential was revoked
288[migration 0006](../migrations/0006_secret_scanning_cc6_1.sql) (CC6.1) and 311*or* that someone decided to leave it — the recorded reason is what the
289[migration 0007](../migrations/0007_close_coverage_gaps.sql) (A.5.17). 312reviewer must check.
290 313
291**Fit assessment: all three defensible.** CC6.6 is the external-threat framing, 314**Fit assessment: all three defensible.** CC6.6 is the external-threat framing,
292CC6.1 the logical-access framing, A.5.17 the ISO authentication-information 315CC6.1 the logical-access framing, A.5.17 the ISO authentication-information
293framing (added in migration 0007 to close a gap — before it, secret scanning 316framing. Mapping to all three means the export satisfies whichever control the
294produced no ISO evidence). Mapping to all three means the export satisfies 317organization's narrative uses — at the cost of row multiplicity: one open
295whichever control the organization's narrative uses. A further SOC 2 framing, 318secret emits a negative under each of the three. A further SOC 2 framing,
296**CC6.7** (restricting the transmission/movement of information), also touches 319**CC6.7** (restricting the transmission/movement of information), also touches
297this and could be added if an auditor prefers it. Note the multiplicity: one 320this and could be added if an auditor prefers it.
298`open` secret now emits **six** rows — a positive ("scanner running") and a
299negative ("open exposure") under *each* of CC6.6, CC6.1 (SOC 2 export) and A.5.17
300(ISO export). That is intended and reads correctly, but expect the row counts to
301scale accordingly.
302 321
303### 5. Membership changes (webhook trail) 322### 5. Membership changes (webhook trail)
304 323
305**What we collect.** `member`, `team`, and `repository` webhook events — the 324**What we collect.** The *change* events for access administration, each with a
306*change* events, recording that an access-related mutation happened. Normalized 325`subject` so the trail keeps one latest row per person/team rather than one per
307to `resource ∈ {member_access, team, repository}` with the GitHub action as 326repo:
308status. 327
328- `member` webhook → `resource = member_access`, subject = the collaborator's
329 login. **Scope note: this event covers repository collaborators**, not org
330 members.
331- `organization` webhook → `resource = org_membership`, subject = the member's
332 login (or the invitee's login/email). This is where org-level joins, removals
333 and invitations arrive.
334- `team` webhook → `resource = team`, subject = the team slug.
309 335
310**Maps to:** 336**Maps to:**
311 337
312| Framework | Control | When | Posture | 338| Framework | Control | When | Posture |
313| --- | --- | --- | --- | 339| --- | --- | --- | --- |
314| SOC 2 | **CC6.2** | `member_access` `added` | informational — "access grant, logged for review" | 340| SOC 2 | **CC6.2** | `member_access` `added`, `org_membership` `member_added` / `member_invited` | informational — access grant / invitation, logged for review |
315| SOC 2 | **CC6.3** | `member_access` `removed` | positive — "timely access removal" | 341| SOC 2 | **CC6.3** | `member_access` `removed` / `edited`, `org_membership` `member_removed` | informational — modification / deprovisioning recorded |
316| SOC 2 | **CC6.3** | `member_access` `edited` | informational — "access-level change, logged" | 342| ISO 27001 | **A.5.18** | all of the above | informational — access-rights change, audit trail |
317| ISO 27001 | **A.5.18** | `team` (any) | informational — "access-rights change, audit trail" | 343| ISO 27001 | **A.5.18** | `team` (any) | informational — access-rights change, audit trail |
318 344
319**Control meanings.** 345**Control meanings.**
320- **CC6.2** — *Registration & authorization of new users.* Before credentials are 346- **CC6.2** — *Registration & authorization of new users.* Before credentials are
321 issued, new users are registered and authorized; credentials are removed when 347 issued, new users are registered and authorized; credentials are removed when
322 access is no longer authorized. A member being *added* is the provisioning 348 access is no longer authorized. A member being *added* or *invited* is the
323 event this criterion governs. 349 provisioning event this criterion governs.
324- **CC6.3** — *Authorize / modify / remove access.* Access is authorized, 350- **CC6.3** — *Authorize / modify / remove access.* Access is authorized,
325 modified, or removed based on roles, least privilege, and segregation of 351 modified, or removed based on roles, least privilege, and segregation of
326 duties. Member *removal* and *role change* are the modify/remove events here. 352 duties. Removal and role change are the modify/remove events here.
327- **A.5.18** — *Access rights.* Access rights are provisioned, reviewed, 353- **A.5.18** — *Access rights.* Access rights are provisioned, reviewed,
328 modified, and removed per the access-control policy. A team membership change 354 modified, and removed per the access-control policy.
329 is an access-rights mutation on that trail.
330 355
331**Why this holds & posture logic.** These are the *audit trail* of access 356**Why this holds & posture logic.** These are the *audit trail* of access
332administration — evidence that grants/changes are captured, which is what an 357administration — evidence that grants/changes are captured, which is what an
333auditor samples. Most are **informational** (an add or a role change is neither 358auditor samples. **All rows are informational**, removals included: the event
334inherently good nor bad — it needs human review). The one exception is 359proves a removal happened and when, but not that it was *timely* relative to an
335`removed` → **positive**, because timely de-provisioning is itself a control 360offboarding trigger the system cannot see — so the rationale says
336objective (CC6.3), so a captured removal is affirmative evidence. 361"deprovisioning recorded; timeliness subject to review" rather than claiming
362timeliness as a positive. (Earlier versions claimed "timely access removal";
363that was rounding up.)
337 364
338**Fit assessment: strong on the CC6.2/CC6.3 split** (it follows the criteria's 365**Fit assessment: strong on the CC6.2/CC6.3 split** (it follows the criteria's
339own provisioning-vs-modification language). The informational posture is the 366own provisioning-vs-modification language), and honest about scope now that
340right call — this data feeds the access review, it does not pass/fail on its own. 367repo-collaborator and org-member events are separate resources with separate
368rationales.
341 369
342### 6. Membership & team inventory (polled) 370### 6. Membership & team inventory (polled)
343 371
@@ -351,9 +379,9 @@ point-in-time snapshot — this is what powers the [access-review diff](../src/a
351 379
352| Framework | Control | Resource | Posture | 380| Framework | Control | Resource | Posture |
353| --- | --- | --- | --- | 381| --- | --- | --- | --- |
354| SOC 2 | **CC6.2** | `org_member` | informational — "org access inventory, subject to periodic review" | 382| SOC 2 | **CC6.2** | `org_member` | informational — org access inventory, subject to periodic review |
355| SOC 2 | **CC6.3** | `team_member` | informational — "team-based access inventory" | 383| SOC 2 | **CC6.3** | `team_member` | informational — team-based access inventory |
356| ISO 27001 | **A.5.18** | `org_member`, `team_member` | informational — "access-rights inventory" | 384| ISO 27001 | **A.5.18** | `org_member`, `team_member` | informational — access-rights inventory |
357 385
358**Why this holds.** A point-in-time roster of who has access is the raw material 386**Why this holds.** A point-in-time roster of who has access is the raw material
359of a periodic access review — the recurring auditor ask that CC6.2/CC6.3 and 387of a periodic access review — the recurring auditor ask that CC6.2/CC6.3 and
@@ -368,20 +396,21 @@ reasonable but not the only defensible cut — the *review* of org membership is
368arguably as much CC6.3 (appropriateness of access) as CC6.2 (registration). Since 396arguably as much CC6.3 (appropriateness of access) as CC6.2 (registration). Since
369these are informational inventory rows feeding a review, the exact CC6.2/CC6.3 397these are informational inventory rows feeding a review, the exact CC6.2/CC6.3
370attribution is low-stakes; A.5.18 is unambiguous. Note the two resource families 398attribution is low-stakes; A.5.18 is unambiguous. Note the two resource families
371(`member_access`/`team` webhook trail vs. `org_member`/`team_member` polled 399(`member_access`/`org_membership`/`team` webhook trail vs.
372inventory) are deliberately separate resource names so the change-trail and the 400`org_member`/`team_member` polled inventory) are deliberately separate resource
373current-state inventory don't collide. 401names so the change-trail and the current-state inventory don't collide.
374 402
375### 7. Repository inventory 403### 7. Repository inventory
376 404
377**What we collect.** `repository` webhook events — repos created/deleted/renamed 405**What we collect.** `repository` webhook events — repos created/deleted/renamed
378within the installation. `resource = repository`. 406and visibility changes within the installation. `resource = repository`.
379 407
380**Maps to:** 408**Maps to:**
381 409
382| Framework | Control | Posture | 410| Framework | Control | When | Posture |
383| --- | --- | --- | 411| --- | --- | --- | --- |
384| ISO 27001 | **A.5.9** | informational — "asset inventory trail" | 412| ISO 27001 | **A.5.9** | any action | informational — asset inventory trail |
413| SOC 2 | **CC6.1** | `publicized` | informational — repo made public, flagged for review |
385 414
386**Control meaning.** 415**Control meaning.**
387- **A.5.9** — *Inventory of information and other associated assets.* A complete, 416- **A.5.9** — *Inventory of information and other associated assets.* A complete,
@@ -389,8 +418,11 @@ within the installation. `resource = repository`.
389 418
390**Why this holds.** Repositories are information assets. The trail of repo 419**Why this holds.** Repositories are information assets. The trail of repo
391create/delete/rename events is evidence that the asset inventory is maintained as 420create/delete/rename events is evidence that the asset inventory is maintained as
392it changes — exactly A.5.9's requirement. **Informational**: it is inventory, not 421it changes — exactly A.5.9's requirement. One action gets an extra row: a repo
393a pass/fail condition. 422being **publicized** is a visibility change with direct confidentiality impact,
423so it is additionally surfaced under CC6.1 rather than left as a generic
424inventory tick. Both rows are **informational** — whether going public was
425intended is a judgment the reviewer makes.
394 426
395**Fit assessment: strong for what it claims.** The honest caveat is completeness: 427**Fit assessment: strong for what it claims.** The honest caveat is completeness:
396this is a *change trail*, so it evidences that inventory changes are captured, not 428this is a *change trail*, so it evidences that inventory changes are captured, not
@@ -404,56 +436,72 @@ webhook trail.
404## Complete mapping reference 436## Complete mapping reference
405 437
406This table is the authoritative human-readable copy of every row in 438This table is the authoritative human-readable copy of every row in
407[`control_mappings`](../migrations/0002_control_mappings.sql) after all migrations 439[`control_mappings`](../migrations/0002_control_mappings.sql) after all
408(0002 seeds most; 0003 replaces branch-protection/ruleset with the 440migrations (0002 seeds most; 0003 replaces branch-protection/ruleset with the
409enabled/disabled vocabulary; 0005 adds the polled access inventory; 0006 adds 441enabled/disabled vocabulary; 0005 adds the polled access inventory; 0006 adds
410the secret-scanning CC6.1 rows; 0007 closes the cross-framework coverage gaps — 442the secret-scanning CC6.1 rows; 0007 closes cross-framework coverage gaps; 0008
411Dependabot→A.8.8, code scanning→CC7.1, secret scanning→A.5.17). **A "·" in 443applies the mapping-review fixes — per-entity subjects, polled tooling state,
412Status means the mapping's `status` is `NULL` — it matches any status.** 444the CC7.1 consolidation, and informational postures for human dismissals).
445**A "·" in Status means the mapping's `status` is `NULL` — it matches any
446status.** The Rationale column is the exact auditor-facing string in the
447database, and is CI-checked against it.
413 448
414| Resource | Status | Framework | Control | Posture | Rationale | 449| Resource | Status | Framework | Control | Posture | Rationale |
415| --- | --- | --- | --- | --- | --- | 450| --- | --- | --- | --- | --- | --- |
416| `branch_protection` | `enabled` | SOC 2 | CC8.1 | positive | Change management — review before merge | 451| `branch_protection` | `enabled` | SOC 2 | CC8.1 | positive | Change management — a protection rule is enforced on the default branch (rule contents not verified) |
417| `branch_protection` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — direct pushes possible | 452| `branch_protection` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — no protection on the default branch; direct pushes possible |
418| `branch_protection` | `enabled` | ISO 27001 | A.8.32 | positive | Change management | 453| `branch_protection` | `enabled` | ISO 27001 | A.8.32 | positive | Change management — a protection rule is enforced on the default branch (rule contents not verified) |
419| `branch_protection` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — direct pushes possible | 454| `branch_protection` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — no protection on the default branch; direct pushes possible |
420| `repository_ruleset` | `enabled` | SOC 2 | CC8.1 | positive | Change management — review before merge | 455| `repository_ruleset` | `enabled` | SOC 2 | CC8.1 | positive | Change management — an active ruleset covers the default branch (rule contents not verified) |
421| `repository_ruleset` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — direct pushes possible | 456| `repository_ruleset` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — no active ruleset covers the default branch |
422| `repository_ruleset` | `enabled` | ISO 27001 | A.8.32 | positive | Change management | 457| `repository_ruleset` | `enabled` | ISO 27001 | A.8.32 | positive | Change management — an active ruleset covers the default branch (rule contents not verified) |
423| `repository_ruleset` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — direct pushes possible | 458| `repository_ruleset` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — no active ruleset covers the default branch |
424| `dependabot_alert` | · | SOC 2 | CC7.1 | positive | Detection tooling is active | 459| `dependabot` | `enabled` | SOC 2 | CC7.1 | positive | Detection tooling — Dependabot alerts are enabled on the repository |
425| `dependabot_alert` | `open` | SOC 2 | CC7.2 | negative | Unremediated known vulnerability | 460| `dependabot` | `enabled` | ISO 27001 | A.8.8 | positive | Technical vulnerability management — Dependabot alerts are enabled on the repository |
426| `dependabot_alert` | `fixed` | SOC 2 | CC7.2 | positive | Remediated | 461| `code_scanning` | `enabled` | SOC 2 | CC7.1 | positive | Detection tooling — code scanning is enabled on the repository |
427| `dependabot_alert` | `dismissed` | SOC 2 | CC7.2 | positive | Remediated (risk accepted) | 462| `code_scanning` | `enabled` | ISO 27001 | A.8.29 | positive | Security testing in development — code scanning is enabled on the repository |
428| `dependabot_alert` | `auto_dismissed` | SOC 2 | CC7.2 | positive | Remediated (e.g. dependency removed) | 463| `secret_scanning` | `enabled` | SOC 2 | CC6.6 | positive | Leaked-credential detection — secret scanning is enabled on the repository |
429| `dependabot_alert` | · | ISO 27001 | A.8.8 | positive | Technical vulnerability management — detection active | 464| `secret_scanning` | `enabled` | SOC 2 | CC6.1 | positive | Logical-access credential protection — secret scanning is enabled on the repository |
465| `secret_scanning` | `enabled` | ISO 27001 | A.5.17 | positive | Authentication-information protection — secret scanning is enabled on the repository |
466| `dependabot_alert` | `open` | SOC 2 | CC7.1 | negative | Unremediated known vulnerability |
467| `dependabot_alert` | `fixed` | SOC 2 | CC7.1 | positive | Vulnerability remediated |
468| `dependabot_alert` | `dismissed` | SOC 2 | CC7.1 | informational | Dismissed by a user — risk-acceptance justification subject to review |
469| `dependabot_alert` | `auto_dismissed` | SOC 2 | CC7.1 | positive | Auto-dismissed by GitHub (e.g. dependency removed) |
430| `dependabot_alert` | `open` | ISO 27001 | A.8.8 | negative | Unremediated known technical vulnerability | 470| `dependabot_alert` | `open` | ISO 27001 | A.8.8 | negative | Unremediated known technical vulnerability |
431| `dependabot_alert` | `fixed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated | 471| `dependabot_alert` | `fixed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated |
432| `dependabot_alert` | `dismissed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated (risk accepted) | 472| `dependabot_alert` | `dismissed` | ISO 27001 | A.8.8 | informational | Dismissed by a user — risk-acceptance justification subject to review |
433| `dependabot_alert` | `auto_dismissed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated (e.g. dependency removed) | 473| `dependabot_alert` | `auto_dismissed` | ISO 27001 | A.8.8 | positive | Auto-dismissed by GitHub (e.g. dependency removed) |
434| `code_scanning_alert` | · | ISO 27001 | A.8.29 | positive | Security testing in development is active | 474| `code_scanning_alert` | `open` | SOC 2 | CC7.1 | negative | Unremediated static-analysis finding |
435| `code_scanning_alert` | `open` | ISO 27001 | A.8.28 | negative | Unremediated finding | 475| `code_scanning_alert` | `fixed` | SOC 2 | CC7.1 | positive | Finding remediated |
436| `code_scanning_alert` | `fixed` | ISO 27001 | A.8.28 | positive | Remediated | 476| `code_scanning_alert` | `dismissed` | SOC 2 | CC7.1 | informational | Dismissed by a user — risk-acceptance justification subject to review |
437| `code_scanning_alert` | `dismissed` | ISO 27001 | A.8.28 | positive | Remediated (risk accepted) | 477| `code_scanning_alert` | `open` | ISO 27001 | A.8.28 | negative | Unremediated static-analysis finding |
438| `code_scanning_alert` | · | SOC 2 | CC7.1 | positive | Detection tooling is active (findings unmapped in SOC 2) | 478| `code_scanning_alert` | `fixed` | ISO 27001 | A.8.28 | positive | Finding remediated |
439| `secret_scanning_alert` | · | SOC 2 | CC6.6 | positive | Leaked-credential detection is active | 479| `code_scanning_alert` | `dismissed` | ISO 27001 | A.8.28 | informational | Dismissed by a user — risk-acceptance justification subject to review |
440| `secret_scanning_alert` | `open` | SOC 2 | CC6.6 | negative | Live credential exposure | 480| `secret_scanning_alert` | `open` | SOC 2 | CC6.6 | negative | Live credential exposure |
441| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.6 | positive | Exposure remediated | 481| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.6 | informational | Resolution recorded — reason (revoked vs. won't-fix) subject to review |
442| `secret_scanning_alert` | · | SOC 2 | CC6.1 | positive | Logical-access credential protection — detection active |
443| `secret_scanning_alert` | `open` | SOC 2 | CC6.1 | negative | Exposed credential undermines logical access controls | 482| `secret_scanning_alert` | `open` | SOC 2 | CC6.1 | negative | Exposed credential undermines logical access controls |
444| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.1 | positive | Logical access control restored — exposure remediated | 483| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.1 | informational | Resolution recorded — reason (revoked vs. won't-fix) subject to review |
445| `secret_scanning_alert` | · | ISO 27001 | A.5.17 | positive | Authentication-information protection — detection active |
446| `secret_scanning_alert` | `open` | ISO 27001 | A.5.17 | negative | Exposed authentication information | 484| `secret_scanning_alert` | `open` | ISO 27001 | A.5.17 | negative | Exposed authentication information |
447| `secret_scanning_alert` | `resolved` | ISO 27001 | A.5.17 | positive | Authentication-information exposure remediated | 485| `secret_scanning_alert` | `resolved` | ISO 27001 | A.5.17 | informational | Resolution recorded — reason (revoked vs. won't-fix) subject to review |
448| `member_access` | `added` | SOC 2 | CC6.2 | informational | Access grant — logged for review | 486| `member_access` | `added` | SOC 2 | CC6.2 | informational | Repository collaborator added — access grant logged for review |
449| `member_access` | `removed` | SOC 2 | CC6.3 | positive | Timely access removal | 487| `member_access` | `removed` | SOC 2 | CC6.3 | informational | Repository collaborator removed — deprovisioning recorded; timeliness subject to review |
450| `member_access` | `edited` | SOC 2 | CC6.3 | informational | Access-level change — logged for review | 488| `member_access` | `edited` | SOC 2 | CC6.3 | informational | Repository collaborator permission changed — logged for review |
489| `member_access` | `added` | ISO 27001 | A.5.18 | informational | Repository collaborator added — access-rights change, audit trail |
490| `member_access` | `removed` | ISO 27001 | A.5.18 | informational | Repository collaborator removed — access-rights change, audit trail |
491| `member_access` | `edited` | ISO 27001 | A.5.18 | informational | Repository collaborator permission changed — access-rights change, audit trail |
492| `org_membership` | `member_added` | SOC 2 | CC6.2 | informational | Organization member added — access grant logged for review |
493| `org_membership` | `member_removed` | SOC 2 | CC6.3 | informational | Organization member removed — deprovisioning recorded; timeliness subject to review |
494| `org_membership` | `member_invited` | SOC 2 | CC6.2 | informational | Organization invitation issued — logged for review |
495| `org_membership` | `member_added` | ISO 27001 | A.5.18 | informational | Organization member added — access-rights change, audit trail |
496| `org_membership` | `member_removed` | ISO 27001 | A.5.18 | informational | Organization member removed — access-rights change, audit trail |
497| `org_membership` | `member_invited` | ISO 27001 | A.5.18 | informational | Organization invitation issued — access-rights change, audit trail |
451| `team` | · | ISO 27001 | A.5.18 | informational | Access-rights change, audit trail | 498| `team` | · | ISO 27001 | A.5.18 | informational | Access-rights change, audit trail |
452| `repository` | · | ISO 27001 | A.5.9 | informational | Asset inventory trail | 499| `repository` | · | ISO 27001 | A.5.9 | informational | Asset inventory trail |
453| `org_member` | · | SOC 2 | CC6.2 | informational | Org access inventory — subject to periodic review | 500| `repository` | `publicized` | SOC 2 | CC6.1 | informational | Repository made public — visibility change affecting asset confidentiality, flagged for review |
454| `org_member` | · | ISO 27001 | A.5.18 | informational | Access-rights inventory | 501| `org_member` | · | SOC 2 | CC6.2 | informational | Organization access inventory — subject to periodic access review |
502| `org_member` | · | ISO 27001 | A.5.18 | informational | Access rights inventory |
455| `team_member` | · | SOC 2 | CC6.3 | informational | Team-based access inventory | 503| `team_member` | · | SOC 2 | CC6.3 | informational | Team-based access inventory |
456| `team_member` | · | ISO 27001 | A.5.18 | informational | Access-rights inventory | 504| `team_member` | · | ISO 27001 | A.5.18 | informational | Access rights inventory |
457 505
458### Control glossary 506### Control glossary
459 507
@@ -464,7 +512,6 @@ Status means the mapping's `status` is `NULL` — it matches any status.**
464| **CC6.3** | Authorize, modify, and remove access by role, with least privilege and segregation of duties | 512| **CC6.3** | Authorize, modify, and remove access by role, with least privilege and segregation of duties |
465| **CC6.6** | Protect against threats originating outside the system boundary | 513| **CC6.6** | Protect against threats originating outside the system boundary |
466| **CC7.1** | Detect configuration changes that introduce vulnerabilities, and susceptibility to newly discovered ones | 514| **CC7.1** | Detect configuration changes that introduce vulnerabilities, and susceptibility to newly discovered ones |
467| **CC7.2** | Monitor components for anomalies and analyze them as potential security events |
468| **CC8.1** | Put changes through an authorized, controlled process; block unauthorized changes | 515| **CC8.1** | Put changes through an authorized, controlled process; block unauthorized changes |
469| **A.5.9** | Maintain an inventory of information and associated assets, with owners | 516| **A.5.9** | Maintain an inventory of information and associated assets, with owners |
470| **A.5.17** | Control the allocation and management of authentication information (passwords, keys, tokens) | 517| **A.5.17** | Control the allocation and management of authentication information (passwords, keys, tokens) |
@@ -501,20 +548,23 @@ so bias toward under-claiming.
501 mapping is worth more to an auditor than three tenuous ones. Tenuous mappings 548 mapping is worth more to an auditor than three tenuous ones. Tenuous mappings
502 erode trust in the whole evidence pack. 549 erode trust in the whole evidence pack.
5034. **Assign posture from the control's expectation, not the signal's sentiment:** 5504. **Assign posture from the control's expectation, not the signal's sentiment:**
504 - `positive` — the state is what the control wants. 551 - `positive` — the state is what the control wants, and the platform verified
552 it (not merely a human clicking "dismiss").
505 - `negative` — the state is a concrete gap the control would flag. 553 - `negative` — the state is a concrete gap the control would flag.
506 - `informational` — the state is audit-trail/inventory that feeds a review but 554 - `informational` — the state is audit-trail/inventory that feeds a review but
507 is not itself pass/fail. When in doubt, use `informational`. 555 is not itself pass/fail. Human decisions (dismissals, resolutions with a
5085. **Decide detection-vs-finding.** If the signal is a scanner/alert stream, you 556 reason) belong here. When in doubt, use `informational`.
509 usually want two mapping kinds: a `status = NULL` row for "the control's 5575. **Separate tooling state from findings.** If the signal is a scanner/alert
510 *tooling* exists" (positive), and per-status rows for individual findings. 558 stream, attest "the control's *tooling* is on" from the feature's own state
511 Remember rule 3 in [How mapping works](#how-mapping-works-mechanically): both 559 (polled), not from the existence of alerts — and attest each finding's
512 fire on the same snapshot. 560 lifecycle per alert, with the alert number as `subject` so findings don't
561 mask each other.
5136. **Pin the edition.** State which version of the framework you mapped (e.g. 5626. **Pin the edition.** State which version of the framework you mapped (e.g.
514 "PCI DSS v4.0.1", "NIST CSF 2.0") — control numbers move between editions. 563 "PCI DSS v4.0.1", "NIST CSF 2.0") — control numbers move between editions.
5157. **Write the rationale** in the `rationale` column *and* the fit assessment 5647. **Write the rationale** in the `rationale` column *and* copy it into the
516 here. The `rationale` is what an auditor reads in the export; make it a 565 reference table here verbatim — it is CI-checked. The `rationale` is what an
517 complete thought, not a keyword. 566 auditor reads in the export; make it a complete thought that claims no more
567 than the signal proves.
518 568
519### What the code needs 569### What the code needs
520 570
@@ -570,7 +620,7 @@ a row here with no SQL, is a bug.
570This is enforced. [`scripts/check-mappings.mjs`](../scripts/check-mappings.mjs) 620This is enforced. [`scripts/check-mappings.mjs`](../scripts/check-mappings.mjs)
571applies every migration to an in-memory SQLite database, reads back 621applies every migration to an in-memory SQLite database, reads back
572`control_mappings`, and diffs the `(resource, status, framework, control_id, 622`control_mappings`, and diffs the `(resource, status, framework, control_id,
573posture)` tuples against the rows parsed out of the 623posture, rationale)` tuples against the rows parsed out of the
574[reference table](#complete-mapping-reference) above. It fails with a row-level 624[reference table](#complete-mapping-reference) above. It fails with a row-level
575diff if the two drift. Run it with: 625diff if the two drift. Run it with:
576 626
migrations/0008_mapping_review_fixes.sql added +141
@@ -0,0 +1,141 @@
1-- Migration 0008: fixes from the evidence-to-control mapping review.
2--
3-- 1. Secret-scanning status vocabulary. The secret_scanning_alert webhook
4-- payload has no alert.state field (unlike Dependabot / code scanning), so
5-- ingest recorded the webhook action (created/reopened/...) and the 'open'
6-- mappings never matched — an open leak exported only positive rows.
7-- Ingest now derives open/resolved from alert.resolution; historical rows
8-- are backfilled the same way below.
9-- 2. Per-entity subjects. Alert/member/team snapshots had a NULL subject, so
10-- "latest row wins per (repo, subject, resource)" collapsed every alert of
11-- a type in a repo into one evidence row — one fixed alert masked any
12-- number of still-open ones. Ingest now stores the alert number / login /
13-- slug in subject; historical rows are backfilled from raw_payload.
14-- 3. Tooling-active re-homed. "Detection tooling is active" was inferred from
15-- the latest alert event, which keeps attesting after the scanner is
16-- disabled and never fires for a clean repo. The poller now reports the
17-- feature state directly (resources dependabot / code_scanning /
18-- secret_scanning, status enabled|disabled|unavailable); the status-NULL
19-- alert mappings are replaced by 'enabled' mappings on those resources.
20-- disabled/unavailable stay unmapped — missing tooling is recorded but not
21-- claimed either way, matching the branch-protection precedent.
22-- 4. SOC 2 vulnerability lifecycle consolidated under CC7.1, resolving the
23-- CC7.1-vs-CC7.2 question left open in docs/framework-mapping.md: a known
24-- vulnerability sits in CC7.1's "susceptibility to newly discovered
25-- vulnerabilities" language, not CC7.2's runtime anomaly monitoring. Code
26-- scanning gains the finding-level SOC 2 rows deferred on that decision.
27-- 5. Human dismissals downgraded to informational. A dismissal (or a secret
28-- "resolved" that may be wont_fix) is a recorded human decision, not a
29-- verified remediation — the justification is what an auditor samples.
30-- Machine-verified outcomes (fixed, auto_dismissed) stay positive.
31-- 6. Branch-protection rationales reworded to what is actually verified: a
32-- protection rule / active ruleset covers the default branch; the rule
33-- contents are not checked.
34-- 7. The `member` webhook is repository-collaborator scoped: rationales now
35-- say so, "removed" no longer claims timeliness the event can't prove, and
36-- org-level membership events (`organization` webhook -> org_membership)
37-- are mapped. member_access gains the ISO A.5.18 rows it was missing.
38-- 8. repository 'publicized' additionally flagged to SOC 2 CC6.1 — a repo
39-- going public is a visibility change worth surfacing, not just an
40-- inventory tick.
41
42-- (1) Backfill secret-scanning statuses recorded from the raw webhook action.
43UPDATE snapshots
44SET status = CASE
45 WHEN json_extract(raw_payload, '$.alert.resolution') IS NULL THEN 'open'
46 ELSE 'resolved'
47END
48WHERE resource = 'secret_scanning_alert'
49 AND status NOT IN ('open', 'resolved')
50 AND raw_payload IS NOT NULL;
51
52-- (2) Backfill per-entity subjects from the retained webhook payloads.
53UPDATE snapshots
54SET subject = CAST(json_extract(raw_payload, '$.alert.number') AS TEXT)
55WHERE resource IN ('dependabot_alert', 'code_scanning_alert', 'secret_scanning_alert')
56 AND subject IS NULL
57 AND json_extract(raw_payload, '$.alert.number') IS NOT NULL;
58
59UPDATE snapshots
60SET subject = json_extract(raw_payload, '$.member.login')
61WHERE resource = 'member_access'
62 AND subject IS NULL
63 AND json_extract(raw_payload, '$.member.login') IS NOT NULL;
64
65UPDATE snapshots
66SET subject = json_extract(raw_payload, '$.team.slug')
67WHERE resource = 'team'
68 AND subject IS NULL
69 AND json_extract(raw_payload, '$.team.slug') IS NOT NULL;
70
71-- (3)-(8) Replace the mappings for every affected resource wholesale (same
72-- pattern as migration 0003).
73DELETE FROM control_mappings WHERE resource IN
74 ('dependabot_alert', 'code_scanning_alert', 'secret_scanning_alert',
75 'branch_protection', 'repository_ruleset', 'member_access');
76
77INSERT INTO control_mappings (resource, status, framework, control_id, posture, rationale) VALUES
78 -- Branch protection / rulesets: state of the default branch's merge gate.
79 ('branch_protection', 'enabled', 'soc2', 'CC8.1', 'positive', 'Change management — a protection rule is enforced on the default branch (rule contents not verified)'),
80 ('branch_protection', 'disabled', 'soc2', 'CC8.1', 'negative', 'Change-control gap — no protection on the default branch; direct pushes possible'),
81 ('branch_protection', 'enabled', 'iso27001', 'A.8.32', 'positive', 'Change management — a protection rule is enforced on the default branch (rule contents not verified)'),
82 ('branch_protection', 'disabled', 'iso27001', 'A.8.32', 'negative', 'Change-control gap — no protection on the default branch; direct pushes possible'),
83 ('repository_ruleset', 'enabled', 'soc2', 'CC8.1', 'positive', 'Change management — an active ruleset covers the default branch (rule contents not verified)'),
84 ('repository_ruleset', 'disabled', 'soc2', 'CC8.1', 'negative', 'Change-control gap — no active ruleset covers the default branch'),
85 ('repository_ruleset', 'enabled', 'iso27001', 'A.8.32', 'positive', 'Change management — an active ruleset covers the default branch (rule contents not verified)'),
86 ('repository_ruleset', 'disabled', 'iso27001', 'A.8.32', 'negative', 'Change-control gap — no active ruleset covers the default branch'),
87
88 -- Detection tooling state (polled; disabled/unavailable deliberately unmapped).
89 ('dependabot', 'enabled', 'soc2', 'CC7.1', 'positive', 'Detection tooling — Dependabot alerts are enabled on the repository'),
90 ('dependabot', 'enabled', 'iso27001', 'A.8.8', 'positive', 'Technical vulnerability management — Dependabot alerts are enabled on the repository'),
91 ('code_scanning', 'enabled', 'soc2', 'CC7.1', 'positive', 'Detection tooling — code scanning is enabled on the repository'),
92 ('code_scanning', 'enabled', 'iso27001', 'A.8.29', 'positive', 'Security testing in development — code scanning is enabled on the repository'),
93 ('secret_scanning', 'enabled', 'soc2', 'CC6.6', 'positive', 'Leaked-credential detection — secret scanning is enabled on the repository'),
94 ('secret_scanning', 'enabled', 'soc2', 'CC6.1', 'positive', 'Logical-access credential protection — secret scanning is enabled on the repository'),
95 ('secret_scanning', 'enabled', 'iso27001', 'A.5.17', 'positive', 'Authentication-information protection — secret scanning is enabled on the repository'),
96
97 -- Dependabot findings.
98 ('dependabot_alert', 'open', 'soc2', 'CC7.1', 'negative', 'Unremediated known vulnerability'),
99 ('dependabot_alert', 'fixed', 'soc2', 'CC7.1', 'positive', 'Vulnerability remediated'),
100 ('dependabot_alert', 'dismissed', 'soc2', 'CC7.1', 'informational', 'Dismissed by a user — risk-acceptance justification subject to review'),
101 ('dependabot_alert', 'auto_dismissed', 'soc2', 'CC7.1', 'positive', 'Auto-dismissed by GitHub (e.g. dependency removed)'),
102 ('dependabot_alert', 'open', 'iso27001', 'A.8.8', 'negative', 'Unremediated known technical vulnerability'),
103 ('dependabot_alert', 'fixed', 'iso27001', 'A.8.8', 'positive', 'Vulnerability remediated'),
104 ('dependabot_alert', 'dismissed', 'iso27001', 'A.8.8', 'informational', 'Dismissed by a user — risk-acceptance justification subject to review'),
105 ('dependabot_alert', 'auto_dismissed', 'iso27001', 'A.8.8', 'positive', 'Auto-dismissed by GitHub (e.g. dependency removed)'),
106
107 -- Code scanning findings.
108 ('code_scanning_alert', 'open', 'soc2', 'CC7.1', 'negative', 'Unremediated static-analysis finding'),
109 ('code_scanning_alert', 'fixed', 'soc2', 'CC7.1', 'positive', 'Finding remediated'),
110 ('code_scanning_alert', 'dismissed', 'soc2', 'CC7.1', 'informational', 'Dismissed by a user — risk-acceptance justification subject to review'),
111 ('code_scanning_alert', 'open', 'iso27001', 'A.8.28', 'negative', 'Unremediated static-analysis finding'),
112 ('code_scanning_alert', 'fixed', 'iso27001', 'A.8.28', 'positive', 'Finding remediated'),
113 ('code_scanning_alert', 'dismissed', 'iso27001', 'A.8.28', 'informational', 'Dismissed by a user — risk-acceptance justification subject to review'),
114
115 -- Secret scanning findings ("resolved" may be wont_fix — a human decision,
116 -- not a verified remediation).
117 ('secret_scanning_alert', 'open', 'soc2', 'CC6.6', 'negative', 'Live credential exposure'),
118 ('secret_scanning_alert', 'resolved', 'soc2', 'CC6.6', 'informational', 'Resolution recorded — reason (revoked vs. won''t-fix) subject to review'),
119 ('secret_scanning_alert', 'open', 'soc2', 'CC6.1', 'negative', 'Exposed credential undermines logical access controls'),
120 ('secret_scanning_alert', 'resolved', 'soc2', 'CC6.1', 'informational', 'Resolution recorded — reason (revoked vs. won''t-fix) subject to review'),
121 ('secret_scanning_alert', 'open', 'iso27001', 'A.5.17', 'negative', 'Exposed authentication information'),
122 ('secret_scanning_alert', 'resolved', 'iso27001', 'A.5.17', 'informational', 'Resolution recorded — reason (revoked vs. won''t-fix) subject to review'),
123
124 -- Repository collaborators (the `member` webhook is repo-scoped).
125 ('member_access', 'added', 'soc2', 'CC6.2', 'informational', 'Repository collaborator added — access grant logged for review'),
126 ('member_access', 'removed', 'soc2', 'CC6.3', 'informational', 'Repository collaborator removed — deprovisioning recorded; timeliness subject to review'),
127 ('member_access', 'edited', 'soc2', 'CC6.3', 'informational', 'Repository collaborator permission changed — logged for review'),
128 ('member_access', 'added', 'iso27001', 'A.5.18', 'informational', 'Repository collaborator added — access-rights change, audit trail'),
129 ('member_access', 'removed', 'iso27001', 'A.5.18', 'informational', 'Repository collaborator removed — access-rights change, audit trail'),
130 ('member_access', 'edited', 'iso27001', 'A.5.18', 'informational', 'Repository collaborator permission changed — access-rights change, audit trail'),
131
132 -- Organization membership changes (`organization` webhook).
133 ('org_membership', 'member_added', 'soc2', 'CC6.2', 'informational', 'Organization member added — access grant logged for review'),
134 ('org_membership', 'member_removed', 'soc2', 'CC6.3', 'informational', 'Organization member removed — deprovisioning recorded; timeliness subject to review'),
135 ('org_membership', 'member_invited', 'soc2', 'CC6.2', 'informational', 'Organization invitation issued — logged for review'),
136 ('org_membership', 'member_added', 'iso27001', 'A.5.18', 'informational', 'Organization member added — access-rights change, audit trail'),
137 ('org_membership', 'member_removed', 'iso27001', 'A.5.18', 'informational', 'Organization member removed — access-rights change, audit trail'),
138 ('org_membership', 'member_invited', 'iso27001', 'A.5.18', 'informational', 'Organization invitation issued — access-rights change, audit trail'),
139
140 -- Repository made public: worth surfacing beyond the generic inventory trail.
141 ('repository', 'publicized', 'soc2', 'CC6.1', 'informational', 'Repository made public — visibility change affecting asset confidentiality, flagged for review');
scripts/check-mappings.mjs +14 −10
@@ -4,9 +4,10 @@
4// relies on. If they drift, the doc is lying — so this fails CI. 4// relies on. If they drift, the doc is lying — so this fails CI.
5// 5//
6// It works by actually applying every migration to an in-memory SQLite database 6// It works by actually applying every migration to an in-memory SQLite database
7// (so migration 0003's delete-and-reinsert is handled exactly as production D1 7// (so a migration's delete-and-reinsert is handled exactly as production D1
8// would), reading back control_mappings, and diffing against the rows parsed out 8// would), reading back control_mappings, and diffing against the rows parsed out
9// of the doc's "Complete mapping reference" table. 9// of the doc's "Complete mapping reference" table — including the rationale
10// text, which is what an auditor reads in every export.
10// 11//
11// Run: npm run test:mappings (no dependencies — uses Node's built-in sqlite) 12// Run: npm run test:mappings (no dependencies — uses Node's built-in sqlite)
12 13
@@ -22,8 +23,11 @@ const docPath = join(repoRoot, "docs", "framework-mapping.md");
22// A "·" in the doc's Status column means the mapping's status is NULL (matches 23// A "·" in the doc's Status column means the mapping's status is NULL (matches
23// any status); normalize both sides to this sentinel so they compare equal. 24// any status); normalize both sides to this sentinel so they compare equal.
24const NULL_STATUS = "·"; 25const NULL_STATUS = "·";
25const key = (resource, status, framework, control, posture) => 26// The rationale is part of the key: it is the auditor-facing string in every
26 `${resource}|${status ?? NULL_STATUS}|${framework}|${control}|${posture}`; 27// export, so the doc's copy drifting from the SQL is as much a lie as a
28// wrong posture.
29const key = (resource, status, framework, control, posture, rationale) =>
30 `${resource}|${status ?? NULL_STATUS}|${framework}|${control}|${posture}|${rationale}`;
27 31
28// --- 1. Source of truth: apply migrations, read control_mappings. --- 32// --- 1. Source of truth: apply migrations, read control_mappings. ---
29function rowsFromMigrations() { 33function rowsFromMigrations() {
@@ -35,10 +39,10 @@ function rowsFromMigrations() {
35 db.exec(readFileSync(join(migrationsDir, file), "utf8")); 39 db.exec(readFileSync(join(migrationsDir, file), "utf8"));
36 } 40 }
37 const rows = db 41 const rows = db
38 .prepare("SELECT resource, status, framework, control_id, posture FROM control_mappings") 42 .prepare("SELECT resource, status, framework, control_id, posture, rationale FROM control_mappings")
39 .all(); 43 .all();
40 db.close(); 44 db.close();
41 return new Set(rows.map((r) => key(r.resource, r.status, r.framework, r.control_id, r.posture))); 45 return new Set(rows.map((r) => key(r.resource, r.status, r.framework, r.control_id, r.posture, r.rationale)));
42} 46}
43 47
44// --- 2. Human-readable copy: parse the doc's reference table. --- 48// --- 2. Human-readable copy: parse the doc's reference table. ---
@@ -56,13 +60,13 @@ function rowsFromDoc() {
56 const set = new Set(); 60 const set = new Set();
57 for (const line of readFileSync(docPath, "utf8").split("\n")) { 61 for (const line of readFileSync(docPath, "utf8").split("\n")) {
58 if (!line.startsWith("|")) continue; 62 if (!line.startsWith("|")) continue;
59 // Leading "|" yields an empty cells[0]; data lives in cells[1..5]. 63 // Leading "|" yields an empty cells[0]; data lives in cells[1..6].
60 const cells = line.split("|").map((c) => c.trim()); 64 const cells = line.split("|").map((c) => c.trim());
61 const [, resource, statusCell, frameworkLabel, control, posture] = cells; 65 const [, resource, statusCell, frameworkLabel, control, posture, rationale] = cells;
62 const framework = FRAMEWORK_LABELS[frameworkLabel]; 66 const framework = FRAMEWORK_LABELS[frameworkLabel];
63 if (!framework || !POSTURES.has(posture) || !BACKTICKED.test(resource ?? "")) continue; 67 if (!framework || !POSTURES.has(posture) || !BACKTICKED.test(resource ?? "")) continue;
64 const status = statusCell.replaceAll("`", ""); // "·" for NULL 68 const status = statusCell.replaceAll("`", ""); // "·" for NULL
65 set.add(key(resource.replaceAll("`", ""), status, framework, control, posture)); 69 set.add(key(resource.replaceAll("`", ""), status, framework, control, posture, rationale ?? ""));
66 } 70 }
67 return set; 71 return set;
68} 72}
@@ -80,7 +84,7 @@ if (onlyInDb.length === 0 && onlyInDoc.length === 0) {
80} 84}
81 85
82console.error("✗ control-mapping drift between migrations/ and docs/framework-mapping.md\n"); 86console.error("✗ control-mapping drift between migrations/ and docs/framework-mapping.md\n");
83console.error(" columns: resource | status | framework | control | posture\n"); 87console.error(" columns: resource | status | framework | control | posture | rationale\n");
84if (onlyInDb.length) { 88if (onlyInDb.length) {
85 console.error(` In migrations but MISSING from the doc (${onlyInDb.length}):`); 89 console.error(` In migrations but MISSING from the doc (${onlyInDb.length}):`);
86 for (const k of onlyInDb) console.error(` + ${k}`); 90 for (const k of onlyInDb) console.error(` + ${k}`);
src/index.ts +11 −8
@@ -15,7 +15,7 @@ import {
15} from "./auth"; 15} from "./auth";
16import { parseCookies, setCookieHeader, clearCookieHeader } from "./cookies"; 16import { parseCookies, setCookieHeader, clearCookieHeader } from "./cookies";
17import { createAppJwt, getInstallationToken } from "./github-app"; 17import { createAppJwt, getInstallationToken } from "./github-app";
18import { listInstallationRepos, pollRepoProtection, pollOrgAccess } from "./poller"; 18import { listInstallationRepos, pollRepoProtection, pollRepoAlerts, pollOrgAccess } from "./poller";
19import { buildEvidenceRows, renderCsv, renderPdf, type Framework, type ExportFormat } from "./exporter"; 19import { buildEvidenceRows, renderCsv, renderPdf, type Framework, type ExportFormat } from "./exporter";
20import { 20import {
21 renderDashboard, 21 renderDashboard,
@@ -211,13 +211,16 @@ async function pollInstallation(env: Env, installationId: number, summary: PollS
211 // Per-repo isolation: one failing repo must not abort the rest of the 211 // Per-repo isolation: one failing repo must not abort the rest of the
212 // installation's poll. 212 // installation's poll.
213 try { 213 try {
214 const facts = await pollRepoProtection(installationToken, repo); 214 const facts = [
215 ...(await pollRepoProtection(installationToken, repo)),
216 ...(await pollRepoAlerts(installationToken, repo)),
217 ];
215 for (const fact of facts) { 218 for (const fact of facts) {
216 await env.DB.prepare( 219 await env.DB.prepare(
217 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at) 220 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at, subject)
218 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, 221 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
219 ) 222 )
220 .bind(installationId, fact.repo, fact.resource, fact.status, fact.rawPayload, capturedAt) 223 .bind(installationId, fact.repo, fact.resource, fact.status, fact.rawPayload, capturedAt, fact.subject)
221 .run(); 224 .run();
222 summary.written.push({ installationId, repo: fact.repo, resource: fact.resource, status: fact.status }); 225 summary.written.push({ installationId, repo: fact.repo, resource: fact.resource, status: fact.status });
223 } 226 }
@@ -828,10 +831,10 @@ async function handleWebhook(request: Request, env: Env): Promise<Response> {
828 const repo = extractRepoFullName(payload); 831 const repo = extractRepoFullName(payload);
829 832
830 await env.DB.prepare( 833 await env.DB.prepare(
831 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at) 834 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at, subject)
832 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, 835 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
833 ) 836 )
834 .bind(installationId, repo, fact.resource, fact.status, bodyText, capturedAt) 837 .bind(installationId, repo, fact.resource, fact.status, bodyText, capturedAt, fact.subject)
835 .run(); 838 .run();
836 839
837 return new Response("OK", { status: 200 }); 840 return new Response("OK", { status: 200 });
src/poller.ts +98 −14
@@ -68,31 +68,42 @@ async function fetchBranchProtection(
68 return { status: "enabled", raw: await res.json() }; 68 return { status: "enabled", raw: await res.json() };
69} 69}
70 70
71async function fetchRulesets(installationToken: string, owner: string, repo: string): Promise<ProtectionCheck> { 71async function fetchDefaultBranchRules(
72 const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/rulesets?per_page=100`, { 72 installationToken: string,
73 headers: authHeaders(installationToken), 73 owner: string,
74 }); 74 repo: string,
75 if (res.status === 403) return { status: "unavailable", raw: null }; 75 branch: string,
76 if (!res.ok) throw new Error(`Failed to fetch rulesets for ${owner}/${repo} (${res.status})`); 76): Promise<ProtectionCheck> {
77 // /rules/branches/{branch} aggregates the rules from every ACTIVE ruleset —
78 // repo- and org-level — that applies to this branch. Evaluate-mode
79 // (monitor-only) rulesets are excluded, and a ruleset targeting only other
80 // branches contributes nothing, so a non-empty result means the default
81 // branch is actually covered by at least one enforcing ruleset.
82 const res = await fetch(
83 `${GITHUB_API}/repos/${owner}/${repo}/rules/branches/${encodeURIComponent(branch)}?per_page=100`,
84 { headers: authHeaders(installationToken) },
85 );
86 // 403 = feature not available; 404 = branch not found (e.g. empty repo).
87 // Both deliberately unmapped, like branch protection's "unavailable".
88 if (res.status === 403 || res.status === 404) return { status: "unavailable", raw: null };
89 if (!res.ok) throw new Error(`Failed to fetch branch rules for ${owner}/${repo} (${res.status})`);
77 90
78 const rulesets = (await res.json()) as Array<{ enforcement: string; target: string }>; 91 const rules = (await res.json()) as unknown[];
79 // "evaluate" is dry-run/monitor-only — doesn't actually block anything, so 92 return { status: rules.length > 0 ? "enabled" : "disabled", raw: rules };
80 // it doesn't count as protection being enabled.
81 const enabled = rulesets.some((r) => r.enforcement === "active" && r.target === "branch");
82 return { status: enabled ? "enabled" : "disabled", raw: rulesets };
83} 93}
84 94
85export interface PolledFact { 95export interface PolledFact {
86 repo: string; 96 repo: string;
87 resource: "branch_protection" | "repository_ruleset"; 97 resource: string;
88 status: "enabled" | "disabled" | "unavailable"; 98 status: string;
99 subject: string | null;
89 rawPayload: string | null; 100 rawPayload: string | null;
90} 101}
91 102
92export async function pollRepoProtection(installationToken: string, repo: RepoRef): Promise<PolledFact[]> { 103export async function pollRepoProtection(installationToken: string, repo: RepoRef): Promise<PolledFact[]> {
93 const [branchProtection, rulesets] = await Promise.all([ 104 const [branchProtection, rulesets] = await Promise.all([
94 fetchBranchProtection(installationToken, repo.owner, repo.name, repo.defaultBranch), 105 fetchBranchProtection(installationToken, repo.owner, repo.name, repo.defaultBranch),
95 fetchRulesets(installationToken, repo.owner, repo.name), 106 fetchDefaultBranchRules(installationToken, repo.owner, repo.name, repo.defaultBranch),
96 ]); 107 ]);
97 108
98 return [ 109 return [
@@ -100,17 +111,90 @@ export async function pollRepoProtection(installationToken: string, repo: RepoRe
100 repo: repo.fullName, 111 repo: repo.fullName,
101 resource: "branch_protection", 112 resource: "branch_protection",
102 status: branchProtection.status, 113 status: branchProtection.status,
114 subject: null,
103 rawPayload: branchProtection.raw ? JSON.stringify(branchProtection.raw) : null, 115 rawPayload: branchProtection.raw ? JSON.stringify(branchProtection.raw) : null,
104 }, 116 },
105 { 117 {
106 repo: repo.fullName, 118 repo: repo.fullName,
107 resource: "repository_ruleset", 119 resource: "repository_ruleset",
108 status: rulesets.status, 120 status: rulesets.status,
121 subject: null,
109 rawPayload: rulesets.raw ? JSON.stringify(rulesets.raw) : null, 122 rawPayload: rulesets.raw ? JSON.stringify(rulesets.raw) : null,
110 }, 123 },
111 ]; 124 ];
112} 125}
113 126
127// ---------------------------------------------------------------------------
128// Alert streams: baseline + keep-alive poll.
129// ---------------------------------------------------------------------------
130
131// The list endpoint doubles as the tooling-enabled signal: 200 means the
132// feature is on regardless of whether it has ever produced an alert, 404
133// means it is switched off, 403 means it is not available (plan / GHAS).
134// Only `enabled` is mapped in control_mappings — absence of the tooling is
135// recorded but never counted as evidence either way.
136const ALERT_FEATURES = [
137 { feature: "dependabot", alertResource: "dependabot_alert", path: "/dependabot/alerts" },
138 { feature: "code_scanning", alertResource: "code_scanning_alert", path: "/code-scanning/alerts" },
139 { feature: "secret_scanning", alertResource: "secret_scanning_alert", path: "/secret-scanning/alerts" },
140] as const;
141
142interface AlertsCheck {
143 feature: "enabled" | "disabled" | "unavailable";
144 alerts: Array<{ number: number; state: string }>;
145}
146
147// Both offset (code/secret scanning) and cursor (Dependabot) pagination
148// advertise the next page in the Link header.
149function nextPageUrl(linkHeader: string | null): string | null {
150 const match = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
151 return match?.[1] ?? null;
152}
153
154async function fetchOpenAlerts(
155 installationToken: string,
156 owner: string,
157 repo: string,
158 path: string,
159): Promise<AlertsCheck> {
160 const alerts: AlertsCheck["alerts"] = [];
161 let url: string | null = `${GITHUB_API}/repos/${owner}/${repo}${path}?state=open&per_page=100`;
162 while (url) {
163 const res: Response = await fetch(url, { headers: authHeaders(installationToken) });
164 if (res.status === 404) return { feature: "disabled", alerts: [] };
165 if (res.status === 403) return { feature: "unavailable", alerts: [] };
166 if (!res.ok) throw new Error(`Failed to list ${path} for ${owner}/${repo} (${res.status})`);
167
168 const page = (await res.json()) as Array<{ number: number; state: string }>;
169 for (const alert of page) alerts.push({ number: alert.number, state: alert.state });
170 url = nextPageUrl(res.headers.get("Link"));
171 }
172 return { feature: "enabled", alerts };
173}
174
175// Webhooks record alert transitions, but (a) alerts already open before the
176// App was installed never sent one, and (b) an open alert with no events for
177// the whole retention window would age out of evidence. Re-recording the open
178// set every poll fixes both. Subrequest cost is 3+ per repo, on top of the 2
179// for protection state.
180export async function pollRepoAlerts(installationToken: string, repo: RepoRef): Promise<PolledFact[]> {
181 const facts: PolledFact[] = [];
182 for (const { feature, alertResource, path } of ALERT_FEATURES) {
183 const check = await fetchOpenAlerts(installationToken, repo.owner, repo.name, path);
184 facts.push({ repo: repo.fullName, resource: feature, status: check.feature, subject: null, rawPayload: null });
185 for (const alert of check.alerts) {
186 facts.push({
187 repo: repo.fullName,
188 resource: alertResource,
189 status: alert.state,
190 subject: String(alert.number),
191 rawPayload: null,
192 });
193 }
194 }
195 return facts;
196}
197
114// --------------------------------------------------------------------------- 198// ---------------------------------------------------------------------------
115// Access review: org membership and team membership. 199// Access review: org membership and team membership.
116// --------------------------------------------------------------------------- 200// ---------------------------------------------------------------------------
src/webhook.ts +83 −17
@@ -27,6 +27,10 @@ export async function verifySignature(
27interface ExtractedFact { 27interface ExtractedFact {
28 resource: string; 28 resource: string;
29 status: string; 29 status: string;
30 // Which entity within the repo/org the fact is about (alert number, member
31 // login, team slug). Part of the exporter's latest-row-wins key, so facts
32 // about different entities in the same repo don't overwrite each other.
33 subject: string | null;
30} 34}
31 35
32// Minimal resource/status extraction per event type. Control-ID mapping 36// Minimal resource/status extraction per event type. Control-ID mapping
@@ -35,34 +39,96 @@ export function extractFact(eventType: string, payload: Record<string, unknown>)
35 const action = typeof payload.action === "string" ? payload.action : undefined; 39 const action = typeof payload.action === "string" ? payload.action : undefined;
36 40
37 switch (eventType) { 41 switch (eventType) {
38 // Normalized to current-state vocabulary (enabled/disabled) rather than 42 // The event is scoped to one rule, which may target any branch. Only a
39 // the raw action, so this lines up with what the poller reports for 43 // rule whose pattern is exactly the default branch changes the repo's
40 // pre-existing protection state — the mapping table joins on one 44 // protection state (normalized to the enabled/disabled vocabulary the
41 // vocabulary regardless of source. 45 // poller shares); any other rule is recorded as an unmapped trail event,
42 case "branch_protection_rule": 46 // with the hourly poll authoritative for current state.
43 return { resource: "branch_protection", status: action === "deleted" ? "disabled" : "enabled" }; 47 case "branch_protection_rule": {
44 case "repository_ruleset": 48 const rule = payload.rule as Record<string, unknown> | undefined;
45 return { resource: "repository_ruleset", status: action === "deleted" ? "disabled" : "enabled" }; 49 const repository = payload.repository as Record<string, unknown> | undefined;
50 const pattern = typeof rule?.name === "string" ? rule.name : null;
51 const defaultBranch = typeof repository?.default_branch === "string" ? repository.default_branch : undefined;
52 if (pattern !== null && pattern === defaultBranch) {
53 return { resource: "branch_protection", status: action === "deleted" ? "disabled" : "enabled", subject: null };
54 }
55 return { resource: "branch_protection_rule_event", status: action ?? "unknown", subject: pattern };
56 }
57 // Ruleset events are ruleset-scoped: one ruleset being created or deleted
58 // says nothing about whether *other* active rulesets still cover the
59 // default branch, so this is trail-only; current repository_ruleset state
60 // comes from the poller's /rules/branches/{default-branch} check.
61 case "repository_ruleset": {
62 const ruleset = payload.repository_ruleset as Record<string, unknown> | undefined;
63 const id = typeof ruleset?.id === "number" ? String(ruleset.id) : null;
64 return { resource: "repository_ruleset_event", status: action ?? "unknown", subject: id };
65 }
46 case "dependabot_alert": 66 case "dependabot_alert":
47 case "code_scanning_alert": 67 case "code_scanning_alert": {
68 const alert = payload.alert as Record<string, unknown> | undefined;
69 const state = typeof alert?.state === "string" ? alert.state : undefined;
70 return { resource: eventType, status: state ?? action ?? "unknown", subject: alertNumber(alert) };
71 }
72 // Unlike the two alert payloads above, the secret-scanning webhook alert
73 // carries no `state` field — only `resolution`, which is set iff the
74 // alert is resolved. Derive open/resolved from that (falling back to
75 // `state` should GitHub ever add it).
48 case "secret_scanning_alert": { 76 case "secret_scanning_alert": {
49 const alert = payload.alert as Record<string, unknown> | undefined; 77 const alert = payload.alert as Record<string, unknown> | undefined;
50 const state = typeof alert?.state === "string" ? alert.state : undefined; 78 const state = typeof alert?.state === "string" ? alert.state : undefined;
51 return { resource: eventType, status: state ?? action ?? "unknown" }; 79 return {
80 resource: eventType,
81 status: state ?? (alert?.resolution ? "resolved" : "open"),
82 subject: alertNumber(alert),
83 };
84 }
85 // Repository collaborators. Org-level membership arrives on the
86 // `organization` event below, not here.
87 case "member": {
88 const member = payload.member as Record<string, unknown> | undefined;
89 return {
90 resource: "member_access",
91 status: action ?? "unknown",
92 subject: typeof member?.login === "string" ? member.login : null,
93 };
94 }
95 case "team": {
96 const team = payload.team as Record<string, unknown> | undefined;
97 return {
98 resource: "team",
99 status: action ?? "unknown",
100 subject: typeof team?.slug === "string" ? team.slug : null,
101 };
102 }
103 case "organization": {
104 const membership = payload.membership as Record<string, unknown> | undefined;
105 const user = membership?.user as Record<string, unknown> | undefined;
106 // member_invited identifies the invitee via `invitation` (login for
107 // existing users, email otherwise) rather than `membership`.
108 const invitation = payload.invitation as Record<string, unknown> | undefined;
109 const subject =
110 typeof user?.login === "string"
111 ? user.login
112 : typeof invitation?.login === "string"
113 ? invitation.login
114 : typeof invitation?.email === "string"
115 ? invitation.email
116 : null;
117 return { resource: "org_membership", status: action ?? "unknown", subject };
52 } 118 }
53 case "member":
54 return { resource: "member_access", status: action ?? "unknown" };
55 case "team":
56 return { resource: "team", status: action ?? "unknown" };
57 case "repository": 119 case "repository":
58 return { resource: "repository", status: action ?? "unknown" }; 120 return { resource: "repository", status: action ?? "unknown", subject: null };
59 case "push": 121 case "push":
60 return { resource: "push", status: "received" }; 122 return { resource: "push", status: "received", subject: null };
61 default: 123 default:
62 return { resource: eventType, status: action ?? "received" }; 124 return { resource: eventType, status: action ?? "received", subject: null };
63 } 125 }
64} 126}
65 127
128function alertNumber(alert: Record<string, unknown> | undefined): string | null {
129 return typeof alert?.number === "number" ? String(alert.number) : null;
130}
131
66export function extractRepoFullName(payload: Record<string, unknown>): string | null { 132export function extractRepoFullName(payload: Record<string, unknown>): string | null {
67 const repository = payload.repository as Record<string, unknown> | undefined; 133 const repository = payload.repository as Record<string, unknown> | undefined;
68 return typeof repository?.full_name === "string" ? repository.full_name : null; 134 return typeof repository?.full_name === "string" ? repository.full_name : null;