import { z } from "zod"; import { MAX_IN_LIST } from "../db"; import { forbidden, notFoundError } from "../errors"; import { limitBrowse, limitWrite } from "../limits"; import { loadPull } from "../pulls"; import { branchSha, listRefs } from "../git/refs"; import { hasPermission, repoPermission } from "../repos"; import { advisoryCounts, canManageAdvisories } from "../security/advisories"; import { codeAlertCounts, listCodeAlerts, listSarifUploads, loadCodeAlert, pullCodeAlerts, setCodeAlertDismissed, } from "../security/code-scanning"; import { listDependencies } from "../security/dependencies"; import { loadSecurityPolicy } from "../security/policy"; import { bypassPushBlock, loadPushBlock } from "../security/push-protection"; import { enqueueSecurity } from "../security/queue"; import { listSecretAlerts, requiresAdminResolution, secretAlertCounts, setSecretAlertState, } from "../security/secret-alerts"; import { loadSecuritySettings, saveSecuritySettings } from "../security/settings"; import { loadSignatures } from "../security/signatures"; import { credentialCoversRepo, scopedRepoPermission } from "../token-scope"; import { noContent, readJson, requireScopedAdminWrite, requireScopedRepo, unprocessable } from "./http"; import type { ApiContext, ScopedRepo } from "./http"; import type { Permission } from "~/lib/types"; const MAX_VERIFY_ON_VIEW = 20; const MAX_PAGE = 1000; const MAX_REF_LENGTH = 255; const MAX_QUERY_LENGTH = 100; const GHSA_ID = /^GITFOR-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/; function query(ctx: ApiContext, key: string): string | null { return ctx.url.searchParams.get(key); } function pageParam(ctx: ApiContext): number { const raw = query(ctx, "page"); if (raw === null) return 1; const page = Number(raw); if (!Number.isSafeInteger(page) || page < 1 || page > MAX_PAGE) throw unprocessable("page is out of range"); return page; } function enumParam(ctx: ApiContext, key: string, allowed: readonly T[], fallback: T): T { const raw = query(ctx, key); if (raw === null) return fallback; if (!(allowed as readonly string[]).includes(raw)) throw unprocessable(`${key} must be one of ${allowed.join(", ")}`); return raw as T; } export function ghsa(raw: string): string { if (!GHSA_ID.test(raw)) throw notFoundError("Advisory not found"); return raw; } /** * `identity` is the viewer the advisory helpers may treat as reporter or invited collaborator: only when the * credential's grant covers the repository, so a token can't open embargoed advisories outside its repository list. */ export type AdvisoryAccess = ScopedRepo & { identity: ApiContext["viewer"] | null }; /** Contents read for visibility; manager rights additionally require the token's admin area, like other admin APIs. */ export async function advisoryAccess(ctx: ApiContext, owner: string, name: string): Promise { const scoped = await requireScopedRepo(ctx, owner, name, "contents", "read"); const admin = await scopedRepoPermission(scoped.repo, ctx.credential, "admin"); const permission: Permission = canManageAdvisories(admin) ? admin : scoped.permission; const covered = await credentialCoversRepo(ctx.credential, scoped.repo.id); return { repo: scoped.repo, permission, identity: covered ? ctx.viewer : null }; } /** Alert reads: a contents:read token suffices, but the user must be a writer since alerts can quote secrets. */ async function requireAlertReader(ctx: ApiContext, owner: string, name: string): Promise { const scoped = await requireScopedRepo(ctx, owner, name, "contents", "read"); if (!(await credentialCoversRepo(ctx.credential, scoped.repo.id))) throw forbidden(); if (!hasPermission(await repoPermission(scoped.repo, ctx.viewer), "write")) throw forbidden(); return scoped; } export async function requireAdvisoryAdmin(ctx: ApiContext, owner: string, name: string): Promise { const access = await advisoryAccess(ctx, owner, name); if (!canManageAdvisories(access.permission)) throw forbidden(); return access; } export async function getSecurityOverviewApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo, permission } = await advisoryAccess(ctx, owner, name); const canWrite = hasPermission(await scopedRepoPermission(repo, ctx.credential, "contents"), "write"); const [settings, policy, advisories] = await Promise.all([ loadSecuritySettings(repo.id), loadSecurityPolicy(repo), advisoryCounts(repo.id, permission), ]); const [secrets, codeAlerts] = canWrite ? await Promise.all([secretAlertCounts(repo.id), codeAlertCounts(repo.id, `refs/heads/${repo.default_branch}`)]) : [null, null]; return Response.json({ settings, policy, advisories, secrets, code_alerts: codeAlerts, can_write: canWrite, can_admin: canManageAdvisories(permission), can_report: settings.privateReporting, }); } const settingsInput = z.object({ secret_scanning: z.boolean(), push_protection: z.boolean(), private_reporting: z.boolean(), dependency_graph: z.boolean(), }); export async function putSecuritySettingsApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, settingsInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedAdminWrite(ctx, owner, name); await saveSecuritySettings(repo.id, { secretScanning: data.secret_scanning, pushProtection: data.push_protection, privateReporting: data.private_reporting, dependencyGraph: data.dependency_graph, }); if (data.dependency_graph) { const head = branchSha(await listRefs(repo), repo.default_branch); if (head) await enqueueSecurity([{ kind: "deps.scan", repoId: repo.id, sha: head }]); } return noContent(); } export async function listSecretAlertsApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireAlertReader(ctx, owner, name); const state = enumParam(ctx, "state", ["open", "resolved"] as const, "open"); const [list, counts] = await Promise.all([ listSecretAlerts(repo.id, state, pageParam(ctx)), secretAlertCounts(repo.id), ]); return Response.json({ ...list, counts }); } const secretResolutionInput = z.object({ resolution: z.enum(["revoked", "false_positive", "used_in_tests", "wont_fix"]).nullable(), }); export async function updateSecretAlertApi( ctx: ApiContext, owner: string, name: string, number: number, ): Promise { const data = await readJson(ctx.request, secretResolutionInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "write"); const isAdmin = requiresAdminResolution(data.resolution) ? Boolean(await requireScopedRepo(ctx, owner, name, "admin", "admin")) : false; if (!(await setSecretAlertState(repo.id, number, data.resolution, ctx.viewer.id, isAdmin))) { throw notFoundError("Alert not found"); } return noContent(); } export async function getPushBlockApi(ctx: ApiContext, owner: string, name: string, id: string): Promise { const { repo } = await requireAlertReader(ctx, owner, name); return Response.json(await loadPushBlock(repo.id, id)); } const bypassInput = z.object({ reason: z.enum(["false_positive", "used_in_tests", "fix_later"]) }); export async function bypassPushBlockApi(ctx: ApiContext, owner: string, name: string, id: string): Promise { const data = await readJson(ctx.request, bypassInput); await limitWrite(ctx.viewer); const { repo, permission } = await requireScopedRepo(ctx, owner, name, "contents", "write"); const admin = await scopedRepoPermission(repo, ctx.credential, "admin"); await bypassPushBlock(repo.id, id, ctx.viewer.id, data.reason, permission === "admin" && admin === "admin"); return noContent(); } export async function listCodeAlertsApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireAlertReader(ctx, owner, name); const ref = query(ctx, "ref") ?? `refs/heads/${repo.default_branch}`; if (ref.length > MAX_REF_LENGTH) throw unprocessable("ref is too long"); const state = enumParam(ctx, "state", ["open", "fixed", "dismissed"] as const, "open"); const [list, uploads] = await Promise.all([ listCodeAlerts(repo.id, { ref, state, page: pageParam(ctx) }), listSarifUploads(repo.id), ]); return Response.json({ ...list, ref, uploads }); } export async function getCodeAlertApi(ctx: ApiContext, owner: string, name: string, number: number) { const { repo } = await requireAlertReader(ctx, owner, name); const alert = await loadCodeAlert(repo.id, number); if (!alert) throw notFoundError("Alert not found"); return Response.json(alert); } const dismissInput = z.object({ reason: z.enum(["false_positive", "wont_fix", "used_in_tests"]).nullable() }); export async function updateCodeAlertApi(ctx: ApiContext, owner: string, name: string, number: number) { const data = await readJson(ctx.request, dismissInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "write"); if (!(await setCodeAlertDismissed(repo.id, number, data.reason, ctx.viewer.id))) { throw notFoundError("Alert not found, or it is already fixed"); } return noContent(); } export async function getPullCodeAlertsApi(ctx: ApiContext, owner: string, name: string, number: number) { const { repo, permission } = await requireScopedRepo(ctx, owner, name, "pulls", "read"); const contents = await scopedRepoPermission(repo, ctx.credential, "contents"); if (!hasPermission(permission, "write") && !hasPermission(contents, "write")) return Response.json([]); const pull = await loadPull(repo.id, number); if (!pull) throw notFoundError("Pull request not found"); return Response.json(await pullCodeAlerts(repo.id, number, pull.head_sha, pull.head_ref)); } const ECOSYSTEMS = ["npm", "pip", "go", "cargo", "rubygems", "composer"] as const; export async function getDependenciesApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); const search = query(ctx, "query") ?? ""; if (search.length > MAX_QUERY_LENGTH) throw unprocessable("query is too long"); const rawEcosystem = query(ctx, "ecosystem"); const ecosystem = rawEcosystem === null ? null : enumParam(ctx, "ecosystem", ECOSYSTEMS, "npm"); const settings = await loadSecuritySettings(repo.id); if (!settings.dependencyGraph) { return Response.json({ enabled: false, manifests: [], dependencies: [], hasMore: false }); } return Response.json({ enabled: true, ...(await listDependencies(repo.id, { query: search, ecosystem, page: pageParam(ctx) })), }); } const signaturesInput = z.object({ shas: z .array(z.string().regex(/^[0-9a-f]{40}$/, "shas must be full SHAs")) .min(1) .max(MAX_IN_LIST), }); /** Returns recorded verification results and queues verification for unchecked commits; callers poll. */ export async function getCommitSignaturesApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, signaturesInput); await limitBrowse(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); const known = await loadSignatures(repo, data.shas); const seen = new Set(known.map((info) => info.sha)); const missing = data.shas.filter((candidate) => !seen.has(candidate)).slice(0, MAX_VERIFY_ON_VIEW); if (missing.length > 0) await enqueueSecurity([{ kind: "signatures.verify", repoId: repo.id, shas: missing }]); return Response.json({ signatures: known, pending: missing }); }