| 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 | } |