Skip to content

g1t/services/security/src/overview.rs

407 lines18,653 bytesCodeBlame
1//! Settings, and the workspace's security overview: open alerts by type
2//! and severity across its repositories, how they moved, which repository
3//! has which feature on, and those most in need.
4
5use std::collections::BTreeMap;
6
7use g1t_contracts::access::{self, Capability};
8use g1t_contracts::security::SeverityCounts;
9use g1t_contracts::security_suite::{
10 AlertType, RepoCoverage, SecuritySettingsArgs, SecuritySettingsView, SetSecuritySettingsArgs, SetWorkspaceSecuritySettingsArgs,
11 TrendPoint, TypeTotals, WorkspaceAlert, WorkspaceAlertsArgs, WorkspaceOverview, WorkspaceOverviewArgs,
12 WorkspaceSecuritySettingsArgs, WorkspaceSecurityView,
13};
14use g1t_contracts::repos::{MAX_READABLE, ReadableArgs, Repo};
15use g1t_contracts::time::rfc3339;
16use g1t_contracts::{FailureCode, Outcome};
17use g1t_kit::now_ms;
18use worker::Result;
19
20use crate::Security;
21use crate::store::RepoRow;
22
23const DAY_MS: u64 = 24 * 60 * 60 * 1000;
24/// Repositories snapshotted per sweep.
25const SNAPSHOTS_PER_SWEEP: u32 = 25;
26const GATES: [&str; 6] = ["none", "errors", "critical", "high", "medium", "any"];
27const SEVERITY_NAMES: [&str; 5] = ["critical", "high", "medium", "low", "none"];
28/// Licenses a repository may deny, at most.
29const MAX_DENIED: usize = 50;
30
31fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
32 Outcome::fail(code, message)
33}
34
35fn add(total: &mut SeverityCounts, counts: &SeverityCounts) {
36 total.critical += counts.critical;
37 total.high += counts.high;
38 total.medium += counts.medium;
39 total.low += counts.low;
40 total.unknown += counts.unknown;
41}
42
43fn sum(counts: &SeverityCounts) -> u32 {
44 counts.critical + counts.high + counts.medium + counts.low + counts.unknown
45}
46
47/// The days of a trend, oldest first, ending today: `YYYY-MM-DD`.
48pub fn days(today_ms: u64, count: u32) -> Vec<String> {
49 (0..count).rev().map(|ago| rfc3339(today_ms.saturating_sub(u64::from(ago) * DAY_MS))[..10].to_owned()).collect()
50}
51
52/// Repositories most in need first: open critical, then high, then the rest.
53pub fn rank(repos: &mut [RepoCoverage]) {
54 let key = |repo: &RepoCoverage| {
55 let mut total = SeverityCounts::default();
56 add(&mut total, &repo.secrets);
57 add(&mut total, &repo.code);
58 add(&mut total, &repo.vulnerabilities);
59 (std::cmp::Reverse(total.critical), std::cmp::Reverse(total.high), std::cmp::Reverse(sum(&total)))
60 };
61 repos.sort_by(|a, b| key(a).cmp(&key(b)).then_with(|| a.name.cmp(&b.name)));
62}
63
64/// A trend from daily snapshots: each day's open alerts by type, carrying
65/// a repository's last known counts over days it was not snapshotted.
66pub fn trend(days: &[String], rows: &[crate::suite_store::SnapshotRow]) -> Vec<TrendPoint> {
67 let mut by_day: BTreeMap<&str, TrendPoint> = BTreeMap::new();
68 for row in rows {
69 let point = by_day.entry(row.day.as_str()).or_insert_with(|| TrendPoint { day: row.day.clone(), ..TrendPoint::default() });
70 let n = (row.critical + row.high + row.medium + row.low + row.unknown).max(0) as u32;
71 match row.alert_type.as_str() {
72 "secret_scanning" => point.secret_scanning += n,
73 "code_scanning" => point.code_scanning += n,
74 _ => point.vulnerability += n,
75 }
76 }
77 days.iter()
78 .map(|day| by_day.get(day.as_str()).cloned().unwrap_or_else(|| TrendPoint { day: day.clone(), ..TrendPoint::default() }))
79 .collect()
80}
81
82impl Security {
83 pub(crate) async fn security_settings(&self, a: SecuritySettingsArgs) -> Result<Outcome<SecuritySettingsView>> {
84 let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
85 Outcome::Ok(repo) => repo,
86 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
87 };
88 let (settings, private) = self.store.repo_settings(&repo.repo_id).await?;
89 Ok(Outcome::Ok(SecuritySettingsView {
90 settings,
91 workspace: self.store.workspace_settings(&repo.namespace).await?,
92 private,
93 entitled: !private || self.activated(&repo.namespace).await,
94 upkeep: repo.upkeep != 0,
95 }))
96 }
97
98 pub(crate) async fn set_security_settings(&self, a: SetSecuritySettingsArgs) -> Result<Outcome<SecuritySettingsView>> {
99 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Capability::ManageSecurity).await? {
100 Outcome::Ok(repo) => repo,
101 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
102 };
103 if !a.actor.verified {
104 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
105 }
106 let mut settings = a.settings;
107 if !GATES.contains(&settings.code_scanning_gate.as_str()) {
108 return Ok(fail(FailureCode::Invalid, "code_scanning_gate is none, errors, critical, high, medium or any."));
109 }
110 if !SEVERITY_NAMES.contains(&settings.review_fail_on.as_str()) {
111 return Ok(fail(FailureCode::Invalid, "review_fail_on is critical, high, medium, low or none."));
112 }
113 settings.review_deny_licenses = settings
114 .review_deny_licenses
115 .iter()
116 .map(|id| id.trim().to_owned())
117 .filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '+')))
118 .take(MAX_DENIED)
119 .collect();
120 self.store.set_repo_settings(&repo.repo_id, &settings).await?;
121 self.audit(
122 &a.actor,
123 "security_settings",
124 Some(&repo),
125 &repo.namespace,
126 None,
127 &format!(
128 "Security settings: code scanning fails at {}, dependency review {} (fails at {})",
129 settings.code_scanning_gate,
130 if settings.dependency_review { "on" } else { "off" },
131 settings.review_fail_on
132 ),
133 )
134 .await;
135 self.security_settings(SecuritySettingsArgs { viewer: Some(a.actor), repo: a.repo }).await
136 }
137
138 pub(crate) async fn workspace_security_settings(&self, a: WorkspaceSecuritySettingsArgs) -> Result<Outcome<WorkspaceSecurityView>> {
139 let workspace = a.workspace.to_lowercase();
140 if !a.viewer.as_ref().is_some_and(|user| user.is_member(&workspace)) {
141 return Ok(fail(FailureCode::NotFound, "Workspace not found."));
142 }
143 Ok(Outcome::Ok(WorkspaceSecurityView {
144 settings: self.store.workspace_settings(&workspace).await?,
145 activated: self.activated(&workspace).await,
146 }))
147 }
148
149 pub(crate) async fn set_workspace_security_settings(&self, a: SetWorkspaceSecuritySettingsArgs) -> Result<Outcome<WorkspaceSecurityView>> {
150 let workspace = a.workspace.to_lowercase();
151 if !a.actor.manages_security(&workspace) {
152 return Ok(fail(FailureCode::Forbidden, "Only an owner or a security manager can change the workspace's security settings."));
153 }
154 if !a.actor.verified {
155 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
156 }
157 self.store.set_workspace_settings(&workspace, &a.settings, &a.actor.username).await?;
158 self.audit(
159 &a.actor,
160 "security_settings",
161 None,
162 &workspace,
163 None,
164 &format!(
165 "Workspace security settings: delegated bypass {}, validity checks {}",
166 if a.settings.delegated_bypass { "on" } else { "off" },
167 if a.settings.validity_checks { "on" } else { "off" }
168 ),
169 )
170 .await;
171 self.workspace_security_settings(WorkspaceSecuritySettingsArgs { viewer: Some(a.actor), workspace }).await
172 }
173
174 /// The repositories of a workspace whose findings `viewer` may see,
175 /// with whether each is private: as the repositories service says now,
176 /// the record here corrected when it was wrong (a repository recorded
177 /// from an event before its visibility was known), else as recorded.
178 async fn visible_repos(&self, workspace: &str, viewer: &g1t_contracts::User) -> Result<Vec<(RepoRow, bool)>> {
179 let rows: Vec<RepoRow> = self
180 .store
181 .in_namespace(workspace)
182 .await?
183 .into_iter()
184 .filter(|repo| {
185 let target = access::RepoRef { id: &repo.repo_id, namespace: workspace, private: true };
186 access::can(Some(viewer), target, crate::SEE_FINDINGS)
187 })
188 .collect();
189 let (recorded, live) = futures_util::future::join(
190 futures_util::future::try_join_all(rows.iter().map(|repo| self.store.repo_settings(&repo.repo_id))),
191 self.live_privacy(&rows, viewer),
192 )
193 .await;
194 let mut out = Vec::with_capacity(rows.len());
195 for (repo, (_, recorded)) in rows.into_iter().zip(recorded?) {
196 let private = match live.get(&repo.repo_id) {
197 Some(&now) => {
198 if now != recorded {
199 self.store.set_private(&repo.repo_id, now).await?;
200 }
201 now
202 }
203 None => recorded,
204 };
205 out.push((repo, private));
206 }
207 Ok(out)
208 }
209
210 /// Whether each repository is private, by id, as the repositories
211 /// service says now: one call for all of them. Empty when it cannot say.
212 async fn live_privacy(&self, rows: &[RepoRow], viewer: &g1t_contracts::User) -> BTreeMap<String, bool> {
213 if rows.is_empty() {
214 return BTreeMap::new();
215 }
216 let ids = rows.iter().take(MAX_READABLE).map(|repo| repo.repo_id.clone()).collect();
217 let found: Result<Vec<Repo>> =
218 g1t_kit::call(&self.repos, "readable", &ReadableArgs { ids, viewer: Some(viewer.clone()) }).await;
219 match found {
220 Ok(found) => found.into_iter().map(|repo| (repo.id, repo.is_private)).collect(),
221 Err(error) => {
222 worker::console_error!("security: readable: {error}");
223 BTreeMap::new()
224 }
225 }
226 }
227
228 /// One repository's row in the overview: its features and open counts,
229 /// read at once.
230 async fn coverage(&self, workspace: &str, validity_checks: bool, repo: RepoRow, private: bool) -> Result<RepoCoverage> {
231 let ((repo_settings, custom_patterns, code_scanning_at), (secrets, code, vulnerabilities)) = futures_util::future::try_join(
232 futures_util::future::try_join3(
233 self.store.repo_settings(&repo.repo_id),
234 self.store.pattern_count(workspace, &repo.repo_id),
235 self.store.last_analysis_at(&repo.repo_id),
236 ),
237 futures_util::future::try_join3(
238 self.store.secret_severity_counts(&repo.repo_id),
239 self.store.code_counts(&repo.repo_id),
240 self.store.vulnerability_counts(&repo.repo_id),
241 ),
242 )
243 .await?;
244 Ok(RepoCoverage {
245 custom_patterns,
246 validity_checks,
247 code_scanning_at,
248 dependency_review: repo_settings.0.dependency_review,
249 security_updates: repo.upkeep != 0,
250 lockfiles: repo.scan_state().lockfiles.len() as u32,
251 secrets,
252 code,
253 vulnerabilities,
254 repo_id: repo.repo_id,
255 name: repo.name,
256 private,
257 })
258 }
259
260 pub(crate) async fn security_overview(&self, a: WorkspaceOverviewArgs) -> Result<Outcome<WorkspaceOverview>> {
261 let workspace = a.workspace.to_lowercase();
262 let Some(viewer) = a.viewer.as_ref().filter(|user| user.is_member(&workspace)) else {
263 return Ok(fail(FailureCode::NotFound, "Workspace not found."));
264 };
265 // Each repository's queries, and the repositories, at once: one
266 // after another they took seconds for a workspace of a dozen.
267 let (activated, settings, visible) = futures_util::future::try_join3(
268 async { Ok::<_, worker::Error>(self.activated(&workspace).await) },
269 self.store.workspace_settings(&workspace),
270 self.visible_repos(&workspace, viewer),
271 )
272 .await?;
273 // Private repositories count with the activation only.
274 let hidden = visible.iter().filter(|(_, private)| *private && !activated).count() as u32;
275 let mut repos = futures_util::future::try_join_all(
276 visible
277 .into_iter()
278 .filter(|(_, private)| !*private || activated)
279 .map(|(repo, private)| self.coverage(&workspace, settings.validity_checks, repo, private)),
280 )
281 .await?;
282 let ids: Vec<String> = repos.iter().map(|repo| repo.repo_id.clone()).collect();
283 let span = a.days.unwrap_or(30).clamp(7, 90);
284 let since = rfc3339(now_ms().saturating_sub(u64::from(span) * DAY_MS));
285 let moved = futures_util::future::try_join_all(
286 AlertType::ALL.iter().map(|alert_type| self.store.opened_and_closed(alert_type.as_str(), &ids, &since)),
287 )
288 .await?;
289 let mut totals = Vec::new();
290 for (alert_type, (opened, closed)) in AlertType::ALL.into_iter().zip(moved) {
291 let mut open = SeverityCounts::default();
292 for repo in &repos {
293 add(&mut open, match alert_type {
294 AlertType::SecretScanning => &repo.secrets,
295 AlertType::CodeScanning => &repo.code,
296 AlertType::Vulnerability => &repo.vulnerabilities,
297 });
298 }
299 totals.push(TypeTotals { alert_type: alert_type.as_str().to_owned(), open, opened, closed });
300 }
301 let days = days(now_ms(), span);
302 let snapshots = self.store.snapshots(&workspace, &days[0], &ids).await?;
303 let trend = trend(&days, &snapshots);
304 rank(&mut repos);
305 Ok(Outcome::Ok(WorkspaceOverview { activated, private_hidden: hidden, totals, trend, repos }))
306 }
307
308 pub(crate) async fn workspace_alerts(&self, a: WorkspaceAlertsArgs) -> Result<Outcome<Vec<WorkspaceAlert>>> {
309 let workspace = a.workspace.to_lowercase();
310 let Some(viewer) = a.viewer.as_ref().filter(|user| user.is_member(&workspace)) else {
311 return Ok(fail(FailureCode::NotFound, "Workspace not found."));
312 };
313 let activated = self.activated(&workspace).await;
314 let mut alerts = Vec::new();
315 for (repo, private) in self.visible_repos(&workspace, viewer).await? {
316 match a.alert_type {
317 AlertType::SecretScanning => alerts.extend(self.store.secrets(&repo.repo_id).await?.into_iter().map(|secret| WorkspaceAlert {
318 repo: repo.name.clone(),
319 secret: Some(secret),
320 code: None,
321 vulnerability: None,
322 })),
323 AlertType::Vulnerability => {
324 alerts.extend(self.store.vulnerabilities(&repo.repo_id).await?.into_iter().map(|vuln| WorkspaceAlert {
325 repo: repo.name.clone(),
326 secret: None,
327 code: None,
328 vulnerability: Some(vuln),
329 }))
330 }
331 // Code scanning on a private repository is the activation's.
332 AlertType::CodeScanning if private && !activated => {}
333 AlertType::CodeScanning => alerts.extend(self.store.code_alerts(&repo.repo_id).await?.into_iter().map(|code| WorkspaceAlert {
334 repo: repo.name.clone(),
335 secret: None,
336 code: Some(code),
337 vulnerability: None,
338 })),
339 }
340 if alerts.len() >= 5_000 {
341 break;
342 }
343 }
344 Ok(Outcome::Ok(alerts))
345 }
346
347 /// The sweep's part: today's open counts for repositories that have
348 /// none yet, and validity checks where the workspace turned them on.
349 pub(crate) async fn sweep_suite(&self) -> Result<()> {
350 let today = rfc3339(now_ms())[..10].to_owned();
351 for repo in self.store.unsnapshotted(&today, SNAPSHOTS_PER_SWEEP).await? {
352 let secrets = self.store.secret_severity_counts(&repo.repo_id).await?;
353 let code = self.store.code_counts(&repo.repo_id).await?;
354 let vulnerabilities = self.store.vulnerability_counts(&repo.repo_id).await?;
355 for (kind, counts) in [("secret_scanning", &secrets), ("code_scanning", &code), ("vulnerability", &vulnerabilities)] {
356 self.store.snapshot(&repo.repo_id, &repo.namespace, &today, kind, counts).await?;
357 }
358 if let Err(error) = self.sweep_validity(&repo).await {
359 worker::console_error!("security: validity checks for {}: {error}", repo.repo_id);
360 }
361 }
362 Ok(())
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::suite_store::SnapshotRow;
370
371 fn counts(critical: u32, high: u32) -> SeverityCounts {
372 SeverityCounts { critical, high, ..SeverityCounts::default() }
373 }
374
375 #[test]
376 fn the_repositories_most_in_need_come_first() {
377 let repo = |name: &str, code: SeverityCounts, vulnerabilities: SeverityCounts| RepoCoverage {
378 name: name.into(),
379 code,
380 vulnerabilities,
381 ..RepoCoverage::default()
382 };
383 let mut repos = vec![repo("a", counts(0, 1), counts(0, 0)), repo("b", counts(1, 0), counts(0, 0)), repo("c", counts(0, 3), counts(0, 1))];
384 rank(&mut repos);
385 let order: Vec<&str> = repos.iter().map(|repo| repo.name.as_str()).collect();
386 assert_eq!(order, ["b", "c", "a"]);
387 }
388
389 #[test]
390 fn a_trend_has_every_day_and_sums_each_type() {
391 let today = 1_791_374_400_000; // 2026-10-07T12:00:00Z
392 let span = days(today, 3);
393 assert_eq!(span, ["2026-10-05", "2026-10-06", "2026-10-07"]);
394 let row = |day: &str, kind: &str, critical| SnapshotRow {
395 day: day.into(),
396 alert_type: kind.into(),
397 critical,
398 high: 1,
399 medium: 0,
400 low: 0,
401 unknown: 0,
402 };
403 let points = trend(&span, &[row("2026-10-06", "code_scanning", 2), row("2026-10-06", "vulnerability", 0), row("2026-10-07", "code_scanning", 1)]);
404 assert_eq!(points.len(), 3);
405 assert_eq!((points[0].code_scanning, points[1].code_scanning, points[1].vulnerability, points[2].code_scanning), (0, 3, 1, 2));
406 }
407}