Skip to content
759 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.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 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 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 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 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 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 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 list_vulnerability_reports(&self, a: ListReportsArgs) -> Result<Outcome<Vec<VulnerabilityReport>>> {
652 let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
653 Outcome::Ok(repo) => repo,
654 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
655 };
656 let state = a.state.as_deref().filter(|state| REPORT_STATES.contains(state));
657 if a.state.is_some() && state.is_none() {
658 return Ok(fail(FailureCode::Invalid, "state is new, accepted or declined."));
659 }
660 Ok(Outcome::Ok(self.store.reports(&repo, state).await?))
661 }
662
663 pub(crate) async fn review_vulnerability_report(&self, a: ReviewReportArgs) -> Result<Outcome<ReviewedReport>> {
664 let repo = match self.managed_repo(&a.repo, &a.actor).await? {
665 Outcome::Ok(repo) => repo,
666 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
667 };
668 let Some(report) = self.store.report(&repo, &a.id).await? else {
669 return Ok(fail(FailureCode::NotFound, "No such report."));
670 };
671 if report.state != "new" {
672 return Ok(fail(FailureCode::Conflict, format!("This report was {} already.", report.state)));
673 }
674 let note = clean(Some(&a.note), 1000);
675 let (state, advisory_id) = match a.decision.as_str() {
676 "accept" => {
677 let draft = draft_from(&report);
678 let id = self.store.add_advisory(&repo, &draft, &a.actor.username, Some(&report.id)).await?;
679 ("accepted", Some(id))
680 }
681 "decline" => ("declined", None),
682 _ => return Ok(fail(FailureCode::Invalid, "decision is accept or decline.")),
683 };
684 if !self.store.review_report(&repo, &report.id, state, &a.actor.username, note.as_deref(), advisory_id.as_deref()).await? {
685 return Ok(fail(FailureCode::Conflict, "This report was reviewed already."));
686 }
687 let advisory = match &advisory_id {
688 Some(id) => self.store.security_advisory(&repo, id).await?,
689 None => None,
690 };
691 let Some(report) = self.store.report(&repo, &report.id).await? else {
692 return Ok(fail(FailureCode::NotFound, "No such report."));
693 };
694 self.audit(
695 &a.actor,
696 "security_advisory.report_reviewed",
697 Some(&repo),
698 &repo.namespace,
699 None,
700 &format!("Report {} {state}{}", report.id, advisory.as_ref().map(|advisory| format!(", drafted as {}", advisory.g1tsa_id)).unwrap_or_default()),
701 )
702 .await;
703 Ok(Outcome::Ok(ReviewedReport { report, advisory }))
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[test]
712 fn an_advisory_is_checked_and_cleaned() {
713 let input = AdvisoryInput {
714 title: " Path traversal in the archive reader ".into(),
715 description: "Details…".into(),
716 severity: "high".into(),
717 cve_id: Some("cve-2026-12345".into()),
718 cvss_score: Some(7.5),
719 credits: vec!["ana".into(), " ".into(), "bo".into()],
720 ..AdvisoryInput::default()
721 };
722 let clean = validate(&input).unwrap();
723 assert_eq!(clean.title, "Path traversal in the archive reader");
724 assert_eq!(clean.cve_id.as_deref(), Some("CVE-2026-12345"));
725 assert_eq!(clean.credits, vec!["ana", "bo"]);
726 assert!(validate(&AdvisoryInput { title: " ".into(), severity: "high".into(), ..AdvisoryInput::default() }).is_err());
727 assert!(validate(&AdvisoryInput { title: "x".into(), severity: "severe".into(), ..AdvisoryInput::default() }).is_err());
728 assert!(validate(&AdvisoryInput { title: "x".into(), severity: "low".into(), cve_id: Some("CVE-1".into()), ..AdvisoryInput::default() }).is_err());
729 assert!(validate(&AdvisoryInput { title: "x".into(), severity: "low".into(), cvss_score: Some(11.0), ..AdvisoryInput::default() }).is_err());
730 }
731
732 #[test]
733 fn an_accepted_report_becomes_a_draft_crediting_its_reporter() {
734 let report = VulnerabilityReport {
735 title: "XSS in the search box".into(),
736 description: "Type <script> into…".into(),
737 severity: Some("medium".into()),
738 reporter: None,
739 reporter_email: Some("finder@example.com".into()),
740 ..VulnerabilityReport::default()
741 };
742 let draft = draft_from(&report);
743 assert_eq!(draft.title, "XSS in the search box");
744 assert_eq!(draft.credits, vec!["finder@example.com"]);
745 let signed_in = VulnerabilityReport { reporter: Some("ana".into()), severity: None, ..report };
746 let draft = draft_from(&signed_in);
747 assert_eq!(draft.credits, vec!["ana"]);
748 assert_eq!(draft.severity, "medium");
749 }
750
751 #[test]
752 fn addresses_and_cves_are_checked_loosely() {
753 assert!(looks_like_email("finder@example.com"));
754 assert!(!looks_like_email("finder") && !looks_like_email("@example.com") && !looks_like_email("a b@example.com"));
755 assert!(is_cve("CVE-2026-12345") && is_cve("cve-2026-0001"));
756 assert!(!is_cve("CVE-26-1") && !is_cve("GHSA-xxxx"));
757 assert!(year_of(0) == 1970);
758 }
759}