Skip to content
315 linesCodeBlameRaw
1//! Repository security advisories and private vulnerability reporting,
2//! the Reporting half of a repository's Security page.
3//!
4//! An **advisory** is a repository's own account of a vulnerability in
5//! what it ships: what is affected, what fixes it, how bad it is and who
6//! found it. Maintainers write it as a draft, then publish it; a published
7//! advisory is visible to anyone who can see the repository, and may be
8//! withdrawn. Each has an id of the form `G1TSA-2026-abcd-efgh`.
9//!
10//! A **vulnerability report** is how someone outside tells the maintainers
11//! privately, when the repository has private vulnerability reporting on:
12//! a signed-in person who can see the repository, or anyone with an email
13//! address on a public one. The maintainers accept it, which makes a draft
14//! advisory from it, or decline it. Reporters never see the repository's
15//! other reports.
16//!
17//! Mirrors `packages/contracts/src/advisories.ts`.
18
19use serde::{Deserialize, Serialize};
20
21use crate::User;
22use crate::repos::RepoPath;
23
24/// The severities an advisory or report takes.
25pub const SEVERITIES: [&str; 4] = ["critical", "high", "medium", "low"];
26
27/// An advisory's states.
28pub const ADVISORY_STATES: [&str; 3] = ["draft", "published", "withdrawn"];
29
30/// A report's states.
31pub const REPORT_STATES: [&str; 3] = ["new", "accepted", "declined"];
32
33/// The longest title, description and version range kept.
34pub const MAX_TITLE_CHARS: usize = 200;
35pub const MAX_DESCRIPTION_CHARS: usize = 20_000;
36pub const MAX_RANGE_CHARS: usize = 200;
37/// The most people credited on one advisory.
38pub const MAX_CREDITS: usize = 20;
39
40/// What a repository says about a vulnerability in what it ships.
41#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct Advisory {
44 /// `adv_…`, for the API and pages.
45 pub id: String,
46 /// `G1TSA-2026-abcd-efgh`: what people quote.
47 pub g1tsa_id: String,
48 pub repo_id: String,
49 /// `owner/name`.
50 pub repo: String,
51 pub title: String,
52 /// Markdown: what it is, how it is exploited, what to do.
53 pub description: String,
54 /// `critical`, `high`, `medium` or `low`.
55 pub severity: String,
56 /// A CVSS vector string, when the maintainers scored it, and its score.
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub cvss_vector: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub cvss_score: Option<f64>,
61 /// `CVE-2026-12345`, when one was issued elsewhere.
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub cve_id: Option<String>,
64 /// The package affected: its ecosystem (`npm`, `crates.io`, `Go`,
65 /// `PyPI`, or how the project is distributed), its name, the versions
66 /// affected and the versions that fix it, as people write ranges
67 /// (`>= 1.2.0, < 1.4.1`).
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub ecosystem: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub package: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub affected_versions: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub patched_versions: Option<String>,
76 /// `draft`, `published` or `withdrawn`.
77 pub state: String,
78 /// Who found or fixed it: usernames or names, as the maintainers wrote them.
79 #[serde(default)]
80 pub credits: Vec<String>,
81 /// The report it was made from, if any.
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub report_id: Option<String>,
84 pub created_by: String,
85 pub created_at: String,
86 pub updated_at: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub published_at: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub withdrawn_at: Option<String>,
91}
92
93/// What a maintainer writes when creating or changing an advisory.
94#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct AdvisoryInput {
97 pub title: String,
98 #[serde(default)]
99 pub description: String,
100 pub severity: String,
101 #[serde(default)]
102 pub cvss_vector: Option<String>,
103 #[serde(default)]
104 pub cvss_score: Option<f64>,
105 #[serde(default)]
106 pub cve_id: Option<String>,
107 #[serde(default)]
108 pub ecosystem: Option<String>,
109 #[serde(default)]
110 pub package: Option<String>,
111 #[serde(default)]
112 pub affected_versions: Option<String>,
113 #[serde(default)]
114 pub patched_versions: Option<String>,
115 #[serde(default)]
116 pub credits: Vec<String>,
117}
118
119/// `list_advisories`: a repository's advisories, newest first. Anyone who
120/// can see the repository sees the published ones; those who see its
121/// findings (Write, or a security manager) see drafts and withdrawn ones
122/// too. Returns `Outcome<Vec<Advisory>>`.
123#[derive(Debug, Serialize, Deserialize)]
124pub struct ListAdvisoriesArgs {
125 pub viewer: Option<User>,
126 pub repo: RepoPath,
127 /// `draft`, `published` or `withdrawn`; all the viewer may see when absent.
128 #[serde(default)]
129 pub state: Option<String>,
130}
131
132/// `get_advisory`: one advisory by `adv_…` id or `G1TSA-…` id, with the
133/// same visibility. Returns `Outcome<Advisory>`.
134#[derive(Debug, Serialize, Deserialize)]
135pub struct GetAdvisoryArgs {
136 pub viewer: Option<User>,
137 pub repo: RepoPath,
138 pub id: String,
139}
140
141/// `create_advisory`: a draft. Admin on the repository, or a security
142/// manager of its workspace (`ManageSecurity`). Returns `Outcome<Advisory>`.
143#[derive(Debug, Serialize, Deserialize)]
144pub struct CreateAdvisoryArgs {
145 pub actor: User,
146 pub repo: RepoPath,
147 pub advisory: AdvisoryInput,
148}
149
150/// `update_advisory`: changes an advisory's text and fields in any state;
151/// a published one's change is visible at once. Returns `Outcome<Advisory>`.
152#[derive(Debug, Serialize, Deserialize)]
153pub struct UpdateAdvisoryArgs {
154 pub actor: User,
155 pub repo: RepoPath,
156 pub id: String,
157 pub advisory: AdvisoryInput,
158}
159
160/// `set_advisory_state`: publishes a draft (`published`), withdraws a
161/// published one (`withdrawn`), or puts a withdrawn one back
162/// (`published`). Publishing tells webhooks and the repository's watchers.
163/// Returns `Outcome<Advisory>`.
164#[derive(Debug, Serialize, Deserialize)]
165pub struct SetAdvisoryStateArgs {
166 pub actor: User,
167 pub repo: RepoPath,
168 pub id: String,
169 pub state: String,
170}
171
172/// Something someone told the maintainers privately.
173#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct VulnerabilityReport {
176 /// `vrp_…`.
177 pub id: String,
178 pub repo_id: String,
179 pub repo: String,
180 /// The reporter: a username when signed in, else the address they gave.
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub reporter: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub reporter_email: Option<String>,
185 pub title: String,
186 pub description: String,
187 /// What the reporter thinks: `critical`, `high`, `medium` or `low`, or none.
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub severity: Option<String>,
190 /// `new`, `accepted` or `declined`.
191 pub state: String,
192 /// The draft advisory an accepted report became.
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub advisory_id: Option<String>,
195 pub created_at: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub reviewed_by: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub reviewed_at: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub review_note: Option<String>,
202}
203
204/// `report_vulnerability`: tells the maintainers privately. Needs private
205/// vulnerability reporting on for the repository; anyone who can see the
206/// repository may report, and on a public one someone signed out may with
207/// an `email`. Returns `Outcome<VulnerabilityReport>`; the reporter sees
208/// only their own report.
209#[derive(Debug, Serialize, Deserialize)]
210pub struct ReportVulnerabilityArgs {
211 pub viewer: Option<User>,
212 pub repo: RepoPath,
213 pub title: String,
214 pub description: String,
215 #[serde(default)]
216 pub severity: Option<String>,
217 /// For someone signed out: where the maintainers can answer.
218 #[serde(default)]
219 pub email: Option<String>,
220}
221
222/// `list_vulnerability_reports`: a repository's reports, newest first.
223/// Those who see findings. Returns `Outcome<Vec<VulnerabilityReport>>`.
224#[derive(Debug, Serialize, Deserialize)]
225pub struct ListReportsArgs {
226 pub viewer: Option<User>,
227 pub repo: RepoPath,
228 /// `new`, `accepted` or `declined`; all when absent.
229 #[serde(default)]
230 pub state: Option<String>,
231}
232
233/// `review_vulnerability_report`: `accept`, which makes a draft advisory
234/// from it, or `decline`. `ManageSecurity`. Returns `Outcome<ReviewedReport>`.
235#[derive(Debug, Serialize, Deserialize)]
236pub struct ReviewReportArgs {
237 pub actor: User,
238 pub repo: RepoPath,
239 pub id: String,
240 /// `accept` or `decline`.
241 pub decision: String,
242 #[serde(default)]
243 pub note: String,
244}
245
246#[derive(Clone, Debug, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct ReviewedReport {
249 pub report: VulnerabilityReport,
250 /// The draft made, on accept.
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub advisory: Option<Advisory>,
253}
254
255/// What `security_advisory.published`, `.withdrawn` and `.reported` carry,
256/// for webhooks and the inbox. A report's event names no reporter and
257/// carries no description: it tells the maintainers to look.
258#[derive(Clone, Debug, Default, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct AdvisoryEvent {
261 pub repo_id: String,
262 /// `adv_…`, or for a report `vrp_…`.
263 pub id: String,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub g1tsa_id: Option<String>,
266 pub title: String,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub severity: Option<String>,
269 pub state: String,
270 /// Its page, from the site's root.
271 pub link: String,
272 /// Who hears of it: the workspace's owners and security managers, for
273 /// a report. Usernames.
274 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub notify: Vec<String>,
276}
277
278/// The id people quote, from a time and some randomness: `G1TSA-2026-abcd-efgh`.
279pub fn g1tsa_id(year: u32, random: &[u8]) -> String {
280 const ALPHABET: &[u8; 32] = b"abcdefghjkmnpqrstuvwxyz23456789-";
281 let mut letters: Vec<char> = random.iter().take(8).map(|byte| ALPHABET[(*byte as usize) % 31] as char).collect();
282 while letters.len() < 8 {
283 letters.push('a');
284 }
285 let first: String = letters[..4].iter().collect();
286 let second: String = letters[4..].iter().collect();
287 format!("G1TSA-{year}-{first}-{second}")
288}
289
290/// Whether text is a `G1TSA-…` id.
291pub fn is_g1tsa_id(text: &str) -> bool {
292 text.starts_with("G1TSA-")
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn ids_people_quote_have_a_year_and_eight_letters() {
301 let id = g1tsa_id(2026, &[0, 1, 2, 3, 4, 5, 6, 7]);
302 assert_eq!(id, "G1TSA-2026-abcd-efgh");
303 assert!(is_g1tsa_id(&id) && !is_g1tsa_id("adv_1"));
304 // Short randomness is padded, never a panic.
305 assert_eq!(g1tsa_id(2026, &[]).len(), "G1TSA-2026-aaaa-aaaa".len());
306 }
307
308 #[test]
309 fn events_leave_out_what_is_empty() {
310 let event = AdvisoryEvent { repo_id: "rep_1".into(), id: "vrp_1".into(), title: "XSS in the search box".into(), state: "new".into(), ..Default::default() };
311 let json = serde_json::to_value(&event).unwrap();
312 assert!(json.get("g1tsaId").is_none() && json.get("notify").is_none());
313 assert_eq!(json["repoId"], "rep_1");
314 }
315}