Commit c5440e09f4

c5440e09f4b4c091c04623dddb729a72a82b9719

parent: 96fa93e33b

Unsigned

cmc <hello@cleberg.net> · 2026-08-20 00:02 UTC

Allow repositories to be excluded from scanning and reporting

Organizations often have repos — archives, sandboxes, forks — whose
posture is not part of the audit scope but which still filled the
dashboard with gaps and cost a subrequest budget on every poll.

Adds repo_exclusions (installation-scoped) with a dashboard section to
exclude a repo and to include it again. Excluded repos are skipped by
pollInstallation and filtered out of buildEvidenceRows, so they affect
both new collection and existing evidence. Snapshots are kept rather than
deleted, so the decision is reversible; a purge clears the exclusions
along with the rest of the installation's data.

Closes #26

Layout: unified · split

PRIVACY.md +5 −1
@@ -19,7 +19,9 @@ The App processes data only for organizations that have installed it,
19and only within the scope of the permissions granted at installation. 19and 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
22login, and installation/suspension timestamps. 22login, and installation/suspension timestamps. If you exclude repositories
23from scanning, the App stores the excluded repository names for your
24installation.
23 25
24**Security & access-control signals (the evidence).** As your 26**Security & access-control signals (the evidence).** As your
25configuration changes and on a periodic re-sync, the App records 27configuration 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
56Session routes are scoped by installation; admin routes require `ADMIN_TOKEN`. 56Session routes are scoped by installation; admin routes require `ADMIN_TOKEN`.
57 57
58Repositories can be excluded from the dashboard: an excluded repo is skipped by
59the poll and contributes no evidence, while its existing history is retained so
60the 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.
176erDiagram 176erDiagram
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
236Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply 242Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply
237produce no rows — no evidence in either direction. 243produce no rows — no evidence in either direction.
238 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
239Classic branch protection and repository rulesets both attest the same control, 249Classic branch protection and repository rulesets both attest the same control,
240so the two are collapsed to one row per (framework, control, repo) — enabled 250so the two are collapsed to one row per (framework, control, repo) — enabled
241wins over disabled — rather than letting an unused mechanism report a gap the 251wins 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.
6CREATE 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> {
205async function pollInstallation(env: Env, installationId: number, summary: PollSummary): Promise<void> { 208async 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.
555async 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.
565async 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.