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 | //! One event per scan per alert type, for the inbox: what the scan opened, |
| 2 | //! what it fixed, and what is left open, with the worst severity among the | |
| 3 | //! new alerts and the newest one's title. The inbox keeps one thread per | |
| 4 | //! repository and type from these (`<repo>/security/vulnerabilities`), so a | |
| 5 | //! scan that finds forty vulnerabilities is one item and the next scan | |
| 6 | //! brings the same item up to date; the per-alert `.created` and `.fixed` | |
| 7 | //! events stay for webhooks (`g1t_contracts::security_suite::SecuritySummary`). | |
| 8 | ||
| 9 | use g1t_contracts::security::SeverityCounts; | |
| 10 | use g1t_contracts::security_suite::{AlertType, MAX_SUMMARY_IDS, SecuritySummary}; | |
| 11 | use worker::Result; | |
| 12 | ||
| 13 | use crate::Security; | |
| 14 | use crate::store::RepoRow; | |
| 15 | use crate::suite::link; | |
| 16 | ||
| 17 | /// What one scan changed, as the modules report it. | |
| 18 | #[derive(Debug, Default)] | |
| 19 | pub struct Changed<'a> { | |
| 20 | /// `push`, `history`, `dependencies`, `analysis` or `change`. | |
| 21 | pub scan: &'a str, | |
| 22 | /// Every alert it opened, newest first. | |
| 23 | pub opened: Vec<String>, | |
| 24 | /// Every alert it closed as fixed. | |
| 25 | pub fixed: Vec<String>, | |
| 26 | pub dismissed: u32, | |
| 27 | /// Who pushed, when push protection refused the push. | |
| 28 | pub pusher: Option<&'a str>, | |
| 29 | /// How many secrets stopped that push. | |
| 30 | pub blocked: u32, | |
| 31 | } | |
| 32 | ||
| 33 | /// Lower is worse. | |
| 34 | fn rank(severity: &str) -> u8 { | |
| 35 | match severity { | |
| 36 | "critical" => 0, | |
| 37 | "high" => 1, | |
| 38 | "medium" => 2, | |
| 39 | "low" => 3, | |
| 40 | _ => 4, | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | /// The worst of some severities, or none. | |
| 45 | pub fn worst<'a>(severities: impl Iterator<Item = &'a str>) -> Option<String> { | |
| 46 | severities.min_by_key(|severity| rank(severity)).map(str::to_owned) | |
| 47 | } | |
| 48 | ||
| 49 | /// Severities counted. | |
| 50 | pub fn count<'a>(severities: impl Iterator<Item = &'a str>) -> SeverityCounts { | |
| 51 | let mut counts = SeverityCounts::default(); | |
| 52 | for severity in severities { | |
| 53 | match severity { | |
| 54 | "critical" => counts.critical += 1, | |
| 55 | "high" => counts.high += 1, | |
| 56 | "medium" => counts.medium += 1, | |
| 57 | "low" => counts.low += 1, | |
| 58 | _ => counts.unknown += 1, | |
| 59 | } | |
| 60 | } | |
| 61 | counts | |
| 62 | } | |
| 63 | ||
| 64 | /// The summary of a scan, from what it changed, the new alerts (severity | |
| 65 | /// and title, newest first) and what is open after it. Pure. | |
| 66 | pub fn shape(repo: &RepoRow, alert_type: AlertType, changed: &Changed<'_>, news: &[(String, String)], open: SeverityCounts) -> SecuritySummary { | |
| 67 | SecuritySummary { | |
| 68 | repo_id: repo.repo_id.clone(), | |
| 69 | alert_type: alert_type.as_str().to_owned(), | |
| 70 | scan: changed.scan.to_owned(), | |
| 71 | opened: changed.opened.iter().take(MAX_SUMMARY_IDS).cloned().collect(), | |
| 72 | opened_count: changed.opened.len() as u32, | |
| 73 | fixed: changed.fixed.iter().take(MAX_SUMMARY_IDS).cloned().collect(), | |
| 74 | fixed_count: changed.fixed.len() as u32, | |
| 75 | dismissed_count: changed.dismissed, | |
| 76 | new_by_severity: count(news.iter().map(|(severity, _)| severity.as_str())), | |
| 77 | open, | |
| 78 | worst: worst(news.iter().map(|(severity, _)| severity.as_str())), | |
| 79 | latest: news.first().map(|(_, title)| title.clone()), | |
| 80 | link: link(repo, alert_type.page()), | |
| 81 | pusher: changed.pusher.map(str::to_owned), | |
| 82 | blocked: changed.blocked, | |
| 83 | notify: Vec::new(), | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | impl Security { | |
| 88 | /// Publishes what a scan changed, once, unless it changed nothing. | |
| 89 | pub(crate) async fn summarize(&self, repo: &RepoRow, alert_type: AlertType, changed: Changed<'_>, actor_id: Option<String>) -> Result<()> { | |
| 90 | if changed.opened.is_empty() && changed.fixed.is_empty() && changed.dismissed == 0 && changed.scan != "change" { | |
| 91 | return Ok(()); | |
| 92 | } | |
| 93 | let mut news = Vec::with_capacity(changed.opened.len().min(MAX_SUMMARY_IDS)); | |
| 94 | for id in changed.opened.iter().take(MAX_SUMMARY_IDS) { | |
| 95 | let found = match alert_type { | |
| 96 | AlertType::SecretScanning => self.store.secret(&repo.repo_id, id).await?.map(|secret| { | |
| 97 | let event = crate::secret_alerts::secret_event(repo, &secret); | |
| 98 | (event.severity, event.title) | |
| 99 | }), | |
| 100 | AlertType::CodeScanning => self.store.code_alert_by_id(&repo.repo_id, id).await?.map(|alert| { | |
| 101 | let event = crate::code_scanning::code_event(repo, &alert); | |
| 102 | (event.severity, event.title) | |
| 103 | }), | |
| 104 | AlertType::Vulnerability => self.store.vulnerability(&repo.repo_id, id).await?.map(|vuln| { | |
| 105 | let event = crate::deps::vulnerability_event(repo, &vuln); | |
| 106 | (event.severity, event.title) | |
| 107 | }), | |
| 108 | }; | |
| 109 | if let Some(news_item) = found { | |
| 110 | news.push(news_item); | |
| 111 | } | |
| 112 | } | |
| 113 | // The worst new alert is the headline, whatever order the scan found them in. | |
| 114 | news.sort_by_key(|(severity, _)| rank(severity)); | |
| 115 | let open = self.open_counts(repo, alert_type).await?; | |
| 116 | let summary = shape(repo, alert_type, &changed, &news, open); | |
| 117 | self.publish_summary(alert_type.summary_event(), repo, &summary, actor_id).await; | |
| 118 | Ok(()) | |
| 119 | } | |
| 120 | ||
| 121 | /// What is open of one type, by severity. A secret in the history that | |
| 122 | /// looks real, or stopped at a push and not yet decided, is critical; a | |
| 123 | /// likely test value is low. | |
| 124 | async fn open_counts(&self, repo: &RepoRow, alert_type: AlertType) -> Result<SeverityCounts> { | |
| 125 | Ok(match alert_type { | |
| 126 | AlertType::SecretScanning => { | |
| 127 | let secrets = self.store.secret_counts(&repo.repo_id).await?; | |
| 128 | SeverityCounts { critical: secrets.open + secrets.blocked, low: secrets.test_values, ..SeverityCounts::default() } | |
| 129 | } | |
| 130 | AlertType::CodeScanning => self.store.code_counts(&repo.repo_id).await?, | |
| 131 | AlertType::Vulnerability => { | |
| 132 | let rows = self.store.open_vulnerabilities(&repo.repo_id).await?; | |
| 133 | count(rows.iter().map(|row| row.severity.as_str())) | |
| 134 | } | |
| 135 | }) | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | #[cfg(test)] | |
| 140 | mod tests { | |
| 141 | use super::*; | |
| 142 | ||
| 143 | fn repo() -> RepoRow { | |
| 144 | RepoRow { | |
| 145 | repo_id: "rep_1".into(), | |
| 146 | namespace: "acme".into(), | |
| 147 | name: "rocket".into(), | |
| 148 | upkeep: 1, | |
| 149 | history: "done".into(), | |
| 150 | history_cursor: None, | |
| 151 | history_commits: 0, | |
| 152 | history_finished_at: None, | |
| 153 | deps_scanned_at: None, | |
| 154 | deps_error: None, | |
| 155 | lockfiles: "[]".into(), | |
| 156 | version_updates: None, | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | #[test] | |
| 161 | fn a_scan_is_one_summary_with_counts_the_worst_and_the_newest() { | |
| 162 | let changed = Changed { scan: "dependencies", opened: vec!["vul_3".into(), "vul_2".into(), "vul_1".into()], fixed: vec!["vul_0".into()], ..Changed::default() }; | |
| 163 | let news = vec![ | |
| 164 | ("high".to_owned(), "lodash 4.17.20 in package-lock.json: GHSA-1".to_owned()), | |
| 165 | ("critical".to_owned(), "minimist 1.2.5 in package-lock.json: GHSA-2".to_owned()), | |
| 166 | ("low".to_owned(), "ms 2.0.0 in package-lock.json: GHSA-3".to_owned()), | |
| 167 | ]; | |
| 168 | let open = SeverityCounts { critical: 1, high: 2, ..SeverityCounts::default() }; | |
| 169 | let summary = shape(&repo(), AlertType::Vulnerability, &changed, &news, open); | |
| 170 | assert_eq!(summary.opened_count, 3); | |
| 171 | assert_eq!(summary.fixed, vec!["vul_0"]); | |
| 172 | assert_eq!(summary.worst.as_deref(), Some("critical")); | |
| 173 | assert_eq!(summary.latest.as_deref(), Some("lodash 4.17.20 in package-lock.json: GHSA-1")); | |
| 174 | assert_eq!((summary.new_by_severity.critical, summary.new_by_severity.high, summary.new_by_severity.low), (1, 1, 1)); | |
| 175 | assert_eq!(summary.link, "/acme/rocket/security/vulnerabilities"); | |
| 176 | assert_eq!(summary.open_total(), 3); | |
| 177 | assert!(summary.urgent()); | |
| 178 | } | |
| 179 | ||
| 180 | #[test] | |
| 181 | fn the_id_lists_are_capped_but_the_counts_are_not() { | |
| 182 | let opened: Vec<String> = (0..500).map(|n| format!("cod_{n}")).collect(); | |
| 183 | let changed = Changed { scan: "analysis", opened, ..Changed::default() }; | |
| 184 | let summary = shape(&repo(), AlertType::CodeScanning, &changed, &[], SeverityCounts::default()); | |
| 185 | assert_eq!(summary.opened.len(), MAX_SUMMARY_IDS); | |
| 186 | assert_eq!(summary.opened_count, 500); | |
| 187 | assert!(summary.worst.is_none() && summary.latest.is_none()); | |
| 188 | assert!(!summary.urgent()); | |
| 189 | } | |
| 190 | ||
| 191 | #[test] | |
| 192 | fn a_refused_push_is_urgent_whatever_was_found() { | |
| 193 | let changed = Changed { scan: "push", opened: vec!["sec_1".into()], pusher: Some("bo"), blocked: 1, ..Changed::default() }; | |
| 194 | let summary = shape(&repo(), AlertType::SecretScanning, &changed, &[("low".into(), "An example key in README.md".into())], SeverityCounts::default()); | |
| 195 | assert_eq!(summary.pusher.as_deref(), Some("bo")); | |
| 196 | assert!(summary.urgent()); | |
| 197 | assert_eq!(worst(["low", "medium", "high"].into_iter()).as_deref(), Some("high")); | |
| 198 | assert_eq!(worst(std::iter::empty()), None); | |
| 199 | } | |
| 200 | } |