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.
2121| --- | --- |
2222| Branch protection, repository rulesets | SOC 2 CC8.1 · ISO 27001 A.8.32 |
2323| 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 |
2525| Code scanning alerts | SOC 2 CC7.1 · ISO 27001 A.8.28, A.8.29 |
2626| Organization / team membership | SOC 2 CC6.2, CC6.3 · ISO 27001 A.5.18 |
2727| Repository inventory | ISO 27001 A.5.9 |
@@ -34,8 +34,8 @@ documented in [docs/framework-mapping.md](docs/framework-mapping.md).
3434## How it works
3535
3636Webhooks capture changes as they happen; an hourly cron polls for state that
37webhooks never announce (protection that existed before install, and current
38membership). Each observation is stored as a timestamped snapshot in D1.
37webhooks never announce (protection and open alerts that predate the install,
38whether each scanner is enabled, and current membership). Each observation is stored as a timestamped snapshot in D1.
3939Exports render off the request path via a queue, into R2.
4040
4141**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
2525- [The frameworks in one paragraph each](#the-frameworks-in-one-paragraph-each)
2626- [The signals and their mappings](#the-signals-and-their-mappings)
2727 - [Branch protection & repository rulesets](#1-branch-protection--repository-rulesets)
28 - [Dependabot alerts](#2-dependabot-alerts)
29 - [Code scanning alerts](#3-code-scanning-alerts)
30 - [Secret scanning alerts](#4-secret-scanning-alerts)
28 - [Dependabot](#2-dependabot)
29 - [Code scanning](#3-code-scanning)
30 - [Secret scanning](#4-secret-scanning)
3131 - [Membership changes (webhook trail)](#5-membership-changes-webhook-trail)
3232 - [Membership & team inventory (polled)](#6-membership--team-inventory-polled)
3333 - [Repository inventory](#7-repository-inventory)
@@ -44,25 +44,27 @@ Understanding the evidence output requires understanding four rules in the
4444mapping engine. All four live in [`control_mappings`](../migrations/0002_control_mappings.sql)
4545and [`buildEvidenceRows`](../src/exporter.ts).
4646
47**1. A snapshot is a `(resource, status)` pair; a mapping is a row that attaches
48a control to one.** The poller and webhook handler both normalize GitHub events
49into a small vocabulary — `resource` (e.g. `branch_protection`, `dependabot_alert`)
50and `status` (e.g. `enabled`, `open`, `fixed`). See [`extractFact`](../src/webhook.ts)
51and [`poller.ts`](../src/poller.ts). Mapping happens as a **join at export time**,
52never at ingest, so a mapping can be corrected without re-ingesting history.
47**1. A snapshot is a `(resource, status)` pair about a `subject`; a mapping is a
48row that attaches a control to a `(resource, status)`.** The poller and webhook
49handler both normalize GitHub events into a small vocabulary — `resource`
50(e.g. `branch_protection`, `dependabot_alert`) and `status` (e.g. `enabled`,
51`open`, `fixed`) — plus a `subject` identifying which entity within the repo or
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.
5356
5457**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
56"the tooling exists and is producing signal" fact is expressed independently of
57any individual finding's state.
58
59**3. Consequently, one snapshot can emit multiple evidence rows.** A single
60Dependabot alert with `status = 'open'` matches *both* the `NULL` mapping
61(CC7.1, "detection tooling is active", **positive**) *and* the `'open'` mapping
62(CC7.2, "unremediated vulnerability", **negative**). This is intentional: the
63existence of the scanner and the existence of an open finding are two different
64facts about two different control expectations. This behavior is called out
65per-signal below wherever it applies.
58join condition is `cm.status IS NULL OR cm.status = l.status`. This is used for
59trail/inventory resources (`team`, `repository`, `org_member`, `team_member`)
60where every state is the same kind of informational fact.
61
62**3. One snapshot can emit multiple evidence rows.** A single secret-scanning
63alert with `status = 'open'` matches the `open` mapping under **each** of
64CC6.6, CC6.1 (SOC 2) and A.5.17 (ISO). This is intentional: the same fact is
65legitimate evidence for more than one control expectation, and attesting all of
66them lets the export serve whichever control the organization's narrative uses.
67This behavior is called out per-signal below wherever it applies.
6668
6769**4. `posture` is the auditor-facing verdict on a row**, one of:
6870
@@ -70,19 +72,26 @@ per-signal below wherever it applies.
7072| --- | --- | --- |
7173| `positive` | State supports the control | Branch protection enabled |
7274| `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 |
7476
7577Two more rules affect *which* snapshots become evidence at all:
7678
7779- **Unmapped states produce no evidence, in either direction.** A
7880 `branch_protection` status of `unavailable` (GitHub returned 403 — the feature
7981 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
81 `push` events.
82 no mapping row, so it never counts as a pass *or* a fail. The same applies to
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.
8288- **"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
84 member who lost access stops being attested — only the most recent poll batch
85 counts. See the CTE in [`buildEvidenceRows`](../src/exporter.ts).
89 current posture from an append-only table. Because `subject` carries the
90 alert number / login / slug, this operates **per entity**: one alert being
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).
8695
8796---
8897
@@ -114,14 +123,22 @@ assessment.
114123
115124### 1. Branch protection & repository rulesets
116125
117**What we collect.** For each repo's default branch, whether merge controls are
118in force — via the `branch_protection_rule` and `repository_ruleset` webhooks
119(change events) and an hourly poll of the branch-protection and rulesets APIs
120(baseline, for protection that predates the install). Normalized to
121`resource ∈ {branch_protection, repository_ruleset}`, `status ∈ {enabled,
122disabled}`. A ruleset in `evaluate` (monitor-only) mode counts as **not**
123enabled because it does not actually block anything — see
124[`fetchRulesets`](../src/poller.ts).
126**What we collect.** For each repo's **default branch**, whether merge controls
127are in force. The hourly poll is authoritative: the branch-protection API for
128classic protection, and the `rules/branches/{default-branch}` API for rulesets —
129which aggregates the rules from every *active* ruleset (repo- and org-level)
130that actually applies to that branch, so evaluate-mode (monitor-only) rulesets
131and rulesets targeting other branches correctly count as **not** enabled.
132Normalized to `resource ∈ {branch_protection, repository_ruleset}`, `status ∈
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).
125142
126143**Maps to:**
127144
@@ -131,36 +148,46 @@ enabled because it does not actually block anything — see
131148| ISO 27001 | **A.8.32** | *Change management.* Changes to information systems must follow formal change-management procedures to prevent unauthorized or destabilizing changes. |
132149
133150**Why this holds.** Branch protection / rulesets are the technical enforcement of
134change control in a Git workflow: requiring pull-request review before merge,
135blocking direct pushes to the default branch, and requiring status checks to
136pass. That is exactly the "controlled process… stops unauthorized changes"
137language of both controls. Enabled → **positive**; disabled → **negative**
138("direct pushes now possible" is a concrete change-control gap).
139
140**Fit assessment: strong.** This is the least ambiguous mapping in the system —
141both frameworks name "change management" explicitly, and branch protection is
142the canonical GitHub-native implementation of it. The one nuance an auditor will
143probe is *scope*: we check the **default branch** only, and "enabled" does not
144verify that the *specific* rules (required reviewers, etc.) match the
145organization's policy. The evidence attests that a change-control gate exists,
146not that its configuration is sufficient.
147
148### 2. Dependabot alerts
149
150**What we collect.** `dependabot_alert` webhook events — known-vulnerability
151alerts against the repo's dependencies. `status` is the alert state: `open`,
152`fixed`, `dismissed`, `auto_dismissed`.
151change control in a Git workflow. Enabled → **positive**; disabled →
152**negative** ("direct pushes possible" is a concrete change-control gap).
153
154**Fit assessment: strong, with the scope stated in the evidence itself.** Both
155frameworks name "change management" explicitly, and branch protection is the
156canonical GitHub-native implementation of it. What the evidence attests is that
157a change-control gate **exists on the default branch** — it does not verify that
158the *specific* rules (required reviewers, status checks, …) match the
159organization's policy, and the exported rationale says so explicitly ("rule
160contents not verified") rather than claiming review is required.
161
162### 2. Dependabot
163
164**What we collect.** Two distinct facts:
165
166- **Tooling state** (`resource = dependabot`, `status ∈ {enabled, disabled,
167 unavailable}`) — the hourly poll checks whether Dependabot alerts are enabled
168 on each repo (the alert-list API answering at all is the signal; see
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.
153178
154179**Maps to:**
155180
156181| Framework | Control | When | Posture |
157182| --- | --- | --- | --- |
158| SOC 2 | **CC7.1** | any alert (`status = NULL`) | positive — "detection tooling is active" |
159| SOC 2 | **CC7.2** | `open` | negative — "unremediated known vulnerability" |
160| SOC 2 | **CC7.2** | `fixed` / `dismissed` / `auto_dismissed` | positive — "remediated" |
161| ISO 27001 | **A.8.8** | any alert (`status = NULL`) | positive — "technical vulnerability management active" |
162| ISO 27001 | **A.8.8** | `open` | negative — "unremediated known vulnerability" |
163| ISO 27001 | **A.8.8** | `fixed` / `dismissed` / `auto_dismissed` | positive — "remediated" |
183| SOC 2 | **CC7.1** | tooling `enabled` | positive — detection tooling is on |
184| SOC 2 | **CC7.1** | `open` | negative — unremediated known vulnerability |
185| SOC 2 | **CC7.1** | `fixed` / `auto_dismissed` | positive — remediated (machine-verified) |
186| SOC 2 | **CC7.1** | `dismissed` | informational — human risk-acceptance, justification subject to review |
187| ISO 27001 | **A.8.8** | tooling `enabled` | positive — vulnerability management active |
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 |
164191
165192**Control meanings.**
166193- **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`,
168195 susceptibilities to *newly discovered* vulnerabilities. Dependabot is a
169196 textbook example: it continuously matches your dependency tree against newly
170197 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.
173198- **A.8.8** — *Management of technical vulnerabilities.* Information about
174199 technical vulnerabilities must be obtained, exposure evaluated, and
175 appropriate measures taken. This is a single control spanning the whole
176 vulnerability lifecycle — detect, evaluate, remediate.
177
178**Why this holds.** The *presence* of Dependabot alerts proves the detection
179capability required by CC7.1 exists and is running — hence the `NULL` mapping
180fires positive on any alert regardless of state. Each *individual* alert's
181lifecycle (open vs. remediated) is then evidence for CC7.2: an open alert is an
182unresolved condition, a fixed/dismissed one is a closed one. On the **ISO** side
183the entire story lands on a *single* control, A.8.8, because A.8.8 explicitly
184covers the full lifecycle — so every status maps to A.8.8 (open → negative,
185remediated → positive, tooling-active → positive). See
186[migration 0007](../migrations/0007_close_coverage_gaps.sql).
187
188**Fit assessment: CC7.1 strong; CC7.2 defensible but the weakest link in the
189system.** CC7.2's formal text is about anomalies "indicative of malicious acts,
190natural disasters, and errors" — i.e. runtime security events. An unpatched
191dependency is a *known vulnerability*, which sits more naturally in CC7.1's
192"susceptibility to newly discovered vulnerabilities" language than in CC7.2's
193anomaly-detection language. Many auditors keep the **entire** dependency story
194(detection *and* remediation tracking) under CC7.1. **Recommendation:** before
195you present this to an auditor, decide whether open/remediated Dependabot state
196belongs under CC7.1 or CC7.2 in your control narrative, and align the mapping to
197that decision. Both are defensible; the current split is a design choice, not a
198requirement. The **ISO A.8.8** mapping, by contrast, is a strong, clean fit —
199A.8.8 is purpose-built for technical-vulnerability management and absorbs the
200whole lifecycle without the CC7.1/CC7.2 ambiguity. It was added in migration 0007
201to close a gap: before it, Dependabot produced no evidence at all in an ISO
202export.
203
204### 3. Code scanning alerts
205
206**What we collect.** `code_scanning_alert` webhook events — SAST findings from
207CodeQL or a third-party analyzer. `status ∈ {open, fixed, dismissed}`.
200 appropriate measures taken — one control spanning the whole lifecycle.
201
202**Why this holds.** The tooling-`enabled` fact proves the detection capability
203required by CC7.1/A.8.8 exists and is on *right now* — it comes from the
204feature's own state, not (as in earlier versions) from inference off the latest
205alert event, which kept attesting after a scanner was switched off and never
206fired for a clean repo. Each individual alert's lifecycle is then finding-level
207evidence under the same controls: an open alert is an unresolved known
208vulnerability; a `fixed` (patched) or `auto_dismissed` (e.g. dependency
209removed) alert is a machine-verified closure; a `dismissed` alert is a **human
210decision** — it may be sound risk acceptance or may be rubber-stamping, and the
211justification is exactly what an auditor samples, so it is recorded as
212informational rather than claimed as remediation.
213
214**Fit assessment: strong on both frameworks.** The whole SOC 2 lifecycle sits
215under CC7.1, whose "susceptibility to newly discovered vulnerabilities"
216language covers known-CVE management directly. (Earlier versions split
217open/remediated state to CC7.2; CC7.2's anomaly-monitoring text is about
218runtime security events, which made it the weakest link in the system — that
219split has been removed.) A.8.8 is purpose-built for this signal and absorbs the
220whole lifecycle.
221
222### 3. Code scanning
223
224**What we collect.** Same two-fact shape as Dependabot: tooling state
225(`resource = code_scanning`, from the hourly poll; only `enabled` mapped) and
226findings (`resource = code_scanning_alert`, `status ∈ {open, fixed,
227dismissed}`, `subject` = alert number) from `code_scanning_alert` webhooks plus
228the hourly re-record of open alerts. Findings are SAST results from CodeQL or a
229third-party analyzer.
208230
209231**Maps to:**
210232
211233| Framework | Control | When | Posture |
212234| --- | --- | --- | --- |
213| ISO 27001 | **A.8.29** | any alert (`status = NULL`) | positive — "security testing in development is active" |
214| ISO 27001 | **A.8.28** | `open` | negative — "unremediated finding" |
215| ISO 27001 | **A.8.28** | `fixed` / `dismissed` | positive — "remediated" |
216| SOC 2 | **CC7.1** | any alert (`status = NULL`) | positive — "detection tooling is active" |
235| ISO 27001 | **A.8.29** | tooling `enabled` | positive — security testing in development is on |
236| ISO 27001 | **A.8.28** | `open` | negative — unremediated finding |
237| ISO 27001 | **A.8.28** | `fixed` | positive — remediated |
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 |
217243
218244**Control meanings.**
219245- **A.8.29** — *Security testing in development and acceptance.* Security testing
220 processes must be defined and run within the development lifecycle so
221 vulnerabilities are found before production. The existence of code scanning
222 *is* that testing process.
246 processes must be defined and run within the development lifecycle. The
247 existence of code scanning *is* that testing process.
223248- **A.8.28** — *Secure coding.* Secure coding principles must be applied during
224249 development. An open finding is evidence of a secure-coding gap in the source;
225250 a remediated one is evidence the gap was closed.
226251- **CC7.1** — *Detection & monitoring.* (Same control as Dependabot's SOC 2
227 mapping.) Code scanning is detection tooling that surfaces vulnerabilities, so
228 its presence satisfies the "detection procedures exist and run" expectation.
252 mapping.)
229253
230254**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`
232mapping) versus *"the code itself is/ isn't secure"* (A.8.28, from each finding's
233state). On the **SOC 2** side (added in [migration 0007](../migrations/0007_close_coverage_gaps.sql))
234only the tooling-active fact is mapped, to CC7.1 — mirroring how Dependabot's
235tooling-active fact maps to CC7.1.
236
237**Fit assessment: strong on ISO; SOC 2 intentionally partial.** The
238A.8.29-vs-A.8.28 split is clean — one control is about *having* the testing
239process, the other about the *code quality* it reveals — and both titles match
240the signal directly. The new SOC 2 CC7.1 mapping covers only detection-active,
241**not** finding-level state: code-scanning `open`/`fixed` rows are deliberately
242*not* routed to CC7.2, because whether the vulnerability lifecycle belongs under
243CC7.1 or CC7.2 is still an open decision (see the Dependabot fit assessment). Once
244that is settled, finding-level SOC 2 rows for code scanning can be added to match
245Dependabot. Until then a SOC 2 export shows code scanning as "detection active"
246only — which under-claims rather than over-claims, the safe direction.
247
248### 4. Secret scanning alerts
249
250**What we collect.** `secret_scanning_alert` webhook events — detected
251credentials/tokens committed to the repo. `status ∈ {open, resolved}`.
255that map to two facts: *"a testing process exists"* (A.8.29, from tooling
256state) versus *"the code itself is / isn't secure"* (A.8.28, from each
257finding's state). On the **SOC 2** side the full lifecycle mirrors Dependabot
258under CC7.1 — the finding-level rows were previously deferred pending the
259CC7.1-vs-CC7.2 decision, which is now settled in CC7.1's favor.
260
261**Fit assessment: strong on both.** The A.8.29-vs-A.8.28 split follows the
262controls' own having-a-process vs. code-quality distinction, and the SOC 2 side
263is now symmetric with Dependabot rather than intentionally partial.
264
265### 4. Secret scanning
266
267**What we collect.** Tooling state (`resource = secret_scanning`, from the
268hourly poll; only `enabled` mapped) and findings (`resource =
269secret_scanning_alert`, `status ∈ {open, resolved}`, `subject` = alert number)
270from `secret_scanning_alert` webhooks plus the hourly re-record of open alerts.
271One payload quirk matters: unlike the other two alert payloads, the
272secret-scanning webhook alert carries **no `state` field** — ingest derives
273open/resolved from `alert.resolution`, which is set iff the alert is resolved
274(see [`extractFact`](../src/webhook.ts)).
252275
253276**Maps to:**
254277
255278| Framework | Control | When | Posture |
256279| --- | --- | --- | --- |
257| SOC 2 | **CC6.6** | any alert (`status = NULL`) | positive — "leaked-credential detection is active" |
258| SOC 2 | **CC6.6** | `open` | negative — "live credential exposure" |
259| SOC 2 | **CC6.6** | `resolved` | positive — "exposure remediated" |
260| SOC 2 | **CC6.1** | any alert (`status = NULL`) | positive — "logical-access credential protection active" |
261| SOC 2 | **CC6.1** | `open` | negative — "exposed credential undermines logical access controls" |
262| SOC 2 | **CC6.1** | `resolved` | positive — "logical access control restored" |
263| ISO 27001 | **A.5.17** | any alert (`status = NULL`) | positive — "authentication-information protection active" |
264| ISO 27001 | **A.5.17** | `open` | negative — "exposed authentication information" |
265| ISO 27001 | **A.5.17** | `resolved` | positive — "exposure remediated" |
280| SOC 2 | **CC6.6** | tooling `enabled` | positive — leaked-credential detection is on |
281| SOC 2 | **CC6.6** | `open` | negative — live credential exposure |
282| SOC 2 | **CC6.6** | `resolved` | informational — resolution reason subject to review |
283| SOC 2 | **CC6.1** | tooling `enabled` | positive — logical-access credential protection is on |
284| SOC 2 | **CC6.1** | `open` | negative — exposed credential undermines logical access controls |
285| SOC 2 | **CC6.1** | `resolved` | informational — resolution reason subject to review |
286| ISO 27001 | **A.5.17** | tooling `enabled` | positive — authentication-information protection is on |
287| ISO 27001 | **A.5.17** | `open` | negative — exposed authentication information |
288| ISO 27001 | **A.5.17** | `resolved` | informational — resolution reason subject to review |
266289
267290**Control meanings.**
268291- **CC6.6** — *Protection against external threats.* The entity implements
@@ -279,65 +302,70 @@ credentials/tokens committed to the repo. `status ∈ {open, resolved}`.
279302
280303**Why this holds.** A committed credential is relevant to all three controls at
281304once. 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
283keys the control is meant to safeguard, so a leak is a compromise of the access
284controls themselves. For **A.5.17**, the credential is authentication information
285whose confidentiality the control requires. In every case: scanning active →
286**positive** (a protective measure exists); open alert → **negative** (a live
287gap); resolved → **positive** (gap closed). See
288[migration 0006](../migrations/0006_secret_scanning_cc6_1.sql) (CC6.1) and
289[migration 0007](../migrations/0007_close_coverage_gaps.sql) (A.5.17).
305system boundary. For **CC6.1**, the credential is itself one of the
306logical-access keys the control is meant to safeguard. For **A.5.17**, the
307credential is authentication information whose confidentiality the control
308requires. Scanning enabled → **positive**; open alert → **negative**. A
309`resolved` alert is **informational**, not positive: GitHub's resolution
310reasons include `wont_fix`, so "resolved" may mean the credential was revoked
311*or* that someone decided to leave it — the recorded reason is what the
312reviewer must check.
290313
291314**Fit assessment: all three defensible.** CC6.6 is the external-threat framing,
292315CC6.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
294produced no ISO evidence). Mapping to all three means the export satisfies
295whichever control the organization's narrative uses. A further SOC 2 framing,
316framing. Mapping to all three means the export satisfies whichever control the
317organization's narrative uses — at the cost of row multiplicity: one open
318secret emits a negative under each of the three. A further SOC 2 framing,
296319**CC6.7** (restricting the transmission/movement of information), also touches
297this and could be added if an auditor prefers it. Note the multiplicity: one
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.
320this and could be added if an auditor prefers it.
302321
303322### 5. Membership changes (webhook trail)
304323
305**What we collect.** `member`, `team`, and `repository` webhook events — the
306*change* events, recording that an access-related mutation happened. Normalized
307to `resource ∈ {member_access, team, repository}` with the GitHub action as
308status.
324**What we collect.** The *change* events for access administration, each with a
325`subject` so the trail keeps one latest row per person/team rather than one per
326repo:
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.
309335
310336**Maps to:**
311337
312338| Framework | Control | When | Posture |
313339| --- | --- | --- | --- |
314| SOC 2 | **CC6.2** | `member_access` `added` | informational — "access grant, logged for review" |
315| SOC 2 | **CC6.3** | `member_access` `removed` | positive — "timely access removal" |
316| SOC 2 | **CC6.3** | `member_access` `edited` | informational — "access-level change, logged" |
317| ISO 27001 | **A.5.18** | `team` (any) | informational — "access-rights change, audit trail" |
340| SOC 2 | **CC6.2** | `member_access` `added`, `org_membership` `member_added` / `member_invited` | informational — access grant / invitation, logged for review |
341| SOC 2 | **CC6.3** | `member_access` `removed` / `edited`, `org_membership` `member_removed` | informational — modification / deprovisioning recorded |
342| ISO 27001 | **A.5.18** | all of the above | informational — access-rights change, audit trail |
343| ISO 27001 | **A.5.18** | `team` (any) | informational — access-rights change, audit trail |
318344
319345**Control meanings.**
320346- **CC6.2** — *Registration & authorization of new users.* Before credentials are
321347 issued, new users are registered and authorized; credentials are removed when
322 access is no longer authorized. A member being *added* is the provisioning
323 event this criterion governs.
348 access is no longer authorized. A member being *added* or *invited* is the
349 provisioning event this criterion governs.
324350- **CC6.3** — *Authorize / modify / remove access.* Access is authorized,
325351 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.
327353- **A.5.18** — *Access rights.* Access rights are provisioned, reviewed,
328 modified, and removed per the access-control policy. A team membership change
329 is an access-rights mutation on that trail.
354 modified, and removed per the access-control policy.
330355
331356**Why this holds & posture logic.** These are the *audit trail* of access
332357administration — evidence that grants/changes are captured, which is what an
333auditor samples. Most are **informational** (an add or a role change is neither
334inherently good nor bad — it needs human review). The one exception is
335`removed` → **positive**, because timely de-provisioning is itself a control
336objective (CC6.3), so a captured removal is affirmative evidence.
358auditor samples. **All rows are informational**, removals included: the event
359proves a removal happened and when, but not that it was *timely* relative to an
360offboarding trigger the system cannot see — so the rationale says
361"deprovisioning recorded; timeliness subject to review" rather than claiming
362timeliness as a positive. (Earlier versions claimed "timely access removal";
363that was rounding up.)
337364
338365**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
340right call — this data feeds the access review, it does not pass/fail on its own.
366own provisioning-vs-modification language), and honest about scope now that
367repo-collaborator and org-member events are separate resources with separate
368rationales.
341369
342370### 6. Membership & team inventory (polled)
343371
@@ -351,9 +379,9 @@ point-in-time snapshot — this is what powers the [access-review diff](../src/a
351379
352380| Framework | Control | Resource | Posture |
353381| --- | --- | --- | --- |
354| 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" |
356| ISO 27001 | **A.5.18** | `org_member`, `team_member` | informational — "access-rights inventory" |
382| SOC 2 | **CC6.2** | `org_member` | informational — org access inventory, subject to periodic review |
383| SOC 2 | **CC6.3** | `team_member` | informational — team-based access inventory |
384| ISO 27001 | **A.5.18** | `org_member`, `team_member` | informational — access-rights inventory |
357385
358386**Why this holds.** A point-in-time roster of who has access is the raw material
359387of 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
368396arguably as much CC6.3 (appropriateness of access) as CC6.2 (registration). Since
369397these are informational inventory rows feeding a review, the exact CC6.2/CC6.3
370398attribution 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
372inventory) are deliberately separate resource names so the change-trail and the
373current-state inventory don't collide.
399(`member_access`/`org_membership`/`team` webhook trail vs.
400`org_member`/`team_member` polled inventory) are deliberately separate resource
401names so the change-trail and the current-state inventory don't collide.
374402
375403### 7. Repository inventory
376404
377405**What we collect.** `repository` webhook events — repos created/deleted/renamed
378within the installation. `resource = repository`.
406and visibility changes within the installation. `resource = repository`.
379407
380408**Maps to:**
381409
382| Framework | Control | Posture |
383| --- | --- | --- |
384| ISO 27001 | **A.5.9** | informational — "asset inventory trail" |
410| Framework | Control | When | Posture |
411| --- | --- | --- | --- |
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 |
385414
386415**Control meaning.**
387416- **A.5.9** — *Inventory of information and other associated assets.* A complete,
@@ -389,8 +418,11 @@ within the installation. `resource = repository`.
389418
390419**Why this holds.** Repositories are information assets. The trail of repo
391420create/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
393a pass/fail condition.
421it changes — exactly A.5.9's requirement. One action gets an extra row: a repo
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.
394426
395427**Fit assessment: strong for what it claims.** The honest caveat is completeness:
396428this is a *change trail*, so it evidences that inventory changes are captured, not
@@ -404,56 +436,72 @@ webhook trail.
404436## Complete mapping reference
405437
406438This table is the authoritative human-readable copy of every row in
407[`control_mappings`](../migrations/0002_control_mappings.sql) after all migrations
408(0002 seeds most; 0003 replaces branch-protection/ruleset with the
439[`control_mappings`](../migrations/0002_control_mappings.sql) after all
440migrations (0002 seeds most; 0003 replaces branch-protection/ruleset with the
409441enabled/disabled vocabulary; 0005 adds the polled access inventory; 0006 adds
410the secret-scanning CC6.1 rows; 0007 closes the cross-framework coverage gaps —
411Dependabot→A.8.8, code scanning→CC7.1, secret scanning→A.5.17). **A "·" in
412Status means the mapping's `status` is `NULL` — it matches any status.**
442the secret-scanning CC6.1 rows; 0007 closes cross-framework coverage gaps; 0008
443applies the mapping-review fixes — per-entity subjects, polled tooling state,
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.
413448
414449| Resource | Status | Framework | Control | Posture | Rationale |
415450| --- | --- | --- | --- | --- | --- |
416| `branch_protection` | `enabled` | SOC 2 | CC8.1 | positive | Change management — review before merge |
417| `branch_protection` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — direct pushes possible |
418| `branch_protection` | `enabled` | ISO 27001 | A.8.32 | positive | Change management |
419| `branch_protection` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — direct pushes possible |
420| `repository_ruleset` | `enabled` | SOC 2 | CC8.1 | positive | Change management — review before merge |
421| `repository_ruleset` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — direct pushes possible |
422| `repository_ruleset` | `enabled` | ISO 27001 | A.8.32 | positive | Change management |
423| `repository_ruleset` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — direct pushes possible |
424| `dependabot_alert` | · | SOC 2 | CC7.1 | positive | Detection tooling is active |
425| `dependabot_alert` | `open` | SOC 2 | CC7.2 | negative | Unremediated known vulnerability |
426| `dependabot_alert` | `fixed` | SOC 2 | CC7.2 | positive | Remediated |
427| `dependabot_alert` | `dismissed` | SOC 2 | CC7.2 | positive | Remediated (risk accepted) |
428| `dependabot_alert` | `auto_dismissed` | SOC 2 | CC7.2 | positive | Remediated (e.g. dependency removed) |
429| `dependabot_alert` | · | ISO 27001 | A.8.8 | positive | Technical vulnerability management — detection active |
451| `branch_protection` | `enabled` | SOC 2 | CC8.1 | positive | Change management — a protection rule is enforced on the default branch (rule contents not verified) |
452| `branch_protection` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — no protection on the default branch; direct pushes possible |
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) |
454| `branch_protection` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — no protection on the default branch; direct pushes possible |
455| `repository_ruleset` | `enabled` | SOC 2 | CC8.1 | positive | Change management — an active ruleset covers the default branch (rule contents not verified) |
456| `repository_ruleset` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — no active ruleset covers the default branch |
457| `repository_ruleset` | `enabled` | ISO 27001 | A.8.32 | positive | Change management — an active ruleset covers the default branch (rule contents not verified) |
458| `repository_ruleset` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — no active ruleset covers the default branch |
459| `dependabot` | `enabled` | SOC 2 | CC7.1 | positive | Detection tooling — Dependabot alerts are enabled on the repository |
460| `dependabot` | `enabled` | ISO 27001 | A.8.8 | positive | Technical vulnerability management — Dependabot alerts are enabled on the repository |
461| `code_scanning` | `enabled` | SOC 2 | CC7.1 | positive | Detection tooling — code scanning is enabled on the repository |
462| `code_scanning` | `enabled` | ISO 27001 | A.8.29 | positive | Security testing in development — code scanning is enabled on the repository |
463| `secret_scanning` | `enabled` | SOC 2 | CC6.6 | positive | Leaked-credential detection — secret scanning is enabled on the repository |
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) |
430470| `dependabot_alert` | `open` | ISO 27001 | A.8.8 | negative | Unremediated known technical vulnerability |
431471| `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) |
433| `dependabot_alert` | `auto_dismissed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated (e.g. dependency removed) |
434| `code_scanning_alert` | · | ISO 27001 | A.8.29 | positive | Security testing in development is active |
435| `code_scanning_alert` | `open` | ISO 27001 | A.8.28 | negative | Unremediated finding |
436| `code_scanning_alert` | `fixed` | ISO 27001 | A.8.28 | positive | Remediated |
437| `code_scanning_alert` | `dismissed` | ISO 27001 | A.8.28 | positive | Remediated (risk accepted) |
438| `code_scanning_alert` | · | SOC 2 | CC7.1 | positive | Detection tooling is active (findings unmapped in SOC 2) |
439| `secret_scanning_alert` | · | SOC 2 | CC6.6 | positive | Leaked-credential detection is active |
472| `dependabot_alert` | `dismissed` | ISO 27001 | A.8.8 | informational | Dismissed by a user — risk-acceptance justification subject to review |
473| `dependabot_alert` | `auto_dismissed` | ISO 27001 | A.8.8 | positive | Auto-dismissed by GitHub (e.g. dependency removed) |
474| `code_scanning_alert` | `open` | SOC 2 | CC7.1 | negative | Unremediated static-analysis finding |
475| `code_scanning_alert` | `fixed` | SOC 2 | CC7.1 | positive | Finding remediated |
476| `code_scanning_alert` | `dismissed` | SOC 2 | CC7.1 | informational | Dismissed by a user — risk-acceptance justification subject to review |
477| `code_scanning_alert` | `open` | ISO 27001 | A.8.28 | negative | Unremediated static-analysis finding |
478| `code_scanning_alert` | `fixed` | ISO 27001 | A.8.28 | positive | Finding remediated |
479| `code_scanning_alert` | `dismissed` | ISO 27001 | A.8.28 | informational | Dismissed by a user — risk-acceptance justification subject to review |
440480| `secret_scanning_alert` | `open` | SOC 2 | CC6.6 | negative | Live credential exposure |
441| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.6 | positive | Exposure remediated |
442| `secret_scanning_alert` | · | SOC 2 | CC6.1 | positive | Logical-access credential protection — detection active |
481| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.6 | informational | Resolution recorded — reason (revoked vs. won't-fix) subject to review |
443482| `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 |
445| `secret_scanning_alert` | · | ISO 27001 | A.5.17 | positive | Authentication-information protection — detection active |
483| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.1 | informational | Resolution recorded — reason (revoked vs. won't-fix) subject to review |
446484| `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 |
448| `member_access` | `added` | SOC 2 | CC6.2 | informational | Access grant — logged for review |
449| `member_access` | `removed` | SOC 2 | CC6.3 | positive | Timely access removal |
450| `member_access` | `edited` | SOC 2 | CC6.3 | informational | Access-level change — logged for review |
485| `secret_scanning_alert` | `resolved` | ISO 27001 | A.5.17 | informational | Resolution recorded — reason (revoked vs. won't-fix) subject to review |
486| `member_access` | `added` | SOC 2 | CC6.2 | informational | Repository collaborator added — access grant logged for review |
487| `member_access` | `removed` | SOC 2 | CC6.3 | informational | Repository collaborator removed — deprovisioning recorded; timeliness subject to 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 |
451498| `team` | · | ISO 27001 | A.5.18 | informational | Access-rights change, audit trail |
452499| `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 |
454| `org_member` | · | ISO 27001 | A.5.18 | informational | Access-rights inventory |
500| `repository` | `publicized` | SOC 2 | CC6.1 | informational | Repository made public — visibility change affecting asset confidentiality, flagged for review |
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 |
455503| `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 |
457505
458506### Control glossary
459507
@@ -464,7 +512,6 @@ Status means the mapping's `status` is `NULL` — it matches any status.**
464512| **CC6.3** | Authorize, modify, and remove access by role, with least privilege and segregation of duties |
465513| **CC6.6** | Protect against threats originating outside the system boundary |
466514| **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 |
468515| **CC8.1** | Put changes through an authorized, controlled process; block unauthorized changes |
469516| **A.5.9** | Maintain an inventory of information and associated assets, with owners |
470517| **A.5.17** | Control the allocation and management of authentication information (passwords, keys, tokens) |
@@ -501,20 +548,23 @@ so bias toward under-claiming.
501548 mapping is worth more to an auditor than three tenuous ones. Tenuous mappings
502549 erode trust in the whole evidence pack.
5035504. **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").
505553 - `negative` — the state is a concrete gap the control would flag.
506554 - `informational` — the state is audit-trail/inventory that feeds a review but
507 is not itself pass/fail. When in doubt, use `informational`.
5085. **Decide detection-vs-finding.** If the signal is a scanner/alert stream, you
509 usually want two mapping kinds: a `status = NULL` row for "the control's
510 *tooling* exists" (positive), and per-status rows for individual findings.
511 Remember rule 3 in [How mapping works](#how-mapping-works-mechanically): both
512 fire on the same snapshot.
555 is not itself pass/fail. Human decisions (dismissals, resolutions with a
556 reason) belong here. When in doubt, use `informational`.
5575. **Separate tooling state from findings.** If the signal is a scanner/alert
558 stream, attest "the control's *tooling* is on" from the feature's own state
559 (polled), not from the existence of alerts — and attest each finding's
560 lifecycle per alert, with the alert number as `subject` so findings don't
561 mask each other.
5135626. **Pin the edition.** State which version of the framework you mapped (e.g.
514563 "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
516 here. The `rationale` is what an auditor reads in the export; make it a
517 complete thought, not a keyword.
5647. **Write the rationale** in the `rationale` column *and* copy it into the
565 reference table here verbatim — it is CI-checked. The `rationale` is what an
566 auditor reads in the export; make it a complete thought that claims no more
567 than the signal proves.
518568
519569### What the code needs
520570
@@ -570,7 +620,7 @@ a row here with no SQL, is a bug.
570620This is enforced. [`scripts/check-mappings.mjs`](../scripts/check-mappings.mjs)
571621applies every migration to an in-memory SQLite database, reads back
572622`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
574624[reference table](#complete-mapping-reference) above. It fails with a row-level
575625diff if the two drift. Run it with:
576626
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 @@
44// relies on. If they drift, the doc is lying — so this fails CI.
55//
66// 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
88// 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.
1011//
1112// Run: npm run test:mappings (no dependencies — uses Node's built-in sqlite)
1213
@@ -22,8 +23,11 @@ const docPath = join(repoRoot, "docs", "framework-mapping.md");
2223// A "·" in the doc's Status column means the mapping's status is NULL (matches
2324// any status); normalize both sides to this sentinel so they compare equal.
2425const NULL_STATUS = "·";
25const key = (resource, status, framework, control, posture) =>
26 `${resource}|${status ?? NULL_STATUS}|${framework}|${control}|${posture}`;
26// The rationale is part of the key: it is the auditor-facing string in every
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}`;
2731
2832// --- 1. Source of truth: apply migrations, read control_mappings. ---
2933function rowsFromMigrations() {
@@ -35,10 +39,10 @@ function rowsFromMigrations() {
3539 db.exec(readFileSync(join(migrationsDir, file), "utf8"));
3640 }
3741 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")
3943 .all();
4044 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)));
4246}
4347
4448// --- 2. Human-readable copy: parse the doc's reference table. ---
@@ -56,13 +60,13 @@ function rowsFromDoc() {
5660 const set = new Set();
5761 for (const line of readFileSync(docPath, "utf8").split("\n")) {
5862 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].
6064 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;
6266 const framework = FRAMEWORK_LABELS[frameworkLabel];
6367 if (!framework || !POSTURES.has(posture) || !BACKTICKED.test(resource ?? "")) continue;
6468 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 ?? ""));
6670 }
6771 return set;
6872}
@@ -80,7 +84,7 @@ if (onlyInDb.length === 0 && onlyInDoc.length === 0) {
8084}
8185
8286console.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");
8488if (onlyInDb.length) {
8589 console.error(` In migrations but MISSING from the doc (${onlyInDb.length}):`);
8690 for (const k of onlyInDb) console.error(` + ${k}`);
src/index.ts +11 −8
@@ -15,7 +15,7 @@ import {
1515} from "./auth";
1616import { parseCookies, setCookieHeader, clearCookieHeader } from "./cookies";
1717import { createAppJwt, getInstallationToken } from "./github-app";
18import { listInstallationRepos, pollRepoProtection, pollOrgAccess } from "./poller";
18import { listInstallationRepos, pollRepoProtection, pollRepoAlerts, pollOrgAccess } from "./poller";
1919import { buildEvidenceRows, renderCsv, renderPdf, type Framework, type ExportFormat } from "./exporter";
2020import {
2121 renderDashboard,
@@ -211,13 +211,16 @@ async function pollInstallation(env: Env, installationId: number, summary: PollS
211211 // Per-repo isolation: one failing repo must not abort the rest of the
212212 // installation's poll.
213213 try {
214 const facts = await pollRepoProtection(installationToken, repo);
214 const facts = [
215 ...(await pollRepoProtection(installationToken, repo)),
216 ...(await pollRepoAlerts(installationToken, repo)),
217 ];
215218 for (const fact of facts) {
216219 await env.DB.prepare(
217 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at)
218 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
220 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at, subject)
221 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
219222 )
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)
221224 .run();
222225 summary.written.push({ installationId, repo: fact.repo, resource: fact.resource, status: fact.status });
223226 }
@@ -828,10 +831,10 @@ async function handleWebhook(request: Request, env: Env): Promise<Response> {
828831 const repo = extractRepoFullName(payload);
829832
830833 await env.DB.prepare(
831 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at)
832 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
834 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at, subject)
835 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
833836 )
834 .bind(installationId, repo, fact.resource, fact.status, bodyText, capturedAt)
837 .bind(installationId, repo, fact.resource, fact.status, bodyText, capturedAt, fact.subject)
835838 .run();
836839
837840 return new Response("OK", { status: 200 });
src/poller.ts +98 −14
@@ -68,31 +68,42 @@ async function fetchBranchProtection(
6868 return { status: "enabled", raw: await res.json() };
6969}
7070
71async function fetchRulesets(installationToken: string, owner: string, repo: string): Promise<ProtectionCheck> {
72 const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/rulesets?per_page=100`, {
73 headers: authHeaders(installationToken),
74 });
75 if (res.status === 403) return { status: "unavailable", raw: null };
76 if (!res.ok) throw new Error(`Failed to fetch rulesets for ${owner}/${repo} (${res.status})`);
71async function fetchDefaultBranchRules(
72 installationToken: string,
73 owner: string,
74 repo: string,
75 branch: string,
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})`);
7790
78 const rulesets = (await res.json()) as Array<{ enforcement: string; target: string }>;
79 // "evaluate" is dry-run/monitor-only — doesn't actually block anything, so
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 };
91 const rules = (await res.json()) as unknown[];
92 return { status: rules.length > 0 ? "enabled" : "disabled", raw: rules };
8393}
8494
8595export interface PolledFact {
8696 repo: string;
87 resource: "branch_protection" | "repository_ruleset";
88 status: "enabled" | "disabled" | "unavailable";
97 resource: string;
98 status: string;
99 subject: string | null;
89100 rawPayload: string | null;
90101}
91102
92103export async function pollRepoProtection(installationToken: string, repo: RepoRef): Promise<PolledFact[]> {
93104 const [branchProtection, rulesets] = await Promise.all([
94105 fetchBranchProtection(installationToken, repo.owner, repo.name, repo.defaultBranch),
95 fetchRulesets(installationToken, repo.owner, repo.name),
106 fetchDefaultBranchRules(installationToken, repo.owner, repo.name, repo.defaultBranch),
96107 ]);
97108
98109 return [
@@ -100,17 +111,90 @@ export async function pollRepoProtection(installationToken: string, repo: RepoRe
100111 repo: repo.fullName,
101112 resource: "branch_protection",
102113 status: branchProtection.status,
114 subject: null,
103115 rawPayload: branchProtection.raw ? JSON.stringify(branchProtection.raw) : null,
104116 },
105117 {
106118 repo: repo.fullName,
107119 resource: "repository_ruleset",
108120 status: rulesets.status,
121 subject: null,
109122 rawPayload: rulesets.raw ? JSON.stringify(rulesets.raw) : null,
110123 },
111124 ];
112125}
113126
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
114198// ---------------------------------------------------------------------------
115199// Access review: org membership and team membership.
116200// ---------------------------------------------------------------------------
src/webhook.ts +83 −17
@@ -27,6 +27,10 @@ export async function verifySignature(
2727interface ExtractedFact {
2828 resource: string;
2929 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;
3034}
3135
3236// Minimal resource/status extraction per event type. Control-ID mapping
@@ -35,34 +39,96 @@ export function extractFact(eventType: string, payload: Record<string, unknown>)
3539 const action = typeof payload.action === "string" ? payload.action : undefined;
3640
3741 switch (eventType) {
38 // Normalized to current-state vocabulary (enabled/disabled) rather than
39 // the raw action, so this lines up with what the poller reports for
40 // pre-existing protection state — the mapping table joins on one
41 // vocabulary regardless of source.
42 case "branch_protection_rule":
43 return { resource: "branch_protection", status: action === "deleted" ? "disabled" : "enabled" };
44 case "repository_ruleset":
45 return { resource: "repository_ruleset", status: action === "deleted" ? "disabled" : "enabled" };
42 // The event is scoped to one rule, which may target any branch. Only a
43 // rule whose pattern is exactly the default branch changes the repo's
44 // protection state (normalized to the enabled/disabled vocabulary the
45 // poller shares); any other rule is recorded as an unmapped trail event,
46 // with the hourly poll authoritative for current state.
47 case "branch_protection_rule": {
48 const rule = payload.rule as Record<string, unknown> | undefined;
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 }
4666 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).
4876 case "secret_scanning_alert": {
4977 const alert = payload.alert as Record<string, unknown> | undefined;
5078 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 };
52118 }
53 case "member":
54 return { resource: "member_access", status: action ?? "unknown" };
55 case "team":
56 return { resource: "team", status: action ?? "unknown" };
57119 case "repository":
58 return { resource: "repository", status: action ?? "unknown" };
120 return { resource: "repository", status: action ?? "unknown", subject: null };
59121 case "push":
60 return { resource: "push", status: "received" };
122 return { resource: "push", status: "received", subject: null };
61123 default:
62 return { resource: eventType, status: action ?? "received" };
124 return { resource: eventType, status: action ?? "received", subject: null };
63125 }
64126}
65127
128function alertNumber(alert: Record<string, unknown> | undefined): string | null {
129 return typeof alert?.number === "number" ? String(alert.number) : null;
130}
131
66132export function extractRepoFullName(payload: Record<string, unknown>): string | null {
67133 const repository = payload.repository as Record<string, unknown> | undefined;
68134 return typeof repository?.full_name === "string" ? repository.full_name : null;