| 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 | |
| 5 | use std::collections::BTreeMap; |
| 6 | |
| 7 | use g1t_contracts::access::{self, Capability}; |
| 8 | use g1t_contracts::security::SeverityCounts; |
| 9 | use g1t_contracts::security_suite::{ |
| 10 | AlertType, RepoCoverage, SecuritySettingsArgs, SecuritySettingsView, SetSecuritySettingsArgs, SetWorkspaceSecuritySettingsArgs, |
| 11 | TrendPoint, TypeTotals, WorkspaceAlert, WorkspaceAlertsArgs, WorkspaceOverview, WorkspaceOverviewArgs, |
| 12 | WorkspaceSecuritySettingsArgs, WorkspaceSecurityView, |
| 13 | }; |
| 14 | use g1t_contracts::repos::{MAX_READABLE, ReadableArgs, Repo}; |
| 15 | use g1t_contracts::time::rfc3339; |
| 16 | use g1t_contracts::{FailureCode, Outcome}; |
| 17 | use g1t_kit::now_ms; |
| 18 | use worker::Result; |
| 19 | |
| 20 | use crate::Security; |
| 21 | use crate::store::RepoRow; |
| 22 | |
| 23 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; |
| 24 | /// Repositories snapshotted per sweep. |
| 25 | const SNAPSHOTS_PER_SWEEP: u32 = 25; |
| 26 | const GATES: [&str; 6] = ["none", "errors", "critical", "high", "medium", "any"]; |
| 27 | const SEVERITY_NAMES: [&str; 5] = ["critical", "high", "medium", "low", "none"]; |
| 28 | /// Licenses a repository may deny, at most. |
| 29 | const MAX_DENIED: usize = 50; |
| 30 | |
| 31 | fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> { |
| 32 | Outcome::fail(code, message) |
| 33 | } |
| 34 | |
| 35 | fn 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 | |
| 43 | fn 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`. |
| 48 | pub 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. |
| 53 | pub 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. |
| 66 | pub 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 | |
| 82 | impl 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)] |
| 367 | mod 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 | } |