audit-labs/gh-attest

GitHub Audit Evidence Extractor

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

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