Skip to content
341 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.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! 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::time::rfc3339;
15use g1t_contracts::{FailureCode, Outcome, Role};
16use g1t_kit::now_ms;
17use worker::Result;
18
19use crate::Security;
20use crate::store::RepoRow;
21
22const DAY_MS: u64 = 24 * 60 * 60 * 1000;
23/// Repositories snapshotted per sweep.
24const SNAPSHOTS_PER_SWEEP: u32 = 25;
25const GATES: [&str; 6] = ["none", "errors", "critical", "high", "medium", "any"];
26const SEVERITY_NAMES: [&str; 5] = ["critical", "high", "medium", "low", "none"];
27/// Licenses a repository may deny, at most.
28const MAX_DENIED: usize = 50;
29
30fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
31 Outcome::fail(code, message)
32}
33
34fn 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
42fn 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`.
47pub 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.
52pub 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.
65pub 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
81impl 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)]
301mod 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}