import { env } from "cloudflare:workers"; import { z } from "zod"; import { loadRepoById } from "../repos"; import { ZERO_SHA } from "../git/store"; import { MAX_COMMITS_PER_FETCH } from "../git/upload-pack"; import { processSarifUpload } from "./code-scanning"; import { scanDependencies } from "./dependencies"; import { scanPushForSecrets } from "./secret-scan"; import { verifyCommits } from "./signatures"; export const SECURITY_QUEUE = "gitfor-security"; const RETRY_SECONDS = 30; const MAX_PUSH_SIGNATURES = 50; const sha = z.string().regex(/^[0-9a-f]{40}$/); const securityMessage = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("secrets.push"), repoId: z.string(), ref: z.string(), before: z.string(), after: sha }), z.object({ kind: z.literal("deps.scan"), repoId: z.string(), sha }), z.object({ kind: z.literal("signatures.verify"), repoId: z.string(), shas: z.array(sha).max(MAX_COMMITS_PER_FETCH), }), z.object({ kind: z.literal("sarif.process"), repoId: z.string(), uploadId: z.string() }), ]); export type SecurityMessage = z.infer; export async function enqueueSecurity(messages: SecurityMessage[]): Promise { if (messages.length === 0) return; await env.SECURITY_QUEUE.sendBatch(messages.map((body) => ({ body }))); } const MANIFEST_HINT = /(^|\/)(package(-lock)?\.json|npm-shrinkwrap\.json|bun\.lock|yarn\.lock|pnpm-lock\.yaml|requirements(-dev)?\.txt|pyproject\.toml|Pipfile\.lock|go\.mod|Cargo\.(toml|lock)|Gemfile\.lock|composer\.(json|lock))$/; /** Fans a landed push out to bounded, retryable security jobs on the security queue. */ export async function queuePushSecurity(push: { repoId: string; ref: string; before: string; after: string; isDefaultBranch: boolean; commitShas: string[]; changedPaths: string[] | null; }): Promise { if (push.after === ZERO_SHA) return 0; const messages: SecurityMessage[] = [ { kind: "secrets.push", repoId: push.repoId, ref: push.ref, before: push.before, after: push.after }, ]; const touchesManifests = push.changedPaths === null || push.changedPaths.some((path) => MANIFEST_HINT.test(path)); if (push.isDefaultBranch && touchesManifests) messages.push({ kind: "deps.scan", repoId: push.repoId, sha: push.after }); const shas = push.commitShas.slice(0, MAX_PUSH_SIGNATURES); if (shas.length > 0) messages.push({ kind: "signatures.verify", repoId: push.repoId, shas }); await enqueueSecurity(messages); return messages.length; } async function handle(message: SecurityMessage): Promise { if (message.kind === "secrets.push") { await scanPushForSecrets(message.repoId, message.before, message.after); } else if (message.kind === "deps.scan") { await scanDependencies(message.repoId, message.sha); } else if (message.kind === "signatures.verify") { const repo = await loadRepoById(message.repoId); if (repo) await verifyCommits(repo, message.shas); } else { await processSarifUpload(message.repoId, message.uploadId); } } /** Consumer for the security queue; malformed messages are dropped, failures retried with backoff. */ export async function handleSecurityBatch(batch: MessageBatch): Promise { for (const message of batch.messages) { const parsed = securityMessage.safeParse(message.body); if (!parsed.success) { message.ack(); continue; } try { await handle(parsed.data); message.ack(); } catch (error) { console.error("Security job failed", parsed.data.kind, error); message.retry({ delaySeconds: RETRY_SECONDS * message.attempts }); } } }