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}
31
32// Minimal resource/status extraction per event type. Control-ID mapping
33// (resource+status -> SOC 2 / ISO 27001 control) is a separate, later step.
34export function extractFact(eventType: string, payload: Record<string, unknown>): ExtractedFact {
35 const action = typeof payload.action === "string" ? payload.action : undefined;
36
37 switch (eventType) {
38 // Normalized to current-state vocabulary (enabled/disabled) rather than
39 // the raw action, so this lines up with what the poller reports for
40 // pre-existing protection state — the mapping table joins on one
41 // vocabulary regardless of source.
42 case "branch_protection_rule":
43 return { resource: "branch_protection", status: action === "deleted" ? "disabled" : "enabled" };
44 case "repository_ruleset":
45 return { resource: "repository_ruleset", status: action === "deleted" ? "disabled" : "enabled" };
46 case "dependabot_alert":
47 case "code_scanning_alert":
48 case "secret_scanning_alert": {
49 const alert = payload.alert as Record<string, unknown> | undefined;
50 const state = typeof alert?.state === "string" ? alert.state : undefined;
51 return { resource: eventType, status: state ?? action ?? "unknown" };
52 }
53 case "member":
54 return { resource: "member_access", status: action ?? "unknown" };
55 case "team":
56 return { resource: "team", status: action ?? "unknown" };
57 case "repository":
58 return { resource: "repository", status: action ?? "unknown" };
59 case "push":
60 return { resource: "push", status: "received" };
61 default:
62 return { resource: eventType, status: action ?? "received" };
63 }
64}
65
66export function extractRepoFullName(payload: Record<string, unknown>): string | null {
67 const repository = payload.repository as Record<string, unknown> | undefined;
68 return typeof repository?.full_name === "string" ? repository.full_name : null;
69}
70
71export function extractInstallationId(payload: Record<string, unknown>): number | null {
72 const installation = payload.installation as Record<string, unknown> | undefined;
73 return typeof installation?.id === "number" ? installation.id : null;
74}