audit-labs/gh-attest

GitHub Audit Evidence Extractor

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

main: docs/architecture.md · raw

  1# Architecture
  2
  3gh-attest is a single [Cloudflare Worker](../src/index.ts) with three entry
  4points — an HTTP router, an hourly cron, and a queue consumer — backed by D1,
  5R2, and a Queue, all provisioned in Cloudflare's `eu` jurisdiction. It ingests
  6GitHub security posture as immutable timestamped snapshots and renders them into
  7auditor-ready evidence packages mapped to SOC 2 / ISO 27001 controls.
  8
  9This document is the shape of the system. For *why* a given GitHub signal counts
 10as evidence for a given control, see [framework-mapping.md](framework-mapping.md).
 11
 12## System context
 13
 14```mermaid
 15flowchart LR
 16  subgraph ext[External]
 17    GH["GitHub App<br/>webhooks · OAuth · REST API"]
 18    USER["User / Auditor<br/>browser"]
 19  end
 20
 21  subgraph cf["Cloudflare Worker — src/index.ts"]
 22    FETCH["fetch()<br/>HTTP router"]
 23    CRON["scheduled()<br/>hourly cron"]
 24    QUEUE["queue()<br/>export consumer"]
 25  end
 26
 27  subgraph store["Storage — EU jurisdiction"]
 28    D1[("D1<br/>gh-attest-db-eu")]
 29    R2[("R2<br/>gh-attest-exports-eu")]
 30    Q[["Queue<br/>generate-export"]]
 31  end
 32
 33  GH -- "POST /webhooks/*" --> FETCH
 34  USER -- "login · dashboard · exports" --> FETCH
 35  CRON -- "poll (installation token)" --> GH
 36
 37  FETCH -- "snapshots · export rows" --> D1
 38  FETCH -- "enqueue job" --> Q
 39  FETCH -- "stream download" --> R2
 40
 41  CRON -- "snapshots · retention" --> D1
 42  CRON -- "retention delete" --> R2
 43
 44  Q --> QUEUE
 45  QUEUE -- "read evidence" --> D1
 46  QUEUE -- "write file" --> R2
 47```
 48
 49## Entry points
 50
 51| Handler | Trigger | Responsibility | Code |
 52| --- | --- | --- | --- |
 53| `fetch` | HTTP request | Webhooks, OAuth/session dashboard, export requests + downloads, admin ops | [index.ts](../src/index.ts) |
 54| `scheduled` | Cron `0 * * * *` (hourly) | Poll GitHub for state webhooks never announce; enforce retention | [index.ts](../src/index.ts) |
 55| `queue` | `generate-export` message | Render CSV/PDF off the request path into R2 | [index.ts](../src/index.ts) |
 56
 57### Routes and their auth
 58
 59| Route | Auth | Purpose |
 60| --- | --- | --- |
 61| `POST /webhooks/github`, `/webhooks/marketplace` | HMAC (`GITHUB_WEBHOOK_SECRET`) | Ingest App / marketplace events |
 62| `GET /login`, `/callback`, `/logout` | OAuth state cookie | Dashboard sign-in |
 63| `GET /`, `/access-review` | Session cookie | Posture dashboard, membership diff |
 64| `POST /exports`, `/resync`, `/switch`, `/exclusions` | Session cookie | Dashboard actions (installation-scoped) |
 65| `GET /exports/:id[/download]` | Session cookie | Export status / file (scoped to installation) |
 66| `POST /admin/{poll,export,cleanup,purge}`, `GET /admin/export/:id` | Bearer (`ADMIN_TOKEN`) | Operations |
 67
 68## Ingestion — two paths into one table
 69
 70Both paths write to the append-only `snapshots` table; nothing is ever updated
 71in place, which is what makes the table a point-in-time audit trail.
 72
 73### Webhooks (change events)
 74
 75```mermaid
 76sequenceDiagram
 77  autonumber
 78  participant GH as GitHub
 79  participant W as Worker (fetch)
 80  participant D1 as D1
 81  participant R2 as R2
 82
 83  GH->>W: POST /webhooks/github (event + X-Hub-Signature-256)
 84  W->>W: verifySignature(body, GITHUB_WEBHOOK_SECRET)
 85  alt invalid signature
 86    W-->>GH: 401
 87  else valid
 88    W->>W: extractFact(event) → {resource, status}
 89    W->>D1: upsert installations row
 90    alt installation.deleted
 91      W->>D1: DELETE all rows for installation
 92      W->>R2: delete export objects
 93      W-->>GH: 200 (purged)
 94    else suspend / unsuspend
 95      W->>D1: toggle suspended_at
 96      W-->>GH: 200
 97    else normal event
 98      W->>D1: INSERT snapshot (append-only)
 99      W-->>GH: 200
100    end
101  end
102```
103
104### Hourly poll + retention (baseline / drift)
105
106Webhooks only fire on change, so protection that existed *before* install, and
107current membership, would never appear. The cron closes that gap and enforces
108the retention windows in the same invocation.
109
110```mermaid
111sequenceDiagram
112  autonumber
113  participant Cron as scheduled (hourly)
114  participant W as Worker
115  participant GH as GitHub REST
116  participant D1 as D1
117  participant R2 as R2
118
119  Cron->>W: fire
120  par Poll every active installation
121    W->>GH: app JWT → installation token
122    W->>GH: repos (minus exclusions) · branch protection · rulesets · org/team members
123    GH-->>W: current state
124    W->>D1: INSERT snapshots (one captured_at per batch)
125  and Retention cleanup
126    W->>D1: SELECT expired export r2_keys
127    W->>R2: delete expired objects
128    W->>D1: DELETE exports > 90d, snapshots > 396d
129  end
130```
131
132## Export pipeline
133
134Rendering runs off the request path via the Queue because PDF / large CSV can
135exceed request CPU limits. The `exports` row is the job's state machine
136(`queued → processing → done | error`).
137
138```mermaid
139sequenceDiagram
140  autonumber
141  participant U as User (session)
142  participant W as Worker (fetch)
143  participant D1 as D1
144  participant Q as Queue
145  participant C as Worker (queue)
146  participant R2 as R2
147
148  U->>W: POST /exports (framework, format)
149  W->>D1: INSERT exports (status=queued)
150  W->>Q: send job
151  W-->>U: 303 redirect to dashboard
152
153  Q->>C: deliver job
154  C->>D1: status=processing
155  C->>D1: buildEvidenceRows — snapshots ⋈ control_mappings
156  C->>C: renderCsv / renderPdf
157  C->>R2: put file at exports/{installation}/{jobId}.{fmt}
158  C->>D1: status=done, r2_key
159  Note over C,D1: render failure → status=error (deterministic, not retried)
160
161  U->>W: GET /exports/:id/download
162  W->>D1: lookup scoped to session installation
163  W->>R2: get object
164  R2-->>W: file body
165  W-->>U: stream (Content-Disposition: attachment)
166```
167
168## Data model
169
170`control_mappings` has **no foreign key** to `snapshots`. Mapping is a join on
171`(resource, status)` performed at query/export time — so a mapping can be
172corrected without re-ingesting webhook history. A mapping row with `status =
173NULL` matches any status for that resource.
174
175```mermaid
176erDiagram
177  installations ||--o{ snapshots : has
178  installations ||--o{ exports : has
179  installations ||--o{ repo_exclusions : has
180  snapshots }o..o{ control_mappings : "query-time join on (resource, status)"
181
182  installations {
183    integer installation_id PK
184    text    org_login
185    text    installed_at
186    text    suspended_at "null unless suspended"
187  }
188  snapshots {
189    integer id PK
190    integer installation_id FK
191    text    repo "null for org-level facts"
192    text    subject "member/team for access facts"
193    text    resource "e.g. branch_protection"
194    text    status "e.g. enabled | open | added"
195    text    raw_payload "original JSON, audit trail"
196    text    captured_at
197  }
198  control_mappings {
199    integer id PK
200    text    resource
201    text    status "null matches any"
202    text    framework "soc2 | iso27001"
203    text    control_id "e.g. CC8.1 | A.8.32"
204    text    posture "positive | negative | informational"
205    text    rationale
206  }
207  repo_exclusions {
208    integer installation_id PK,FK
209    text    repo PK "full name, out of scope"
210    text    excluded_at
211  }
212  exports {
213    text    id PK "uuid"
214    integer installation_id FK
215    text    framework
216    text    format "csv | pdf"
217    text    status "queued|processing|done|error"
218    text    r2_key "null until rendered"
219    text    error
220    text    created_at
221    text    completed_at
222  }
223```
224
225### Evidence query
226
227`buildEvidenceRows` ([exporter.ts](../src/exporter.ts)) reduces the append-only
228table to current posture, then attaches controls:
229
230```mermaid
231flowchart TB
232  S[("snapshots<br/>append-only")] --> L["latest row per<br/>(repo, subject, resource)"]
233  L --> J{{"JOIN control_mappings<br/>on resource + status"}}
234  CM[("control_mappings")] --> J
235  J --> F["filter by framework<br/>(soc2 | iso27001 | all)"]
236  F --> C["collapse branch_protection<br/>+ repository_ruleset to one row"]
237  C --> E["evidence rows<br/>control · posture · rationale"]
238  E --> CSV["renderCsv"]
239  E --> PDF["renderPdf"]
240```
241
242Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply
243produce no rows — no evidence in either direction.
244
245Repos listed in `repo_exclusions` are filtered out of the result and skipped by
246the poll, so an excluded repo costs no subrequests and reports no gaps; its
247snapshots stay in the table, so removing the exclusion restores its history.
248
249Classic branch protection and repository rulesets both attest the same control,
250so the two are collapsed to one row per (framework, control, repo) — enabled
251wins over disabled — rather than letting an unused mechanism report a gap the
252other one covers.
253
254## Boundaries & isolation
255
256- **Multi-tenant scoping.** Every session route is scoped to the session's
257  `installationId`; the allowed set lives in the signed session, so a tampered
258  id can't widen access. Export downloads are looked up with an installation
259  filter, so one org can't read another's file by guessing its UUID.
260- **Three auth schemes.** HMAC for webhooks, signed session cookies
261  (`SESSION_SECRET`) for the dashboard, timing-safe bearer (`ADMIN_TOKEN`) for
262  `/admin/*`.
263- **Data residency.** D1, R2, and the Queue are all in the `eu` jurisdiction;
264  see [wrangler.jsonc](../wrangler.jsonc). No source code or access tokens are
265  stored — only posture facts and their raw event JSON.
266- **Deletion.** Uninstall (`installation.deleted`) purges all D1 rows and R2
267  objects for the installation; `/admin/purge` does the same on request.
268
269## Where things live
270
271| Concern | File |
272| --- | --- |
273| Router, handlers, retention, purge | [src/index.ts](../src/index.ts) |
274| Webhook verification + fact extraction | [src/webhook.ts](../src/webhook.ts) |
275| GitHub polling (protection, rulesets, access) | [src/poller.ts](../src/poller.ts) |
276| App JWT + installation tokens | [src/github-app.ts](../src/github-app.ts) |
277| OAuth + session sign/verify | [src/auth.ts](../src/auth.ts) |
278| Evidence query + CSV/PDF rendering | [src/exporter.ts](../src/exporter.ts) |
279| Access-review diff | [src/access-review.ts](../src/access-review.ts) |
280| Dashboard HTML | [src/dashboard.ts](../src/dashboard.ts) |
281| Schema + control mappings | [migrations/](../migrations) |
282```