audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
1import { timingSafeEqualHex, bytesToHex } from "./crypto-utils";
2
3const SIGNATURE_PREFIX = "sha256=";
4
5export async function verifySignature(
6 rawBody: ArrayBuffer,
7 signatureHeader: string | null,
8 secret: string,
9): Promise<boolean> {
10 if (!signatureHeader?.startsWith(SIGNATURE_PREFIX)) return false;
11
12 const providedHex = signatureHeader.slice(SIGNATURE_PREFIX.length);
13 if (!/^[0-9a-f]+$/i.test(providedHex) || providedHex.length !== 64) return false;
14
15 const key = await crypto.subtle.importKey(
16 "raw",
17 new TextEncoder().encode(secret),
18 { name: "HMAC", hash: "SHA-256" },
19 false,
20 ["sign"],
21 );
22 const expected = bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, rawBody)));
23
24 return timingSafeEqualHex(providedHex, expected);
25}
26
27interface ExtractedFact {
28 resource: string;
29 status: string;
30 // Which entity within the repo/org the fact is about (alert number, member
31 // login, team slug). Part of the exporter's latest-row-wins key, so facts
32 // about different entities in the same repo don't overwrite each other.
33 subject: string | null;
34}
35
36// Minimal resource/status extraction per event type. Control-ID mapping
37// (resource+status -> SOC 2 / ISO 27001 control) is a separate, later step.
38export function extractFact(eventType: string, payload: Record<string, unknown>): ExtractedFact {
39 const action = typeof payload.action === "string" ? payload.action : undefined;
40
41 switch (eventType) {
42 // The event is scoped to one rule, which may target any branch. Only a
43 // rule whose pattern is exactly the default branch changes the repo's
44 // protection state (normalized to the enabled/disabled vocabulary the
45 // poller shares); any other rule is recorded as an unmapped trail event,
46 // with the hourly poll authoritative for current state.
47 case "branch_protection_rule": {
48 const rule = payload.rule as Record<string, unknown> | undefined;
49 const repository = payload.repository as Record<string, unknown> | undefined;
50 const pattern = typeof rule?.name === "string" ? rule.name : null;
51 const defaultBranch = typeof repository?.default_branch === "string" ? repository.default_branch : undefined;
52 if (pattern !== null && pattern === defaultBranch) {
53 return { resource: "branch_protection", status: action === "deleted" ? "disabled" : "enabled", subject: null };
54 }
55 return { resource: "branch_protection_rule_event", status: action ?? "unknown", subject: pattern };
56 }
57 // Ruleset events are ruleset-scoped: one ruleset being created or deleted
58 // says nothing about whether *other* active rulesets still cover the
59 // default branch, so this is trail-only; current repository_ruleset state
60 // comes from the poller's /rules/branches/{default-branch} check.
61 case "repository_ruleset": {
62 const ruleset = payload.repository_ruleset as Record<string, unknown> | undefined;
63 const id = typeof ruleset?.id === "number" ? String(ruleset.id) : null;
64 return { resource: "repository_ruleset_event", status: action ?? "unknown", subject: id };
65 }
66 case "dependabot_alert":
67 case "code_scanning_alert": {
68 const alert = payload.alert as Record<string, unknown> | undefined;
69 const state = typeof alert?.state === "string" ? alert.state : undefined;
70 return { resource: eventType, status: state ?? action ?? "unknown", subject: alertNumber(alert) };
71 }
72 // Unlike the two alert payloads above, the secret-scanning webhook alert
73 // carries no `state` field — only `resolution`, which is set iff the
74 // alert is resolved. Derive open/resolved from that (falling back to
75 // `state` should GitHub ever add it).
76 case "secret_scanning_alert": {
77 const alert = payload.alert as Record<string, unknown> | undefined;
78 const state = typeof alert?.state === "string" ? alert.state : undefined;
79 return {
80 resource: eventType,
81 status: state ?? (alert?.resolution ? "resolved" : "open"),
82 subject: alertNumber(alert),
83 };
84 }
85 // Repository collaborators. Org-level membership arrives on the
86 // `organization` event below, not here.
87 case "member": {
88 const member = payload.member as Record<string, unknown> | undefined;
89 return {
90 resource: "member_access",
91 status: action ?? "unknown",
92 subject: typeof member?.login === "string" ? member.login : null,
93 };
94 }
95 case "team": {
96 const team = payload.team as Record<string, unknown> | undefined;
97 return {
98 resource: "team",
99 status: action ?? "unknown",
100 subject: typeof team?.slug === "string" ? team.slug : null,
101 };
102 }
103 case "organization": {
104 const membership = payload.membership as Record<string, unknown> | undefined;
105 const user = membership?.user as Record<string, unknown> | undefined;
106 // member_invited identifies the invitee via `invitation` (login for
107 // existing users, email otherwise) rather than `membership`.
108 const invitation = payload.invitation as Record<string, unknown> | undefined;
109 const subject =
110 typeof user?.login === "string"
111 ? user.login
112 : typeof invitation?.login === "string"
113 ? invitation.login
114 : typeof invitation?.email === "string"
115 ? invitation.email
116 : null;
117 return { resource: "org_membership", status: action ?? "unknown", subject };
118 }
119 case "repository":
120 return { resource: "repository", status: action ?? "unknown", subject: null };
121 case "push":
122 return { resource: "push", status: "received", subject: null };
123 default:
124 return { resource: eventType, status: action ?? "received", subject: null };
125 }
126}
127
128function alertNumber(alert: Record<string, unknown> | undefined): string | null {
129 return typeof alert?.number === "number" ? String(alert.number) : null;
130}
131
132export function extractRepoFullName(payload: Record<string, unknown>): string | null {
133 const repository = payload.repository as Record<string, unknown> | undefined;
134 return typeof repository?.full_name === "string" ? repository.full_name : null;
135}
136
137export function extractInstallationId(payload: Record<string, unknown>): number | null {
138 const installation = payload.installation as Record<string, unknown> | undefined;
139 return typeof installation?.id === "number" ? installation.id : null;
140}