audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
0aa5470fc53402b7466cf88a0a131b03599e1e39
unsigned
author: Christian Cleberg <hello@cleberg.net> · 2026-08-20T00:02:58Z
PRIVACY.md | 6 ++++- README.md | 6 ++++- docs/architecture.md | 14 ++++++++-- migrations/0009_repo_exclusions.sql | 12 +++++++++ src/dashboard.ts | 33 +++++++++++++++++++++++ src/exporter.ts | 6 +++++ src/index.ts | 54 +++++++++++++++++++++++++++++++++++-- 7 files changed, 125 insertions(+), 6 deletions(-) @@ -19,7 +19,9 @@ The App processes data only for organizations that have installed it, and only within the scope of the permissions granted at installation. **Organization & installation metadata.** Installation ID, organization -login, and installation/suspension timestamps. +login, and installation/suspension timestamps. If you exclude repositories +from scanning, the App stores the excluded repository names for your +installation. **Security & access-control signals (the evidence).** As your 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. - **Uninstall** the App at any time from your organization's GitHub settings to stop all processing and trigger deletion of your data. +- **Exclude repositories** from scanning and reporting from the + dashboard, so the App stops collecting new evidence about them. - **Export** your organization's data as CSV or PDF from the dashboard at any time. - **Request deletion** of your organization's data by contacting us. @@ -47,7 +47,7 @@ under Cloudflare's `eu` jurisdiction. | --- | --- | --- | | `GET /` | session | Dashboard: current posture, exports | | `GET /access-review` | session | Membership changes since a date | -| `POST /exports`, `/resync`, `/switch` | session | Dashboard actions | +| `POST /exports`, `/resync`, `/switch`, `/exclusions` | session | Dashboard actions | | `GET /exports/:id[/download]` | session | Export status / file | | `POST /webhooks/github` | HMAC | App events | | `POST /webhooks/marketplace` | HMAC | Marketplace events | @@ -55,6 +55,10 @@ under Cloudflare's `eu` jurisdiction. Session routes are scoped by installation; admin routes require `ADMIN_TOKEN`. +Repositories can be excluded from the dashboard: an excluded repo is skipped by +the poll and contributes no evidence, while its existing history is retained so +the exclusion can be undone. + ## Development ```sh @@ -61,7 +61,7 @@ flowchart LR | `POST /webhooks/github`, `/webhooks/marketplace` | HMAC (`GITHUB_WEBHOOK_SECRET`) | Ingest App / marketplace events | | `GET /login`, `/callback`, `/logout` | OAuth state cookie | Dashboard sign-in | | `GET /`, `/access-review` | Session cookie | Posture dashboard, membership diff | -| `POST /exports`, `/resync`, `/switch` | Session cookie | Dashboard actions (installation-scoped) | +| `POST /exports`, `/resync`, `/switch`, `/exclusions` | Session cookie | Dashboard actions (installation-scoped) | | `GET /exports/:id[/download]` | Session cookie | Export status / file (scoped to installation) | | `POST /admin/{poll,export,cleanup,purge}`, `GET /admin/export/:id` | Bearer (`ADMIN_TOKEN`) | Operations | @@ -119,7 +119,7 @@ sequenceDiagram Cron->>W: fire par Poll every active installation W->>GH: app JWT → installation token - W->>GH: repos · branch protection · rulesets · org/team members + W->>GH: repos (minus exclusions) · branch protection · rulesets · org/team members GH-->>W: current state W->>D1: INSERT snapshots (one captured_at per batch) and Retention cleanup @@ -176,6 +176,7 @@ NULL` matches any status for that resource. erDiagram installations ||--o{ snapshots : has installations ||--o{ exports : has + installations ||--o{ repo_exclusions : has snapshots }o..o{ control_mappings : "query-time join on (resource, status)" installations { @@ -203,6 +204,11 @@ erDiagram text posture "positive | negative | informational" text rationale } + repo_exclusions { + integer installation_id PK,FK + text repo PK "full name, out of scope" + text excluded_at + } exports { text id PK "uuid" integer installation_id FK @@ -236,6 +242,10 @@ flowchart TB Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply produce no rows — no evidence in either direction. +Repos listed in `repo_exclusions` are filtered out of the result and skipped by +the poll, so an excluded repo costs no subrequests and reports no gaps; its +snapshots stay in the table, so removing the exclusion restores its history. + Classic branch protection and repository rulesets both attest the same control, so the two are collapsed to one row per (framework, control, repo) — enabled wins over disabled — rather than letting an unused mechanism report a gap the new file mode 100644 @@ -0,0 +1,12 @@ +-- Repos an installation has opted out of: they are skipped by the poller and +-- filtered out of the evidence query, so an excluded repo neither costs +-- subrequests nor reports a gap. Snapshots already collected for the repo are +-- left in place — an exclusion is a reporting decision, not a deletion, and +-- removing the exclusion restores the history. +CREATE TABLE repo_exclusions ( + installation_id INTEGER NOT NULL, + repo TEXT NOT NULL, -- full name, e.g. 'acme/api' + excluded_at TEXT NOT NULL, + PRIMARY KEY (installation_id, repo), + FOREIGN KEY (installation_id) REFERENCES installations(installation_id) +); @@ -55,6 +55,10 @@ export interface DashboardData { rows: EvidenceRow[]; exports: ExportListRow[]; lastPolledAt: string | null; + excludedRepos: string[]; + // Repos seen in this installation's snapshots that aren't excluded yet — + // the options the exclusion form offers. + excludableRepos: string[]; } // Deliberately narrower than `unknown`: an object reaching here would render @@ -164,6 +168,26 @@ export function renderDashboard(data: DashboardData): string { }) .join(""); + const excludeForm = data.excludableRepos.length + ? `<div class="bar"><form method="post" action="/exclusions"> + <select name="repo">${data.excludableRepos.map((r) => `<option value="${esc(r)}">${esc(r)}</option>`).join("")}</select> + <button type="submit">Exclude</button> + </form></div>` + : `<p class="muted">No repositories left to exclude.</p>`; + + const exclusionRows = data.excludedRepos + .map( + (repo) => `<tr> + <td>${esc(repo)}</td> + <td><form method="post" action="/exclusions"> + <input type="hidden" name="repo" value="${esc(repo)}"> + <input type="hidden" name="action" value="remove"> + <button class="secondary" type="submit">Include again</button> + </form></td> + </tr>`, + ) + .join(""); + return `<!doctype html> <html lang="en"> <head> @@ -220,6 +244,15 @@ export function renderDashboard(data: DashboardData): string { }</tbody> </table> + <h2 class="section-title">Excluded repositories</h2> + <p class="muted">Excluded repositories are skipped by the sync and contribute no evidence. + Their existing history is kept, so including one again restores it.</p> + ${excludeForm} + <table> + <thead><tr><th>Repository</th><th></th></tr></thead> + <tbody>${exclusionRows || `<tr><td colspan="2" class="muted">No repositories excluded.</td></tr>`}</tbody> + </table> + <h2 class="section-title">Recent exports</h2> <table> <thead><tr><th>Created</th><th>Framework</th><th>Format</th><th>File</th></tr></thead> @@ -57,6 +57,12 @@ export async function buildEvidenceRows( l.resource NOT IN ('org_member', 'team_member') OR l.captured_at = (SELECT t FROM access_latest) ) + -- Excluded repos stay in snapshots (the exclusion is a reporting + -- decision, reversible) but contribute no evidence. + AND ( + l.repo IS NULL + OR l.repo NOT IN (SELECT repo FROM repo_exclusions WHERE installation_id = ?1) + ) -- l.resource last so the change-control collapse below sees -- branch_protection before repository_ruleset deterministically. ORDER BY cm.framework, cm.control_id, l.repo, l.resource`, @@ -89,6 +89,9 @@ export default { if (request.method === "POST" && url.pathname === "/switch") { return handleSwitchInstallation(request, env); } + if (request.method === "POST" && url.pathname === "/exclusions") { + return handleExclusion(request, env); + } const exportMatch = url.pathname.match(/^\/exports\/([0-9a-f-]+)(\/download)?$/); if (request.method === "GET" && exportMatch) { const [, jobId, downloadSuffix] = exportMatch; @@ -205,7 +208,8 @@ async function pollAllInstallations(env: Env): Promise<PollSummary> { async function pollInstallation(env: Env, installationId: number, summary: PollSummary): Promise<void> { const appJwt = await createAppJwt(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY); const installationToken = await getInstallationToken(appJwt, installationId); - const repos = await listInstallationRepos(installationToken); + const excluded = await excludedRepos(env, installationId); + const repos = (await listInstallationRepos(installationToken)).filter((r) => !excluded.has(r.fullName)); const capturedAt = new Date().toISOString(); for (const repo of repos) { @@ -375,6 +379,7 @@ async function purgeInstallation(env: Env, installationId: number): Promise<void await env.DB.batch([ env.DB.prepare("DELETE FROM snapshots WHERE installation_id = ?1").bind(installationId), env.DB.prepare("DELETE FROM exports WHERE installation_id = ?1").bind(installationId), + env.DB.prepare("DELETE FROM repo_exclusions WHERE installation_id = ?1").bind(installationId), env.DB.prepare("DELETE FROM installations WHERE installation_id = ?1").bind(installationId), ]); } @@ -486,7 +491,7 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> { const framework = normalizeFramework(url.searchParams.get("framework") ?? undefined) ?? "all"; const posture = normalizePosture(url.searchParams.get("posture")); - const [rows, orgRow, exportsResult, lastPollRow, installations] = await Promise.all([ + const [rows, orgRow, exportsResult, lastPollRow, installations, excluded, knownRepos] = await Promise.all([ buildEvidenceRows(env.DB, session.installationId, framework), env.DB.prepare("SELECT org_login FROM installations WHERE installation_id = ?1") .bind(session.installationId) @@ -504,6 +509,13 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> { .bind(session.installationId) .first<{ t: string | null }>(), accessibleInstallations(env, session), + excludedRepos(env, session.installationId), + env.DB.prepare( + `SELECT DISTINCT repo FROM snapshots + WHERE installation_id = ?1 AND repo IS NOT NULL ORDER BY repo`, + ) + .bind(session.installationId) + .all<{ repo: string }>(), ]); const html = renderDashboard({ @@ -516,6 +528,8 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> { rows, exports: exportsResult.results, lastPolledAt: lastPollRow?.t ?? null, + excludedRepos: [...excluded].sort((a, b) => a.localeCompare(b)), + excludableRepos: knownRepos.results.map((r) => r.repo).filter((repo) => !excluded.has(repo)), }); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); @@ -536,6 +550,42 @@ async function accessibleInstallations(env: Env, session: SessionPayload): Promi return results; } +// Repos this installation has opted out of. Read by both the dashboard and +// the poller, so they agree on what is out of scope. +async function excludedRepos(env: Env, installationId: number): Promise<Set<string>> { + const { results } = await env.DB.prepare("SELECT repo FROM repo_exclusions WHERE installation_id = ?1") + .bind(installationId) + .all<{ repo: string }>(); + return new Set(results.map((r) => r.repo)); +} + +// POST /exclusions — add or remove a repo exclusion for the session's own +// installation. Snapshots already collected are kept: an exclusion hides a +// repo from evidence and skips it on the next poll, and can be undone. +async function handleExclusion(request: Request, env: Env): Promise<Response> { + const session = await requireSession(request, env); + if (!session) return new Response("Unauthorized", { status: 401 }); + + const form = await request.formData(); + const repo = String(form.get("repo") ?? "").trim(); + if (!repo) return new Response("repo is required", { status: 400 }); + + if (form.get("action") === "remove") { + await env.DB.prepare("DELETE FROM repo_exclusions WHERE installation_id = ?1 AND repo = ?2") + .bind(session.installationId, repo) + .run(); + } else { + await env.DB.prepare( + `INSERT INTO repo_exclusions (installation_id, repo, excluded_at) VALUES (?1, ?2, ?3) + ON CONFLICT(installation_id, repo) DO NOTHING`, + ) + .bind(session.installationId, repo, new Date().toISOString()) + .run(); + } + + return Response.redirect(new URL("/", request.url).toString(), 303); +} + // POST /switch — change which installation the session is viewing. The // allowed set lives in the signed session, so a tampered id can't widen // access beyond what was granted at login.