audit-labs/gh-attest

GitHub Audit Evidence Extractor

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

v1.0.2: src/webhook.ts · raw

 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}