audit-labs/gh-attest

GitHub Audit Evidence Extractor

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

v1.0.1: 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 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}