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