audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
1const GITHUB_API = "https://api.github.com";
2
3function authHeaders(installationToken: string): HeadersInit {
4 return {
5 Authorization: `Bearer ${installationToken}`,
6 Accept: "application/vnd.github+json",
7 "User-Agent": "gh-attest",
8 };
9}
10
11export interface RepoRef {
12 fullName: string;
13 owner: string;
14 name: string;
15 defaultBranch: string;
16}
17
18export async function listInstallationRepos(installationToken: string): Promise<RepoRef[]> {
19 const repos: RepoRef[] = [];
20 let page = 1;
21
22 for (;;) {
23 const res = await fetch(`${GITHUB_API}/installation/repositories?per_page=100&page=${page}`, {
24 headers: authHeaders(installationToken),
25 });
26 if (!res.ok) throw new Error(`Failed to list installation repositories (${res.status})`);
27
28 const data = (await res.json()) as {
29 repositories: Array<{ full_name: string; default_branch: string }>;
30 };
31 if (data.repositories.length === 0) break;
32
33 for (const repo of data.repositories) {
34 const separatorIndex = repo.full_name.indexOf("/");
35 const owner = repo.full_name.slice(0, separatorIndex);
36 const name = repo.full_name.slice(separatorIndex + 1);
37 repos.push({ fullName: repo.full_name, owner, name, defaultBranch: repo.default_branch });
38 }
39
40 if (data.repositories.length < 100) break;
41 page++;
42 }
43
44 return repos;
45}
46
47interface ProtectionCheck {
48 status: "enabled" | "disabled" | "unavailable";
49 raw: unknown;
50}
51
52async function fetchBranchProtection(
53 installationToken: string,
54 owner: string,
55 repo: string,
56 branch: string,
57): Promise<ProtectionCheck> {
58 const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/branches/${branch}/protection`, {
59 headers: authHeaders(installationToken),
60 });
61 if (res.status === 404) return { status: "disabled", raw: null };
62 // 403 = feature not available on this repo's plan (e.g. private repo on a
63 // free account). Recorded as "unavailable" — deliberately unmapped in
64 // control_mappings so it never counts as evidence either way.
65 if (res.status === 403) return { status: "unavailable", raw: null };
66 if (!res.ok) throw new Error(`Failed to fetch branch protection for ${owner}/${repo} (${res.status})`);
67
68 return { status: "enabled", raw: await res.json() };
69}
70
71async function fetchDefaultBranchRules(
72 installationToken: string,
73 owner: string,
74 repo: string,
75 branch: string,
76): Promise<ProtectionCheck> {
77 // /rules/branches/{branch} aggregates the rules from every ACTIVE ruleset —
78 // repo- and org-level — that applies to this branch. Evaluate-mode
79 // (monitor-only) rulesets are excluded, and a ruleset targeting only other
80 // branches contributes nothing, so a non-empty result means the default
81 // branch is actually covered by at least one enforcing ruleset.
82 const res = await fetch(
83 `${GITHUB_API}/repos/${owner}/${repo}/rules/branches/${encodeURIComponent(branch)}?per_page=100`,
84 { headers: authHeaders(installationToken) },
85 );
86 // 403 = feature not available; 404 = branch not found (e.g. empty repo).
87 // Both deliberately unmapped, like branch protection's "unavailable".
88 if (res.status === 403 || res.status === 404) return { status: "unavailable", raw: null };
89 if (!res.ok) throw new Error(`Failed to fetch branch rules for ${owner}/${repo} (${res.status})`);
90
91 const rules = (await res.json()) as unknown[];
92 return { status: rules.length > 0 ? "enabled" : "disabled", raw: rules };
93}
94
95export interface PolledFact {
96 repo: string;
97 resource: string;
98 status: string;
99 subject: string | null;
100 rawPayload: string | null;
101}
102
103export async function pollRepoProtection(installationToken: string, repo: RepoRef): Promise<PolledFact[]> {
104 const [branchProtection, rulesets] = await Promise.all([
105 fetchBranchProtection(installationToken, repo.owner, repo.name, repo.defaultBranch),
106 fetchDefaultBranchRules(installationToken, repo.owner, repo.name, repo.defaultBranch),
107 ]);
108
109 return [
110 {
111 repo: repo.fullName,
112 resource: "branch_protection",
113 status: branchProtection.status,
114 subject: null,
115 rawPayload: branchProtection.raw ? JSON.stringify(branchProtection.raw) : null,
116 },
117 {
118 repo: repo.fullName,
119 resource: "repository_ruleset",
120 status: rulesets.status,
121 subject: null,
122 rawPayload: rulesets.raw ? JSON.stringify(rulesets.raw) : null,
123 },
124 ];
125}
126
127// ---------------------------------------------------------------------------
128// Alert streams: baseline + keep-alive poll.
129// ---------------------------------------------------------------------------
130
131// The list endpoint doubles as the tooling-enabled signal: 200 means the
132// feature is on regardless of whether it has ever produced an alert, 404
133// means it is switched off, 403 means it is not available (plan / GHAS).
134// Only `enabled` is mapped in control_mappings — absence of the tooling is
135// recorded but never counted as evidence either way.
136const ALERT_FEATURES = [
137 { feature: "dependabot", alertResource: "dependabot_alert", path: "/dependabot/alerts" },
138 { feature: "code_scanning", alertResource: "code_scanning_alert", path: "/code-scanning/alerts" },
139 { feature: "secret_scanning", alertResource: "secret_scanning_alert", path: "/secret-scanning/alerts" },
140] as const;
141
142interface AlertsCheck {
143 feature: "enabled" | "disabled" | "unavailable";
144 alerts: Array<{ number: number; state: string }>;
145}
146
147// Both offset (code/secret scanning) and cursor (Dependabot) pagination
148// advertise the next page in the Link header.
149function nextPageUrl(linkHeader: string | null): string | null {
150 const match = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
151 return match?.[1] ?? null;
152}
153
154async function fetchOpenAlerts(
155 installationToken: string,
156 owner: string,
157 repo: string,
158 path: string,
159): Promise<AlertsCheck> {
160 const alerts: AlertsCheck["alerts"] = [];
161 let url: string | null = `${GITHUB_API}/repos/${owner}/${repo}${path}?state=open&per_page=100`;
162 while (url) {
163 const res: Response = await fetch(url, { headers: authHeaders(installationToken) });
164 if (res.status === 404) return { feature: "disabled", alerts: [] };
165 if (res.status === 403) return { feature: "unavailable", alerts: [] };
166 if (!res.ok) throw new Error(`Failed to list ${path} for ${owner}/${repo} (${res.status})`);
167
168 const page = (await res.json()) as Array<{ number: number; state: string }>;
169 for (const alert of page) alerts.push({ number: alert.number, state: alert.state });
170 url = nextPageUrl(res.headers.get("Link"));
171 }
172 return { feature: "enabled", alerts };
173}
174
175// Webhooks record alert transitions, but (a) alerts already open before the
176// App was installed never sent one, and (b) an open alert with no events for
177// the whole retention window would age out of evidence. Re-recording the open
178// set every poll fixes both. Subrequest cost is 3+ per repo, on top of the 2
179// for protection state.
180export async function pollRepoAlerts(installationToken: string, repo: RepoRef): Promise<PolledFact[]> {
181 const facts: PolledFact[] = [];
182 for (const { feature, alertResource, path } of ALERT_FEATURES) {
183 const check = await fetchOpenAlerts(installationToken, repo.owner, repo.name, path);
184 facts.push({ repo: repo.fullName, resource: feature, status: check.feature, subject: null, rawPayload: null });
185 for (const alert of check.alerts) {
186 facts.push({
187 repo: repo.fullName,
188 resource: alertResource,
189 status: alert.state,
190 subject: String(alert.number),
191 rawPayload: null,
192 });
193 }
194 }
195 return facts;
196}
197
198// ---------------------------------------------------------------------------
199// Access review: org membership and team membership.
200// ---------------------------------------------------------------------------
201
202export interface AccessFact {
203 resource: "org_member" | "team_member";
204 subject: string; // member login, or "team-slug:login" for team membership
205 status: string; // org role (admin|member) or team role (maintainer|member)
206}
207
208interface GithubUser {
209 login: string;
210}
211
212async function listUsers(installationToken: string, path: string): Promise<string[]> {
213 const logins: string[] = [];
214 let page = 1;
215
216 for (;;) {
217 const separator = path.includes("?") ? "&" : "?";
218 const res = await fetch(`${GITHUB_API}${path}${separator}per_page=100&page=${page}`, {
219 headers: authHeaders(installationToken),
220 });
221 if (!res.ok) throw new Error(`Failed to list ${path} (${res.status})`);
222
223 const users = (await res.json()) as GithubUser[];
224 for (const user of users) logins.push(user.login);
225
226 if (users.length < 100) break;
227 page++;
228 }
229
230 return logins;
231}
232
233// Returns null when the installation account is a personal User rather than an
234// Organization — there is no membership to review, which is not an error.
235//
236// Subrequest cost is 3 + (2 x team count); an org with very many teams would
237// need to fan this out through a Queue rather than one scheduled invocation.
238export async function pollOrgAccess(installationToken: string, orgLogin: string): Promise<AccessFact[] | null> {
239 const orgRes = await fetch(`${GITHUB_API}/orgs/${orgLogin}`, { headers: authHeaders(installationToken) });
240 if (orgRes.status === 404) return null;
241 if (!orgRes.ok) throw new Error(`Failed to fetch org ${orgLogin} (${orgRes.status})`);
242
243 const facts: AccessFact[] = [];
244
245 for (const role of ["admin", "member"] as const) {
246 for (const login of await listUsers(installationToken, `/orgs/${orgLogin}/members?role=${role}`)) {
247 facts.push({ resource: "org_member", subject: login, status: role });
248 }
249 }
250
251 const teamsRes = await fetch(`${GITHUB_API}/orgs/${orgLogin}/teams?per_page=100`, {
252 headers: authHeaders(installationToken),
253 });
254 if (!teamsRes.ok) throw new Error(`Failed to list teams for ${orgLogin} (${teamsRes.status})`);
255 const teams = (await teamsRes.json()) as Array<{ slug: string }>;
256
257 for (const team of teams) {
258 for (const role of ["maintainer", "member"] as const) {
259 const path = `/orgs/${orgLogin}/teams/${team.slug}/members?role=${role}`;
260 for (const login of await listUsers(installationToken, path)) {
261 facts.push({ resource: "team_member", subject: `${team.slug}:${login}`, status: role });
262 }
263 }
264 }
265
266 return facts;
267}