import { z } from "zod"; import { viewerFromRequest } from "../auth"; import { first } from "../db"; import { badRequest, HttpError, notFoundError, unauthorized } from "../errors"; import { limitHeavy, limitWrite } from "../limits"; import { requireRepoAccess } from "../repos"; import { assertUploadBudget, decodeSarifPayload, SARIF_LIMITS, storeSarifUpload } from "./code-scanning"; import { enqueueSecurity } from "./queue"; const SARIF_UPLOAD_PATH = /^\/api\/repos\/([^/]+)\/([^/]+)\/code-scanning\/sarifs$/; const SARIF_STATUS_PATH = /^\/api\/repos\/([^/]+)\/([^/]+)\/code-scanning\/sarifs\/([a-z0-9]+)$/; const BASE64_OVERHEAD = 4 / 3; const MAX_BODY_BYTES = Math.ceil(SARIF_LIMITS.maxBytes * BASE64_OVERHEAD) + 4096; const HTTP_ACCEPTED = 202; const HTTP_TOO_LARGE = 413; const uploadBody = z.object({ commit_sha: z.string().regex(/^[0-9a-f]{40}$/, "commit_sha must be a full SHA"), ref: z .string() .regex(/^refs\/(heads|tags|pull)\/.+/, "ref must look like refs/heads/ or refs/pull//head") .max(255), sarif: z.string().min(1), }); async function readBody(request: Request): Promise { const declared = Number(request.headers.get("content-length") ?? "0"); if (declared > MAX_BODY_BYTES) throw new HttpError(HTTP_TOO_LARGE, "SARIF upload too large"); if (!request.body) return ""; const reader = request.body.getReader(); const decoder = new TextDecoder(); let text = ""; let total = 0; while (true) { const { value, done } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_BODY_BYTES) { await reader.cancel(); throw new HttpError(HTTP_TOO_LARGE, "SARIF upload too large"); } text += decoder.decode(value, { stream: true }); } return text + decoder.decode(); } async function upload(request: Request, owner: string, name: string): Promise { const viewer = await viewerFromRequest(request); if (!viewer) throw unauthorized("A personal access token is required"); await limitWrite(viewer); const { repo } = await requireRepoAccess(owner, name, viewer, "write"); await assertUploadBudget(repo.id); let json: unknown; try { json = JSON.parse(await readBody(request)); } catch (error) { if (error instanceof HttpError) throw error; throw badRequest("Body must be JSON"); } const parsed = uploadBody.safeParse(json); if (!parsed.success) throw badRequest(parsed.error.issues[0]?.message ?? "Invalid upload"); const sarif = await decodeSarifPayload(parsed.data.sarif); if (sarif.byteLength > SARIF_LIMITS.maxBytes) throw new HttpError(HTTP_TOO_LARGE, "SARIF upload too large"); const id = await storeSarifUpload({ repoId: repo.id, uploaderId: viewer.id, commitSha: parsed.data.commit_sha, ref: parsed.data.ref, sarif, }); await enqueueSecurity([{ kind: "sarif.process", uploadId: id }]); return Response.json( { id, url: new URL(`/api/repos/${repo.owner_name}/${repo.name}/code-scanning/sarifs/${id}`, request.url).toString(), }, { status: HTTP_ACCEPTED }, ); } async function status(request: Request, owner: string, name: string, id: string): Promise { const viewer = await viewerFromRequest(request); await limitHeavy(viewer, request); const { repo } = await requireRepoAccess(owner, name, viewer, "write"); const row = await first<{ status: string; error: string | null; results_count: number | null }>( "SELECT status, error, results_count FROM sarif_uploads WHERE id = ? AND repo_id = ?", id, repo.id, ); if (!row) throw notFoundError("Upload not found"); return Response.json({ processing_status: row.status, errors: row.error ? [row.error] : null, results_count: row.results_count, }); } /** GitHub-compatible SARIF upload API so existing `upload-sarif` style tooling can target stig. */ export async function handleSecurityApi(request: Request, url: URL): Promise { const uploadMatch = SARIF_UPLOAD_PATH.exec(url.pathname); if (uploadMatch && request.method === "POST") return upload(request, uploadMatch[1], uploadMatch[2]); const statusMatch = SARIF_STATUS_PATH.exec(url.pathname); if (statusMatch && request.method === "GET") return status(request, statusMatch[1], statusMatch[2], statusMatch[3]); return null; }