Skip to content
769 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
The advisories a repository publishes live in security_advisories: the security database already had an advisories table, its cache of OSV advisories, so the migration for repository advisories stopped at its first statement on deploy.236 .prepare(format!("SELECT {ADVISORY_COLUMNS} FROM security_advisories WHERE repo_id = ? AND state IN ({marks}) ORDER BY created_at DESC LIMIT ?"))
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.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
The advisories a repository publishes live in security_advisories: the security database already had an advisories table, its cache of OSV advisories, so the migration for repository advisories stopped at its first statement on deploy.249 .prepare(format!("SELECT {ADVISORY_COLUMNS} FROM security_advisories WHERE repo_id = ? AND {column} = ?"))
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.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(
The advisories a repository publishes live in security_advisories: the security database already had an advisories table, its cache of OSV advisories, so the migration for repository advisories stopped at its first statement on deploy.266 "INSERT INTO security_advisories (id, g1tsa_id, repo_id, title, description, severity, cvss_vector, cvss_score, cve_id, ecosystem,
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.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(
The advisories a repository publishes live in security_advisories: the security database already had an advisories table, its cache of OSV advisories, so the migration for repository advisories stopped at its first statement on deploy.298 "UPDATE security_advisories SET title = ?, description = ?, severity = ?, cvss_vector = ?, cvss_score = ?, cve_id = ?, ecosystem = ?,
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.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
The advisories a repository publishes live in security_advisories: the security database already had an advisories table, its cache of OSV advisories, so the migration for repository advisories stopped at its first statement on deploy.331 .prepare(format!("UPDATE security_advisories SET state = ?2, updated_at = ?1, {stamp} WHERE repo_id = ?3 AND id = ?4"))
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.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![
The advisories a repository publishes live in security_advisories: the security database already had an advisories table, its cache of OSV advisories, so the migration for repository advisories stopped at its first statement on deploy.446 self.db.prepare("DELETE FROM security_advisories WHERE repo_id = ?").bind(&[repo_id.into()])?,
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.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
A repository's Security overview is one row per feature in three groups, each with a status word (Enabled, Disabled, Needs setup, Coming), a line on what it does and the one thing to do about it: the security policy (View policy, or Set up a security policy, which commits a starting SECURITY.md on a branch and opens it as a pull request), dependency alerts, security updates (the switch, kept on Vulnerabilities and now in Settings too), code scanning (Enabled once an analysis was uploaded), secret scanning (push protection always on) and code quality as Coming; and under Reporting, advisories and the private vulnerability reporting switch. Anyone who can see a public repository but has no role on it sees the public face at the same address: the policy, the published advisories and, when it is on, Report a vulnerability. Advisories get their pages (the list with its state filter, one advisory with Publish, Withdraw and Edit, a form for a new draft or changes) and reports theirs (new ones first, each accepted into a draft advisory or declined), with a Reports page beside Advisories and an Advisories tab; the public report form takes an email when signed out and counts against the front door's heavy limit. Security settings gain Alerts for secrets in the history and Private vulnerability reporting. Settings → Notifications gains a Security section: critical and high alerts at once (always, for the workspaces one owns or manages security for) and the rest in a daily or weekly digest, at once, or not at all, with security alerts now among the email reasons. The notifications guide says how security alerts arrive and what the digest is, the security guide the overview's rows and the public face, secret protection the history alerts switch, webhooks the summary and advisory events, and a new guide covers advisories and private reporting; the report_settings method lets the public page know whether a repository takes reports.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
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.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}