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

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
 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(-)

diff --git a/PRIVACY.md b/PRIVACY.md
index 485f0b7..4b350ff 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -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.
diff --git a/README.md b/README.md
index 388ea94..26b9d5e 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/architecture.md b/docs/architecture.md
index 47c7aae..cfd914e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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
diff --git a/migrations/0009_repo_exclusions.sql b/migrations/0009_repo_exclusions.sql
new file mode 100644
index 0000000..0fa165d
--- /dev/null
+++ b/migrations/0009_repo_exclusions.sql
@@ -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)
+);
diff --git a/src/dashboard.ts b/src/dashboard.ts
index 708772a..0d183ba 100644
--- a/src/dashboard.ts
+++ b/src/dashboard.ts
@@ -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>
diff --git a/src/exporter.ts b/src/exporter.ts
index 656abfa..0930ad2 100644
--- a/src/exporter.ts
+++ b/src/exporter.ts
@@ -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`,
diff --git a/src/index.ts b/src/index.ts
index f024f94..65e1193 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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.