Skip to content
769 linesCodeBlameRaw
1//! Reporting: a repository's security advisories, and the vulnerability
2//! reports people send its maintainers privately. See
3//! `g1t_contracts::advisories`.
4//!
5//! Who sees and does what: anyone who can see the repository reads its
6//! published advisories and, when the repository takes private reports,
7//! files one (signed out, on a public repository, with an email address).
8//! Those who see findings (Write, or a security manager) see drafts,
9//! withdrawn advisories and the reports. Writing, publishing and
10//! withdrawing an advisory, and reviewing a report, take Admin or a
11//! security manager (`ManageSecurity`). A new report tells the workspace's
12//! owners and security managers at once; a published advisory tells
13//! webhooks and the repository's security watchers.
14
15use g1t_contracts::access::{self, Capability};
16use g1t_contracts::advisories::*;
17use g1t_contracts::repos::{GetArgs, Repo};
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::{FailureCode, Outcome, User, new_id};
20use g1t_kit::now_ms;
21use serde::Deserialize;
22use worker::Result;
23use worker::wasm_bindgen::JsValue;
24
25use crate::Security;
26use crate::store::{RepoRow, Store, now, optional};
27use crate::suite::link;
28
29/// The most advisories and reports listed.
30const LISTED: u32 = 200;
31/// One person files at most this many reports on one repository a day,
32/// and a repository holds at most this many new reports waiting: the form
33/// takes no account, so it must not take a flood either.
34const REPORTS_PER_DAY: u32 = 5;
35const MAX_NEW_REPORTS: u32 = 50;
36const DAY_MS: u64 = 24 * 60 * 60 * 1000;
37
38fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
39 Outcome::fail(code, message)
40}
41
42/// An advisory's page: `/acme/rocket/security/advisories/G1TSA-…`.
43pub fn advisory_link(repo: &RepoRow, advisory: &Advisory) -> String {
44 link(repo, &format!("advisories/{}", advisory.g1tsa_id))
45}
46
47/// Text trimmed and cut to `max` characters; none when empty.
48fn clean(text: Option<&str>, max: usize) -> Option<String> {
49 let text: String = text.unwrap_or_default().trim().chars().take(max).collect();
50 (!text.is_empty()).then_some(text)
51}
52
53/// Whether text reads as an email address: something, an @, something with a dot.
54pub fn looks_like_email(text: &str) -> bool {
55 let text = text.trim();
56 text.len() <= 254
57 && !text.contains(char::is_whitespace)
58 && text.split_once('@').is_some_and(|(user, host)| !user.is_empty() && host.contains('.') && !host.starts_with('.') && !host.ends_with('.'))
59}
60
61/// An advisory's input, checked and cleaned, or what is wrong with it.
62pub fn validate(input: &AdvisoryInput) -> std::result::Result<AdvisoryInput, String> {
63 let title = clean(Some(&input.title), MAX_TITLE_CHARS).ok_or("Give the advisory a title.")?;
64 if !SEVERITIES.contains(&input.severity.as_str()) {
65 return Err("severity is critical, high, medium or low.".to_owned());
66 }
67 if let Some(cve) = clean(input.cve_id.as_deref(), 32)
68 && !is_cve(&cve)
69 {
70 return Err("cve_id looks like CVE-2026-12345.".to_owned());
71 }
72 if input.cvss_score.is_some_and(|score| !(0.0..=10.0).contains(&score)) {
73 return Err("cvss_score is 0 to 10.".to_owned());
74 }
75 let credits: Vec<String> = input
76 .credits
77 .iter()
78 .filter_map(|credit| clean(Some(credit), 100))
79 .take(MAX_CREDITS)
80 .collect();
81 Ok(AdvisoryInput {
82 title,
83 description: input.description.trim().chars().take(MAX_DESCRIPTION_CHARS).collect(),
84 severity: input.severity.clone(),
85 cvss_vector: clean(input.cvss_vector.as_deref(), 200),
86 cvss_score: input.cvss_score,
87 cve_id: clean(input.cve_id.as_deref(), 32).map(|cve| cve.to_uppercase()),
88 ecosystem: clean(input.ecosystem.as_deref(), 50),
89 package: clean(input.package.as_deref(), 200),
90 affected_versions: clean(input.affected_versions.as_deref(), MAX_RANGE_CHARS),
91 patched_versions: clean(input.patched_versions.as_deref(), MAX_RANGE_CHARS),
92 credits,
93 })
94}
95
96/// `CVE-2026-12345`: a year and at least four digits.
97fn is_cve(text: &str) -> bool {
98 let upper = text.to_uppercase();
99 let Some(rest) = upper.strip_prefix("CVE-") else { return false };
100 let Some((year, number)) = rest.split_once('-') else { return false };
101 year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()) && number.len() >= 4 && number.chars().all(|c| c.is_ascii_digit())
102}
103
104/// The draft an accepted report becomes: its words, the reporter credited.
105pub fn draft_from(report: &VulnerabilityReport) -> AdvisoryInput {
106 AdvisoryInput {
107 title: report.title.clone(),
108 description: report.description.clone(),
109 severity: report.severity.clone().filter(|severity| SEVERITIES.contains(&severity.as_str())).unwrap_or_else(|| "medium".to_owned()),
110 credits: report.reporter.iter().chain(report.reporter_email.iter()).take(1).cloned().collect(),
111 ..AdvisoryInput::default()
112 }
113}
114
115/// The year of a time, for the id people quote.
116fn year_of(ms: u64) -> u32 {
117 rfc3339(ms).get(..4).and_then(|year| year.parse().ok()).unwrap_or(2026)
118}
119
120// --- Rows -------------------------------------------------------------------
121
122#[derive(Deserialize)]
123struct AdvisoryRow {
124 id: String,
125 g1tsa_id: String,
126 repo_id: String,
127 title: String,
128 description: String,
129 severity: String,
130 cvss_vector: Option<String>,
131 cvss_score: Option<f64>,
132 cve_id: Option<String>,
133 ecosystem: Option<String>,
134 package: Option<String>,
135 affected_versions: Option<String>,
136 patched_versions: Option<String>,
137 state: String,
138 credits: String,
139 report_id: Option<String>,
140 created_by: String,
141 created_at: String,
142 updated_at: String,
143 published_at: Option<String>,
144 withdrawn_at: Option<String>,
145}
146
147impl AdvisoryRow {
148 fn into_advisory(self, repo: &RepoRow) -> Advisory {
149 Advisory {
150 id: self.id,
151 g1tsa_id: self.g1tsa_id,
152 repo_id: self.repo_id,
153 repo: format!("{}/{}", repo.namespace, repo.name),
154 title: self.title,
155 description: self.description,
156 severity: self.severity,
157 cvss_vector: self.cvss_vector,
158 cvss_score: self.cvss_score,
159 cve_id: self.cve_id,
160 ecosystem: self.ecosystem,
161 package: self.package,
162 affected_versions: self.affected_versions,
163 patched_versions: self.patched_versions,
164 state: self.state,
165 credits: serde_json::from_str(&self.credits).unwrap_or_default(),
166 report_id: self.report_id,
167 created_by: self.created_by,
168 created_at: self.created_at,
169 updated_at: self.updated_at,
170 published_at: self.published_at,
171 withdrawn_at: self.withdrawn_at,
172 }
173 }
174}
175
176#[derive(Deserialize)]
177struct ReportRow {
178 id: String,
179 repo_id: String,
180 reporter: Option<String>,
181 reporter_email: Option<String>,
182 title: String,
183 description: String,
184 severity: Option<String>,
185 state: String,
186 advisory_id: Option<String>,
187 created_at: String,
188 reviewed_by: Option<String>,
189 reviewed_at: Option<String>,
190 review_note: Option<String>,
191}
192
193impl ReportRow {
194 fn into_report(self, repo: &RepoRow) -> VulnerabilityReport {
195 VulnerabilityReport {
196 id: self.id,
197 repo_id: self.repo_id,
198 repo: format!("{}/{}", repo.namespace, repo.name),
199 reporter: self.reporter,
200 reporter_email: self.reporter_email,
201 title: self.title,
202 description: self.description,
203 severity: self.severity,
204 state: self.state,
205 advisory_id: self.advisory_id,
206 created_at: self.created_at,
207 reviewed_by: self.reviewed_by,
208 reviewed_at: self.reviewed_at,
209 review_note: self.review_note,
210 }
211 }
212}
213
214#[derive(Deserialize)]
215struct CountRow {
216 n: f64,
217}
218
219const ADVISORY_COLUMNS: &str = "id, g1tsa_id, repo_id, title, description, severity, cvss_vector, cvss_score, cve_id, ecosystem, package,
220 affected_versions, patched_versions, state, credits, report_id, created_by, created_at, updated_at, published_at, withdrawn_at";
221const REPORT_COLUMNS: &str =
222 "id, repo_id, reporter, reporter_email, title, description, severity, state, advisory_id, created_at, reviewed_by, reviewed_at, review_note";
223
224impl Store {
225 /// A repository's advisories in `states`, newest first.
226 pub async fn security_advisories(&self, repo: &RepoRow, states: &[&str]) -> Result<Vec<Advisory>> {
227 if states.is_empty() {
228 return Ok(Vec::new());
229 }
230 let marks = vec!["?"; states.len()].join(", ");
231 let mut values: Vec<JsValue> = vec![repo.repo_id.as_str().into()];
232 values.extend(states.iter().map(|state| JsValue::from(*state)));
233 values.push(LISTED.into());
234 let rows = self
235 .db
236 .prepare(format!("SELECT {ADVISORY_COLUMNS} FROM security_advisories WHERE repo_id = ? AND state IN ({marks}) ORDER BY created_at DESC LIMIT ?"))
237 .bind(&values)?
238 .all()
239 .await?
240 .results::<AdvisoryRow>()?;
241 Ok(rows.into_iter().map(|row| row.into_advisory(repo)).collect())
242 }
243
244 /// One advisory by `adv_…` or `G1TSA-…` id.
245 pub async fn security_advisory(&self, repo: &RepoRow, id: &str) -> Result<Option<Advisory>> {
246 let column = if is_g1tsa_id(id) { "g1tsa_id" } else { "id" };
247 Ok(self
248 .db
249 .prepare(format!("SELECT {ADVISORY_COLUMNS} FROM security_advisories WHERE repo_id = ? AND {column} = ?"))
250 .bind(&[repo.repo_id.as_str().into(), id.into()])?
251 .first::<AdvisoryRow>(None)
252 .await?
253 .map(|row| row.into_advisory(repo)))
254 }
255
256 pub async fn add_advisory(&self, repo: &RepoRow, input: &AdvisoryInput, by: &str, report_id: Option<&str>) -> Result<String> {
257 let now_ms = now_ms();
258 let id = new_id("adv", now_ms);
259 let mut random = [0u8; 8];
260 if let Err(error) = getrandom::getrandom(&mut random) {
261 worker::console_error!("security: no randomness for an advisory id: {error}");
262 }
263 let at = rfc3339(now_ms);
264 self.db
265 .prepare(
266 "INSERT INTO security_advisories (id, g1tsa_id, repo_id, title, description, severity, cvss_vector, cvss_score, cve_id, ecosystem,
267 package, affected_versions, patched_versions, state, credits, report_id, created_by, created_at, updated_at)
268 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)",
269 )
270 .bind(&[
271 id.as_str().into(),
272 g1tsa_id(year_of(now_ms), &random).into(),
273 repo.repo_id.as_str().into(),
274 input.title.as_str().into(),
275 input.description.as_str().into(),
276 input.severity.as_str().into(),
277 optional(input.cvss_vector.as_deref()),
278 input.cvss_score.map_or(JsValue::NULL, JsValue::from),
279 optional(input.cve_id.as_deref()),
280 optional(input.ecosystem.as_deref()),
281 optional(input.package.as_deref()),
282 optional(input.affected_versions.as_deref()),
283 optional(input.patched_versions.as_deref()),
284 serde_json::to_string(&input.credits)?.into(),
285 optional(report_id),
286 by.into(),
287 at.as_str().into(),
288 at.as_str().into(),
289 ])?
290 .run()
291 .await?;
292 Ok(id)
293 }
294
295 pub async fn update_advisory(&self, repo: &RepoRow, id: &str, input: &AdvisoryInput) -> Result<()> {
296 self.db
297 .prepare(
298 "UPDATE security_advisories SET title = ?, description = ?, severity = ?, cvss_vector = ?, cvss_score = ?, cve_id = ?, ecosystem = ?,
299 package = ?, affected_versions = ?, patched_versions = ?, credits = ?, updated_at = ?
300 WHERE repo_id = ? AND id = ?",
301 )
302 .bind(&[
303 input.title.as_str().into(),
304 input.description.as_str().into(),
305 input.severity.as_str().into(),
306 optional(input.cvss_vector.as_deref()),
307 input.cvss_score.map_or(JsValue::NULL, JsValue::from),
308 optional(input.cve_id.as_deref()),
309 optional(input.ecosystem.as_deref()),
310 optional(input.package.as_deref()),
311 optional(input.affected_versions.as_deref()),
312 optional(input.patched_versions.as_deref()),
313 serde_json::to_string(&input.credits)?.into(),
314 now().into(),
315 repo.repo_id.as_str().into(),
316 id.into(),
317 ])?
318 .run()
319 .await?;
320 Ok(())
321 }
322
323 pub async fn set_advisory_state(&self, repo: &RepoRow, id: &str, state: &str) -> Result<()> {
324 let at = now();
325 let stamp = match state {
326 "published" => "published_at = COALESCE(published_at, ?1), withdrawn_at = NULL",
327 "withdrawn" => "withdrawn_at = ?1",
328 _ => "published_at = published_at",
329 };
330 self.db
331 .prepare(format!("UPDATE security_advisories SET state = ?2, updated_at = ?1, {stamp} WHERE repo_id = ?3 AND id = ?4"))
332 .bind(&[at.as_str().into(), state.into(), repo.repo_id.as_str().into(), id.into()])?
333 .run()
334 .await?;
335 Ok(())
336 }
337
338 pub async fn reports(&self, repo: &RepoRow, state: Option<&str>) -> Result<Vec<VulnerabilityReport>> {
339 let rows = match state {
340 Some(state) => {
341 self.db
342 .prepare(format!(
343 "SELECT {REPORT_COLUMNS} FROM vulnerability_reports WHERE repo_id = ? AND state = ? ORDER BY created_at DESC LIMIT ?"
344 ))
345 .bind(&[repo.repo_id.as_str().into(), state.into(), LISTED.into()])?
346 }
347 None => self
348 .db
349 .prepare(format!(
350 "SELECT {REPORT_COLUMNS} FROM vulnerability_reports WHERE repo_id = ?
351 ORDER BY CASE state WHEN 'new' THEN 0 ELSE 1 END, created_at DESC LIMIT ?"
352 ))
353 .bind(&[repo.repo_id.as_str().into(), LISTED.into()])?,
354 }
355 .all()
356 .await?
357 .results::<ReportRow>()?;
358 Ok(rows.into_iter().map(|row| row.into_report(repo)).collect())
359 }
360
361 pub async fn report(&self, repo: &RepoRow, id: &str) -> Result<Option<VulnerabilityReport>> {
362 Ok(self
363 .db
364 .prepare(format!("SELECT {REPORT_COLUMNS} FROM vulnerability_reports WHERE repo_id = ? AND id = ?"))
365 .bind(&[repo.repo_id.as_str().into(), id.into()])?
366 .first::<ReportRow>(None)
367 .await?
368 .map(|row| row.into_report(repo)))
369 }
370
371 /// How many reports `reporter_key` filed on the repository since `since`,
372 /// and how many new ones the repository holds.
373 pub async fn report_counts(&self, repo: &RepoRow, reporter_key: &str, since: &str) -> Result<(u32, u32)> {
374 let results = self
375 .db
376 .batch(vec![
377 self.db
378 .prepare("SELECT count(*) AS n FROM vulnerability_reports WHERE repo_id = ? AND reporter_key = ? AND created_at >= ?")
379 .bind(&[repo.repo_id.as_str().into(), reporter_key.into(), since.into()])?,
380 self.db
381 .prepare("SELECT count(*) AS n FROM vulnerability_reports WHERE repo_id = ? AND state = 'new'")
382 .bind(&[repo.repo_id.as_str().into()])?,
383 ])
384 .await?;
385 let count = |at: usize| results.get(at).and_then(|result| result.results::<CountRow>().ok()).and_then(|rows| rows.first().map(|row| row.n as u32)).unwrap_or(0);
386 Ok((count(0), count(1)))
387 }
388
389 pub async fn add_report(
390 &self,
391 repo: &RepoRow,
392 reporter: Option<&str>,
393 email: Option<&str>,
394 reporter_key: &str,
395 title: &str,
396 description: &str,
397 severity: Option<&str>,
398 ) -> Result<String> {
399 let now_ms = now_ms();
400 let id = new_id("vrp", now_ms);
401 self.db
402 .prepare(
403 "INSERT INTO vulnerability_reports (id, repo_id, reporter, reporter_email, reporter_key, title, description, severity, state, created_at)
404 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', ?)",
405 )
406 .bind(&[
407 id.as_str().into(),
408 repo.repo_id.as_str().into(),
409 optional(reporter),
410 optional(email),
411 reporter_key.into(),
412 title.into(),
413 description.into(),
414 optional(severity),
415 rfc3339(now_ms).into(),
416 ])?
417 .run()
418 .await?;
419 Ok(id)
420 }
421
422 /// Settles a new report. Returns whether it was still new.
423 pub async fn review_report(&self, repo: &RepoRow, id: &str, state: &str, by: &str, note: Option<&str>, advisory_id: Option<&str>) -> Result<bool> {
424 #[derive(Deserialize)]
425 struct Changed {
426 #[allow(dead_code)]
427 id: String,
428 }
429 let changed = self
430 .db
431 .prepare(
432 "UPDATE vulnerability_reports SET state = ?, reviewed_by = ?, reviewed_at = ?, review_note = ?, advisory_id = ?
433 WHERE repo_id = ? AND id = ? AND state = 'new' RETURNING id",
434 )
435 .bind(&[state.into(), by.into(), now().into(), optional(note), optional(advisory_id), repo.repo_id.as_str().into(), id.into()])?
436 .all()
437 .await?
438 .results::<Changed>()?;
439 Ok(!changed.is_empty())
440 }
441
442 /// A repository purged: its advisories and reports go with it.
443 pub async fn purge_advisories(&self, repo_id: &str) -> Result<()> {
444 self.db
445 .batch(vec![
446 self.db.prepare("DELETE FROM security_advisories WHERE repo_id = ?").bind(&[repo_id.into()])?,
447 self.db.prepare("DELETE FROM vulnerability_reports WHERE repo_id = ?").bind(&[repo_id.into()])?,
448 ])
449 .await?;
450 Ok(())
451 }
452}
453
454// --- The service --------------------------------------------------------------
455
456impl Security {
457 /// The repository, for someone who can see it (signed out, a public
458 /// one), recorded here, with what the viewer may do in it.
459 async fn visible_repo(&self, path: &g1t_contracts::repos::RepoPath, viewer: &Option<User>) -> Result<Outcome<(RepoRow, Repo)>> {
460 let found: Outcome<Repo> = g1t_kit::call(&self.repos, "get", &GetArgs { path: path.clone(), viewer: viewer.clone() }).await?;
461 match found {
462 Outcome::Ok(repo) => {
463 let row = self.store.register(&repo.id, &repo.namespace, &repo.name).await?;
464 self.store.set_private(&repo.id, repo.is_private).await?;
465 Ok(Outcome::Ok((row, repo)))
466 }
467 Outcome::Fail(_) => Ok(fail(FailureCode::NotFound, "Repository not found.")),
468 }
469 }
470
471 /// The states of advisories the viewer may see: every one for those who
472 /// see findings, else published ones alone.
473 fn visible_states<'a>(viewer: Option<&User>, repo: &Repo, asked: Option<&'a str>) -> Vec<&'a str> {
474 let all = access::can(viewer, repo, Capability::SecurityAlerts);
475 match asked {
476 Some(state) if ADVISORY_STATES.contains(&state) => {
477 if all || state == "published" {
478 vec![state]
479 } else {
480 Vec::new()
481 }
482 }
483 Some(_) => Vec::new(),
484 None if all => ADVISORY_STATES.to_vec(),
485 None => vec!["published"],
486 }
487 }
488
489 pub(crate) async fn list_advisories(&self, a: ListAdvisoriesArgs) -> Result<Outcome<Vec<Advisory>>> {
490 let (row, repo) = match self.visible_repo(&a.repo, &a.viewer).await? {
491 Outcome::Ok(found) => found,
492 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
493 };
494 let states = Self::visible_states(a.viewer.as_ref(), &repo, a.state.as_deref());
495 Ok(Outcome::Ok(self.store.security_advisories(&row, &states).await?))
496 }
497
498 pub(crate) async fn get_advisory(&self, a: GetAdvisoryArgs) -> Result<Outcome<Advisory>> {
499 let (row, repo) = match self.visible_repo(&a.repo, &a.viewer).await? {
500 Outcome::Ok(found) => found,
501 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
502 };
503 let states = Self::visible_states(a.viewer.as_ref(), &repo, None);
504 match self.store.security_advisory(&row, &a.id).await? {
505 Some(advisory) if states.contains(&advisory.state.as_str()) => Ok(Outcome::Ok(advisory)),
506 _ => Ok(fail(FailureCode::NotFound, "No such advisory.")),
507 }
508 }
509
510 /// The repository, for an actor who manages its security.
511 async fn managed_repo(&self, path: &g1t_contracts::repos::RepoPath, actor: &User) -> Result<Outcome<RepoRow>> {
512 if !actor.verified {
513 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
514 }
515 self.member_repo(path, &Some(actor.clone()), Capability::ManageSecurity).await
516 }
517
518 pub(crate) async fn create_advisory(&self, a: CreateAdvisoryArgs) -> Result<Outcome<Advisory>> {
519 let repo = match self.managed_repo(&a.repo, &a.actor).await? {
520 Outcome::Ok(repo) => repo,
521 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
522 };
523 let input = match validate(&a.advisory) {
524 Ok(input) => input,
525 Err(message) => return Ok(fail(FailureCode::Invalid, message)),
526 };
527 let id = self.store.add_advisory(&repo, &input, &a.actor.username, None).await?;
528 let Some(advisory) = self.store.security_advisory(&repo, &id).await? else {
529 return Ok(fail(FailureCode::NotFound, "No such advisory."));
530 };
531 self.audit(&a.actor, "security_advisory.created", Some(&repo), &repo.namespace, None, &format!("Drafted advisory {}: {}", advisory.g1tsa_id, advisory.title))
532 .await;
533 Ok(Outcome::Ok(advisory))
534 }
535
536 pub(crate) async fn update_advisory(&self, a: UpdateAdvisoryArgs) -> Result<Outcome<Advisory>> {
537 let repo = match self.managed_repo(&a.repo, &a.actor).await? {
538 Outcome::Ok(repo) => repo,
539 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
540 };
541 let Some(advisory) = self.store.security_advisory(&repo, &a.id).await? else {
542 return Ok(fail(FailureCode::NotFound, "No such advisory."));
543 };
544 let input = match validate(&a.advisory) {
545 Ok(input) => input,
546 Err(message) => return Ok(fail(FailureCode::Invalid, message)),
547 };
548 self.store.update_advisory(&repo, &advisory.id, &input).await?;
549 let Some(advisory) = self.store.security_advisory(&repo, &advisory.id).await? else {
550 return Ok(fail(FailureCode::NotFound, "No such advisory."));
551 };
552 self.audit(&a.actor, "security_advisory.updated", Some(&repo), &repo.namespace, None, &format!("Changed advisory {}", advisory.g1tsa_id)).await;
553 Ok(Outcome::Ok(advisory))
554 }
555
556 pub(crate) async fn set_advisory_state(&self, a: SetAdvisoryStateArgs) -> Result<Outcome<Advisory>> {
557 let repo = match self.managed_repo(&a.repo, &a.actor).await? {
558 Outcome::Ok(repo) => repo,
559 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
560 };
561 let Some(advisory) = self.store.security_advisory(&repo, &a.id).await? else {
562 return Ok(fail(FailureCode::NotFound, "No such advisory."));
563 };
564 let kind = match (advisory.state.as_str(), a.state.as_str()) {
565 ("draft" | "withdrawn", "published") => "security_advisory.published",
566 ("published", "withdrawn") => "security_advisory.withdrawn",
567 (same, asked) if same == asked => return Ok(fail(FailureCode::Conflict, format!("This advisory is {same} already."))),
568 (_, "published" | "withdrawn") => return Ok(fail(FailureCode::Conflict, "Only a published advisory can be withdrawn.")),
569 _ => return Ok(fail(FailureCode::Invalid, "state is published or withdrawn.")),
570 };
571 if kind == "security_advisory.published" && advisory.description.trim().is_empty() {
572 return Ok(fail(FailureCode::Invalid, "Describe the vulnerability before publishing the advisory."));
573 }
574 self.store.set_advisory_state(&repo, &advisory.id, &a.state).await?;
575 let Some(advisory) = self.store.security_advisory(&repo, &advisory.id).await? else {
576 return Ok(fail(FailureCode::NotFound, "No such advisory."));
577 };
578 let event = AdvisoryEvent {
579 repo_id: repo.repo_id.clone(),
580 id: advisory.id.clone(),
581 g1tsa_id: Some(advisory.g1tsa_id.clone()),
582 title: advisory.title.clone(),
583 severity: Some(advisory.severity.clone()),
584 state: advisory.state.clone(),
585 link: advisory_link(&repo, &advisory),
586 notify: Vec::new(),
587 };
588 self.publish_advisory(kind, &repo, &event, Some(a.actor.id.clone())).await;
589 self.audit(&a.actor, kind, Some(&repo), &repo.namespace, None, &format!("Advisory {} {}", advisory.g1tsa_id, advisory.state)).await;
590 Ok(Outcome::Ok(advisory))
591 }
592
593 pub(crate) async fn report_vulnerability(&self, a: ReportVulnerabilityArgs) -> Result<Outcome<VulnerabilityReport>> {
594 let (repo, record) = match self.visible_repo(&a.repo, &a.viewer).await? {
595 Outcome::Ok(found) => found,
596 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
597 };
598 if !self.store.repo_settings(&repo.repo_id).await?.0.private_vulnerability_reporting {
599 return Ok(fail(FailureCode::Conflict, "This repository does not take private vulnerability reports."));
600 }
601 let email = clean(a.email.as_deref(), 254).map(|email| email.to_lowercase());
602 let (reporter, key) = match a.viewer.as_ref().filter(|user| !user.username.is_empty()) {
603 Some(user) => (Some(user.username.to_lowercase()), format!("user:{}", user.username.to_lowercase())),
604 None => {
605 if record.is_private {
606 return Ok(fail(FailureCode::NotFound, "Repository not found."));
607 }
608 let Some(email) = email.as_deref().filter(|email| looks_like_email(email)) else {
609 return Ok(fail(FailureCode::Invalid, "Give an email address the maintainers can answer at, or sign in."));
610 };
611 (None, format!("email:{email}"))
612 }
613 };
614 let Some(title) = clean(Some(&a.title), MAX_TITLE_CHARS) else {
615 return Ok(fail(FailureCode::Invalid, "Give the report a title."));
616 };
617 let description: String = a.description.trim().chars().take(MAX_DESCRIPTION_CHARS).collect();
618 if description.is_empty() {
619 return Ok(fail(FailureCode::Invalid, "Describe the vulnerability: what is affected and how to reproduce it."));
620 }
621 let severity = a.severity.as_deref().map(str::trim).filter(|severity| !severity.is_empty());
622 if severity.is_some_and(|severity| !SEVERITIES.contains(&severity)) {
623 return Ok(fail(FailureCode::Invalid, "severity is critical, high, medium or low, or left out."));
624 }
625 let since = rfc3339(now_ms().saturating_sub(DAY_MS));
626 let (by_reporter, waiting) = self.store.report_counts(&repo, &key, &since).await?;
627 if by_reporter >= REPORTS_PER_DAY {
628 return Ok(fail(FailureCode::Limit, format!("You have sent {REPORTS_PER_DAY} reports to this repository today. Try again tomorrow.")));
629 }
630 if waiting >= MAX_NEW_REPORTS {
631 return Ok(fail(FailureCode::Conflict, "This repository has as many reports waiting as it takes. Try again once its maintainers have reviewed them."));
632 }
633 let id = self.store.add_report(&repo, reporter.as_deref(), email.as_deref(), &key, &title, &description, severity).await?;
634 let Some(report) = self.store.report(&repo, &id).await? else {
635 return Ok(fail(FailureCode::NotFound, "No such report."));
636 };
637 let event = AdvisoryEvent {
638 repo_id: repo.repo_id.clone(),
639 id: report.id.clone(),
640 g1tsa_id: None,
641 title: report.title.clone(),
642 severity: report.severity.clone(),
643 state: report.state.clone(),
644 link: link(&repo, "reports"),
645 notify: Vec::new(),
646 };
647 self.publish_advisory("security_advisory.reported", &repo, &event, a.viewer.as_ref().map(|user| user.id.clone())).await;
648 Ok(Outcome::Ok(report))
649 }
650
651 pub(crate) async fn report_settings(&self, a: ReportSettingsArgs) -> Result<Outcome<ReportSettings>> {
652 let (repo, _) = match self.visible_repo(&a.repo, &a.viewer).await? {
653 Outcome::Ok(found) => found,
654 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
655 };
656 let (settings, _) = self.store.repo_settings(&repo.repo_id).await?;
657 let published = self.store.security_advisories(&repo, &["published"]).await?.len() as u32;
658 Ok(Outcome::Ok(ReportSettings { private_vulnerability_reporting: settings.private_vulnerability_reporting, published }))
659 }
660
661 pub(crate) async fn list_vulnerability_reports(&self, a: ListReportsArgs) -> Result<Outcome<Vec<VulnerabilityReport>>> {
662 let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
663 Outcome::Ok(repo) => repo,
664 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
665 };
666 let state = a.state.as_deref().filter(|state| REPORT_STATES.contains(state));
667 if a.state.is_some() && state.is_none() {
668 return Ok(fail(FailureCode::Invalid, "state is new, accepted or declined."));
669 }
670 Ok(Outcome::Ok(self.store.reports(&repo, state).await?))
671 }
672
673 pub(crate) async fn review_vulnerability_report(&self, a: ReviewReportArgs) -> Result<Outcome<ReviewedReport>> {
674 let repo = match self.managed_repo(&a.repo, &a.actor).await? {
675 Outcome::Ok(repo) => repo,
676 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
677 };
678 let Some(report) = self.store.report(&repo, &a.id).await? else {
679 return Ok(fail(FailureCode::NotFound, "No such report."));
680 };
681 if report.state != "new" {
682 return Ok(fail(FailureCode::Conflict, format!("This report was {} already.", report.state)));
683 }
684 let note = clean(Some(&a.note), 1000);
685 let (state, advisory_id) = match a.decision.as_str() {
686 "accept" => {
687 let draft = draft_from(&report);
688 let id = self.store.add_advisory(&repo, &draft, &a.actor.username, Some(&report.id)).await?;
689 ("accepted", Some(id))
690 }
691 "decline" => ("declined", None),
692 _ => return Ok(fail(FailureCode::Invalid, "decision is accept or decline.")),
693 };
694 if !self.store.review_report(&repo, &report.id, state, &a.actor.username, note.as_deref(), advisory_id.as_deref()).await? {
695 return Ok(fail(FailureCode::Conflict, "This report was reviewed already."));
696 }
697 let advisory = match &advisory_id {
698 Some(id) => self.store.security_advisory(&repo, id).await?,
699 None => None,
700 };
701 let Some(report) = self.store.report(&repo, &report.id).await? else {
702 return Ok(fail(FailureCode::NotFound, "No such report."));
703 };
704 self.audit(
705 &a.actor,
706 "security_advisory.report_reviewed",
707 Some(&repo),
708 &repo.namespace,
709 None,
710 &format!("Report {} {state}{}", report.id, advisory.as_ref().map(|advisory| format!(", drafted as {}", advisory.g1tsa_id)).unwrap_or_default()),
711 )
712 .await;
713 Ok(Outcome::Ok(ReviewedReport { report, advisory }))
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720
721 #[test]
722 fn an_advisory_is_checked_and_cleaned() {
723 let input = AdvisoryInput {
724 title: " Path traversal in the archive reader ".into(),
725 description: "Details…".into(),
726 severity: "high".into(),
727 cve_id: Some("cve-2026-12345".into()),
728 cvss_score: Some(7.5),
729 credits: vec!["ana".into(), " ".into(), "bo".into()],
730 ..AdvisoryInput::default()
731 };
732 let clean = validate(&input).unwrap();
733 assert_eq!(clean.title, "Path traversal in the archive reader");
734 assert_eq!(clean.cve_id.as_deref(), Some("CVE-2026-12345"));
735 assert_eq!(clean.credits, vec!["ana", "bo"]);
736 assert!(validate(&AdvisoryInput { title: " ".into(), severity: "high".into(), ..AdvisoryInput::default() }).is_err());
737 assert!(validate(&AdvisoryInput { title: "x".into(), severity: "severe".into(), ..AdvisoryInput::default() }).is_err());
738 assert!(validate(&AdvisoryInput { title: "x".into(), severity: "low".into(), cve_id: Some("CVE-1".into()), ..AdvisoryInput::default() }).is_err());
739 assert!(validate(&AdvisoryInput { title: "x".into(), severity: "low".into(), cvss_score: Some(11.0), ..AdvisoryInput::default() }).is_err());
740 }
741
742 #[test]
743 fn an_accepted_report_becomes_a_draft_crediting_its_reporter() {
744 let report = VulnerabilityReport {
745 title: "XSS in the search box".into(),
746 description: "Type <script> into…".into(),
747 severity: Some("medium".into()),
748 reporter: None,
749 reporter_email: Some("finder@example.com".into()),
750 ..VulnerabilityReport::default()
751 };
752 let draft = draft_from(&report);
753 assert_eq!(draft.title, "XSS in the search box");
754 assert_eq!(draft.credits, vec!["finder@example.com"]);
755 let signed_in = VulnerabilityReport { reporter: Some("ana".into()), severity: None, ..report };
756 let draft = draft_from(&signed_in);
757 assert_eq!(draft.credits, vec!["ana"]);
758 assert_eq!(draft.severity, "medium");
759 }
760
761 #[test]
762 fn addresses_and_cves_are_checked_loosely() {
763 assert!(looks_like_email("finder@example.com"));
764 assert!(!looks_like_email("finder") && !looks_like_email("@example.com") && !looks_like_email("a b@example.com"));
765 assert!(is_cve("CVE-2026-12345") && is_cve("cve-2026-0001"));
766 assert!(!is_cve("CVE-26-1") && !is_cve("GHSA-xxxx"));
767 assert!(year_of(0) == 1970);
768 }
769}