audit-labs/gh-attest

GitHub Audit Evidence Extractor

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

main: src/poller.ts · raw

  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}