import { env } from "cloudflare:workers"; import { chunks } from "../db"; import { repoDb, stmt, type ScopedStatement } from "../scoped-db"; import { fromBase64, randomId, sha256Hex } from "../crypto"; import { badRequest } from "../errors"; import { parseSarif } from "./sarif"; import type { SarifResult } from "./sarif"; import { notifyCodeScanningAlerts } from "../notify/security"; import { reserveSecurityNumbers } from "./settings"; const MAX_SARIF_BYTES = 10 * 1024 * 1024; const MAX_UPLOADS_PER_DAY = 200; const DAY_MS = 24 * 60 * 60 * 1000; const STATEMENTS_PER_BATCH = 50; const MAX_TOOLS_JSON = 4096; const ALERT_PAGE = 50; const GZIP_MAGIC = [0x1f, 0x8b]; export const SARIF_LIMITS = { maxBytes: MAX_SARIF_BYTES, uploadsPerDay: MAX_UPLOADS_PER_DAY }; export function sarifKey(repoId: string, uploadId: string): string { return `sarif/${repoId}/${uploadId}.json`; } /** Accepts GitHub's encoding (base64 of gzipped SARIF) as well as base64 of plain JSON. */ export async function decodeSarifPayload(encoded: string): Promise> { let bytes: Uint8Array; try { bytes = fromBase64(encoded.replace(/\s+/g, "")); } catch { throw badRequest("sarif must be base64 encoded"); } if (bytes[0] !== GZIP_MAGIC[0] || bytes[1] !== GZIP_MAGIC[1]) return bytes; const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); const reader = stream.getReader(); const parts: Uint8Array[] = []; let total = 0; while (true) { const { value, done } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_SARIF_BYTES) { await reader.cancel(); throw badRequest(`SARIF exceeds ${MAX_SARIF_BYTES} bytes once decompressed`); } parts.push(value); } const out = new Uint8Array(total); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.byteLength; } return out; } export async function assertUploadBudget(repoId: string): Promise { const row = await repoDb(repoId).first<{ count: number }>( "SELECT COUNT(*) AS count FROM sarif_uploads WHERE repo_id = ? AND created_at > ?", repoId, Date.now() - DAY_MS, ); if ((row?.count ?? 0) >= MAX_UPLOADS_PER_DAY) { throw badRequest(`At most ${MAX_UPLOADS_PER_DAY} SARIF uploads per repository per day`); } } export async function storeSarifUpload(input: { repoId: string; uploaderId: string; commitSha: string; ref: string; sarif: Uint8Array; }): Promise { const id = randomId(); const key = sarifKey(input.repoId, id); await env.BUCKET.put(key, input.sarif, { httpMetadata: { contentType: "application/sarif+json" } }); await repoDb(input.repoId).run( `INSERT INTO sarif_uploads (id, repo_id, uploader_id, commit_sha, ref, r2_key, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, id, input.repoId, input.uploaderId, input.commitSha, input.ref, key, input.sarif.byteLength, Date.now(), ); return id; } async function fingerprintOf(result: SarifResult): Promise { if (result.fingerprint) return result.fingerprint.slice(0, 200); return sha256Hex(`${result.ruleId}\0${result.path ?? ""}\0${result.message.replace(/\s+/g, " ").trim()}`); } type UploadRow = { id: string; repo_id: string; commit_sha: string; ref: string; r2_key: string; status: string }; /** Queue-side ingestion: parses the stored SARIF, upserts alerts per (ref, tool) and marks vanished ones fixed. */ export async function processSarifUpload(repoId: string, uploadId: string): Promise { const db = repoDb(repoId); const upload = await db.first("SELECT * FROM sarif_uploads WHERE id = ?", uploadId); if (!upload || upload.status !== "pending") return; const object = await env.BUCKET.get(upload.r2_key); if (!object) { await db.run( "UPDATE sarif_uploads SET status = 'failed', error = ?, processed_at = ? WHERE id = ?", "SARIF missing", Date.now(), uploadId, ); return; } let parsed: ReturnType; try { parsed = parseSarif(await object.json()); } catch (error) { const message = error instanceof Error ? error.message.slice(0, 500) : "Invalid SARIF"; await db.run( "UPDATE sarif_uploads SET status = 'failed', error = ?, processed_at = ? WHERE id = ?", message, Date.now(), uploadId, ); return; } const now = Date.now(); const statements: ScopedStatement[] = []; let freshAlerts = 0; const byTool = new Map(); for (const item of parsed.results) { const bucket = byTool.get(item.tool); if (bucket) bucket.push(item); else byTool.set(item.tool, [item]); } for (const tool of parsed.tools) { const results = byTool.get(tool) ?? []; const existing = new Set( ( await db.all<{ fingerprint: string }>( "SELECT fingerprint FROM code_scanning_alerts WHERE repo_id = ? AND ref = ? AND tool = ?", upload.repo_id, upload.ref, tool, ) ).map((row) => row.fingerprint), ); const unique = new Map(); for (const item of results) unique.set(await fingerprintOf(item), item); const fresh = [...unique.keys()].filter((fingerprint) => !existing.has(fingerprint)); freshAlerts += fresh.length; let number = fresh.length > 0 ? await reserveSecurityNumbers(upload.repo_id, "code", fresh.length) : 0; for (const [fingerprint, item] of unique) { statements.push( stmt( `INSERT INTO code_scanning_alerts (id, repo_id, number, ref, tool, rule_id, rule_name, severity, security_severity, message, path, start_line, end_line, commit_sha, fingerprint, last_upload_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (repo_id, ref, tool, fingerprint) DO UPDATE SET rule_name = excluded.rule_name, severity = excluded.severity, security_severity = excluded.security_severity, message = excluded.message, path = excluded.path, start_line = excluded.start_line, end_line = excluded.end_line, commit_sha = excluded.commit_sha, last_upload_id = excluded.last_upload_id, updated_at = excluded.updated_at, state = CASE WHEN code_scanning_alerts.state = 'dismissed' THEN 'dismissed' ELSE 'open' END`, randomId(), upload.repo_id, existing.has(fingerprint) ? 0 : number++, upload.ref, tool, item.ruleId, item.ruleName, item.severity, item.securitySeverity, item.message, item.path, item.startLine, item.endLine, upload.commit_sha, fingerprint, upload.id, now, now, ), ); } // An upload with no results for a tool is not evidence that its alerts were fixed (INT-A16): only a scan // that reported something may retire the alerts it no longer reports. if (results.length === 0) continue; statements.push( stmt( `UPDATE code_scanning_alerts SET state = 'fixed', updated_at = ? WHERE repo_id = ? AND ref = ? AND tool = ? AND last_upload_id != ? AND state = 'open'`, now, upload.repo_id, upload.ref, tool, upload.id, ), ); } for (const group of chunks(statements, STATEMENTS_PER_BATCH)) await db.batch(group); await db.run( "UPDATE sarif_uploads SET status = 'complete', tools = ?, results_count = ?, error = ?, processed_at = ? WHERE id = ?", JSON.stringify(parsed.tools).slice(0, MAX_TOOLS_JSON), parsed.results.length, parsed.truncated ? "Results were truncated to the per-upload limit" : null, now, uploadId, ); await notifyCodeScanningAlerts(upload.repo_id, upload.ref, freshAlerts, parsed.tools); } export type CodeAlert = { number: number; ref: string; tool: string; ruleId: string; ruleName: string | null; severity: string; securitySeverity: string | null; message: string; path: string | null; startLine: number | null; endLine: number | null; commitSha: string; state: "open" | "fixed" | "dismissed"; dismissedReason: string | null; createdAt: number; updatedAt: number; }; type CodeAlertRow = { number: number; ref: string; tool: string; rule_id: string; rule_name: string | null; severity: string; security_severity: string | null; message: string; path: string | null; start_line: number | null; end_line: number | null; commit_sha: string; state: CodeAlert["state"]; dismissed_reason: string | null; created_at: number; updated_at: number; }; function toCodeAlert(row: CodeAlertRow): CodeAlert { return { number: row.number, ref: row.ref, tool: row.tool, ruleId: row.rule_id, ruleName: row.rule_name, severity: row.severity, securitySeverity: row.security_severity, message: row.message, path: row.path, startLine: row.start_line, endLine: row.end_line, commitSha: row.commit_sha, state: row.state, dismissedReason: row.dismissed_reason, createdAt: row.created_at, updatedAt: row.updated_at, }; } export async function listCodeAlerts( repoId: string, filter: { ref: string; state: CodeAlert["state"]; page: number }, ): Promise<{ alerts: CodeAlert[]; hasMore: boolean; refs: string[] }> { const rows = await repoDb(repoId).all( `SELECT * FROM code_scanning_alerts WHERE repo_id = ? AND ref = ? AND state = ? ORDER BY CASE severity WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, number DESC LIMIT ? OFFSET ?`, repoId, filter.ref, filter.state, ALERT_PAGE + 1, (filter.page - 1) * ALERT_PAGE, ); const refs = await repoDb(repoId).all<{ ref: string }>( "SELECT DISTINCT ref FROM code_scanning_alerts WHERE repo_id = ? ORDER BY ref LIMIT 50", repoId, ); return { alerts: rows.slice(0, ALERT_PAGE).map(toCodeAlert), hasMore: rows.length > ALERT_PAGE, refs: refs.map((row) => row.ref), }; } export async function loadCodeAlert(repoId: string, number: number): Promise { const row = await repoDb(repoId).first( "SELECT * FROM code_scanning_alerts WHERE repo_id = ? AND number = ?", repoId, number, ); return row ? toCodeAlert(row) : null; } export async function setCodeAlertDismissed( repoId: string, number: number, reason: string | null, userId: string, ): Promise { const result = await repoDb(repoId).run( `UPDATE code_scanning_alerts SET state = ?, dismissed_reason = ?, dismissed_by = ?, updated_at = ? WHERE repo_id = ? AND number = ? AND state != 'fixed'`, reason ? "dismissed" : "open", reason, reason ? userId : null, Date.now(), repoId, number, ); return result.meta.changes > 0; } const PULL_ANNOTATION_LIMIT = 200; /** Open alerts reported against a pull request's head commit or its pull refs, for diff annotations. */ export async function pullCodeAlerts(repoId: string, prNumber: number, headSha: string, headRef: string) { const rows = await repoDb(repoId).all( `SELECT * FROM code_scanning_alerts WHERE repo_id = ? AND state = 'open' AND (commit_sha = ? OR ref IN (?, ?, ?)) ORDER BY path, start_line LIMIT ?`, repoId, headSha, `refs/pull/${prNumber}/head`, `refs/pull/${prNumber}/merge`, `refs/heads/${headRef}`, PULL_ANNOTATION_LIMIT, ); return rows.map(toCodeAlert); } export async function listSarifUploads(repoId: string) { return repoDb(repoId).all<{ id: string; ref: string; commit_sha: string; status: string; error: string | null; tools: string | null; results_count: number | null; created_at: number; }>( `SELECT id, ref, commit_sha, status, error, tools, results_count, created_at FROM sarif_uploads WHERE repo_id = ? ORDER BY created_at DESC LIMIT 20`, repoId, ); } export async function codeAlertCounts(repoId: string, ref: string): Promise { const row = await repoDb(repoId).first<{ count: number }>( "SELECT COUNT(*) AS count FROM code_scanning_alerts WHERE repo_id = ? AND ref = ? AND state = 'open'", repoId, ref, ); return row?.count ?? 0; }