import { z } from "zod"; import { badRequest, forbidden, notFoundError } from "../errors"; import { limitWrite, limitAccountWrite } from "../limits"; import { addAdvisoryComment, advisoryDetail, canManageAdvisories, createAdvisory, listAdvisories, setAdvisoryCollaborator, setAdvisoryState, updateAdvisory, } from "../security/advisories"; import { loadSecuritySettings } from "../security/settings"; import { addSigningKey, deleteSigningKey, listSigningKeys } from "../security/signatures"; import { noContent, readJson, requireAccountScope } from "./http"; import type { ApiContext } from "./http"; import { advisoryAccess, ghsa, requireAdvisoryAdmin } from "./security"; import { accountName, markdownBody, title } from "~/lib/validation"; const optionalText = (max: number) => z .string() .trim() .max(max) .transform((value) => value || null) .nullable() .default(null); const advisoryFields = z.object({ title, description: markdownBody.min(1, "Describe the vulnerability"), severity: z.enum(["low", "moderate", "high", "critical"]), cve_id: optionalText(32).refine((value) => value === null || /^CVE-\d{4}-\d{4,}$/.test(value), "Use CVE-YYYY-NNNN"), cwe_ids: optionalText(200), ecosystem: z.enum(["npm", "pip", "go", "cargo", "rubygems", "composer", "other"]).nullable().default(null), package_name: optionalText(214), affected_versions: optionalText(200), patched_versions: optionalText(200), }); function advisoryInput(data: z.infer) { return { title: data.title, description: data.description, severity: data.severity, cveId: data.cve_id, cweIds: data.cwe_ids, ecosystem: data.ecosystem, packageName: data.package_name, affectedVersions: data.affected_versions, patchedVersions: data.patched_versions, }; } export async function listAdvisoriesApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo, permission, identity } = await advisoryAccess(ctx, owner, name); return Response.json(await listAdvisories(repo, identity, permission)); } const submitInput = advisoryFields.extend({ mode: z.enum(["draft", "report"]) }); /** `draft` is the maintainer flow; `report` is private vulnerability reporting, open to any reader when enabled. */ export async function createAdvisoryApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, submitInput); await limitWrite(ctx.viewer); const { repo, permission, identity } = await advisoryAccess(ctx, owner, name); if (!identity) throw forbidden("This token does not cover this repository"); if (data.mode === "draft") { if (!canManageAdvisories(permission)) throw forbidden(); } else if (!(await loadSecuritySettings(repo.id)).privateReporting) { throw badRequest("Private vulnerability reporting is not enabled for this repository"); } const id = await createAdvisory( repo, ctx.viewer, advisoryInput(data), data.mode === "draft" ? "maintainer" : "report", ); return Response.json({ ghsa_id: id }, { status: 201 }); } export async function getAdvisoryApi(ctx: ApiContext, owner: string, name: string, id: string): Promise { const { repo, permission, identity } = await advisoryAccess(ctx, owner, name); return Response.json(await advisoryDetail(repo, ghsa(id), identity, permission)); } export async function updateAdvisoryApi(ctx: ApiContext, owner: string, name: string, id: string): Promise { const data = await readJson(ctx.request, advisoryFields); await limitWrite(ctx.viewer); const { repo } = await requireAdvisoryAdmin(ctx, owner, name); await updateAdvisory(repo, ghsa(id), advisoryInput(data)); return noContent(); } const stateInput = z.object({ state: z.enum(["draft", "published", "closed"]) }); export async function setAdvisoryStateApi(ctx: ApiContext, owner: string, name: string, id: string) { const data = await readJson(ctx.request, stateInput); await limitWrite(ctx.viewer); const { repo } = await requireAdvisoryAdmin(ctx, owner, name); await setAdvisoryState(repo, ghsa(id), data.state); return noContent(); } const commentInput = z.object({ body: markdownBody.min(1) }); export async function commentAdvisoryApi(ctx: ApiContext, owner: string, name: string, id: string) { const data = await readJson(ctx.request, commentInput); await limitWrite(ctx.viewer); const { repo, permission, identity } = await advisoryAccess(ctx, owner, name); if (!identity) throw forbidden("This token does not cover this repository"); await addAdvisoryComment(repo, ghsa(id), identity, permission, data.body); return new Response(null, { status: 201 }); } export async function setAdvisoryCollaboratorApi( ctx: ApiContext, owner: string, name: string, id: string, username: string, add: boolean, ): Promise { const user = accountName.safeParse(username); if (!user.success) throw notFoundError("User not found"); await limitWrite(ctx.viewer); const { repo } = await requireAdvisoryAdmin(ctx, owner, name); await setAdvisoryCollaborator(repo, ghsa(id), user.data, add); return noContent(); } export async function listSigningKeysApi(ctx: ApiContext): Promise { requireAccountScope(ctx); return Response.json(await listSigningKeys(ctx.viewer.id)); } const signingKeyInput = z.object({ kind: z.enum(["ssh", "gpg"]), title: z.string().trim().min(1, "Title is required").max(100), public_key: z .string() .trim() .min(1, "Paste a public key") .max(64 * 1024), }); export async function createSigningKeyApi(ctx: ApiContext): Promise { const data = await readJson(ctx.request, signingKeyInput); requireAccountScope(ctx); await limitAccountWrite(ctx.viewer); const created = await addSigningKey(ctx.viewer.id, data.kind, data.title, data.public_key); return Response.json(created, { status: 201 }); } export async function deleteSigningKeyApi(ctx: ApiContext, id: string): Promise { requireAccountScope(ctx); await limitAccountWrite(ctx.viewer); await deleteSigningKey(ctx.viewer.id, id); return noContent(); }