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,
1919and only within the scope of the permissions granted at installation.
2020
2121**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.
2325
2426**Security & access-control signals (the evidence).** As your
2527configuration changes and on a periodic re-sync, the App records
@@ -116,6 +118,8 @@ is uninstalled. You may also request deletion at any time.
116118
117119- **Uninstall** the App at any time from your organization's GitHub
118120 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.
119123- **Export** your organization's data as CSV or PDF from the dashboard
120124 at any time.
121125- **Request deletion** of your organization's data by contacting us.
README.md +5 −1
@@ -47,7 +47,7 @@ under Cloudflare's `eu` jurisdiction.
4747| --- | --- | --- |
4848| `GET /` | session | Dashboard: current posture, exports |
4949| `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 |
5151| `GET /exports/:id[/download]` | session | Export status / file |
5252| `POST /webhooks/github` | HMAC | App events |
5353| `POST /webhooks/marketplace` | HMAC | Marketplace events |
@@ -55,6 +55,10 @@ under Cloudflare's `eu` jurisdiction.
5555
5656Session routes are scoped by installation; admin routes require `ADMIN_TOKEN`.
5757
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
5862## Development
5963
6064```sh
docs/architecture.md +12 −2
@@ -61,7 +61,7 @@ flowchart LR
6161| `POST /webhooks/github`, `/webhooks/marketplace` | HMAC (`GITHUB_WEBHOOK_SECRET`) | Ingest App / marketplace events |
6262| `GET /login`, `/callback`, `/logout` | OAuth state cookie | Dashboard sign-in |
6363| `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) |
6565| `GET /exports/:id[/download]` | Session cookie | Export status / file (scoped to installation) |
6666| `POST /admin/{poll,export,cleanup,purge}`, `GET /admin/export/:id` | Bearer (`ADMIN_TOKEN`) | Operations |
6767
@@ -119,7 +119,7 @@ sequenceDiagram
119119 Cron->>W: fire
120120 par Poll every active installation
121121 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
123123 GH-->>W: current state
124124 W->>D1: INSERT snapshots (one captured_at per batch)
125125 and Retention cleanup
@@ -176,6 +176,7 @@ NULL` matches any status for that resource.
176176erDiagram
177177 installations ||--o{ snapshots : has
178178 installations ||--o{ exports : has
179 installations ||--o{ repo_exclusions : has
179180 snapshots }o..o{ control_mappings : "query-time join on (resource, status)"
180181
181182 installations {
@@ -203,6 +204,11 @@ erDiagram
203204 text posture "positive | negative | informational"
204205 text rationale
205206 }
207 repo_exclusions {
208 integer installation_id PK,FK
209 text repo PK "full name, out of scope"
210 text excluded_at
211 }
206212 exports {
207213 text id PK "uuid"
208214 integer installation_id FK
@@ -236,6 +242,10 @@ flowchart TB
236242Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply
237243produce no rows — no evidence in either direction.
238244
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
239249Classic branch protection and repository rulesets both attest the same control,
240250so the two are collapsed to one row per (framework, control, repo) — enabled
241251wins 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 {
5555 rows: EvidenceRow[];
5656 exports: ExportListRow[];
5757 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[];
5862}
5963
6064// Deliberately narrower than `unknown`: an object reaching here would render
@@ -164,6 +168,26 @@ export function renderDashboard(data: DashboardData): string {
164168 })
165169 .join("");
166170
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
167191 return `<!doctype html>
168192<html lang="en">
169193<head>
@@ -220,6 +244,15 @@ export function renderDashboard(data: DashboardData): string {
220244 }</tbody>
221245 </table>
222246
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
223256 <h2 class="section-title">Recent exports</h2>
224257 <table>
225258 <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(
5757 l.resource NOT IN ('org_member', 'team_member')
5858 OR l.captured_at = (SELECT t FROM access_latest)
5959 )
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 )
6066 -- l.resource last so the change-control collapse below sees
6167 -- branch_protection before repository_ruleset deterministically.
6268 ORDER BY cm.framework, cm.control_id, l.repo, l.resource`,
src/index.ts +52 −2
@@ -89,6 +89,9 @@ export default {
8989 if (request.method === "POST" && url.pathname === "/switch") {
9090 return handleSwitchInstallation(request, env);
9191 }
92 if (request.method === "POST" && url.pathname === "/exclusions") {
93 return handleExclusion(request, env);
94 }
9295 const exportMatch = url.pathname.match(/^\/exports\/([0-9a-f-]+)(\/download)?$/);
9396 if (request.method === "GET" && exportMatch) {
9497 const [, jobId, downloadSuffix] = exportMatch;
@@ -205,7 +208,8 @@ async function pollAllInstallations(env: Env): Promise<PollSummary> {
205208async function pollInstallation(env: Env, installationId: number, summary: PollSummary): Promise<void> {
206209 const appJwt = await createAppJwt(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY);
207210 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));
209213 const capturedAt = new Date().toISOString();
210214
211215 for (const repo of repos) {
@@ -375,6 +379,7 @@ async function purgeInstallation(env: Env, installationId: number): Promise<void
375379 await env.DB.batch([
376380 env.DB.prepare("DELETE FROM snapshots WHERE installation_id = ?1").bind(installationId),
377381 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),
378383 env.DB.prepare("DELETE FROM installations WHERE installation_id = ?1").bind(installationId),
379384 ]);
380385}
@@ -486,7 +491,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
486491 const framework = normalizeFramework(url.searchParams.get("framework") ?? undefined) ?? "all";
487492 const posture = normalizePosture(url.searchParams.get("posture"));
488493
489 const [rows, orgRow, exportsResult, lastPollRow, installations] = await Promise.all([
494 const [rows, orgRow, exportsResult, lastPollRow, installations, excluded, knownRepos] = await Promise.all([
490495 buildEvidenceRows(env.DB, session.installationId, framework),
491496 env.DB.prepare("SELECT org_login FROM installations WHERE installation_id = ?1")
492497 .bind(session.installationId)
@@ -504,6 +509,13 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
504509 .bind(session.installationId)
505510 .first<{ t: string | null }>(),
506511 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 }>(),
507519 ]);
508520
509521 const html = renderDashboard({
@@ -516,6 +528,8 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
516528 rows,
517529 exports: exportsResult.results,
518530 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)),
519533 });
520534
521535 return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
@@ -536,6 +550,42 @@ async function accessibleInstallations(env: Env, session: SessionPayload): Promi
536550 return results;
537551}
538552
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
539589// POST /switch — change which installation the session is viewing. The
540590// allowed set lives in the signed session, so a tampered id can't widen
541591// access beyond what was granted at login.