import { z } from "zod"; import { credentialAllows, requestCredential } from "../token-scope"; import { repoDb } from "../scoped-db"; import { badRequest, HttpError, notFoundError, unauthorized, forbidden } from "../errors"; import { limitHeavy, limitWrite } from "../limits"; import { requireRepoAccess } from "../repos"; import { isReachableCommit } from "../git/reachable"; import { listRefs } from "../git/refs"; import type { RepoRow } from "~/lib/types"; 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(); } /** * The ref must exist (branch, tag or an open pull's head) and the commit must be reachable in this repo, * so an upload cannot plant annotations on a stranger's pull or mark a branch's alerts fixed from thin air. */ async function assertUploadTarget(repo: RepoRow, ref: string, commitSha: string): Promise { const refs = await listRefs(repo); const pullHead = /^refs\/pull\/(\d+)\/head$/.exec(ref); let known = false; if (pullHead) { const pull = await repoDb(repo.id).first<{ head_sha: string }>( `SELECT p.head_sha FROM pull_requests p JOIN issues i ON i.id = p.issue_id WHERE p.repo_id = ? AND i.number = ? AND i.is_pull = 1`, repo.id, Number(pullHead[1]), ); known = pull !== null; } else { known = refs.branches.some((item) => `refs/heads/${item.name}` === ref) || refs.tags.some((item) => `refs/tags/${item.name}` === ref); } if (!known) throw badRequest(`${ref} does not exist in this repository`); if (!(await isReachableCommit(repo, commitSha))) throw badRequest("commit_sha is not a commit in this repository"); } async function upload(request: Request, owner: string, name: string): Promise { const credential = await requestCredential(request, false); const viewer = credential.viewer; if (!viewer) throw unauthorized("A personal access token is required"); await limitWrite(viewer); const { repo } = await requireRepoAccess(owner, name, viewer, "write"); if (!(await credentialAllows(repo, credential, "contents", "write"))) throw forbidden("Token lacks contents: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"); await assertUploadTarget(repo, parsed.data.ref, parsed.data.commit_sha); 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", repoId: repo.id, 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 credential = await requestCredential(request, false); const viewer = credential.viewer; await limitHeavy(viewer, request); const { repo } = await requireRepoAccess(owner, name, viewer, "write"); if (!(await credentialAllows(repo, credential, "contents", "write"))) throw forbidden("Token lacks contents:write"); const row = await repoDb(repo.id).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 gitfor. */ 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; }