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 | ||
| 15 | use g1t_contracts::access::{self, Capability}; | |
| 16 | use g1t_contracts::advisories::*; | |
| 17 | use g1t_contracts::repos::{GetArgs, Repo}; | |
| 18 | use g1t_contracts::time::rfc3339; | |
| 19 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; | |
| 20 | use g1t_kit::now_ms; | |
| 21 | use serde::Deserialize; | |
| 22 | use worker::Result; | |
| 23 | use worker::wasm_bindgen::JsValue; | |
| 24 | ||
| 25 | use crate::Security; | |
| 26 | use crate::store::{RepoRow, Store, now, optional}; | |
| 27 | use crate::suite::link; | |
| 28 | ||
| 29 | /// The most advisories and reports listed. | |
| 30 | const 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. | |
| 34 | const REPORTS_PER_DAY: u32 = 5; | |
| 35 | const MAX_NEW_REPORTS: u32 = 50; | |
| 36 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 37 | ||
| 38 | fn 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-…`. | |
| 43 | pub 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. | |
| 48 | fn 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. | |
| 54 | pub 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. | |
| 62 | pub 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. | |
| 97 | fn 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. | |
| 105 | pub 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. | |
| 116 | fn 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)] | |
| 123 | struct 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 | ||
| 147 | impl 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)] | |
| 177 | struct 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 | ||
| 193 | impl 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)] | |
| 215 | struct CountRow { | |
| 216 | n: f64, | |
| 217 | } | |
| 218 | ||
| 219 | const 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"; | |
| 221 | const 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 | ||
| 224 | impl 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 | ||
| 456 | impl 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)] | |
| 718 | mod 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 | } |