import { first } from "../db"; import { repoDb, stmt } from "../scoped-db"; import { registerLocator } from "../scoped-db/locators"; import { scopedId } from "../scoped-db/ids"; import { accountsById } from "../people"; import { indexAdvisory } from "./advisory-packages"; import { resolveMentions } from "../mentions"; import { randomId } from "../crypto"; import { badRequest, forbidden, notFoundError } from "../errors"; import { assertBelowCap } from "../limits"; import { hasPermission, repoPermission } from "../repos"; import { renderMarkdown } from "../markdown"; import { notifyAdvisoryUpdate } from "../notify/security"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; const ID_ALPHABET = "23456789cfghjmpqrvwx"; const ID_GROUPS = 3; const ID_GROUP_LENGTH = 4; const MAX_OPEN_REPORTS_PER_USER = 5; const MAX_ADVISORIES_PER_REPO = 500; const MAX_COMMENTS_PER_ADVISORY = 500; const MAX_COLLABORATORS = 20; const ADVISORY_PAGE = 100; export type AdvisoryState = "triage" | "draft" | "published" | "closed"; export type Severity = "low" | "moderate" | "high" | "critical"; export type AdvisoryInput = { title: string; description: string; severity: Severity; cveId: string | null; cweIds: string | null; ecosystem: string | null; packageName: string | null; affectedVersions: string | null; patchedVersions: string | null; }; type AdvisoryRow = { id: string; repo_id: string; ghsa_id: string; title: string; description: string; severity: Severity; cve_id: string | null; cwe_ids: string | null; ecosystem: string | null; package_name: string | null; affected_versions: string | null; patched_versions: string | null; state: AdvisoryState; source: "maintainer" | "report"; author_id: string | null; author_name: string | null; published_at: number | null; created_at: number; updated_at: number; }; export type AdvisorySummary = { ghsaId: string; title: string; severity: Severity; state: AdvisoryState; source: "maintainer" | "report"; author: string | null; packageName: string | null; publishedAt: number | null; updatedAt: number; }; function generateGhsaId(): string { const bytes = crypto.getRandomValues(new Uint8Array(ID_GROUPS * ID_GROUP_LENGTH)); const chars = [...bytes].map((byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join(""); const groups = Array.from({ length: ID_GROUPS }, (_, index) => chars.slice(index * ID_GROUP_LENGTH, (index + 1) * ID_GROUP_LENGTH), ); return `GITFOR-${groups.join("-")}`; } const SELECT = "SELECT sa.* FROM security_advisories sa"; type StoredAdvisory = Omit; async function withAuthors(rows: StoredAdvisory[]): Promise { const authors = await accountsById(rows.map((row) => row.author_id)); return rows.map((row) => ({ ...row, author_name: row.author_id ? (authors.get(row.author_id)?.name ?? null) : null, })); } function toSummary(row: AdvisoryRow): AdvisorySummary { return { ghsaId: row.ghsa_id, title: row.title, severity: row.severity, state: row.state, source: row.source, author: row.author_name, packageName: row.package_name, publishedAt: row.published_at, updatedAt: row.updated_at, }; } export function canManageAdvisories(permission: Permission): boolean { return hasPermission(permission, "admin"); } /** Unpublished advisories are private to admins, the reporter/author and invited collaborators. */ async function canSeePrivate(row: AdvisoryRow, viewer: Viewer | null, permission: Permission): Promise { if (canManageAdvisories(permission)) return true; if (!viewer) return false; if (row.author_id === viewer.id) return true; const collaborator = await repoDb(row.repo_id).first<{ ok: number }>( "SELECT 1 AS ok FROM advisory_collaborators WHERE advisory_id = ? AND user_id = ?", row.id, viewer.id, ); return collaborator !== null; } export async function listAdvisories(repo: RepoRow, viewer: Viewer | null, permission: Permission) { const db = repoDb(repo.id); const rows = canManageAdvisories(permission) ? await db.all( `${SELECT} WHERE sa.repo_id = ? ORDER BY sa.updated_at DESC LIMIT ?`, repo.id, ADVISORY_PAGE, ) : await db.all( `${SELECT} WHERE sa.repo_id = ?1 AND (sa.state = 'published' OR sa.author_id = ?2 OR EXISTS (SELECT 1 FROM advisory_collaborators c WHERE c.advisory_id = sa.id AND c.user_id = ?2)) ORDER BY sa.updated_at DESC LIMIT ?3`, repo.id, viewer?.id ?? "", ADVISORY_PAGE, ); return (await withAuthors(rows)).map(toSummary); } async function loadRow(repo: RepoRow, ghsaId: string): Promise { const row = await repoDb(repo.id).first( `${SELECT} WHERE sa.repo_id = ? AND sa.ghsa_id = ?`, repo.id, ghsaId, ); if (!row) throw notFoundError("Advisory not found"); const [withAuthor] = await withAuthors([row]); return withAuthor; } /** Loads an advisory the viewer may see, returning 404 (not 403) for private ones to avoid disclosure. */ export async function requireAdvisory(repo: RepoRow, ghsaId: string, viewer: Viewer | null, permission: Permission) { const row = await loadRow(repo, ghsaId); const isPrivate = row.state !== "published"; if (isPrivate && !(await canSeePrivate(row, viewer, permission))) throw notFoundError("Advisory not found"); return row; } export async function advisoryDetail(repo: RepoRow, ghsaId: string, viewer: Viewer | null, permission: Permission) { const row = await requireAdvisory(repo, ghsaId, viewer, permission); const context = { owner: repo.owner_name, repo: repo.name, ref: repo.default_branch, basePath: "" }; const canParticipate = row.state !== "published" || canManageAdvisories(permission); const db = repoDb(repo.id); const storedComments = canParticipate ? await db.all<{ id: string; author_id: string | null; body: string; created_at: number }>( "SELECT id, author_id, body, created_at FROM advisory_comments WHERE advisory_id = ? ORDER BY created_at LIMIT ?", row.id, MAX_COMMENTS_PER_ADVISORY, ) : []; const collaboratorIds = canManageAdvisories(permission) ? await db.all<{ user_id: string }>("SELECT user_id FROM advisory_collaborators WHERE advisory_id = ?", row.id) : []; const people = await accountsById([ ...storedComments.map((comment) => comment.author_id), ...collaboratorIds.map((collaborator) => collaborator.user_id), ]); const comments = storedComments.map((comment) => ({ ...comment, author: comment.author_id ? (people.get(comment.author_id)?.name ?? null) : null, })); // Reporters and invited collaborators see the discussion, but only managers see who else was invited. const collaborators = collaboratorIds .flatMap((collaborator) => { const name = people.get(collaborator.user_id)?.name; return name ? [{ name }] : []; }) .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)); const mentions = await resolveMentions([row.description, ...comments.map((comment) => comment.body)]); return { ...toSummary(row), description: row.description, descriptionHtml: renderMarkdown(row.description, context, mentions), cveId: row.cve_id, cweIds: row.cwe_ids, ecosystem: row.ecosystem, affectedVersions: row.affected_versions, patchedVersions: row.patched_versions, createdAt: row.created_at, canManage: canManageAdvisories(permission), canComment: canParticipate && viewer !== null, comments: comments.map((comment) => ({ id: comment.id, author: comment.author, html: renderMarkdown(comment.body, context, mentions), createdAt: comment.created_at, })), collaborators: collaborators.map((collaborator) => collaborator.name), }; } export async function createAdvisory( repo: RepoRow, author: Viewer, input: AdvisoryInput, source: "maintainer" | "report", ): Promise { const db = repoDb(repo.id); const total = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM security_advisories WHERE repo_id = ?", repo.id, ); assertBelowCap(total?.count ?? 0, MAX_ADVISORIES_PER_REPO, "advisories per repository"); if (source === "report") { const open = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM security_advisories WHERE repo_id = ? AND author_id = ? AND state = 'triage'", repo.id, author.id, ); assertBelowCap(open?.count ?? 0, MAX_OPEN_REPORTS_PER_USER, "open reports per repository"); } const ghsaId = generateGhsaId(); const id = scopedId(repo.id); const now = Date.now(); await db.run( `INSERT INTO security_advisories (id, repo_id, ghsa_id, title, description, severity, cve_id, cwe_ids, ecosystem, package_name, affected_versions, patched_versions, state, source, author_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, repo.id, ghsaId, input.title, input.description, input.severity, input.cveId, input.cweIds, input.ecosystem, input.packageName, input.affectedVersions, input.patchedVersions, source === "report" ? "triage" : "draft", source, author.id, now, now, ); await registerLocator("security_advisory", id, repo.id); if (source === "report") await notifyAdvisoryUpdate(repo.id, ghsaId, input.title, "reported", null, author.id); return ghsaId; } export async function updateAdvisory(repo: RepoRow, ghsaId: string, input: AdvisoryInput): Promise { const row = await loadRow(repo, ghsaId); await repoDb(repo.id).run( `UPDATE security_advisories SET title = ?, description = ?, severity = ?, cve_id = ?, cwe_ids = ?, ecosystem = ?, package_name = ?, affected_versions = ?, patched_versions = ?, updated_at = ? WHERE id = ?`, input.title, input.description, input.severity, input.cveId, input.cweIds, input.ecosystem, input.packageName, input.affectedVersions, input.patchedVersions, Date.now(), row.id, ); if (row.state === "published") await indexAdvisory(repo.id, row.id); } const TRANSITIONS: Record = { triage: ["draft", "closed"], draft: ["published", "closed"], published: ["closed"], closed: ["draft"], }; export async function setAdvisoryState( repo: RepoRow, ghsaId: string, state: AdvisoryState, actorId: string | null = null, ): Promise { const row = await loadRow(repo, ghsaId); if (!TRANSITIONS[row.state].includes(state)) throw badRequest(`Cannot move an advisory from ${row.state} to ${state}`); const now = Date.now(); await repoDb(repo.id).run( "UPDATE security_advisories SET state = ?, published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ?", state, state === "published" ? now : null, now, row.id, ); if (state === "published" || row.state === "published") await indexAdvisory(repo.id, row.id); await notifyAdvisoryUpdate(repo.id, ghsaId, row.title, `moved to ${state}`, row.author_id, actorId); } export async function addAdvisoryComment( repo: RepoRow, ghsaId: string, viewer: Viewer, permission: Permission, body: string, ): Promise { const row = await requireAdvisory(repo, ghsaId, viewer, permission); if (row.state === "published" && !canManageAdvisories(permission)) throw forbidden(); const db = repoDb(repo.id); const count = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM advisory_comments WHERE advisory_id = ?", row.id, ); assertBelowCap(count?.count ?? 0, MAX_COMMENTS_PER_ADVISORY, "comments per advisory"); const now = Date.now(); await db.batch([ stmt( "INSERT INTO advisory_comments (id, advisory_id, author_id, body, created_at) VALUES (?, ?, ?, ?, ?)", randomId(), row.id, viewer.id, body, now, ), stmt("UPDATE security_advisories SET updated_at = ? WHERE id = ?", now, row.id), ]); await notifyAdvisoryUpdate(repo.id, ghsaId, row.title, `commented on by ${viewer.name}`, row.author_id, viewer.id); } export async function setAdvisoryCollaborator( repo: RepoRow, ghsaId: string, username: string, add: boolean, ): Promise { const row = await loadRow(repo, ghsaId); const user = await first<{ id: string }>( "SELECT a.id FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.name = ?", username, ); if (!user) throw notFoundError("User not found"); const db = repoDb(repo.id); if (!add) { await db.run("DELETE FROM advisory_collaborators WHERE advisory_id = ? AND user_id = ?", row.id, user.id); return; } const account = await first<{ name: string; is_admin: number }>( "SELECT a.name, u.is_admin FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.id = ?", user.id, ); const candidate: Viewer = { id: user.id, name: account?.name ?? username, displayName: null, email: "", isAdmin: false, emailVerified: true, }; if ((await repoPermission(repo, candidate)) === "none") { throw badRequest("Advisory collaborators need read access to the repository first"); } const count = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM advisory_collaborators WHERE advisory_id = ?", row.id, ); assertBelowCap(count?.count ?? 0, MAX_COLLABORATORS, "collaborators per advisory"); await db.run( "INSERT OR IGNORE INTO advisory_collaborators (advisory_id, user_id, created_at) VALUES (?, ?, ?)", row.id, user.id, Date.now(), ); } /** `open` (unpublished reports in triage/draft) is only disclosed to advisory managers; readers see `null`. */ export async function advisoryCounts( repoId: string, permission: Permission, ): Promise<{ published: number; open: number | null }> { const row = await repoDb(repoId).first<{ published: number; open: number }>( `SELECT COALESCE(SUM(state = 'published'), 0) AS published, COALESCE(SUM(state IN ('triage', 'draft')), 0) AS open FROM security_advisories WHERE repo_id = ?`, repoId, ); const published = row?.published ?? 0; return { published, open: canManageAdvisories(permission) ? (row?.open ?? 0) : null }; }