| 1 | import type { ServiceBinding } from "./clients"; |
| 2 | import type { User, Viewer } from "./identity"; |
| 3 | import type { RepoPath } from "./repos"; |
| 4 | import type { Result } from "./result"; |
| 5 | |
| 6 | /** |
| 7 | * Repository security advisories and private vulnerability reporting, the |
| 8 | * Reporting half of a repository's Security page. Mirrors |
| 9 | * `crates/contracts/src/advisories.rs`. |
| 10 | * |
| 11 | * An advisory is a repository's own account of a vulnerability in what it |
| 12 | * ships: a draft until its maintainers publish it, visible to anyone who can |
| 13 | * see the repository once they do, and withdrawable. A vulnerability report |
| 14 | * is how someone outside tells the maintainers privately, when the |
| 15 | * repository has private vulnerability reporting on; accepting one makes a |
| 16 | * draft advisory from it. |
| 17 | */ |
| 18 | |
| 19 | export type AdvisorySeverity = "critical" | "high" | "medium" | "low"; |
| 20 | |
| 21 | export const ADVISORY_SEVERITIES: AdvisorySeverity[] = ["critical", "high", "medium", "low"]; |
| 22 | |
| 23 | export type AdvisoryState = "draft" | "published" | "withdrawn"; |
| 24 | |
| 25 | export const ADVISORY_STATES: AdvisoryState[] = ["draft", "published", "withdrawn"]; |
| 26 | |
| 27 | export type ReportState = "new" | "accepted" | "declined"; |
| 28 | |
| 29 | export const REPORT_STATES: ReportState[] = ["new", "accepted", "declined"]; |
| 30 | |
| 31 | /** The longest title, description and version range kept. */ |
| 32 | export const MAX_ADVISORY_TITLE = 200; |
| 33 | export const MAX_ADVISORY_DESCRIPTION = 20_000; |
| 34 | export const MAX_ADVISORY_RANGE = 200; |
| 35 | export const MAX_ADVISORY_CREDITS = 20; |
| 36 | |
| 37 | export type Advisory = { |
| 38 | /** `adv_…`. */ |
| 39 | id: string; |
| 40 | /** `G1TSA-2026-abcd-efgh`: what people quote. */ |
| 41 | g1tsaId: string; |
| 42 | repoId: string; |
| 43 | /** `owner/name`. */ |
| 44 | repo: string; |
| 45 | title: string; |
| 46 | /** Markdown. */ |
| 47 | description: string; |
| 48 | severity: AdvisorySeverity; |
| 49 | cvssVector?: string | null; |
| 50 | cvssScore?: number | null; |
| 51 | cveId?: string | null; |
| 52 | ecosystem?: string | null; |
| 53 | package?: string | null; |
| 54 | affectedVersions?: string | null; |
| 55 | patchedVersions?: string | null; |
| 56 | state: AdvisoryState; |
| 57 | credits: string[]; |
| 58 | /** The report it was made from, if any. */ |
| 59 | reportId?: string | null; |
| 60 | createdBy: string; |
| 61 | createdAt: string; |
| 62 | updatedAt: string; |
| 63 | publishedAt?: string | null; |
| 64 | withdrawnAt?: string | null; |
| 65 | }; |
| 66 | |
| 67 | /** What a maintainer writes when creating or changing an advisory. */ |
| 68 | export type AdvisoryInput = { |
| 69 | title: string; |
| 70 | description: string; |
| 71 | severity: AdvisorySeverity; |
| 72 | cvssVector?: string | null; |
| 73 | cvssScore?: number | null; |
| 74 | cveId?: string | null; |
| 75 | ecosystem?: string | null; |
| 76 | package?: string | null; |
| 77 | affectedVersions?: string | null; |
| 78 | patchedVersions?: string | null; |
| 79 | credits?: string[]; |
| 80 | }; |
| 81 | |
| 82 | export type VulnerabilityReport = { |
| 83 | /** `vrp_…`. */ |
| 84 | id: string; |
| 85 | repoId: string; |
| 86 | repo: string; |
| 87 | /** A username when signed in, else the address given. */ |
| 88 | reporter?: string | null; |
| 89 | reporterEmail?: string | null; |
| 90 | title: string; |
| 91 | description: string; |
| 92 | severity?: AdvisorySeverity | null; |
| 93 | state: ReportState; |
| 94 | /** The draft advisory an accepted report became. */ |
| 95 | advisoryId?: string | null; |
| 96 | createdAt: string; |
| 97 | reviewedBy?: string | null; |
| 98 | reviewedAt?: string | null; |
| 99 | reviewNote?: string | null; |
| 100 | }; |
| 101 | |
| 102 | export type ReviewedReport = { report: VulnerabilityReport; advisory?: Advisory | null }; |
| 103 | |
| 104 | /** Whether a repository takes private reports, and how many advisories it published: for anyone who can see it. */ |
| 105 | export type ReportSettings = { privateVulnerabilityReporting: boolean; published: number }; |
| 106 | |
| 107 | export interface AdvisoriesApi { |
| 108 | /** Published ones for anyone who can see the repository; every state for those who see findings. */ |
| 109 | list(repo: RepoPath, viewer: Viewer, state?: AdvisoryState | null): Promise<Result<Advisory[]>>; |
| 110 | /** By `adv_…` or `G1TSA-…` id. */ |
| 111 | get(repo: RepoPath, id: string, viewer: Viewer): Promise<Result<Advisory>>; |
| 112 | /** A draft. Admin, or a security manager of the workspace. */ |
| 113 | create(actor: User, repo: RepoPath, advisory: AdvisoryInput): Promise<Result<Advisory>>; |
| 114 | update(actor: User, repo: RepoPath, id: string, advisory: AdvisoryInput): Promise<Result<Advisory>>; |
| 115 | /** `published` publishes a draft or brings a withdrawn one back; `withdrawn` withdraws. */ |
| 116 | setState(actor: User, repo: RepoPath, id: string, state: "published" | "withdrawn"): Promise<Result<Advisory>>; |
| 117 | /** Tells the maintainers privately. Someone signed out gives an `email`. */ |
| 118 | report( |
| 119 | viewer: Viewer, |
| 120 | repo: RepoPath, |
| 121 | report: { title: string; description: string; severity?: AdvisorySeverity | null; email?: string | null }, |
| 122 | ): Promise<Result<VulnerabilityReport>>; |
| 123 | /** Whether the repository takes private reports, and how many advisories it published. Anyone who can see it. */ |
| 124 | reportSettings(repo: RepoPath, viewer: Viewer): Promise<Result<ReportSettings>>; |
| 125 | /** The repository's reports, newest first. Those who see findings. */ |
| 126 | reports(repo: RepoPath, viewer: Viewer, state?: ReportState | null): Promise<Result<VulnerabilityReport[]>>; |
| 127 | /** `accept` makes a draft advisory from the report; `decline` closes it. */ |
| 128 | review(actor: User, repo: RepoPath, id: string, decision: "accept" | "decline", note: string): Promise<Result<ReviewedReport>>; |
| 129 | } |
| 130 | |
| 131 | /** Advisories and reports: methods of the security service. */ |
| 132 | export function advisoriesClient(service: ServiceBinding): AdvisoriesApi { |
| 133 | const call = async <T>(method: string, args: object): Promise<T> => { |
| 134 | const response = await service.fetch(`https://service/rpc/${method}`, { |
| 135 | method: "POST", |
| 136 | headers: { "content-type": "application/json" }, |
| 137 | body: JSON.stringify(args), |
| 138 | }); |
| 139 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 140 | return (await response.json()) as T; |
| 141 | }; |
| 142 | return { |
| 143 | list: (repo, viewer, state = null) => call("list_advisories", { repo, viewer, state }), |
| 144 | get: (repo, id, viewer) => call("get_advisory", { repo, id, viewer }), |
| 145 | create: (actor, repo, advisory) => call("create_advisory", { actor, repo, advisory }), |
| 146 | update: (actor, repo, id, advisory) => call("update_advisory", { actor, repo, id, advisory }), |
| 147 | setState: (actor, repo, id, state) => call("set_advisory_state", { actor, repo, id, state }), |
| 148 | report: (viewer, repo, report) => |
| 149 | call("report_vulnerability", { |
| 150 | viewer, |
| 151 | repo, |
| 152 | title: report.title, |
| 153 | description: report.description, |
| 154 | severity: report.severity ?? null, |
| 155 | email: report.email ?? null, |
| 156 | }), |
| 157 | reportSettings: (repo, viewer) => call("report_settings", { repo, viewer }), |
| 158 | reports: (repo, viewer, state = null) => call("list_vulnerability_reports", { repo, viewer, state }), |
| 159 | review: (actor, repo, id, decision, note) => call("review_vulnerability_report", { actor, repo, id, decision, note }), |
| 160 | }; |
| 161 | } |