Commit c5440e09f4
Unsigned
Layout: unified · split
PRIVACY.md +5 −1
| @@ -19,7 +19,9 @@ The App processes data only for organizations that have installed it, | |||
| 19 | and only within the scope of the permissions granted at installation. | 19 | and only within the scope of the permissions granted at installation. |
| 20 | 20 | ||
| 21 | **Organization & installation metadata.** Installation ID, organization | 21 | **Organization & installation metadata.** Installation ID, organization |
| 22 | login, and installation/suspension timestamps. | 22 | login, and installation/suspension timestamps. If you exclude repositories |
| 23 | from scanning, the App stores the excluded repository names for your | ||
| 24 | installation. | ||
| 23 | 25 | ||
| 24 | **Security & access-control signals (the evidence).** As your | 26 | **Security & access-control signals (the evidence).** As your |
| 25 | configuration changes and on a periodic re-sync, the App records | 27 | configuration changes and on a periodic re-sync, the App records |
| @@ -116,6 +118,8 @@ is uninstalled. You may also request deletion at any time. | |||
| 116 | 118 | ||
| 117 | - **Uninstall** the App at any time from your organization's GitHub | 119 | - **Uninstall** the App at any time from your organization's GitHub |
| 118 | settings to stop all processing and trigger deletion of your data. | 120 | settings to stop all processing and trigger deletion of your data. |
| 121 | - **Exclude repositories** from scanning and reporting from the | ||
| 122 | dashboard, so the App stops collecting new evidence about them. | ||
| 119 | - **Export** your organization's data as CSV or PDF from the dashboard | 123 | - **Export** your organization's data as CSV or PDF from the dashboard |
| 120 | at any time. | 124 | at any time. |
| 121 | - **Request deletion** of your organization's data by contacting us. | 125 | - **Request deletion** of your organization's data by contacting us. |
README.md +5 −1
| @@ -47,7 +47,7 @@ under Cloudflare's `eu` jurisdiction. | |||
| 47 | | --- | --- | --- | | 47 | | --- | --- | --- | |
| 48 | | `GET /` | session | Dashboard: current posture, exports | | 48 | | `GET /` | session | Dashboard: current posture, exports | |
| 49 | | `GET /access-review` | session | Membership changes since a date | | 49 | | `GET /access-review` | session | Membership changes since a date | |
| 50 | | `POST /exports`, `/resync`, `/switch` | session | Dashboard actions | | 50 | | `POST /exports`, `/resync`, `/switch`, `/exclusions` | session | Dashboard actions | |
| 51 | | `GET /exports/:id[/download]` | session | Export status / file | | 51 | | `GET /exports/:id[/download]` | session | Export status / file | |
| 52 | | `POST /webhooks/github` | HMAC | App events | | 52 | | `POST /webhooks/github` | HMAC | App events | |
| 53 | | `POST /webhooks/marketplace` | HMAC | Marketplace events | | 53 | | `POST /webhooks/marketplace` | HMAC | Marketplace events | |
| @@ -55,6 +55,10 @@ under Cloudflare's `eu` jurisdiction. | |||
| 55 | 55 | ||
| 56 | Session routes are scoped by installation; admin routes require `ADMIN_TOKEN`. | 56 | Session routes are scoped by installation; admin routes require `ADMIN_TOKEN`. |
| 57 | 57 | ||
| 58 | Repositories can be excluded from the dashboard: an excluded repo is skipped by | ||
| 59 | the poll and contributes no evidence, while its existing history is retained so | ||
| 60 | the exclusion can be undone. | ||
| 61 | |||
| 58 | ## Development | 62 | ## Development |
| 59 | 63 | ||
| 60 | ```sh | 64 | ```sh |
docs/architecture.md +12 −2
| @@ -61,7 +61,7 @@ flowchart LR | |||
| 61 | | `POST /webhooks/github`, `/webhooks/marketplace` | HMAC (`GITHUB_WEBHOOK_SECRET`) | Ingest App / marketplace events | | 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 | | 62 | | `GET /login`, `/callback`, `/logout` | OAuth state cookie | Dashboard sign-in | |
| 63 | | `GET /`, `/access-review` | Session cookie | Posture dashboard, membership diff | | 63 | | `GET /`, `/access-review` | Session cookie | Posture dashboard, membership diff | |
| 64 | | `POST /exports`, `/resync`, `/switch` | Session cookie | Dashboard actions (installation-scoped) | | 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) | | 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 | | 66 | | `POST /admin/{poll,export,cleanup,purge}`, `GET /admin/export/:id` | Bearer (`ADMIN_TOKEN`) | Operations | |
| 67 | 67 | ||
| @@ -119,7 +119,7 @@ sequenceDiagram | |||
| 119 | Cron->>W: fire | 119 | Cron->>W: fire |
| 120 | par Poll every active installation | 120 | par Poll every active installation |
| 121 | W->>GH: app JWT → installation token | 121 | W->>GH: app JWT → installation token |
| 122 | W->>GH: repos · branch protection · rulesets · org/team members | 122 | W->>GH: repos (minus exclusions) · branch protection · rulesets · org/team members |
| 123 | GH-->>W: current state | 123 | GH-->>W: current state |
| 124 | W->>D1: INSERT snapshots (one captured_at per batch) | 124 | W->>D1: INSERT snapshots (one captured_at per batch) |
| 125 | and Retention cleanup | 125 | and Retention cleanup |
| @@ -176,6 +176,7 @@ NULL` matches any status for that resource. | |||
| 176 | erDiagram | 176 | erDiagram |
| 177 | installations ||--o{ snapshots : has | 177 | installations ||--o{ snapshots : has |
| 178 | installations ||--o{ exports : has | 178 | installations ||--o{ exports : has |
| 179 | installations ||--o{ repo_exclusions : has | ||
| 179 | snapshots }o..o{ control_mappings : "query-time join on (resource, status)" | 180 | snapshots }o..o{ control_mappings : "query-time join on (resource, status)" |
| 180 | 181 | ||
| 181 | installations { | 182 | installations { |
| @@ -203,6 +204,11 @@ erDiagram | |||
| 203 | text posture "positive | negative | informational" | 204 | text posture "positive | negative | informational" |
| 204 | text rationale | 205 | text rationale |
| 205 | } | 206 | } |
| 207 | repo_exclusions { | ||
| 208 | integer installation_id PK,FK | ||
| 209 | text repo PK "full name, out of scope" | ||
| 210 | text excluded_at | ||
| 211 | } | ||
| 206 | exports { | 212 | exports { |
| 207 | text id PK "uuid" | 213 | text id PK "uuid" |
| 208 | integer installation_id FK | 214 | integer installation_id FK |
| @@ -236,6 +242,10 @@ flowchart TB | |||
| 236 | Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply | 242 | Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply |
| 237 | produce no rows — no evidence in either direction. | 243 | produce no rows — no evidence in either direction. |
| 238 | 244 | ||
| 245 | Repos listed in `repo_exclusions` are filtered out of the result and skipped by | ||
| 246 | the poll, so an excluded repo costs no subrequests and reports no gaps; its | ||
| 247 | snapshots stay in the table, so removing the exclusion restores its history. | ||
| 248 | |||
| 239 | Classic branch protection and repository rulesets both attest the same control, | 249 | Classic branch protection and repository rulesets both attest the same control, |
| 240 | so the two are collapsed to one row per (framework, control, repo) — enabled | 250 | so the two are collapsed to one row per (framework, control, repo) — enabled |
| 241 | wins over disabled — rather than letting an unused mechanism report a gap the | 251 | wins over disabled — rather than letting an unused mechanism report a gap the |
migrations/0009_repo_exclusions.sql added +12
| @@ -0,0 +1,12 @@ | |||
| 1 | -- Repos an installation has opted out of: they are skipped by the poller and | ||
| 2 | -- filtered out of the evidence query, so an excluded repo neither costs | ||
| 3 | -- subrequests nor reports a gap. Snapshots already collected for the repo are | ||
| 4 | -- left in place — an exclusion is a reporting decision, not a deletion, and | ||
| 5 | -- removing the exclusion restores the history. | ||
| 6 | CREATE TABLE repo_exclusions ( | ||
| 7 | installation_id INTEGER NOT NULL, | ||
| 8 | repo TEXT NOT NULL, -- full name, e.g. 'acme/api' | ||
| 9 | excluded_at TEXT NOT NULL, | ||
| 10 | PRIMARY KEY (installation_id, repo), | ||
| 11 | FOREIGN KEY (installation_id) REFERENCES installations(installation_id) | ||
| 12 | ); | ||
src/dashboard.ts +33
| @@ -55,6 +55,10 @@ export interface DashboardData { | |||
| 55 | rows: EvidenceRow[]; | 55 | rows: EvidenceRow[]; |
| 56 | exports: ExportListRow[]; | 56 | exports: ExportListRow[]; |
| 57 | lastPolledAt: string | null; | 57 | lastPolledAt: string | null; |
| 58 | excludedRepos: string[]; | ||
| 59 | // Repos seen in this installation's snapshots that aren't excluded yet — | ||
| 60 | // the options the exclusion form offers. | ||
| 61 | excludableRepos: string[]; | ||
| 58 | } | 62 | } |
| 59 | 63 | ||
| 60 | // Deliberately narrower than `unknown`: an object reaching here would render | 64 | // Deliberately narrower than `unknown`: an object reaching here would render |
| @@ -164,6 +168,26 @@ export function renderDashboard(data: DashboardData): string { | |||
| 164 | }) | 168 | }) |
| 165 | .join(""); | 169 | .join(""); |
| 166 | 170 | ||
| 171 | const excludeForm = data.excludableRepos.length | ||
| 172 | ? `<div class="bar"><form method="post" action="/exclusions"> | ||
| 173 | <select name="repo">${data.excludableRepos.map((r) => `<option value="${esc(r)}">${esc(r)}</option>`).join("")}</select> | ||
| 174 | <button type="submit">Exclude</button> | ||
| 175 | </form></div>` | ||
| 176 | : `<p class="muted">No repositories left to exclude.</p>`; | ||
| 177 | |||
| 178 | const exclusionRows = data.excludedRepos | ||
| 179 | .map( | ||
| 180 | (repo) => `<tr> | ||
| 181 | <td>${esc(repo)}</td> | ||
| 182 | <td><form method="post" action="/exclusions"> | ||
| 183 | <input type="hidden" name="repo" value="${esc(repo)}"> | ||
| 184 | <input type="hidden" name="action" value="remove"> | ||
| 185 | <button class="secondary" type="submit">Include again</button> | ||
| 186 | </form></td> | ||
| 187 | </tr>`, | ||
| 188 | ) | ||
| 189 | .join(""); | ||
| 190 | |||
| 167 | return `<!doctype html> | 191 | return `<!doctype html> |
| 168 | <html lang="en"> | 192 | <html lang="en"> |
| 169 | <head> | 193 | <head> |
| @@ -220,6 +244,15 @@ export function renderDashboard(data: DashboardData): string { | |||
| 220 | }</tbody> | 244 | }</tbody> |
| 221 | </table> | 245 | </table> |
| 222 | 246 | ||
| 247 | <h2 class="section-title">Excluded repositories</h2> | ||
| 248 | <p class="muted">Excluded repositories are skipped by the sync and contribute no evidence. | ||
| 249 | Their existing history is kept, so including one again restores it.</p> | ||
| 250 | ${excludeForm} | ||
| 251 | <table> | ||
| 252 | <thead><tr><th>Repository</th><th></th></tr></thead> | ||
| 253 | <tbody>${exclusionRows || `<tr><td colspan="2" class="muted">No repositories excluded.</td></tr>`}</tbody> | ||
| 254 | </table> | ||
| 255 | |||
| 223 | <h2 class="section-title">Recent exports</h2> | 256 | <h2 class="section-title">Recent exports</h2> |
| 224 | <table> | 257 | <table> |
| 225 | <thead><tr><th>Created</th><th>Framework</th><th>Format</th><th>File</th></tr></thead> | 258 | <thead><tr><th>Created</th><th>Framework</th><th>Format</th><th>File</th></tr></thead> |
src/exporter.ts +6
| @@ -57,6 +57,12 @@ export async function buildEvidenceRows( | |||
| 57 | l.resource NOT IN ('org_member', 'team_member') | 57 | l.resource NOT IN ('org_member', 'team_member') |
| 58 | OR l.captured_at = (SELECT t FROM access_latest) | 58 | OR l.captured_at = (SELECT t FROM access_latest) |
| 59 | ) | 59 | ) |
| 60 | -- Excluded repos stay in snapshots (the exclusion is a reporting | ||
| 61 | -- decision, reversible) but contribute no evidence. | ||
| 62 | AND ( | ||
| 63 | l.repo IS NULL | ||
| 64 | OR l.repo NOT IN (SELECT repo FROM repo_exclusions WHERE installation_id = ?1) | ||
| 65 | ) | ||
| 60 | -- l.resource last so the change-control collapse below sees | 66 | -- l.resource last so the change-control collapse below sees |
| 61 | -- branch_protection before repository_ruleset deterministically. | 67 | -- branch_protection before repository_ruleset deterministically. |
| 62 | ORDER BY cm.framework, cm.control_id, l.repo, l.resource`, | 68 | ORDER BY cm.framework, cm.control_id, l.repo, l.resource`, |
src/index.ts +52 −2
| @@ -89,6 +89,9 @@ export default { | |||
| 89 | if (request.method === "POST" && url.pathname === "/switch") { | 89 | if (request.method === "POST" && url.pathname === "/switch") { |
| 90 | return handleSwitchInstallation(request, env); | 90 | return handleSwitchInstallation(request, env); |
| 91 | } | 91 | } |
| 92 | if (request.method === "POST" && url.pathname === "/exclusions") { | ||
| 93 | return handleExclusion(request, env); | ||
| 94 | } | ||
| 92 | const exportMatch = url.pathname.match(/^\/exports\/([0-9a-f-]+)(\/download)?$/); | 95 | const exportMatch = url.pathname.match(/^\/exports\/([0-9a-f-]+)(\/download)?$/); |
| 93 | if (request.method === "GET" && exportMatch) { | 96 | if (request.method === "GET" && exportMatch) { |
| 94 | const [, jobId, downloadSuffix] = exportMatch; | 97 | const [, jobId, downloadSuffix] = exportMatch; |
| @@ -205,7 +208,8 @@ async function pollAllInstallations(env: Env): Promise<PollSummary> { | |||
| 205 | async function pollInstallation(env: Env, installationId: number, summary: PollSummary): Promise<void> { | 208 | async function pollInstallation(env: Env, installationId: number, summary: PollSummary): Promise<void> { |
| 206 | const appJwt = await createAppJwt(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY); | 209 | const appJwt = await createAppJwt(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY); |
| 207 | const installationToken = await getInstallationToken(appJwt, installationId); | 210 | const installationToken = await getInstallationToken(appJwt, installationId); |
| 208 | const repos = await listInstallationRepos(installationToken); | 211 | const excluded = await excludedRepos(env, installationId); |
| 212 | const repos = (await listInstallationRepos(installationToken)).filter((r) => !excluded.has(r.fullName)); | ||
| 209 | const capturedAt = new Date().toISOString(); | 213 | const capturedAt = new Date().toISOString(); |
| 210 | 214 | ||
| 211 | for (const repo of repos) { | 215 | for (const repo of repos) { |
| @@ -375,6 +379,7 @@ async function purgeInstallation(env: Env, installationId: number): Promise<void | |||
| 375 | await env.DB.batch([ | 379 | await env.DB.batch([ |
| 376 | env.DB.prepare("DELETE FROM snapshots WHERE installation_id = ?1").bind(installationId), | 380 | env.DB.prepare("DELETE FROM snapshots WHERE installation_id = ?1").bind(installationId), |
| 377 | env.DB.prepare("DELETE FROM exports WHERE installation_id = ?1").bind(installationId), | 381 | env.DB.prepare("DELETE FROM exports WHERE installation_id = ?1").bind(installationId), |
| 382 | env.DB.prepare("DELETE FROM repo_exclusions WHERE installation_id = ?1").bind(installationId), | ||
| 378 | env.DB.prepare("DELETE FROM installations WHERE installation_id = ?1").bind(installationId), | 383 | env.DB.prepare("DELETE FROM installations WHERE installation_id = ?1").bind(installationId), |
| 379 | ]); | 384 | ]); |
| 380 | } | 385 | } |
| @@ -486,7 +491,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> { | |||
| 486 | const framework = normalizeFramework(url.searchParams.get("framework") ?? undefined) ?? "all"; | 491 | const framework = normalizeFramework(url.searchParams.get("framework") ?? undefined) ?? "all"; |
| 487 | const posture = normalizePosture(url.searchParams.get("posture")); | 492 | const posture = normalizePosture(url.searchParams.get("posture")); |
| 488 | 493 | ||
| 489 | const [rows, orgRow, exportsResult, lastPollRow, installations] = await Promise.all([ | 494 | const [rows, orgRow, exportsResult, lastPollRow, installations, excluded, knownRepos] = await Promise.all([ |
| 490 | buildEvidenceRows(env.DB, session.installationId, framework), | 495 | buildEvidenceRows(env.DB, session.installationId, framework), |
| 491 | env.DB.prepare("SELECT org_login FROM installations WHERE installation_id = ?1") | 496 | env.DB.prepare("SELECT org_login FROM installations WHERE installation_id = ?1") |
| 492 | .bind(session.installationId) | 497 | .bind(session.installationId) |
| @@ -504,6 +509,13 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> { | |||
| 504 | .bind(session.installationId) | 509 | .bind(session.installationId) |
| 505 | .first<{ t: string | null }>(), | 510 | .first<{ t: string | null }>(), |
| 506 | accessibleInstallations(env, session), | 511 | accessibleInstallations(env, session), |
| 512 | excludedRepos(env, session.installationId), | ||
| 513 | env.DB.prepare( | ||
| 514 | `SELECT DISTINCT repo FROM snapshots | ||
| 515 | WHERE installation_id = ?1 AND repo IS NOT NULL ORDER BY repo`, | ||
| 516 | ) | ||
| 517 | .bind(session.installationId) | ||
| 518 | .all<{ repo: string }>(), | ||
| 507 | ]); | 519 | ]); |
| 508 | 520 | ||
| 509 | const html = renderDashboard({ | 521 | const html = renderDashboard({ |
| @@ -516,6 +528,8 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> { | |||
| 516 | rows, | 528 | rows, |
| 517 | exports: exportsResult.results, | 529 | exports: exportsResult.results, |
| 518 | lastPolledAt: lastPollRow?.t ?? null, | 530 | lastPolledAt: lastPollRow?.t ?? null, |
| 531 | excludedRepos: [...excluded].sort((a, b) => a.localeCompare(b)), | ||
| 532 | excludableRepos: knownRepos.results.map((r) => r.repo).filter((repo) => !excluded.has(repo)), | ||
| 519 | }); | 533 | }); |
| 520 | 534 | ||
| 521 | return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); | 535 | return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); |
| @@ -536,6 +550,42 @@ async function accessibleInstallations(env: Env, session: SessionPayload): Promi | |||
| 536 | return results; | 550 | return results; |
| 537 | } | 551 | } |
| 538 | 552 | ||
| 553 | // Repos this installation has opted out of. Read by both the dashboard and | ||
| 554 | // the poller, so they agree on what is out of scope. | ||
| 555 | async function excludedRepos(env: Env, installationId: number): Promise<Set<string>> { | ||
| 556 | const { results } = await env.DB.prepare("SELECT repo FROM repo_exclusions WHERE installation_id = ?1") | ||
| 557 | .bind(installationId) | ||
| 558 | .all<{ repo: string }>(); | ||
| 559 | return new Set(results.map((r) => r.repo)); | ||
| 560 | } | ||
| 561 | |||
| 562 | // POST /exclusions — add or remove a repo exclusion for the session's own | ||
| 563 | // installation. Snapshots already collected are kept: an exclusion hides a | ||
| 564 | // repo from evidence and skips it on the next poll, and can be undone. | ||
| 565 | async function handleExclusion(request: Request, env: Env): Promise<Response> { | ||
| 566 | const session = await requireSession(request, env); | ||
| 567 | if (!session) return new Response("Unauthorized", { status: 401 }); | ||
| 568 | |||
| 569 | const form = await request.formData(); | ||
| 570 | const repo = String(form.get("repo") ?? "").trim(); | ||
| 571 | if (!repo) return new Response("repo is required", { status: 400 }); | ||
| 572 | |||
| 573 | if (form.get("action") === "remove") { | ||
| 574 | await env.DB.prepare("DELETE FROM repo_exclusions WHERE installation_id = ?1 AND repo = ?2") | ||
| 575 | .bind(session.installationId, repo) | ||
| 576 | .run(); | ||
| 577 | } else { | ||
| 578 | await env.DB.prepare( | ||
| 579 | `INSERT INTO repo_exclusions (installation_id, repo, excluded_at) VALUES (?1, ?2, ?3) | ||
| 580 | ON CONFLICT(installation_id, repo) DO NOTHING`, | ||
| 581 | ) | ||
| 582 | .bind(session.installationId, repo, new Date().toISOString()) | ||
| 583 | .run(); | ||
| 584 | } | ||
| 585 | |||
| 586 | return Response.redirect(new URL("/", request.url).toString(), 303); | ||
| 587 | } | ||
| 588 | |||
| 539 | // POST /switch — change which installation the session is viewing. The | 589 | // POST /switch — change which installation the session is viewing. The |
| 540 | // allowed set lives in the signed session, so a tampered id can't widen | 590 | // allowed set lives in the signed session, so a tampered id can't widen |
| 541 | // access beyond what was granted at login. | 591 | // access beyond what was granted at login. |