Skip to content
200 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//! 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}