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 fetchRulesets(installationToken: string, owner: string, repo: string): Promise<ProtectionCheck> {
72 const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/rulesets?per_page=100`, {
73 headers: authHeaders(installationToken),
74 });
75 if (res.status === 403) return { status: "unavailable", raw: null };
76 if (!res.ok) throw new Error(`Failed to fetch rulesets for ${owner}/${repo} (${res.status})`);
77
78 const rulesets = (await res.json()) as Array<{ enforcement: string; target: string }>;
79 // "evaluate" is dry-run/monitor-only — doesn't actually block anything, so
80 // it doesn't count as protection being enabled.
81 const enabled = rulesets.some((r) => r.enforcement === "active" && r.target === "branch");
82 return { status: enabled ? "enabled" : "disabled", raw: rulesets };
83}
84
85export interface PolledFact {
86 repo: string;
87 resource: "branch_protection" | "repository_ruleset";
88 status: "enabled" | "disabled" | "unavailable";
89 rawPayload: string | null;
90}
91
92export async function pollRepoProtection(installationToken: string, repo: RepoRef): Promise<PolledFact[]> {
93 const [branchProtection, rulesets] = await Promise.all([
94 fetchBranchProtection(installationToken, repo.owner, repo.name, repo.defaultBranch),
95 fetchRulesets(installationToken, repo.owner, repo.name),
96 ]);
97
98 return [
99 {
100 repo: repo.fullName,
101 resource: "branch_protection",
102 status: branchProtection.status,
103 rawPayload: branchProtection.raw ? JSON.stringify(branchProtection.raw) : null,
104 },
105 {
106 repo: repo.fullName,
107 resource: "repository_ruleset",
108 status: rulesets.status,
109 rawPayload: rulesets.raw ? JSON.stringify(rulesets.raw) : null,
110 },
111 ];
112}
113
114// ---------------------------------------------------------------------------
115// Access review: org membership and team membership.
116// ---------------------------------------------------------------------------
117
118export interface AccessFact {
119 resource: "org_member" | "team_member";
120 subject: string; // member login, or "team-slug:login" for team membership
121 status: string; // org role (admin|member) or team role (maintainer|member)
122}
123
124interface GithubUser {
125 login: string;
126}
127
128async function listUsers(installationToken: string, path: string): Promise<string[]> {
129 const logins: string[] = [];
130 let page = 1;
131
132 for (;;) {
133 const separator = path.includes("?") ? "&" : "?";
134 const res = await fetch(`${GITHUB_API}${path}${separator}per_page=100&page=${page}`, {
135 headers: authHeaders(installationToken),
136 });
137 if (!res.ok) throw new Error(`Failed to list ${path} (${res.status})`);
138
139 const users = (await res.json()) as GithubUser[];
140 for (const user of users) logins.push(user.login);
141
142 if (users.length < 100) break;
143 page++;
144 }
145
146 return logins;
147}
148
149// Returns null when the installation account is a personal User rather than an
150// Organization — there is no membership to review, which is not an error.
151//
152// Subrequest cost is 3 + (2 x team count); an org with very many teams would
153// need to fan this out through a Queue rather than one scheduled invocation.
154export async function pollOrgAccess(installationToken: string, orgLogin: string): Promise<AccessFact[] | null> {
155 const orgRes = await fetch(`${GITHUB_API}/orgs/${orgLogin}`, { headers: authHeaders(installationToken) });
156 if (orgRes.status === 404) return null;
157 if (!orgRes.ok) throw new Error(`Failed to fetch org ${orgLogin} (${orgRes.status})`);
158
159 const facts: AccessFact[] = [];
160
161 for (const role of ["admin", "member"] as const) {
162 for (const login of await listUsers(installationToken, `/orgs/${orgLogin}/members?role=${role}`)) {
163 facts.push({ resource: "org_member", subject: login, status: role });
164 }
165 }
166
167 const teamsRes = await fetch(`${GITHUB_API}/orgs/${orgLogin}/teams?per_page=100`, {
168 headers: authHeaders(installationToken),
169 });
170 if (!teamsRes.ok) throw new Error(`Failed to list teams for ${orgLogin} (${teamsRes.status})`);
171 const teams = (await teamsRes.json()) as Array<{ slug: string }>;
172
173 for (const team of teams) {
174 for (const role of ["maintainer", "member"] as const) {
175 const path = `/orgs/${orgLogin}/teams/${team.slug}/members?role=${role}`;
176 for (const login of await listUsers(installationToken, path)) {
177 facts.push({ resource: "team_member", subject: `${team.slug}:${login}`, status: role });
178 }
179 }
180 }
181
182 return facts;
183}