audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
72f0cc60e07a3ca8f3ec1f56b74e076ec4d75c3d
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T00:43:32Z
README.md | 6 +- docs/framework-mapping.md | 510 +++++++++++++++++-------------- migrations/0008_mapping_review_fixes.sql | 141 +++++++++ scripts/check-mappings.mjs | 24 +- src/index.ts | 19 +- src/poller.ts | 112 ++++++- src/webhook.ts | 100 ++++-- 7 files changed, 630 insertions(+), 282 deletions(-) @@ -21,7 +21,7 @@ Read-only — it never modifies your repositories, permissions, or membership. | --- | --- | | Branch protection, repository rulesets | SOC 2 CC8.1 · ISO 27001 A.8.32 | | Secret scanning alerts | SOC 2 CC6.1, CC6.6 · ISO 27001 A.5.17 | -| Dependabot alerts | SOC 2 CC7.1, CC7.2 · ISO 27001 A.8.8 | +| Dependabot alerts | SOC 2 CC7.1 · ISO 27001 A.8.8 | | Code scanning alerts | SOC 2 CC7.1 · ISO 27001 A.8.28, A.8.29 | | Organization / team membership | SOC 2 CC6.2, CC6.3 · ISO 27001 A.5.18 | | Repository inventory | ISO 27001 A.5.9 | @@ -34,8 +34,8 @@ documented in [docs/framework-mapping.md](docs/framework-mapping.md). ## How it works Webhooks capture changes as they happen; an hourly cron polls for state that -webhooks never announce (protection that existed before install, and current -membership). Each observation is stored as a timestamped snapshot in D1. +webhooks never announce (protection and open alerts that predate the install, +whether each scanner is enabled, and current membership). Each observation is stored as a timestamped snapshot in D1. Exports render off the request path via a queue, into R2. **Stack:** Cloudflare Workers · D1 · R2 · Queues. All storage is provisioned @@ -25,9 +25,9 @@ It is the human-readable companion to the machine-readable mappings in - [The frameworks in one paragraph each](#the-frameworks-in-one-paragraph-each) - [The signals and their mappings](#the-signals-and-their-mappings) - [Branch protection & repository rulesets](#1-branch-protection--repository-rulesets) - - [Dependabot alerts](#2-dependabot-alerts) - - [Code scanning alerts](#3-code-scanning-alerts) - - [Secret scanning alerts](#4-secret-scanning-alerts) + - [Dependabot](#2-dependabot) + - [Code scanning](#3-code-scanning) + - [Secret scanning](#4-secret-scanning) - [Membership changes (webhook trail)](#5-membership-changes-webhook-trail) - [Membership & team inventory (polled)](#6-membership--team-inventory-polled) - [Repository inventory](#7-repository-inventory) @@ -44,25 +44,27 @@ Understanding the evidence output requires understanding four rules in the mapping engine. All four live in [`control_mappings`](../migrations/0002_control_mappings.sql) and [`buildEvidenceRows`](../src/exporter.ts). -**1. A snapshot is a `(resource, status)` pair; a mapping is a row that attaches -a control to one.** The poller and webhook handler both normalize GitHub events -into a small vocabulary — `resource` (e.g. `branch_protection`, `dependabot_alert`) -and `status` (e.g. `enabled`, `open`, `fixed`). See [`extractFact`](../src/webhook.ts) -and [`poller.ts`](../src/poller.ts). Mapping happens as a **join at export time**, -never at ingest, so a mapping can be corrected without re-ingesting history. +**1. A snapshot is a `(resource, status)` pair about a `subject`; a mapping is a +row that attaches a control to a `(resource, status)`.** The poller and webhook +handler both normalize GitHub events into a small vocabulary — `resource` +(e.g. `branch_protection`, `dependabot_alert`) and `status` (e.g. `enabled`, +`open`, `fixed`) — plus a `subject` identifying which entity within the repo or +org the fact is about (an alert number, a member login, a team slug). See +[`extractFact`](../src/webhook.ts) and [`poller.ts`](../src/poller.ts). Mapping +happens as a **join at export time**, never at ingest, so a mapping can be +corrected without re-ingesting history. **2. `status = NULL` in a mapping matches *any* status for that resource.** The -join condition is `cm.status IS NULL OR cm.status = l.status`. This is how a -"the tooling exists and is producing signal" fact is expressed independently of -any individual finding's state. - -**3. Consequently, one snapshot can emit multiple evidence rows.** A single -Dependabot alert with `status = 'open'` matches *both* the `NULL` mapping -(CC7.1, "detection tooling is active", **positive**) *and* the `'open'` mapping -(CC7.2, "unremediated vulnerability", **negative**). This is intentional: the -existence of the scanner and the existence of an open finding are two different -facts about two different control expectations. This behavior is called out -per-signal below wherever it applies. +join condition is `cm.status IS NULL OR cm.status = l.status`. This is used for +trail/inventory resources (`team`, `repository`, `org_member`, `team_member`) +where every state is the same kind of informational fact. + +**3. One snapshot can emit multiple evidence rows.** A single secret-scanning +alert with `status = 'open'` matches the `open` mapping under **each** of +CC6.6, CC6.1 (SOC 2) and A.5.17 (ISO). This is intentional: the same fact is +legitimate evidence for more than one control expectation, and attesting all of +them lets the export serve whichever control the organization's narrative uses. +This behavior is called out per-signal below wherever it applies. **4. `posture` is the auditor-facing verdict on a row**, one of: @@ -70,19 +72,26 @@ per-signal below wherever it applies. | --- | --- | --- | | `positive` | State supports the control | Branch protection enabled | | `negative` | State is a gap against the control | Branch protection disabled; open secret | -| `informational` | Neither pass nor fail — an audit-trail / inventory fact | A member was added; a repo exists | +| `informational` | Neither pass nor fail — an audit-trail / inventory fact | A member was added; a finding was dismissed by a user | Two more rules affect *which* snapshots become evidence at all: - **Unmapped states produce no evidence, in either direction.** A `branch_protection` status of `unavailable` (GitHub returned 403 — the feature isn't on the repo's plan; see [`fetchBranchProtection`](../src/poller.ts)) has - no mapping row, so it never counts as a pass *or* a fail. Same for raw - `push` events. + no mapping row, so it never counts as a pass *or* a fail. The same applies to + the `disabled`/`unavailable` states of the detection-tooling resources + (`dependabot`, `code_scanning`, `secret_scanning`), to the trail-only + resources `branch_protection_rule_event` and `repository_ruleset_event` + (rule-scoped webhook events that can't be attributed to the default branch — + the poll is authoritative for state), and to raw `push` events. - **"Latest row wins" per `(repo, subject, resource)`** gives point-in-time - current posture from an append-only table. Access facts are special-cased so a - member who lost access stops being attested — only the most recent poll batch - counts. See the CTE in [`buildEvidenceRows`](../src/exporter.ts). + current posture from an append-only table. Because `subject` carries the + alert number / login / slug, this operates **per entity**: one alert being + fixed does not mask another alert that is still open in the same repo. Access + facts are further special-cased so a member who lost access stops being + attested — only the most recent poll batch counts. See the CTE in + [`buildEvidenceRows`](../src/exporter.ts). --- @@ -114,14 +123,22 @@ assessment. ### 1. Branch protection & repository rulesets -**What we collect.** For each repo's default branch, whether merge controls are -in force — via the `branch_protection_rule` and `repository_ruleset` webhooks -(change events) and an hourly poll of the branch-protection and rulesets APIs -(baseline, for protection that predates the install). Normalized to -`resource ∈ {branch_protection, repository_ruleset}`, `status ∈ {enabled, -disabled}`. A ruleset in `evaluate` (monitor-only) mode counts as **not** -enabled because it does not actually block anything — see -[`fetchRulesets`](../src/poller.ts). +**What we collect.** For each repo's **default branch**, whether merge controls +are in force. The hourly poll is authoritative: the branch-protection API for +classic protection, and the `rules/branches/{default-branch}` API for rulesets — +which aggregates the rules from every *active* ruleset (repo- and org-level) +that actually applies to that branch, so evaluate-mode (monitor-only) rulesets +and rulesets targeting other branches correctly count as **not** enabled. +Normalized to `resource ∈ {branch_protection, repository_ruleset}`, `status ∈ +{enabled, disabled}`. + +Webhooks supplement the poll only where they are unambiguous: a +`branch_protection_rule` event whose rule pattern is exactly the default branch +updates state immediately. Any other rule event — and *every* +`repository_ruleset` event, since one ruleset's deletion says nothing about +whether other rulesets still cover the branch — is recorded as an unmapped +trail event (`branch_protection_rule_event`, `repository_ruleset_event`) and +the next poll settles the state. See [`extractFact`](../src/webhook.ts). **Maps to:** @@ -131,36 +148,46 @@ enabled because it does not actually block anything — see | ISO 27001 | **A.8.32** | *Change management.* Changes to information systems must follow formal change-management procedures to prevent unauthorized or destabilizing changes. | **Why this holds.** Branch protection / rulesets are the technical enforcement of -change control in a Git workflow: requiring pull-request review before merge, -blocking direct pushes to the default branch, and requiring status checks to -pass. That is exactly the "controlled process… stops unauthorized changes" -language of both controls. Enabled → **positive**; disabled → **negative** -("direct pushes now possible" is a concrete change-control gap). - -**Fit assessment: strong.** This is the least ambiguous mapping in the system — -both frameworks name "change management" explicitly, and branch protection is -the canonical GitHub-native implementation of it. The one nuance an auditor will -probe is *scope*: we check the **default branch** only, and "enabled" does not -verify that the *specific* rules (required reviewers, etc.) match the -organization's policy. The evidence attests that a change-control gate exists, -not that its configuration is sufficient. - -### 2. Dependabot alerts - -**What we collect.** `dependabot_alert` webhook events — known-vulnerability -alerts against the repo's dependencies. `status` is the alert state: `open`, -`fixed`, `dismissed`, `auto_dismissed`. +change control in a Git workflow. Enabled → **positive**; disabled → +**negative** ("direct pushes possible" is a concrete change-control gap). + +**Fit assessment: strong, with the scope stated in the evidence itself.** Both +frameworks name "change management" explicitly, and branch protection is the +canonical GitHub-native implementation of it. What the evidence attests is that +a change-control gate **exists on the default branch** — it does not verify that +the *specific* rules (required reviewers, status checks, …) match the +organization's policy, and the exported rationale says so explicitly ("rule +contents not verified") rather than claiming review is required. + +### 2. Dependabot + +**What we collect.** Two distinct facts: + +- **Tooling state** (`resource = dependabot`, `status ∈ {enabled, disabled, + unavailable}`) — the hourly poll checks whether Dependabot alerts are enabled + on each repo (the alert-list API answering at all is the signal; see + [`pollRepoAlerts`](../src/poller.ts)). Only `enabled` is mapped; a disabled or + unavailable scanner is recorded but deliberately produces no evidence either + way, matching the branch-protection `unavailable` precedent. +- **Findings** (`resource = dependabot_alert`, `status ∈ {open, fixed, + dismissed, auto_dismissed}`, `subject` = alert number) — from + `dependabot_alert` webhooks, plus the same hourly poll re-recording every + *open* alert. The poll matters twice: alerts already open before the App was + installed never sent a webhook, and an open alert with no events for the + whole retention window would otherwise age out of evidence. **Maps to:** | Framework | Control | When | Posture | | --- | --- | --- | --- | -| SOC 2 | **CC7.1** | any alert (`status = NULL`) | positive — "detection tooling is active" | -| SOC 2 | **CC7.2** | `open` | negative — "unremediated known vulnerability" | -| SOC 2 | **CC7.2** | `fixed` / `dismissed` / `auto_dismissed` | positive — "remediated" | -| ISO 27001 | **A.8.8** | any alert (`status = NULL`) | positive — "technical vulnerability management active" | -| ISO 27001 | **A.8.8** | `open` | negative — "unremediated known vulnerability" | -| ISO 27001 | **A.8.8** | `fixed` / `dismissed` / `auto_dismissed` | positive — "remediated" | +| SOC 2 | **CC7.1** | tooling `enabled` | positive — detection tooling is on | +| SOC 2 | **CC7.1** | `open` | negative — unremediated known vulnerability | +| SOC 2 | **CC7.1** | `fixed` / `auto_dismissed` | positive — remediated (machine-verified) | +| SOC 2 | **CC7.1** | `dismissed` | informational — human risk-acceptance, justification subject to review | +| ISO 27001 | **A.8.8** | tooling `enabled` | positive — vulnerability management active | +| ISO 27001 | **A.8.8** | `open` | negative — unremediated known vulnerability | +| ISO 27001 | **A.8.8** | `fixed` / `auto_dismissed` | positive — remediated (machine-verified) | +| ISO 27001 | **A.8.8** | `dismissed` | informational — human risk-acceptance, justification subject to review | **Control meanings.** - **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`, susceptibilities to *newly discovered* vulnerabilities. Dependabot is a textbook example: it continuously matches your dependency tree against newly published CVEs. -- **CC7.2** — *Anomaly monitoring.* The entity monitors system components for - anomalies and analyzes them to determine whether they are security events. - **A.8.8** — *Management of technical vulnerabilities.* Information about technical vulnerabilities must be obtained, exposure evaluated, and - appropriate measures taken. This is a single control spanning the whole - vulnerability lifecycle — detect, evaluate, remediate. - -**Why this holds.** The *presence* of Dependabot alerts proves the detection -capability required by CC7.1 exists and is running — hence the `NULL` mapping -fires positive on any alert regardless of state. Each *individual* alert's -lifecycle (open vs. remediated) is then evidence for CC7.2: an open alert is an -unresolved condition, a fixed/dismissed one is a closed one. On the **ISO** side -the entire story lands on a *single* control, A.8.8, because A.8.8 explicitly -covers the full lifecycle — so every status maps to A.8.8 (open → negative, -remediated → positive, tooling-active → positive). See -[migration 0007](../migrations/0007_close_coverage_gaps.sql). - -**Fit assessment: CC7.1 strong; CC7.2 defensible but the weakest link in the -system.** CC7.2's formal text is about anomalies "indicative of malicious acts, -natural disasters, and errors" — i.e. runtime security events. An unpatched -dependency is a *known vulnerability*, which sits more naturally in CC7.1's -"susceptibility to newly discovered vulnerabilities" language than in CC7.2's -anomaly-detection language. Many auditors keep the **entire** dependency story -(detection *and* remediation tracking) under CC7.1. **Recommendation:** before -you present this to an auditor, decide whether open/remediated Dependabot state -belongs under CC7.1 or CC7.2 in your control narrative, and align the mapping to -that decision. Both are defensible; the current split is a design choice, not a -requirement. The **ISO A.8.8** mapping, by contrast, is a strong, clean fit — -A.8.8 is purpose-built for technical-vulnerability management and absorbs the -whole lifecycle without the CC7.1/CC7.2 ambiguity. It was added in migration 0007 -to close a gap: before it, Dependabot produced no evidence at all in an ISO -export. - -### 3. Code scanning alerts - -**What we collect.** `code_scanning_alert` webhook events — SAST findings from -CodeQL or a third-party analyzer. `status ∈ {open, fixed, dismissed}`. + appropriate measures taken — one control spanning the whole lifecycle. + +**Why this holds.** The tooling-`enabled` fact proves the detection capability +required by CC7.1/A.8.8 exists and is on *right now* — it comes from the +feature's own state, not (as in earlier versions) from inference off the latest +alert event, which kept attesting after a scanner was switched off and never +fired for a clean repo. Each individual alert's lifecycle is then finding-level +evidence under the same controls: an open alert is an unresolved known +vulnerability; a `fixed` (patched) or `auto_dismissed` (e.g. dependency +removed) alert is a machine-verified closure; a `dismissed` alert is a **human +decision** — it may be sound risk acceptance or may be rubber-stamping, and the +justification is exactly what an auditor samples, so it is recorded as +informational rather than claimed as remediation. + +**Fit assessment: strong on both frameworks.** The whole SOC 2 lifecycle sits +under CC7.1, whose "susceptibility to newly discovered vulnerabilities" +language covers known-CVE management directly. (Earlier versions split +open/remediated state to CC7.2; CC7.2's anomaly-monitoring text is about +runtime security events, which made it the weakest link in the system — that +split has been removed.) A.8.8 is purpose-built for this signal and absorbs the +whole lifecycle. + +### 3. Code scanning + +**What we collect.** Same two-fact shape as Dependabot: tooling state +(`resource = code_scanning`, from the hourly poll; only `enabled` mapped) and +findings (`resource = code_scanning_alert`, `status ∈ {open, fixed, +dismissed}`, `subject` = alert number) from `code_scanning_alert` webhooks plus +the hourly re-record of open alerts. Findings are SAST results from CodeQL or a +third-party analyzer. **Maps to:** | Framework | Control | When | Posture | | --- | --- | --- | --- | -| ISO 27001 | **A.8.29** | any alert (`status = NULL`) | positive — "security testing in development is active" | -| ISO 27001 | **A.8.28** | `open` | negative — "unremediated finding" | -| ISO 27001 | **A.8.28** | `fixed` / `dismissed` | positive — "remediated" | -| SOC 2 | **CC7.1** | any alert (`status = NULL`) | positive — "detection tooling is active" | +| ISO 27001 | **A.8.29** | tooling `enabled` | positive — security testing in development is on | +| ISO 27001 | **A.8.28** | `open` | negative — unremediated finding | +| ISO 27001 | **A.8.28** | `fixed` | positive — remediated | +| ISO 27001 | **A.8.28** | `dismissed` | informational — human risk-acceptance, justification subject to review | +| SOC 2 | **CC7.1** | tooling `enabled` | positive — detection tooling is on | +| SOC 2 | **CC7.1** | `open` | negative — unremediated finding | +| SOC 2 | **CC7.1** | `fixed` | positive — remediated | +| SOC 2 | **CC7.1** | `dismissed` | informational — human risk-acceptance, justification subject to review | **Control meanings.** - **A.8.29** — *Security testing in development and acceptance.* Security testing - processes must be defined and run within the development lifecycle so - vulnerabilities are found before production. The existence of code scanning - *is* that testing process. + processes must be defined and run within the development lifecycle. The + existence of code scanning *is* that testing process. - **A.8.28** — *Secure coding.* Secure coding principles must be applied during development. An open finding is evidence of a secure-coding gap in the source; a remediated one is evidence the gap was closed. - **CC7.1** — *Detection & monitoring.* (Same control as Dependabot's SOC 2 - mapping.) Code scanning is detection tooling that surfaces vulnerabilities, so - its presence satisfies the "detection procedures exist and run" expectation. + mapping.) **Why this holds.** On the **ISO** side this splits cleanly across two controls -that map to two facts: *"a testing process exists"* (A.8.29, from the `NULL` -mapping) versus *"the code itself is/ isn't secure"* (A.8.28, from each finding's -state). On the **SOC 2** side (added in [migration 0007](../migrations/0007_close_coverage_gaps.sql)) -only the tooling-active fact is mapped, to CC7.1 — mirroring how Dependabot's -tooling-active fact maps to CC7.1. - -**Fit assessment: strong on ISO; SOC 2 intentionally partial.** The -A.8.29-vs-A.8.28 split is clean — one control is about *having* the testing -process, the other about the *code quality* it reveals — and both titles match -the signal directly. The new SOC 2 CC7.1 mapping covers only detection-active, -**not** finding-level state: code-scanning `open`/`fixed` rows are deliberately -*not* routed to CC7.2, because whether the vulnerability lifecycle belongs under -CC7.1 or CC7.2 is still an open decision (see the Dependabot fit assessment). Once -that is settled, finding-level SOC 2 rows for code scanning can be added to match -Dependabot. Until then a SOC 2 export shows code scanning as "detection active" -only — which under-claims rather than over-claims, the safe direction. - -### 4. Secret scanning alerts - -**What we collect.** `secret_scanning_alert` webhook events — detected -credentials/tokens committed to the repo. `status ∈ {open, resolved}`. +that map to two facts: *"a testing process exists"* (A.8.29, from tooling +state) versus *"the code itself is / isn't secure"* (A.8.28, from each +finding's state). On the **SOC 2** side the full lifecycle mirrors Dependabot +under CC7.1 — the finding-level rows were previously deferred pending the +CC7.1-vs-CC7.2 decision, which is now settled in CC7.1's favor. + +**Fit assessment: strong on both.** The A.8.29-vs-A.8.28 split follows the +controls' own having-a-process vs. code-quality distinction, and the SOC 2 side +is now symmetric with Dependabot rather than intentionally partial. + +### 4. Secret scanning + +**What we collect.** Tooling state (`resource = secret_scanning`, from the +hourly poll; only `enabled` mapped) and findings (`resource = +secret_scanning_alert`, `status ∈ {open, resolved}`, `subject` = alert number) +from `secret_scanning_alert` webhooks plus the hourly re-record of open alerts. +One payload quirk matters: unlike the other two alert payloads, the +secret-scanning webhook alert carries **no `state` field** — ingest derives +open/resolved from `alert.resolution`, which is set iff the alert is resolved +(see [`extractFact`](../src/webhook.ts)). **Maps to:** | Framework | Control | When | Posture | | --- | --- | --- | --- | -| SOC 2 | **CC6.6** | any alert (`status = NULL`) | positive — "leaked-credential detection is active" | -| SOC 2 | **CC6.6** | `open` | negative — "live credential exposure" | -| SOC 2 | **CC6.6** | `resolved` | positive — "exposure remediated" | -| SOC 2 | **CC6.1** | any alert (`status = NULL`) | positive — "logical-access credential protection active" | -| SOC 2 | **CC6.1** | `open` | negative — "exposed credential undermines logical access controls" | -| SOC 2 | **CC6.1** | `resolved` | positive — "logical access control restored" | -| ISO 27001 | **A.5.17** | any alert (`status = NULL`) | positive — "authentication-information protection active" | -| ISO 27001 | **A.5.17** | `open` | negative — "exposed authentication information" | -| ISO 27001 | **A.5.17** | `resolved` | positive — "exposure remediated" | +| SOC 2 | **CC6.6** | tooling `enabled` | positive — leaked-credential detection is on | +| SOC 2 | **CC6.6** | `open` | negative — live credential exposure | +| SOC 2 | **CC6.6** | `resolved` | informational — resolution reason subject to review | +| SOC 2 | **CC6.1** | tooling `enabled` | positive — logical-access credential protection is on | +| SOC 2 | **CC6.1** | `open` | negative — exposed credential undermines logical access controls | +| SOC 2 | **CC6.1** | `resolved` | informational — resolution reason subject to review | +| ISO 27001 | **A.5.17** | tooling `enabled` | positive — authentication-information protection is on | +| ISO 27001 | **A.5.17** | `open` | negative — exposed authentication information | +| ISO 27001 | **A.5.17** | `resolved` | informational — resolution reason subject to review | **Control meanings.** - **CC6.6** — *Protection against external threats.* The entity implements @@ -279,65 +302,70 @@ credentials/tokens committed to the repo. `status ∈ {open, resolved}`. **Why this holds.** A committed credential is relevant to all three controls at once. For **CC6.6**, it is a direct path for an *external* attacker to cross the -system boundary. For **CC6.1**, the credential is itself one of the logical-access -keys the control is meant to safeguard, so a leak is a compromise of the access -controls themselves. For **A.5.17**, the credential is authentication information -whose confidentiality the control requires. In every case: scanning active → -**positive** (a protective measure exists); open alert → **negative** (a live -gap); resolved → **positive** (gap closed). See -[migration 0006](../migrations/0006_secret_scanning_cc6_1.sql) (CC6.1) and -[migration 0007](../migrations/0007_close_coverage_gaps.sql) (A.5.17). +system boundary. For **CC6.1**, the credential is itself one of the +logical-access keys the control is meant to safeguard. For **A.5.17**, the +credential is authentication information whose confidentiality the control +requires. Scanning enabled → **positive**; open alert → **negative**. A +`resolved` alert is **informational**, not positive: GitHub's resolution +reasons include `wont_fix`, so "resolved" may mean the credential was revoked +*or* that someone decided to leave it — the recorded reason is what the +reviewer must check. **Fit assessment: all three defensible.** CC6.6 is the external-threat framing, CC6.1 the logical-access framing, A.5.17 the ISO authentication-information -framing (added in migration 0007 to close a gap — before it, secret scanning -produced no ISO evidence). Mapping to all three means the export satisfies -whichever control the organization's narrative uses. A further SOC 2 framing, +framing. Mapping to all three means the export satisfies whichever control the +organization's narrative uses — at the cost of row multiplicity: one open +secret emits a negative under each of the three. A further SOC 2 framing, **CC6.7** (restricting the transmission/movement of information), also touches -this and could be added if an auditor prefers it. Note the multiplicity: one -`open` secret now emits **six** rows — a positive ("scanner running") and a -negative ("open exposure") under *each* of CC6.6, CC6.1 (SOC 2 export) and A.5.17 -(ISO export). That is intended and reads correctly, but expect the row counts to -scale accordingly. +this and could be added if an auditor prefers it. ### 5. Membership changes (webhook trail) -**What we collect.** `member`, `team`, and `repository` webhook events — the -*change* events, recording that an access-related mutation happened. Normalized -to `resource ∈ {member_access, team, repository}` with the GitHub action as -status. +**What we collect.** The *change* events for access administration, each with a +`subject` so the trail keeps one latest row per person/team rather than one per +repo: + +- `member` webhook → `resource = member_access`, subject = the collaborator's + login. **Scope note: this event covers repository collaborators**, not org + members. +- `organization` webhook → `resource = org_membership`, subject = the member's + login (or the invitee's login/email). This is where org-level joins, removals + and invitations arrive. +- `team` webhook → `resource = team`, subject = the team slug. **Maps to:** | Framework | Control | When | Posture | | --- | --- | --- | --- | -| SOC 2 | **CC6.2** | `member_access` `added` | informational — "access grant, logged for review" | -| SOC 2 | **CC6.3** | `member_access` `removed` | positive — "timely access removal" | -| SOC 2 | **CC6.3** | `member_access` `edited` | informational — "access-level change, logged" | -| ISO 27001 | **A.5.18** | `team` (any) | informational — "access-rights change, audit trail" | +| SOC 2 | **CC6.2** | `member_access` `added`, `org_membership` `member_added` / `member_invited` | informational — access grant / invitation, logged for review | +| SOC 2 | **CC6.3** | `member_access` `removed` / `edited`, `org_membership` `member_removed` | informational — modification / deprovisioning recorded | +| ISO 27001 | **A.5.18** | all of the above | informational — access-rights change, audit trail | +| ISO 27001 | **A.5.18** | `team` (any) | informational — access-rights change, audit trail | **Control meanings.** - **CC6.2** — *Registration & authorization of new users.* Before credentials are issued, new users are registered and authorized; credentials are removed when - access is no longer authorized. A member being *added* is the provisioning - event this criterion governs. + access is no longer authorized. A member being *added* or *invited* is the + provisioning event this criterion governs. - **CC6.3** — *Authorize / modify / remove access.* Access is authorized, modified, or removed based on roles, least privilege, and segregation of - duties. Member *removal* and *role change* are the modify/remove events here. + duties. Removal and role change are the modify/remove events here. - **A.5.18** — *Access rights.* Access rights are provisioned, reviewed, - modified, and removed per the access-control policy. A team membership change - is an access-rights mutation on that trail. + modified, and removed per the access-control policy. **Why this holds & posture logic.** These are the *audit trail* of access administration — evidence that grants/changes are captured, which is what an -auditor samples. Most are **informational** (an add or a role change is neither -inherently good nor bad — it needs human review). The one exception is -`removed` → **positive**, because timely de-provisioning is itself a control -objective (CC6.3), so a captured removal is affirmative evidence. +auditor samples. **All rows are informational**, removals included: the event +proves a removal happened and when, but not that it was *timely* relative to an +offboarding trigger the system cannot see — so the rationale says +"deprovisioning recorded; timeliness subject to review" rather than claiming +timeliness as a positive. (Earlier versions claimed "timely access removal"; +that was rounding up.) **Fit assessment: strong on the CC6.2/CC6.3 split** (it follows the criteria's -own provisioning-vs-modification language). The informational posture is the -right call — this data feeds the access review, it does not pass/fail on its own. +own provisioning-vs-modification language), and honest about scope now that +repo-collaborator and org-member events are separate resources with separate +rationales. ### 6. Membership & team inventory (polled) @@ -351,9 +379,9 @@ point-in-time snapshot — this is what powers the [access-review diff](../src/a | Framework | Control | Resource | Posture | | --- | --- | --- | --- | -| SOC 2 | **CC6.2** | `org_member` | informational — "org access inventory, subject to periodic review" | -| SOC 2 | **CC6.3** | `team_member` | informational — "team-based access inventory" | -| ISO 27001 | **A.5.18** | `org_member`, `team_member` | informational — "access-rights inventory" | +| SOC 2 | **CC6.2** | `org_member` | informational — org access inventory, subject to periodic review | +| SOC 2 | **CC6.3** | `team_member` | informational — team-based access inventory | +| ISO 27001 | **A.5.18** | `org_member`, `team_member` | informational — access-rights inventory | **Why this holds.** A point-in-time roster of who has access is the raw material of a periodic access review — the recurring auditor ask that CC6.2/CC6.3 and @@ -368,20 +396,21 @@ reasonable but not the only defensible cut — the *review* of org membership is arguably as much CC6.3 (appropriateness of access) as CC6.2 (registration). Since these are informational inventory rows feeding a review, the exact CC6.2/CC6.3 attribution is low-stakes; A.5.18 is unambiguous. Note the two resource families -(`member_access`/`team` webhook trail vs. `org_member`/`team_member` polled -inventory) are deliberately separate resource names so the change-trail and the -current-state inventory don't collide. +(`member_access`/`org_membership`/`team` webhook trail vs. +`org_member`/`team_member` polled inventory) are deliberately separate resource +names so the change-trail and the current-state inventory don't collide. ### 7. Repository inventory **What we collect.** `repository` webhook events — repos created/deleted/renamed -within the installation. `resource = repository`. +and visibility changes within the installation. `resource = repository`. **Maps to:** -| Framework | Control | Posture | -| --- | --- | --- | -| ISO 27001 | **A.5.9** | informational — "asset inventory trail" | +| Framework | Control | When | Posture | +| --- | --- | --- | --- | +| ISO 27001 | **A.5.9** | any action | informational — asset inventory trail | +| SOC 2 | **CC6.1** | `publicized` | informational — repo made public, flagged for review | **Control meaning.** - **A.5.9** — *Inventory of information and other associated assets.* A complete, @@ -389,8 +418,11 @@ within the installation. `resource = repository`. **Why this holds.** Repositories are information assets. The trail of repo create/delete/rename events is evidence that the asset inventory is maintained as -it changes — exactly A.5.9's requirement. **Informational**: it is inventory, not -a pass/fail condition. +it changes — exactly A.5.9's requirement. One action gets an extra row: a repo +being **publicized** is a visibility change with direct confidentiality impact, +so it is additionally surfaced under CC6.1 rather than left as a generic +inventory tick. Both rows are **informational** — whether going public was +intended is a judgment the reviewer makes. **Fit assessment: strong for what it claims.** The honest caveat is completeness: this is a *change trail*, so it evidences that inventory changes are captured, not @@ -404,56 +436,72 @@ webhook trail. ## Complete mapping reference This table is the authoritative human-readable copy of every row in -[`control_mappings`](../migrations/0002_control_mappings.sql) after all migrations -(0002 seeds most; 0003 replaces branch-protection/ruleset with the +[`control_mappings`](../migrations/0002_control_mappings.sql) after all +migrations (0002 seeds most; 0003 replaces branch-protection/ruleset with the enabled/disabled vocabulary; 0005 adds the polled access inventory; 0006 adds -the secret-scanning CC6.1 rows; 0007 closes the cross-framework coverage gaps — -Dependabot→A.8.8, code scanning→CC7.1, secret scanning→A.5.17). **A "·" in -Status means the mapping's `status` is `NULL` — it matches any status.** +the secret-scanning CC6.1 rows; 0007 closes cross-framework coverage gaps; 0008 +applies the mapping-review fixes — per-entity subjects, polled tooling state, +the CC7.1 consolidation, and informational postures for human dismissals). +**A "·" in Status means the mapping's `status` is `NULL` — it matches any +status.** The Rationale column is the exact auditor-facing string in the +database, and is CI-checked against it. | Resource | Status | Framework | Control | Posture | Rationale | | --- | --- | --- | --- | --- | --- | -| `branch_protection` | `enabled` | SOC 2 | CC8.1 | positive | Change management — review before merge | -| `branch_protection` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — direct pushes possible | -| `branch_protection` | `enabled` | ISO 27001 | A.8.32 | positive | Change management | -| `branch_protection` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — direct pushes possible | -| `repository_ruleset` | `enabled` | SOC 2 | CC8.1 | positive | Change management — review before merge | -| `repository_ruleset` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — direct pushes possible | -| `repository_ruleset` | `enabled` | ISO 27001 | A.8.32 | positive | Change management | -| `repository_ruleset` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — direct pushes possible | -| `dependabot_alert` | · | SOC 2 | CC7.1 | positive | Detection tooling is active | -| `dependabot_alert` | `open` | SOC 2 | CC7.2 | negative | Unremediated known vulnerability | -| `dependabot_alert` | `fixed` | SOC 2 | CC7.2 | positive | Remediated | -| `dependabot_alert` | `dismissed` | SOC 2 | CC7.2 | positive | Remediated (risk accepted) | -| `dependabot_alert` | `auto_dismissed` | SOC 2 | CC7.2 | positive | Remediated (e.g. dependency removed) | -| `dependabot_alert` | · | ISO 27001 | A.8.8 | positive | Technical vulnerability management — detection active | +| `branch_protection` | `enabled` | SOC 2 | CC8.1 | positive | Change management — a protection rule is enforced on the default branch (rule contents not verified) | +| `branch_protection` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — no protection on the default branch; direct pushes possible | +| `branch_protection` | `enabled` | ISO 27001 | A.8.32 | positive | Change management — a protection rule is enforced on the default branch (rule contents not verified) | +| `branch_protection` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — no protection on the default branch; direct pushes possible | +| `repository_ruleset` | `enabled` | SOC 2 | CC8.1 | positive | Change management — an active ruleset covers the default branch (rule contents not verified) | +| `repository_ruleset` | `disabled` | SOC 2 | CC8.1 | negative | Change-control gap — no active ruleset covers the default branch | +| `repository_ruleset` | `enabled` | ISO 27001 | A.8.32 | positive | Change management — an active ruleset covers the default branch (rule contents not verified) | +| `repository_ruleset` | `disabled` | ISO 27001 | A.8.32 | negative | Change-control gap — no active ruleset covers the default branch | +| `dependabot` | `enabled` | SOC 2 | CC7.1 | positive | Detection tooling — Dependabot alerts are enabled on the repository | +| `dependabot` | `enabled` | ISO 27001 | A.8.8 | positive | Technical vulnerability management — Dependabot alerts are enabled on the repository | +| `code_scanning` | `enabled` | SOC 2 | CC7.1 | positive | Detection tooling — code scanning is enabled on the repository | +| `code_scanning` | `enabled` | ISO 27001 | A.8.29 | positive | Security testing in development — code scanning is enabled on the repository | +| `secret_scanning` | `enabled` | SOC 2 | CC6.6 | positive | Leaked-credential detection — secret scanning is enabled on the repository | +| `secret_scanning` | `enabled` | SOC 2 | CC6.1 | positive | Logical-access credential protection — secret scanning is enabled on the repository | +| `secret_scanning` | `enabled` | ISO 27001 | A.5.17 | positive | Authentication-information protection — secret scanning is enabled on the repository | +| `dependabot_alert` | `open` | SOC 2 | CC7.1 | negative | Unremediated known vulnerability | +| `dependabot_alert` | `fixed` | SOC 2 | CC7.1 | positive | Vulnerability remediated | +| `dependabot_alert` | `dismissed` | SOC 2 | CC7.1 | informational | Dismissed by a user — risk-acceptance justification subject to review | +| `dependabot_alert` | `auto_dismissed` | SOC 2 | CC7.1 | positive | Auto-dismissed by GitHub (e.g. dependency removed) | | `dependabot_alert` | `open` | ISO 27001 | A.8.8 | negative | Unremediated known technical vulnerability | | `dependabot_alert` | `fixed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated | -| `dependabot_alert` | `dismissed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated (risk accepted) | -| `dependabot_alert` | `auto_dismissed` | ISO 27001 | A.8.8 | positive | Vulnerability remediated (e.g. dependency removed) | -| `code_scanning_alert` | · | ISO 27001 | A.8.29 | positive | Security testing in development is active | -| `code_scanning_alert` | `open` | ISO 27001 | A.8.28 | negative | Unremediated finding | -| `code_scanning_alert` | `fixed` | ISO 27001 | A.8.28 | positive | Remediated | -| `code_scanning_alert` | `dismissed` | ISO 27001 | A.8.28 | positive | Remediated (risk accepted) | -| `code_scanning_alert` | · | SOC 2 | CC7.1 | positive | Detection tooling is active (findings unmapped in SOC 2) | -| `secret_scanning_alert` | · | SOC 2 | CC6.6 | positive | Leaked-credential detection is active | +| `dependabot_alert` | `dismissed` | ISO 27001 | A.8.8 | informational | Dismissed by a user — risk-acceptance justification subject to review | +| `dependabot_alert` | `auto_dismissed` | ISO 27001 | A.8.8 | positive | Auto-dismissed by GitHub (e.g. dependency removed) | +| `code_scanning_alert` | `open` | SOC 2 | CC7.1 | negative | Unremediated static-analysis finding | +| `code_scanning_alert` | `fixed` | SOC 2 | CC7.1 | positive | Finding remediated | +| `code_scanning_alert` | `dismissed` | SOC 2 | CC7.1 | informational | Dismissed by a user — risk-acceptance justification subject to review | +| `code_scanning_alert` | `open` | ISO 27001 | A.8.28 | negative | Unremediated static-analysis finding | +| `code_scanning_alert` | `fixed` | ISO 27001 | A.8.28 | positive | Finding remediated | +| `code_scanning_alert` | `dismissed` | ISO 27001 | A.8.28 | informational | Dismissed by a user — risk-acceptance justification subject to review | | `secret_scanning_alert` | `open` | SOC 2 | CC6.6 | negative | Live credential exposure | -| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.6 | positive | Exposure remediated | -| `secret_scanning_alert` | · | SOC 2 | CC6.1 | positive | Logical-access credential protection — detection active | +| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.6 | informational | Resolution recorded — reason (revoked vs. won't-fix) subject to review | | `secret_scanning_alert` | `open` | SOC 2 | CC6.1 | negative | Exposed credential undermines logical access controls | -| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.1 | positive | Logical access control restored — exposure remediated | -| `secret_scanning_alert` | · | ISO 27001 | A.5.17 | positive | Authentication-information protection — detection active | +| `secret_scanning_alert` | `resolved` | SOC 2 | CC6.1 | informational | Resolution recorded — reason (revoked vs. won't-fix) subject to review | | `secret_scanning_alert` | `open` | ISO 27001 | A.5.17 | negative | Exposed authentication information | -| `secret_scanning_alert` | `resolved` | ISO 27001 | A.5.17 | positive | Authentication-information exposure remediated | -| `member_access` | `added` | SOC 2 | CC6.2 | informational | Access grant — logged for review | -| `member_access` | `removed` | SOC 2 | CC6.3 | positive | Timely access removal | -| `member_access` | `edited` | SOC 2 | CC6.3 | informational | Access-level change — logged for review | +| `secret_scanning_alert` | `resolved` | ISO 27001 | A.5.17 | informational | Resolution recorded — reason (revoked vs. won't-fix) subject to review | +| `member_access` | `added` | SOC 2 | CC6.2 | informational | Repository collaborator added — access grant logged for review | +| `member_access` | `removed` | SOC 2 | CC6.3 | informational | Repository collaborator removed — deprovisioning recorded; timeliness subject to review | +| `member_access` | `edited` | SOC 2 | CC6.3 | informational | Repository collaborator permission changed — logged for review | +| `member_access` | `added` | ISO 27001 | A.5.18 | informational | Repository collaborator added — access-rights change, audit trail | +| `member_access` | `removed` | ISO 27001 | A.5.18 | informational | Repository collaborator removed — access-rights change, audit trail | +| `member_access` | `edited` | ISO 27001 | A.5.18 | informational | Repository collaborator permission changed — access-rights change, audit trail | +| `org_membership` | `member_added` | SOC 2 | CC6.2 | informational | Organization member added — access grant logged for review | +| `org_membership` | `member_removed` | SOC 2 | CC6.3 | informational | Organization member removed — deprovisioning recorded; timeliness subject to review | +| `org_membership` | `member_invited` | SOC 2 | CC6.2 | informational | Organization invitation issued — logged for review | +| `org_membership` | `member_added` | ISO 27001 | A.5.18 | informational | Organization member added — access-rights change, audit trail | +| `org_membership` | `member_removed` | ISO 27001 | A.5.18 | informational | Organization member removed — access-rights change, audit trail | +| `org_membership` | `member_invited` | ISO 27001 | A.5.18 | informational | Organization invitation issued — access-rights change, audit trail | | `team` | · | ISO 27001 | A.5.18 | informational | Access-rights change, audit trail | | `repository` | · | ISO 27001 | A.5.9 | informational | Asset inventory trail | -| `org_member` | · | SOC 2 | CC6.2 | informational | Org access inventory — subject to periodic review | -| `org_member` | · | ISO 27001 | A.5.18 | informational | Access-rights inventory | +| `repository` | `publicized` | SOC 2 | CC6.1 | informational | Repository made public — visibility change affecting asset confidentiality, flagged for review | +| `org_member` | · | SOC 2 | CC6.2 | informational | Organization access inventory — subject to periodic access review | +| `org_member` | · | ISO 27001 | A.5.18 | informational | Access rights inventory | | `team_member` | · | SOC 2 | CC6.3 | informational | Team-based access inventory | -| `team_member` | · | ISO 27001 | A.5.18 | informational | Access-rights inventory | +| `team_member` | · | ISO 27001 | A.5.18 | informational | Access rights inventory | ### Control glossary @@ -464,7 +512,6 @@ Status means the mapping's `status` is `NULL` — it matches any status.** | **CC6.3** | Authorize, modify, and remove access by role, with least privilege and segregation of duties | | **CC6.6** | Protect against threats originating outside the system boundary | | **CC7.1** | Detect configuration changes that introduce vulnerabilities, and susceptibility to newly discovered ones | -| **CC7.2** | Monitor components for anomalies and analyze them as potential security events | | **CC8.1** | Put changes through an authorized, controlled process; block unauthorized changes | | **A.5.9** | Maintain an inventory of information and associated assets, with owners | | **A.5.17** | Control the allocation and management of authentication information (passwords, keys, tokens) | @@ -501,20 +548,23 @@ so bias toward under-claiming. mapping is worth more to an auditor than three tenuous ones. Tenuous mappings erode trust in the whole evidence pack. 4. **Assign posture from the control's expectation, not the signal's sentiment:** - - `positive` — the state is what the control wants. + - `positive` — the state is what the control wants, and the platform verified + it (not merely a human clicking "dismiss"). - `negative` — the state is a concrete gap the control would flag. - `informational` — the state is audit-trail/inventory that feeds a review but - is not itself pass/fail. When in doubt, use `informational`. -5. **Decide detection-vs-finding.** If the signal is a scanner/alert stream, you - usually want two mapping kinds: a `status = NULL` row for "the control's - *tooling* exists" (positive), and per-status rows for individual findings. - Remember rule 3 in [How mapping works](#how-mapping-works-mechanically): both - fire on the same snapshot. + is not itself pass/fail. Human decisions (dismissals, resolutions with a + reason) belong here. When in doubt, use `informational`. +5. **Separate tooling state from findings.** If the signal is a scanner/alert + stream, attest "the control's *tooling* is on" from the feature's own state + (polled), not from the existence of alerts — and attest each finding's + lifecycle per alert, with the alert number as `subject` so findings don't + mask each other. 6. **Pin the edition.** State which version of the framework you mapped (e.g. "PCI DSS v4.0.1", "NIST CSF 2.0") — control numbers move between editions. -7. **Write the rationale** in the `rationale` column *and* the fit assessment - here. The `rationale` is what an auditor reads in the export; make it a - complete thought, not a keyword. +7. **Write the rationale** in the `rationale` column *and* copy it into the + reference table here verbatim — it is CI-checked. The `rationale` is what an + auditor reads in the export; make it a complete thought that claims no more + than the signal proves. ### What the code needs @@ -570,7 +620,7 @@ a row here with no SQL, is a bug. This is enforced. [`scripts/check-mappings.mjs`](../scripts/check-mappings.mjs) applies every migration to an in-memory SQLite database, reads back `control_mappings`, and diffs the `(resource, status, framework, control_id, -posture)` tuples against the rows parsed out of the +posture, rationale)` tuples against the rows parsed out of the [reference table](#complete-mapping-reference) above. It fails with a row-level diff if the two drift. Run it with: new file mode 100644 @@ -0,0 +1,141 @@ +-- Migration 0008: fixes from the evidence-to-control mapping review. +-- +-- 1. Secret-scanning status vocabulary. The secret_scanning_alert webhook +-- payload has no alert.state field (unlike Dependabot / code scanning), so +-- ingest recorded the webhook action (created/reopened/...) and the 'open' +-- mappings never matched — an open leak exported only positive rows. +-- Ingest now derives open/resolved from alert.resolution; historical rows +-- are backfilled the same way below. +-- 2. Per-entity subjects. Alert/member/team snapshots had a NULL subject, so +-- "latest row wins per (repo, subject, resource)" collapsed every alert of +-- a type in a repo into one evidence row — one fixed alert masked any +-- number of still-open ones. Ingest now stores the alert number / login / +-- slug in subject; historical rows are backfilled from raw_payload. +-- 3. Tooling-active re-homed. "Detection tooling is active" was inferred from +-- the latest alert event, which keeps attesting after the scanner is +-- disabled and never fires for a clean repo. The poller now reports the +-- feature state directly (resources dependabot / code_scanning / +-- secret_scanning, status enabled|disabled|unavailable); the status-NULL +-- alert mappings are replaced by 'enabled' mappings on those resources. +-- disabled/unavailable stay unmapped — missing tooling is recorded but not +-- claimed either way, matching the branch-protection precedent. +-- 4. SOC 2 vulnerability lifecycle consolidated under CC7.1, resolving the +-- CC7.1-vs-CC7.2 question left open in docs/framework-mapping.md: a known +-- vulnerability sits in CC7.1's "susceptibility to newly discovered +-- vulnerabilities" language, not CC7.2's runtime anomaly monitoring. Code +-- scanning gains the finding-level SOC 2 rows deferred on that decision. +-- 5. Human dismissals downgraded to informational. A dismissal (or a secret +-- "resolved" that may be wont_fix) is a recorded human decision, not a +-- verified remediation — the justification is what an auditor samples. +-- Machine-verified outcomes (fixed, auto_dismissed) stay positive. +-- 6. Branch-protection rationales reworded to what is actually verified: a +-- protection rule / active ruleset covers the default branch; the rule +-- contents are not checked. +-- 7. The `member` webhook is repository-collaborator scoped: rationales now +-- say so, "removed" no longer claims timeliness the event can't prove, and +-- org-level membership events (`organization` webhook -> org_membership) +-- are mapped. member_access gains the ISO A.5.18 rows it was missing. +-- 8. repository 'publicized' additionally flagged to SOC 2 CC6.1 — a repo +-- going public is a visibility change worth surfacing, not just an +-- inventory tick. + +-- (1) Backfill secret-scanning statuses recorded from the raw webhook action. +UPDATE snapshots +SET status = CASE + WHEN json_extract(raw_payload, '$.alert.resolution') IS NULL THEN 'open' + ELSE 'resolved' +END +WHERE resource = 'secret_scanning_alert' + AND status NOT IN ('open', 'resolved') + AND raw_payload IS NOT NULL; + +-- (2) Backfill per-entity subjects from the retained webhook payloads. +UPDATE snapshots +SET subject = CAST(json_extract(raw_payload, '$.alert.number') AS TEXT) +WHERE resource IN ('dependabot_alert', 'code_scanning_alert', 'secret_scanning_alert') + AND subject IS NULL + AND json_extract(raw_payload, '$.alert.number') IS NOT NULL; + +UPDATE snapshots +SET subject = json_extract(raw_payload, '$.member.login') +WHERE resource = 'member_access' + AND subject IS NULL + AND json_extract(raw_payload, '$.member.login') IS NOT NULL; + +UPDATE snapshots +SET subject = json_extract(raw_payload, '$.team.slug') +WHERE resource = 'team' + AND subject IS NULL + AND json_extract(raw_payload, '$.team.slug') IS NOT NULL; + +-- (3)-(8) Replace the mappings for every affected resource wholesale (same +-- pattern as migration 0003). +DELETE FROM control_mappings WHERE resource IN + ('dependabot_alert', 'code_scanning_alert', 'secret_scanning_alert', + 'branch_protection', 'repository_ruleset', 'member_access'); + +INSERT INTO control_mappings (resource, status, framework, control_id, posture, rationale) VALUES + -- Branch protection / rulesets: state of the default branch's merge gate. + ('branch_protection', 'enabled', 'soc2', 'CC8.1', 'positive', 'Change management — a protection rule is enforced on the default branch (rule contents not verified)'), + ('branch_protection', 'disabled', 'soc2', 'CC8.1', 'negative', 'Change-control gap — no protection on the default branch; direct pushes possible'), + ('branch_protection', 'enabled', 'iso27001', 'A.8.32', 'positive', 'Change management — a protection rule is enforced on the default branch (rule contents not verified)'), + ('branch_protection', 'disabled', 'iso27001', 'A.8.32', 'negative', 'Change-control gap — no protection on the default branch; direct pushes possible'), + ('repository_ruleset', 'enabled', 'soc2', 'CC8.1', 'positive', 'Change management — an active ruleset covers the default branch (rule contents not verified)'), + ('repository_ruleset', 'disabled', 'soc2', 'CC8.1', 'negative', 'Change-control gap — no active ruleset covers the default branch'), + ('repository_ruleset', 'enabled', 'iso27001', 'A.8.32', 'positive', 'Change management — an active ruleset covers the default branch (rule contents not verified)'), + ('repository_ruleset', 'disabled', 'iso27001', 'A.8.32', 'negative', 'Change-control gap — no active ruleset covers the default branch'), + + -- Detection tooling state (polled; disabled/unavailable deliberately unmapped). + ('dependabot', 'enabled', 'soc2', 'CC7.1', 'positive', 'Detection tooling — Dependabot alerts are enabled on the repository'), + ('dependabot', 'enabled', 'iso27001', 'A.8.8', 'positive', 'Technical vulnerability management — Dependabot alerts are enabled on the repository'), + ('code_scanning', 'enabled', 'soc2', 'CC7.1', 'positive', 'Detection tooling — code scanning is enabled on the repository'), + ('code_scanning', 'enabled', 'iso27001', 'A.8.29', 'positive', 'Security testing in development — code scanning is enabled on the repository'), + ('secret_scanning', 'enabled', 'soc2', 'CC6.6', 'positive', 'Leaked-credential detection — secret scanning is enabled on the repository'), + ('secret_scanning', 'enabled', 'soc2', 'CC6.1', 'positive', 'Logical-access credential protection — secret scanning is enabled on the repository'), + ('secret_scanning', 'enabled', 'iso27001', 'A.5.17', 'positive', 'Authentication-information protection — secret scanning is enabled on the repository'), + + -- Dependabot findings. + ('dependabot_alert', 'open', 'soc2', 'CC7.1', 'negative', 'Unremediated known vulnerability'), + ('dependabot_alert', 'fixed', 'soc2', 'CC7.1', 'positive', 'Vulnerability remediated'), + ('dependabot_alert', 'dismissed', 'soc2', 'CC7.1', 'informational', 'Dismissed by a user — risk-acceptance justification subject to review'), + ('dependabot_alert', 'auto_dismissed', 'soc2', 'CC7.1', 'positive', 'Auto-dismissed by GitHub (e.g. dependency removed)'), + ('dependabot_alert', 'open', 'iso27001', 'A.8.8', 'negative', 'Unremediated known technical vulnerability'), + ('dependabot_alert', 'fixed', 'iso27001', 'A.8.8', 'positive', 'Vulnerability remediated'), + ('dependabot_alert', 'dismissed', 'iso27001', 'A.8.8', 'informational', 'Dismissed by a user — risk-acceptance justification subject to review'), + ('dependabot_alert', 'auto_dismissed', 'iso27001', 'A.8.8', 'positive', 'Auto-dismissed by GitHub (e.g. dependency removed)'), + + -- Code scanning findings. + ('code_scanning_alert', 'open', 'soc2', 'CC7.1', 'negative', 'Unremediated static-analysis finding'), + ('code_scanning_alert', 'fixed', 'soc2', 'CC7.1', 'positive', 'Finding remediated'), + ('code_scanning_alert', 'dismissed', 'soc2', 'CC7.1', 'informational', 'Dismissed by a user — risk-acceptance justification subject to review'), + ('code_scanning_alert', 'open', 'iso27001', 'A.8.28', 'negative', 'Unremediated static-analysis finding'), + ('code_scanning_alert', 'fixed', 'iso27001', 'A.8.28', 'positive', 'Finding remediated'), + ('code_scanning_alert', 'dismissed', 'iso27001', 'A.8.28', 'informational', 'Dismissed by a user — risk-acceptance justification subject to review'), + + -- Secret scanning findings ("resolved" may be wont_fix — a human decision, + -- not a verified remediation). + ('secret_scanning_alert', 'open', 'soc2', 'CC6.6', 'negative', 'Live credential exposure'), + ('secret_scanning_alert', 'resolved', 'soc2', 'CC6.6', 'informational', 'Resolution recorded — reason (revoked vs. won''t-fix) subject to review'), + ('secret_scanning_alert', 'open', 'soc2', 'CC6.1', 'negative', 'Exposed credential undermines logical access controls'), + ('secret_scanning_alert', 'resolved', 'soc2', 'CC6.1', 'informational', 'Resolution recorded — reason (revoked vs. won''t-fix) subject to review'), + ('secret_scanning_alert', 'open', 'iso27001', 'A.5.17', 'negative', 'Exposed authentication information'), + ('secret_scanning_alert', 'resolved', 'iso27001', 'A.5.17', 'informational', 'Resolution recorded — reason (revoked vs. won''t-fix) subject to review'), + + -- Repository collaborators (the `member` webhook is repo-scoped). + ('member_access', 'added', 'soc2', 'CC6.2', 'informational', 'Repository collaborator added — access grant logged for review'), + ('member_access', 'removed', 'soc2', 'CC6.3', 'informational', 'Repository collaborator removed — deprovisioning recorded; timeliness subject to review'), + ('member_access', 'edited', 'soc2', 'CC6.3', 'informational', 'Repository collaborator permission changed — logged for review'), + ('member_access', 'added', 'iso27001', 'A.5.18', 'informational', 'Repository collaborator added — access-rights change, audit trail'), + ('member_access', 'removed', 'iso27001', 'A.5.18', 'informational', 'Repository collaborator removed — access-rights change, audit trail'), + ('member_access', 'edited', 'iso27001', 'A.5.18', 'informational', 'Repository collaborator permission changed — access-rights change, audit trail'), + + -- Organization membership changes (`organization` webhook). + ('org_membership', 'member_added', 'soc2', 'CC6.2', 'informational', 'Organization member added — access grant logged for review'), + ('org_membership', 'member_removed', 'soc2', 'CC6.3', 'informational', 'Organization member removed — deprovisioning recorded; timeliness subject to review'), + ('org_membership', 'member_invited', 'soc2', 'CC6.2', 'informational', 'Organization invitation issued — logged for review'), + ('org_membership', 'member_added', 'iso27001', 'A.5.18', 'informational', 'Organization member added — access-rights change, audit trail'), + ('org_membership', 'member_removed', 'iso27001', 'A.5.18', 'informational', 'Organization member removed — access-rights change, audit trail'), + ('org_membership', 'member_invited', 'iso27001', 'A.5.18', 'informational', 'Organization invitation issued — access-rights change, audit trail'), + + -- Repository made public: worth surfacing beyond the generic inventory trail. + ('repository', 'publicized', 'soc2', 'CC6.1', 'informational', 'Repository made public — visibility change affecting asset confidentiality, flagged for review'); @@ -4,9 +4,10 @@ // relies on. If they drift, the doc is lying — so this fails CI. // // It works by actually applying every migration to an in-memory SQLite database -// (so migration 0003's delete-and-reinsert is handled exactly as production D1 +// (so a migration's delete-and-reinsert is handled exactly as production D1 // would), reading back control_mappings, and diffing against the rows parsed out -// of the doc's "Complete mapping reference" table. +// of the doc's "Complete mapping reference" table — including the rationale +// text, which is what an auditor reads in every export. // // Run: npm run test:mappings (no dependencies — uses Node's built-in sqlite) @@ -22,8 +23,11 @@ const docPath = join(repoRoot, "docs", "framework-mapping.md"); // A "·" in the doc's Status column means the mapping's status is NULL (matches // any status); normalize both sides to this sentinel so they compare equal. const NULL_STATUS = "·"; -const key = (resource, status, framework, control, posture) => - `${resource}|${status ?? NULL_STATUS}|${framework}|${control}|${posture}`; +// The rationale is part of the key: it is the auditor-facing string in every +// export, so the doc's copy drifting from the SQL is as much a lie as a +// wrong posture. +const key = (resource, status, framework, control, posture, rationale) => + `${resource}|${status ?? NULL_STATUS}|${framework}|${control}|${posture}|${rationale}`; // --- 1. Source of truth: apply migrations, read control_mappings. --- function rowsFromMigrations() { @@ -35,10 +39,10 @@ function rowsFromMigrations() { db.exec(readFileSync(join(migrationsDir, file), "utf8")); } const rows = db - .prepare("SELECT resource, status, framework, control_id, posture FROM control_mappings") + .prepare("SELECT resource, status, framework, control_id, posture, rationale FROM control_mappings") .all(); db.close(); - return new Set(rows.map((r) => key(r.resource, r.status, r.framework, r.control_id, r.posture))); + return new Set(rows.map((r) => key(r.resource, r.status, r.framework, r.control_id, r.posture, r.rationale))); } // --- 2. Human-readable copy: parse the doc's reference table. --- @@ -56,13 +60,13 @@ function rowsFromDoc() { const set = new Set(); for (const line of readFileSync(docPath, "utf8").split("\n")) { if (!line.startsWith("|")) continue; - // Leading "|" yields an empty cells[0]; data lives in cells[1..5]. + // Leading "|" yields an empty cells[0]; data lives in cells[1..6]. const cells = line.split("|").map((c) => c.trim()); - const [, resource, statusCell, frameworkLabel, control, posture] = cells; + const [, resource, statusCell, frameworkLabel, control, posture, rationale] = cells; const framework = FRAMEWORK_LABELS[frameworkLabel]; if (!framework || !POSTURES.has(posture) || !BACKTICKED.test(resource ?? "")) continue; const status = statusCell.replaceAll("`", ""); // "·" for NULL - set.add(key(resource.replaceAll("`", ""), status, framework, control, posture)); + set.add(key(resource.replaceAll("`", ""), status, framework, control, posture, rationale ?? "")); } return set; } @@ -80,7 +84,7 @@ if (onlyInDb.length === 0 && onlyInDoc.length === 0) { } console.error("✗ control-mapping drift between migrations/ and docs/framework-mapping.md\n"); -console.error(" columns: resource | status | framework | control | posture\n"); +console.error(" columns: resource | status | framework | control | posture | rationale\n"); if (onlyInDb.length) { console.error(` In migrations but MISSING from the doc (${onlyInDb.length}):`); for (const k of onlyInDb) console.error(` + ${k}`); @@ -15,7 +15,7 @@ import { } from "./auth"; import { parseCookies, setCookieHeader, clearCookieHeader } from "./cookies"; import { createAppJwt, getInstallationToken } from "./github-app"; -import { listInstallationRepos, pollRepoProtection, pollOrgAccess } from "./poller"; +import { listInstallationRepos, pollRepoProtection, pollRepoAlerts, pollOrgAccess } from "./poller"; import { buildEvidenceRows, renderCsv, renderPdf, type Framework, type ExportFormat } from "./exporter"; import { renderDashboard, @@ -211,13 +211,16 @@ async function pollInstallation(env: Env, installationId: number, summary: PollS // Per-repo isolation: one failing repo must not abort the rest of the // installation's poll. try { - const facts = await pollRepoProtection(installationToken, repo); + const facts = [ + ...(await pollRepoProtection(installationToken, repo)), + ...(await pollRepoAlerts(installationToken, repo)), + ]; for (const fact of facts) { await env.DB.prepare( - `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, + `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at, subject) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, ) - .bind(installationId, fact.repo, fact.resource, fact.status, fact.rawPayload, capturedAt) + .bind(installationId, fact.repo, fact.resource, fact.status, fact.rawPayload, capturedAt, fact.subject) .run(); summary.written.push({ installationId, repo: fact.repo, resource: fact.resource, status: fact.status }); } @@ -828,10 +831,10 @@ async function handleWebhook(request: Request, env: Env): Promise<Response> { const repo = extractRepoFullName(payload); await env.DB.prepare( - `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, + `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at, subject) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, ) - .bind(installationId, repo, fact.resource, fact.status, bodyText, capturedAt) + .bind(installationId, repo, fact.resource, fact.status, bodyText, capturedAt, fact.subject) .run(); return new Response("OK", { status: 200 }); @@ -68,31 +68,42 @@ async function fetchBranchProtection( return { status: "enabled", raw: await res.json() }; } -async function fetchRulesets(installationToken: string, owner: string, repo: string): Promise<ProtectionCheck> { - const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/rulesets?per_page=100`, { - headers: authHeaders(installationToken), - }); - if (res.status === 403) return { status: "unavailable", raw: null }; - if (!res.ok) throw new Error(`Failed to fetch rulesets for ${owner}/${repo} (${res.status})`); +async function fetchDefaultBranchRules( + installationToken: string, + owner: string, + repo: string, + branch: string, +): Promise<ProtectionCheck> { + // /rules/branches/{branch} aggregates the rules from every ACTIVE ruleset — + // repo- and org-level — that applies to this branch. Evaluate-mode + // (monitor-only) rulesets are excluded, and a ruleset targeting only other + // branches contributes nothing, so a non-empty result means the default + // branch is actually covered by at least one enforcing ruleset. + const res = await fetch( + `${GITHUB_API}/repos/${owner}/${repo}/rules/branches/${encodeURIComponent(branch)}?per_page=100`, + { headers: authHeaders(installationToken) }, + ); + // 403 = feature not available; 404 = branch not found (e.g. empty repo). + // Both deliberately unmapped, like branch protection's "unavailable". + if (res.status === 403 || res.status === 404) return { status: "unavailable", raw: null }; + if (!res.ok) throw new Error(`Failed to fetch branch rules for ${owner}/${repo} (${res.status})`); - const rulesets = (await res.json()) as Array<{ enforcement: string; target: string }>; - // "evaluate" is dry-run/monitor-only — doesn't actually block anything, so - // it doesn't count as protection being enabled. - const enabled = rulesets.some((r) => r.enforcement === "active" && r.target === "branch"); - return { status: enabled ? "enabled" : "disabled", raw: rulesets }; + const rules = (await res.json()) as unknown[]; + return { status: rules.length > 0 ? "enabled" : "disabled", raw: rules }; } export interface PolledFact { repo: string; - resource: "branch_protection" | "repository_ruleset"; - status: "enabled" | "disabled" | "unavailable"; + resource: string; + status: string; + subject: string | null; rawPayload: string | null; } export async function pollRepoProtection(installationToken: string, repo: RepoRef): Promise<PolledFact[]> { const [branchProtection, rulesets] = await Promise.all([ fetchBranchProtection(installationToken, repo.owner, repo.name, repo.defaultBranch), - fetchRulesets(installationToken, repo.owner, repo.name), + fetchDefaultBranchRules(installationToken, repo.owner, repo.name, repo.defaultBranch), ]); return [ @@ -100,17 +111,90 @@ export async function pollRepoProtection(installationToken: string, repo: RepoRe repo: repo.fullName, resource: "branch_protection", status: branchProtection.status, + subject: null, rawPayload: branchProtection.raw ? JSON.stringify(branchProtection.raw) : null, }, { repo: repo.fullName, resource: "repository_ruleset", status: rulesets.status, + subject: null, rawPayload: rulesets.raw ? JSON.stringify(rulesets.raw) : null, }, ]; } +// --------------------------------------------------------------------------- +// Alert streams: baseline + keep-alive poll. +// --------------------------------------------------------------------------- + +// The list endpoint doubles as the tooling-enabled signal: 200 means the +// feature is on regardless of whether it has ever produced an alert, 404 +// means it is switched off, 403 means it is not available (plan / GHAS). +// Only `enabled` is mapped in control_mappings — absence of the tooling is +// recorded but never counted as evidence either way. +const ALERT_FEATURES = [ + { feature: "dependabot", alertResource: "dependabot_alert", path: "/dependabot/alerts" }, + { feature: "code_scanning", alertResource: "code_scanning_alert", path: "/code-scanning/alerts" }, + { feature: "secret_scanning", alertResource: "secret_scanning_alert", path: "/secret-scanning/alerts" }, +] as const; + +interface AlertsCheck { + feature: "enabled" | "disabled" | "unavailable"; + alerts: Array<{ number: number; state: string }>; +} + +// Both offset (code/secret scanning) and cursor (Dependabot) pagination +// advertise the next page in the Link header. +function nextPageUrl(linkHeader: string | null): string | null { + const match = linkHeader?.match(/<([^>]+)>;\s*rel="next"/); + return match?.[1] ?? null; +} + +async function fetchOpenAlerts( + installationToken: string, + owner: string, + repo: string, + path: string, +): Promise<AlertsCheck> { + const alerts: AlertsCheck["alerts"] = []; + let url: string | null = `${GITHUB_API}/repos/${owner}/${repo}${path}?state=open&per_page=100`; + while (url) { + const res: Response = await fetch(url, { headers: authHeaders(installationToken) }); + if (res.status === 404) return { feature: "disabled", alerts: [] }; + if (res.status === 403) return { feature: "unavailable", alerts: [] }; + if (!res.ok) throw new Error(`Failed to list ${path} for ${owner}/${repo} (${res.status})`); + + const page = (await res.json()) as Array<{ number: number; state: string }>; + for (const alert of page) alerts.push({ number: alert.number, state: alert.state }); + url = nextPageUrl(res.headers.get("Link")); + } + return { feature: "enabled", alerts }; +} + +// Webhooks record alert transitions, but (a) alerts already open before the +// App was installed never sent one, and (b) an open alert with no events for +// the whole retention window would age out of evidence. Re-recording the open +// set every poll fixes both. Subrequest cost is 3+ per repo, on top of the 2 +// for protection state. +export async function pollRepoAlerts(installationToken: string, repo: RepoRef): Promise<PolledFact[]> { + const facts: PolledFact[] = []; + for (const { feature, alertResource, path } of ALERT_FEATURES) { + const check = await fetchOpenAlerts(installationToken, repo.owner, repo.name, path); + facts.push({ repo: repo.fullName, resource: feature, status: check.feature, subject: null, rawPayload: null }); + for (const alert of check.alerts) { + facts.push({ + repo: repo.fullName, + resource: alertResource, + status: alert.state, + subject: String(alert.number), + rawPayload: null, + }); + } + } + return facts; +} + // --------------------------------------------------------------------------- // Access review: org membership and team membership. // --------------------------------------------------------------------------- @@ -27,6 +27,10 @@ export async function verifySignature( interface ExtractedFact { resource: string; status: string; + // Which entity within the repo/org the fact is about (alert number, member + // login, team slug). Part of the exporter's latest-row-wins key, so facts + // about different entities in the same repo don't overwrite each other. + subject: string | null; } // Minimal resource/status extraction per event type. Control-ID mapping @@ -35,34 +39,96 @@ export function extractFact(eventType: string, payload: Record<string, unknown>) const action = typeof payload.action === "string" ? payload.action : undefined; switch (eventType) { - // Normalized to current-state vocabulary (enabled/disabled) rather than - // the raw action, so this lines up with what the poller reports for - // pre-existing protection state — the mapping table joins on one - // vocabulary regardless of source. - case "branch_protection_rule": - return { resource: "branch_protection", status: action === "deleted" ? "disabled" : "enabled" }; - case "repository_ruleset": - return { resource: "repository_ruleset", status: action === "deleted" ? "disabled" : "enabled" }; + // The event is scoped to one rule, which may target any branch. Only a + // rule whose pattern is exactly the default branch changes the repo's + // protection state (normalized to the enabled/disabled vocabulary the + // poller shares); any other rule is recorded as an unmapped trail event, + // with the hourly poll authoritative for current state. + case "branch_protection_rule": { + const rule = payload.rule as Record<string, unknown> | undefined; + const repository = payload.repository as Record<string, unknown> | undefined; + const pattern = typeof rule?.name === "string" ? rule.name : null; + const defaultBranch = typeof repository?.default_branch === "string" ? repository.default_branch : undefined; + if (pattern !== null && pattern === defaultBranch) { + return { resource: "branch_protection", status: action === "deleted" ? "disabled" : "enabled", subject: null }; + } + return { resource: "branch_protection_rule_event", status: action ?? "unknown", subject: pattern }; + } + // Ruleset events are ruleset-scoped: one ruleset being created or deleted + // says nothing about whether *other* active rulesets still cover the + // default branch, so this is trail-only; current repository_ruleset state + // comes from the poller's /rules/branches/{default-branch} check. + case "repository_ruleset": { + const ruleset = payload.repository_ruleset as Record<string, unknown> | undefined; + const id = typeof ruleset?.id === "number" ? String(ruleset.id) : null; + return { resource: "repository_ruleset_event", status: action ?? "unknown", subject: id }; + } case "dependabot_alert": - case "code_scanning_alert": + case "code_scanning_alert": { + const alert = payload.alert as Record<string, unknown> | undefined; + const state = typeof alert?.state === "string" ? alert.state : undefined; + return { resource: eventType, status: state ?? action ?? "unknown", subject: alertNumber(alert) }; + } + // Unlike the two alert payloads above, the secret-scanning webhook alert + // carries no `state` field — only `resolution`, which is set iff the + // alert is resolved. Derive open/resolved from that (falling back to + // `state` should GitHub ever add it). case "secret_scanning_alert": { const alert = payload.alert as Record<string, unknown> | undefined; const state = typeof alert?.state === "string" ? alert.state : undefined; - return { resource: eventType, status: state ?? action ?? "unknown" }; + return { + resource: eventType, + status: state ?? (alert?.resolution ? "resolved" : "open"), + subject: alertNumber(alert), + }; + } + // Repository collaborators. Org-level membership arrives on the + // `organization` event below, not here. + case "member": { + const member = payload.member as Record<string, unknown> | undefined; + return { + resource: "member_access", + status: action ?? "unknown", + subject: typeof member?.login === "string" ? member.login : null, + }; + } + case "team": { + const team = payload.team as Record<string, unknown> | undefined; + return { + resource: "team", + status: action ?? "unknown", + subject: typeof team?.slug === "string" ? team.slug : null, + }; + } + case "organization": { + const membership = payload.membership as Record<string, unknown> | undefined; + const user = membership?.user as Record<string, unknown> | undefined; + // member_invited identifies the invitee via `invitation` (login for + // existing users, email otherwise) rather than `membership`. + const invitation = payload.invitation as Record<string, unknown> | undefined; + const subject = + typeof user?.login === "string" + ? user.login + : typeof invitation?.login === "string" + ? invitation.login + : typeof invitation?.email === "string" + ? invitation.email + : null; + return { resource: "org_membership", status: action ?? "unknown", subject }; } - case "member": - return { resource: "member_access", status: action ?? "unknown" }; - case "team": - return { resource: "team", status: action ?? "unknown" }; case "repository": - return { resource: "repository", status: action ?? "unknown" }; + return { resource: "repository", status: action ?? "unknown", subject: null }; case "push": - return { resource: "push", status: "received" }; + return { resource: "push", status: "received", subject: null }; default: - return { resource: eventType, status: action ?? "received" }; + return { resource: eventType, status: action ?? "received", subject: null }; } } +function alertNumber(alert: Record<string, unknown> | undefined): string | null { + return typeof alert?.number === "number" ? String(alert.number) : null; +} + export function extractRepoFullName(payload: Record<string, unknown>): string | null { const repository = payload.repository as Record<string, unknown> | undefined; return typeof repository?.full_name === "string" ? repository.full_name : null;