audit-labs/gh-attest

GitHub Audit Evidence Extractor

clone: git clone https://gitbay.org/audit-labs/gh-attest.git

v1.0.2: src/access-review.ts · raw

  1// Resources written by the access poller. Each poll writes the full current
  2// set with one shared captured_at, so a captured_at value identifies a
  3// coherent point-in-time snapshot to diff against.
  4const ACCESS_RESOURCES = ["org_member", "team_member"] as const;
  5
  6export interface AccessDiffEntry {
  7  resource: string;
  8  subject: string;
  9  change: "added" | "removed" | "changed";
 10  from: string | null;
 11  to: string | null;
 12}
 13
 14export interface AccessDiff {
 15  currentAt: string | null;
 16  priorAt: string | null;
 17  currentCount: number;
 18  entries: AccessDiffEntry[];
 19}
 20
 21interface AccessRow {
 22  resource: string;
 23  subject: string;
 24  status: string;
 25}
 26
 27async function fetchSet(db: D1Database, installationId: number, capturedAt: string): Promise<AccessRow[]> {
 28  const { results } = await db
 29    .prepare(
 30      `SELECT resource, subject, status FROM snapshots
 31       WHERE installation_id = ?1 AND captured_at = ?2
 32         AND resource IN ('org_member', 'team_member')
 33         AND subject IS NOT NULL`,
 34    )
 35    .bind(installationId, capturedAt)
 36    .all<AccessRow>();
 37  return results;
 38}
 39
 40// Compare the most recent access snapshot against the most recent one taken
 41// at or before `since`. Returns an empty diff (with timestamps) when there is
 42// no baseline to compare against yet.
 43export async function buildAccessDiff(
 44  db: D1Database,
 45  installationId: number,
 46  since: string,
 47): Promise<AccessDiff> {
 48  const resourceList = ACCESS_RESOURCES.map((r) => `'${r}'`).join(", ");
 49
 50  const latest = await db
 51    .prepare(
 52      `SELECT MAX(captured_at) AS t FROM snapshots
 53       WHERE installation_id = ?1 AND resource IN (${resourceList})`,
 54    )
 55    .bind(installationId)
 56    .first<{ t: string | null }>();
 57
 58  const currentAt = latest?.t ?? null;
 59  if (!currentAt) return { currentAt: null, priorAt: null, currentCount: 0, entries: [] };
 60
 61  // The baseline is the newest snapshot at or before `since` that is also
 62  // strictly older than the current one. Without the second condition a
 63  // same-day comparison would select the current snapshot as its own
 64  // baseline and report no changes at all.
 65  const prior = await db
 66    .prepare(
 67      `SELECT MAX(captured_at) AS t FROM snapshots
 68       WHERE installation_id = ?1 AND resource IN (${resourceList})
 69         AND captured_at <= ?2 AND captured_at < ?3`,
 70    )
 71    .bind(installationId, since, currentAt)
 72    .first<{ t: string | null }>();
 73  const priorAt = prior?.t ?? null;
 74
 75  const current = await fetchSet(db, installationId, currentAt);
 76
 77  // With no baseline there is nothing to diff. Returning the whole current
 78  // set as "added" would read as though everyone had just been granted
 79  // access, which is exactly the wrong thing to tell an auditor.
 80  if (!priorAt) return { currentAt, priorAt: null, currentCount: current.length, entries: [] };
 81
 82  const priorRows = await fetchSet(db, installationId, priorAt);
 83
 84  const key = (r: AccessRow) => `${r.resource}|${r.subject}`;
 85  const currentMap = new Map(current.map((r) => [key(r), r]));
 86  const priorMap = new Map(priorRows.map((r) => [key(r), r]));
 87
 88  const entries: AccessDiffEntry[] = [];
 89
 90  for (const [k, row] of currentMap) {
 91    const before = priorMap.get(k);
 92    if (!before) {
 93      entries.push({ resource: row.resource, subject: row.subject, change: "added", from: null, to: row.status });
 94    } else if (before.status !== row.status) {
 95      entries.push({
 96        resource: row.resource,
 97        subject: row.subject,
 98        change: "changed",
 99        from: before.status,
100        to: row.status,
101      });
102    }
103  }
104
105  for (const [k, row] of priorMap) {
106    if (!currentMap.has(k)) {
107      entries.push({ resource: row.resource, subject: row.subject, change: "removed", from: row.status, to: null });
108    }
109  }
110
111  entries.sort(
112    (a, b) => a.change.localeCompare(b.change) || a.resource.localeCompare(b.resource) || a.subject.localeCompare(b.subject),
113  );
114
115  return { currentAt, priorAt, currentCount: current.length, entries };
116}