audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
v1.0.1: 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}