Skip to content
200 linesCodeBlameRaw
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
9use g1t_contracts::security::SeverityCounts;
10use g1t_contracts::security_suite::{AlertType, MAX_SUMMARY_IDS, SecuritySummary};
11use worker::Result;
12
13use crate::Security;
14use crate::store::RepoRow;
15use crate::suite::link;
16
17/// What one scan changed, as the modules report it.
18#[derive(Debug, Default)]
19pub 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.
34fn 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.
45pub 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.
50pub 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.
66pub 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
87impl 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)]
140mod 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}