Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
845 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.

Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths1//! The security service: what g1t finds wrong in a repository, and the
2//! upkeep that fixes it without a person.
3//!
4//! - Secrets. The repos service refuses pushes that add one
5//! (`push_blocked` says which were allowed and records the rest), and
6//! each repository's history is scanned once, in the background.
7//! A push too large to scan first is let through, and its new commits
8//! are scanned after they land (`history::advance_push_scan`).
9//! - Alerts are open, dismissed (with a reason, a comment and who) or
10//! fixed, and each keeps an activity log. Likely test values are listed
11//! apart and never block a push or count as critical.
12//! - Dependencies. On every push to a default branch, and daily, the
13//! lockfiles are read and every package checked against OSV. Each
14//! vulnerable package with a fix gets a security update: g1t itself
15//! (`User::system`) opens a pull request raising its version, made in a
16//! sandbox (the runner's `bump`), which lands through the branch's
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.17//! required checks. When code has to change, the alert says why and
18//! the update is tried again later.
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths19//!
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar20//! - The security suite (`g1t_contracts::security_suite`): custom secret
21//! patterns (`patterns`), push protection bypasses, their review and
22//! validity checks (`secret_alerts`), code scanning from SARIF uploads
23//! and its pull request check (`code_scanning`), the dependency graph,
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.24//! its SBOM and dependency review (`supply_chain`), and settings with
25//! the workspace's overview (`overview`).
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar26//! On private repositories its paid parts need the workspace's Security
27//! and quality activation (`suite`); it tells people through events.
28//!
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths29//! Other services reach it over `POST /rpc/<method>`; see
30//! `g1t_contracts::security`.
31
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r932mod advisories;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar33mod code_scanning;
34mod config;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths35mod deps;
36mod history;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar37mod manifests;
38mod overview;
39mod patterns;
40mod planning;
41mod pull_text;
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches42mod resolved;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar43mod ranges;
44mod registries;
45mod schedule;
46mod secret_alerts;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths47mod security_updates;
48mod store;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar49mod suite;
50mod suite_store;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r951mod summaries;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar52mod supply_chain;
53mod timezones;
54mod update_store;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths55mod updates;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar56mod version_updates;
57mod yaml;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths58
59use g1t_contracts::access::{self, Capability};
60use g1t_contracts::events::{Event, WorkspaceRenamed};
61use g1t_contracts::security::UPDATE_BRANCH_PREFIX;
62use g1t_contracts::repos::{GetArgs, PathByIdArgs, Repo, RepoPath, RepoStatus, StatusByIdArgs};
63use g1t_contracts::security::*;
64use g1t_contracts::time::rfc3339;
65use g1t_contracts::{FailureCode, Outcome, User};
66use g1t_kit::{args, now_ms, reply, rpc_method};
67use serde::Deserialize;
68use worker::{Context, Env, Fetcher, MessageBatch, Request, Response, Result, ScheduleContext, ScheduledEvent, event};
69
70use store::{Activity, RepoRow, Store};
71
72/// Repositories whose history is continued per sweep, and pages each.
73const HISTORIES_PER_SWEEP: u32 = 5;
74const PAGES_PER_SWEEP: u32 = 4;
75/// Repositories whose dependencies are read again per sweep.
76const DEPENDENCIES_PER_SWEEP: u32 = 10;
77const DAY_MS: u64 = 24 * 60 * 60 * 1000;
78const MAX_REASON_CHARS: usize = 500;
Merge main (membership, two-factor, GitHub repo roles) into tokens79/// What seeing and dismissing a repository's findings takes: the Write
80/// role, as on GitHub, or a security manager of its workspace. Changing its
81/// security settings takes Admin (`ManageSecurity`).
82const SEE_FINDINGS: Capability = Capability::SecurityAlerts;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths83
84pub struct Security {
85 store: Store,
86 identity: Fetcher,
87 repos: Fetcher,
88 work: Fetcher,
89 runner: Fetcher,
90 billing: Fetcher,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar91 actions: Fetcher,
92 /// The events service: security events for webhooks and the inbox,
93 /// and audit entries. Optional so a deployment without the binding
94 /// still scans.
95 events: Option<Fetcher>,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths96}
97
98fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
99 Outcome::fail(code, message)
100}
101
102/// `git.push`, as far as this service reads it.
103#[derive(Deserialize)]
104#[serde(rename_all = "camelCase")]
105struct Pushed {
106 repo_id: String,
107 #[serde(default)]
108 default_branch: bool,
109 #[serde(default, rename = "ref")]
110 git_ref: String,
111 #[serde(default)]
112 before: Option<String>,
113 #[serde(default)]
114 after: String,
115 /// Too large to scan before it was stored.
116 #[serde(default)]
117 unscanned: bool,
118}
119
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.120/// `pull.merged` and `pull.closed`, as far as this service reads them.
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths121#[derive(Deserialize)]
122#[serde(rename_all = "camelCase")]
123struct PullHappened {
124 repo_id: String,
125 number: u32,
126}
127
128/// The longest comment a dismissal keeps.
129const MAX_COMMENT_CHARS: usize = MAX_REASON_CHARS;
130/// Activity rows the Security page reads, newest first.
131const ACTIVITY_SHOWN: u32 = 500;
132
133#[derive(Deserialize)]
134#[serde(rename_all = "camelCase")]
135struct Created {
136 repo_id: String,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit137 /// Absent from events older than the field.
138 #[serde(default)]
139 is_private: Option<bool>,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths140}
141
142impl Security {
143 fn new(env: &Env) -> Result<Self> {
144 Ok(Security {
145 store: Store { db: env.d1("DB")? },
146 identity: env.service("IDENTITY")?,
147 repos: env.service("REPOS")?,
148 work: env.service("WORK")?,
149 runner: env.service("RUNNER")?,
150 billing: env.service("BILLING")?,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar151 actions: env.service("ACTIONS")?,
152 events: env.service("EVENTS").ok(),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths153 })
154 }
155
156 /// The repository at `path`, recorded here, if `viewer` may see its
157 /// findings (the Write role) and do `capability`. Findings are for those
158 /// who can change the code: to anyone else the page does not exist,
159 /// public repository or not.
160 async fn member_repo(
161 &self,
162 path: &RepoPath,
163 viewer: &Option<User>,
164 capability: Capability,
165 ) -> Result<Outcome<RepoRow>> {
166 let hidden = || fail(FailureCode::NotFound, "Repository not found.");
167 if viewer.is_none() {
168 return Ok(hidden());
169 }
170 let repo: Outcome<Repo> =
171 g1t_kit::call(&self.repos, "get", &GetArgs { path: path.clone(), viewer: viewer.clone() }).await?;
172 let repo = match repo {
173 Outcome::Ok(repo) if repo.fork_of.is_none() => repo,
174 _ => return Ok(hidden()),
175 };
176 if !access::can(viewer.as_ref(), &repo, SEE_FINDINGS) {
177 return Ok(hidden());
178 }
179 if !access::can(viewer.as_ref(), &repo, capability) {
180 return Ok(fail(
181 FailureCode::Forbidden,
182 access::needs(capability, &format!("{}/{}", repo.namespace, repo.name)),
183 ));
184 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar185 let row = self.store.register(&repo.id, &repo.namespace, &repo.name).await?;
186 self.store.set_private(&repo.id, repo.is_private).await?;
187 Ok(Outcome::Ok(row))
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths188 }
189
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.190 /// Where the repository stands: archived or deleted. When repos
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.191 /// cannot say, it is taken as an ordinary active repository.
192 async fn status(&self, repo_id: &str) -> Result<RepoStatus> {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths193 let status: Result<RepoStatus> =
194 g1t_kit::call(&self.repos, "status_by_id", &StatusByIdArgs { id: repo_id.to_owned() }).await;
195 Ok(match status {
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.196 Ok(status) => status,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths197 Err(error) => {
198 worker::console_error!("security: status_by_id {repo_id}: {error}");
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.199 RepoStatus::default()
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths200 }
201 })
202 }
203
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.204 /// Whether the repository is neither archived nor deleted.
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.205 async fn active(&self, repo_id: &str) -> Result<bool> {
206 Ok(self.status(repo_id).await?.active())
207 }
208
209 /// Whether g1t may open branches and pull requests here on its own
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.210 /// (security and dependency updates): it is active. `Err` says why
211 /// not.
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.212 async fn takes_updates(&self, repo: &RepoRow) -> Result<std::result::Result<(), String>> {
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.213 if self.status(&repo.repo_id).await?.active() {
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.214 return Ok(Ok(()));
215 }
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.216 Ok(Err("Updates wait while the repository is archived or deleted.".to_owned()))
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.217 }
218
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths219 /// Records a repository named in an event, by id. Forks are not
220 /// recorded: a pull request's findings belong to its repository.
221 async fn register_by_id(&self, repo_id: &str) -> Result<Option<RepoRow>> {
222 if let Some(row) = self.store.repo(repo_id).await? {
223 return Ok(Some(row));
224 }
225 let path: Option<RepoPath> = g1t_kit::call(&self.repos, "path_by_id", &PathByIdArgs { id: repo_id.to_owned() }).await?;
226 match path {
227 Some(path) => Ok(Some(self.store.register(repo_id, &path.namespace, &path.name).await?)),
228 None => Ok(None),
229 }
230 }
231
232 async fn overview(&self, a: OverviewArgs) -> Result<Outcome<SecurityOverview>> {
233 let mut repo = match self.member_repo(&a.repo, &a.viewer, SEE_FINDINGS).await? {
234 Outcome::Ok(repo) => repo,
235 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
236 };
237 // The first look at a repository reads its dependencies at once;
238 // its history is scanned in the background.
239 if repo.deps_scanned_at.is_none() {
240 self.scan_dependencies(&repo).await?;
241 repo = self.store.repo(&repo.repo_id).await?.unwrap_or(repo);
242 }
243 let (counts, _, _) = self.store.counts(&repo.repo_id).await?;
244 Ok(Outcome::Ok(SecurityOverview {
245 repo_id: repo.repo_id.clone(),
246 counts,
247 secret_counts: self.store.secret_counts(&repo.repo_id).await?,
248 secrets: self.store.secrets(&repo.repo_id).await?,
249 vulnerabilities: self.store.vulnerabilities(&repo.repo_id).await?,
250 activity: self.store.activity(&repo.repo_id, ACTIVITY_SHOWN).await?,
251 scan: repo.scan_state(),
252 upkeep: repo.upkeep != 0,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar253 version_updates: self.version_updates_view(&repo).await?,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths254 }))
255 }
256
Merge main (membership, two-factor, GitHub repo roles) into tokens257 /// What dismissing or reopening alert `id` takes: Write, for a secret
258 /// as for a vulnerable dependency, as on GitHub; a security manager may
259 /// too.
260 fn capability_for(_id: &str) -> Capability {
261 SEE_FINDINGS
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths262 }
263
264 async fn dismiss(&self, a: DismissArgs) -> Result<Outcome<AlertChange>> {
265 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Self::capability_for(&a.id)).await? {
266 Outcome::Ok(repo) => repo,
267 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
268 };
269 if !a.actor.verified {
270 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
271 }
272 let comment: String = a.comment.trim().chars().take(MAX_COMMENT_CHARS).collect();
273 let comment = (!comment.is_empty()).then_some(comment);
274 if let Some(finding) = self.store.secret(&repo.repo_id, &a.id).await? {
275 if !a.reason.for_secrets() {
276 return Ok(fail(
277 FailureCode::Invalid,
278 "A secret is dismissed as false_positive, used_in_tests, revoked or wont_fix.",
279 ));
280 }
281 if finding.state != AlertState::Open {
282 return Ok(fail(FailureCode::Conflict, "This alert is not open. Reopen it first to dismiss it again."));
283 }
284 self.store
285 .dismiss_secret(&repo.repo_id, &a.id, a.reason, &a.actor.username, comment.as_deref())
286 .await?;
287 self.store
288 .record(&repo.repo_id, &[Activity {
289 alert_id: &a.id,
290 action: "dismissed",
291 actor: Some(&a.actor.username),
292 reason: Some(a.reason),
293 comment: comment.as_deref(),
294 number: None,
295 }])
296 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar297 let secret = self.store.secret(&repo.repo_id, &a.id).await?;
298 if let Some(secret) = &secret {
299 // Revoked is fixed; any other reason, dismissed.
300 let action = if a.reason == DismissReason::Revoked { "fixed" } else { "dismissed" };
301 let event = g1t_contracts::security_suite::SecurityEvent {
302 reason: Some(a.reason.as_str().to_owned()),
303 ..secret_alerts::secret_event(&repo, secret)
304 };
305 self.alert_event(g1t_contracts::security_suite::AlertType::SecretScanning, action, &repo, event, Some(a.actor.id.clone()))
306 .await;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9307 let changed = summaries::Changed {
308 scan: "change",
309 fixed: if action == "fixed" { vec![a.id.clone()] } else { Vec::new() },
310 dismissed: u32::from(action == "dismissed"),
311 ..summaries::Changed::default()
312 };
313 self.summarize(&repo, g1t_contracts::security_suite::AlertType::SecretScanning, changed, Some(a.actor.id.clone())).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar314 }
315 return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None }));
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths316 }
317 let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else {
318 return Ok(fail(FailureCode::NotFound, "No such alert."));
319 };
320 if a.reason.for_secrets() {
321 return Ok(fail(
322 FailureCode::Invalid,
323 "A dependency is dismissed as fix_started, no_bandwidth, tolerable_risk, inaccurate or not_used.",
324 ));
325 }
326 if vuln.state != AlertState::Open {
327 return Ok(fail(FailureCode::Conflict, "This alert is not open. Reopen it first to dismiss it again."));
328 }
329 self.store
330 .dismiss_vulnerability(&repo.repo_id, &a.id, a.reason, &a.actor.username, comment.as_deref())
331 .await?;
332 self.store
333 .record(&repo.repo_id, &[Activity {
334 alert_id: &a.id,
335 action: "dismissed",
336 actor: Some(&a.actor.username),
337 reason: Some(a.reason),
338 comment: comment.as_deref(),
339 number: None,
340 }])
341 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar342 let vulnerability = self.store.vulnerability(&repo.repo_id, &a.id).await?;
343 if let Some(vuln) = &vulnerability {
344 let event = g1t_contracts::security_suite::SecurityEvent { reason: Some(a.reason.as_str().to_owned()), ..deps::vulnerability_event(&repo, vuln) };
345 self.alert_event(g1t_contracts::security_suite::AlertType::Vulnerability, "dismissed", &repo, event, Some(a.actor.id.clone())).await;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9346 let changed = summaries::Changed { scan: "change", dismissed: 1, ..summaries::Changed::default() };
347 self.summarize(&repo, g1t_contracts::security_suite::AlertType::Vulnerability, changed, Some(a.actor.id.clone())).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar348 }
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches349 // A security update whose alerts are now all fixed or dismissed closes.
350 if let Err(error) = self.resolve_updates(&repo, None).await {
351 worker::console_error!("security: updates of {} not resolved after a dismissal: {error}", repo.repo_id);
352 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar353 Ok(Outcome::Ok(AlertChange { secret: None, vulnerability }))
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths354 }
355
356 async fn reopen(&self, a: ReopenArgs) -> Result<Outcome<AlertChange>> {
357 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Self::capability_for(&a.id)).await? {
358 Outcome::Ok(repo) => repo,
359 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
360 };
361 if !a.actor.verified {
362 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
363 }
364 let reopened = Activity { alert_id: &a.id, action: "reopened", actor: Some(&a.actor.username), reason: None, comment: None, number: None };
365 if let Some(finding) = self.store.secret(&repo.repo_id, &a.id).await? {
366 if finding.state == AlertState::Open {
367 return Ok(fail(FailureCode::Conflict, "This alert is already open."));
368 }
369 // A secret that never landed has nothing to reopen to but blocked.
370 let status = if finding.source == "push" && finding.test_value.is_none() { SecretStatus::Blocked } else { SecretStatus::Open };
371 self.store.reopen_secret(&repo.repo_id, &a.id, status).await?;
372 self.store.record(&repo.repo_id, &[reopened]).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar373 let secret = self.store.secret(&repo.repo_id, &a.id).await?;
374 if let Some(secret) = &secret {
375 self.alert_event(
376 g1t_contracts::security_suite::AlertType::SecretScanning,
377 "reopened",
378 &repo,
379 secret_alerts::secret_event(&repo, secret),
380 Some(a.actor.id.clone()),
381 )
382 .await;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9383 // The thread's counts move; nobody is told anew of what they reopened.
384 let changed = summaries::Changed { scan: "change", ..summaries::Changed::default() };
385 self.summarize(&repo, g1t_contracts::security_suite::AlertType::SecretScanning, changed, Some(a.actor.id.clone())).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar386 }
387 return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None }));
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths388 }
389 let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else {
390 return Ok(fail(FailureCode::NotFound, "No such alert."));
391 };
392 if vuln.state != AlertState::Dismissed {
393 return Ok(fail(FailureCode::Conflict, "Only a dismissed alert can be reopened; a fixed one reopens when it is found again."));
394 }
395 self.store.reopen_vulnerability(&repo.repo_id, &a.id).await?;
396 self.store.record(&repo.repo_id, &[reopened]).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar397 let vulnerability = self.store.vulnerability(&repo.repo_id, &a.id).await?;
398 if let Some(vuln) = &vulnerability {
399 self.alert_event(
400 g1t_contracts::security_suite::AlertType::Vulnerability,
401 "reopened",
402 &repo,
403 deps::vulnerability_event(&repo, vuln),
404 Some(a.actor.id.clone()),
405 )
406 .await;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9407 let changed = summaries::Changed { scan: "change", ..summaries::Changed::default() };
408 self.summarize(&repo, g1t_contracts::security_suite::AlertType::Vulnerability, changed, Some(a.actor.id.clone())).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar409 }
410 Ok(Outcome::Ok(AlertChange { secret: None, vulnerability }))
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths411 }
412
413 async fn rescan(&self, a: RescanArgs) -> Result<Outcome<ScanState>> {
414 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), SEE_FINDINGS).await? {
415 Outcome::Ok(repo) => repo,
416 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
417 };
418 self.store.restart_history(&repo.repo_id).await?;
419 self.scan_dependencies(&repo).await?;
420 if let Some(fresh) = self.store.repo(&repo.repo_id).await? {
421 self.advance_history(&fresh, 1).await?;
422 }
423 let state = self.store.repo(&repo.repo_id).await?.map(|row| row.scan_state()).unwrap_or_default();
424 Ok(Outcome::Ok(state))
425 }
426
427 async fn set_upkeep(&self, a: SetUpkeepArgs) -> Result<Outcome<bool>> {
428 // Whether agents keep its dependencies up to date is one of its
429 // settings.
430 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Capability::ManageSettings).await? {
431 Outcome::Ok(repo) => repo,
432 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
433 };
434 if !a.actor.verified {
435 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
436 }
437 self.store.set_upkeep(&repo.repo_id, a.enabled, &a.actor.username).await?;
438 Ok(Outcome::Ok(a.enabled))
439 }
440
441 async fn workspace(&self, a: WorkspaceArgs) -> Result<Outcome<Vec<RepoSecurity>>> {
442 let workspace = a.workspace.to_lowercase();
443 if !a.viewer.as_ref().is_some_and(|user| user.is_member(&workspace)) {
444 return Ok(fail(FailureCode::NotFound, "Workspace not found."));
445 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit446 // Only the repositories whose findings the viewer may see. The
447 // Write role is never had through being public, so treating each
448 // as private changes nothing.
449 let repos: Vec<RepoRow> = self
450 .store
451 .in_namespace(&workspace)
452 .await?
453 .into_iter()
454 .filter(|repo| {
455 let target = access::RepoRef { id: &repo.repo_id, namespace: &workspace, private: true };
456 access::can(a.viewer.as_ref(), target, SEE_FINDINGS)
457 })
458 .collect();
459 // Every repository's counts at once, not one after another.
460 let counts = futures_util::future::try_join_all(repos.iter().map(|repo| self.store.counts(&repo.repo_id))).await?;
461 let list = repos
462 .into_iter()
463 .zip(counts)
464 .map(|(repo, (counts, secrets, vulnerabilities))| RepoSecurity {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths465 repo_id: repo.repo_id,
466 name: repo.name,
467 counts,
468 secrets,
469 vulnerabilities,
470 upkeep: repo.upkeep != 0,
471 dependencies_scanned_at: repo.deps_scanned_at,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit472 })
473 .collect();
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths474 Ok(Outcome::Ok(list))
475 }
476
477 /// Push protection's question: which of these secrets were allowed?
478 /// The others are recorded as blocked, so someone can allow them.
479 async fn push_blocked(&self, a: PushBlockedArgs) -> Result<PushVerdict> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar480 let repo = self.store.register(&a.repo_id, &a.path.namespace, &a.path.name).await?;
481 if let Some(private) = a.private {
482 self.store.set_private(&a.repo_id, private).await?;
483 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths484 let fingerprints: Vec<String> = a.secrets.iter().map(|secret| secret.fingerprint.clone()).collect();
485 let known = self.store.known(&a.repo_id, &fingerprints).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar486 let mut allowed = let_through(&known, &a.secrets);
487 // Bypassed with a reason: let through, whatever the alert says now.
488 allowed.extend(self.store.bypassed(&a.repo_id, &fingerprints).await?);
489 allowed.sort();
490 allowed.dedup();
491 let all = a.secrets.clone();
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths492 let fresh: Vec<NewSecret> = a
493 .secrets
494 .into_iter()
495 .filter(|secret| !known.iter().any(|(fingerprint, _, _)| *fingerprint == secret.fingerprint))
496 .collect();
497 // A likely test value goes through, so it lands: open, not blocked.
498 let (tests, real): (Vec<NewSecret>, Vec<NewSecret>) = fresh.into_iter().partition(|secret| secret.test_value.is_some());
499 self.store
500 .add_secrets(&a.repo_id, &real, SecretStatus::Blocked, "push", a.pusher.as_deref())
501 .await?;
502 self.store
503 .add_secrets(&a.repo_id, &tests, SecretStatus::Open, "push", a.pusher.as_deref())
504 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar505 let fresh: Vec<String> = real.iter().map(|secret| secret.fingerprint.clone()).collect();
506 self.secrets_found(&repo, &all, "push", &fresh, a.pusher.as_deref()).await?;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths507 let ids = self
508 .store
509 .known(&a.repo_id, &fingerprints)
510 .await?
511 .into_iter()
512 .filter(|(fingerprint, _, _)| !allowed.contains(fingerprint))
513 .map(|(fingerprint, id, _)| (fingerprint, id))
514 .collect();
515 Ok(PushVerdict { allowed, ids })
516 }
517
518 /// What happens on the bus that concerns this service.
519 async fn on_event(&self, event: &Event) -> Result<()> {
520 match event.kind.as_str() {
521 "git.push" => {
522 let Ok(pushed) = serde_json::from_value::<Pushed>(event.data.clone()) else {
523 return Ok(());
524 };
525 // Too large to scan before it was stored: its new commits
526 // are scanned now, on whichever branch.
527 if pushed.unscanned
528 && !pushed.after.is_empty()
529 && let Some(repo) = self.register_by_id(&pushed.repo_id).await?
530 {
531 let id = self
532 .store
533 .add_push_scan(&repo.repo_id, &pushed.git_ref, &pushed.after, pushed.before.as_deref(), event.actor.as_deref())
534 .await?;
535 self.advance_push_scan(&id, history::PUSH_PAGES_AT_ONCE).await?;
536 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar537 // A version update's branch, or a grouped security update's,
538 // pushed by its sandbox: time for its pull request.
539 if !pushed.default_branch
540 && let Some(branch) = pushed.git_ref.strip_prefix("refs/heads/")
541 && self.update_pull_pushed(&pushed.repo_id, branch, &pushed.after).await?
542 {
543 return Ok(());
544 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths545 // A security update's branch, pushed by its sandbox: time for
546 // its pull request.
547 if let Some(branch) = pushed.git_ref.strip_prefix("refs/heads/")
548 && branch.starts_with(UPDATE_BRANCH_PREFIX)
549 {
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches550 self.update_pushed(&pushed.repo_id, branch, &pushed.after).await?;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths551 return Ok(());
552 }
553 if !pushed.default_branch {
554 return Ok(());
555 }
556 if let Some(repo) = self.register_by_id(&pushed.repo_id).await? {
557 self.scan_dependencies(&repo).await?;
558 if repo.history != "done" {
559 self.advance_history(&repo, 1).await?;
560 }
561 }
562 }
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.563 "pull.merged" | "pull.closed" => {
564 if let Ok(happened) = serde_json::from_value::<PullHappened>(event.data.clone()) {
565 if !self.update_pull_closed(&event.kind, &happened.repo_id, happened.number).await? {
566 self.update_pull_event(&event.kind, &happened.repo_id, happened.number, event.actor.as_deref()).await?;
567 }
568 // One of g1t's update pull requests closed or merged, by
569 // g1t or by a person: its branch goes.
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches570 self.pull_finished(&happened.repo_id, happened.number).await?;
571 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths572 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar573 "comment.created" => self.update_comment(event).await?,
574 // A pull request opened or its head moved: a dependency update
575 // file it changes is checked (not on `pull.ready`, which moves
576 // nothing), and dependency review runs.
577 "pull.opened" | "pull.updated" | "pull.ready" => {
578 if event.kind != "pull.ready" {
579 self.check_dependabot_file(event).await?;
580 }
581 if let Ok(happened) = serde_json::from_value::<PullHappened>(event.data.clone()) {
582 self.review_pull(&happened.repo_id, happened.number).await?;
583 }
584 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths585 "repo.created" => {
586 if let Ok(created) = serde_json::from_value::<Created>(event.data.clone()) {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit587 // Recorded with its visibility, so the overview never takes a
588 // public repository for a private one.
589 if self.register_by_id(&created.repo_id).await?.is_some()
590 && let Some(private) = created.is_private
591 {
592 self.store.set_private(&created.repo_id, private).await?;
593 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths594 }
595 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar596 "repo.visibility_changed" => {
597 if let Ok(changed) = serde_json::from_value::<g1t_contracts::events::RepoVisibilityChanged>(event.data.clone())
598 && self.store.repo(&changed.repo_id).await?.is_some()
599 {
600 self.store.set_private(&changed.repo_id, changed.is_private).await?;
601 }
602 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths603 // A repository transferred or renamed: it is recorded under its new path.
604 "repo.transferred" | "repo.renamed" => {
605 if let Some(moved) = g1t_kit::transfer::read(event) {
606 // Where it is now, so moves heard out of order end in
607 // the same place.
608 let now: Option<g1t_contracts::repos::RepoPath> = g1t_kit::call(
609 &self.repos,
610 "path_by_id",
611 &g1t_contracts::repos::PathByIdArgs { id: moved.repo_id.clone() },
612 )
613 .await?;
614 let current = now.map_or_else(
615 || moved.destination().to_owned(),
616 |path| format!("{}/{}", path.namespace, path.name),
617 );
618 if let Some((namespace, name)) = current.split_once('/') {
619 self.store.moved(&moved.repo_id, namespace, name).await?;
620 }
621 }
622 }
623 // A repository purged: everything found in it goes.
624 "repo.purged" => {
625 if let Some(g1t_kit::lifecycle::Lifecycle::Purged(purged)) = g1t_kit::lifecycle::read(event) {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9626 self.store.purge_advisories(&purged.repo_id).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar627 self.store.purge_suite(&purged.repo_id).await?;
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths628 self.store.purge(&purged.repo_id).await?;
629 }
630 }
631 "workspace.renamed" => {
632 if let Ok(renamed) = serde_json::from_value::<WorkspaceRenamed>(event.data.clone()) {
633 self.store.rename_namespace(&renamed.stale_slugs(&renamed.to), &renamed.to).await?;
634 }
635 }
636 _ => {}
637 }
638 Ok(())
639 }
640
641 /// The sweep: continues history scans and scans of pushes that landed
642 /// unscanned, catches security updates whose sandbox never pushed, and
643 /// reads dependencies that have not been read for a day.
644 async fn sweep(&self) -> Result<()> {
645 for scan in self.store.pending_push_scans(HISTORIES_PER_SWEEP).await? {
646 if let Err(error) = self.advance_push_scan(&scan.id, PAGES_PER_SWEEP).await {
647 worker::console_error!("security: push scan {} not continued: {error}", scan.id);
648 }
649 }
650 if let Err(error) = self.stalled_updates().await {
651 worker::console_error!("security: stalled security updates not handled: {error}");
652 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar653 if let Err(error) = self.sweep_suite().await {
654 worker::console_error!("security: daily snapshots and validity checks: {error}");
655 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths656 // Archived and deleted repositories wait; a few more are looked at
657 // so that they do not hold up the rest.
658 let mut histories = 0;
659 for repo in self.store.unfinished_histories(HISTORIES_PER_SWEEP * 4).await? {
660 if histories == HISTORIES_PER_SWEEP {
661 break;
662 }
663 if !self.active(&repo.repo_id).await? {
664 continue;
665 }
666 histories += 1;
667 if let Err(error) = self.advance_history(&repo, PAGES_PER_SWEEP).await {
668 worker::console_error!("security: history of {} not scanned: {error}", repo.repo_id);
669 }
670 }
671 let day_ago = rfc3339(now_ms().saturating_sub(DAY_MS));
672 for repo in self.store.stale_dependencies(&day_ago, DEPENDENCIES_PER_SWEEP).await? {
673 if !self.active(&repo.repo_id).await? {
674 self.store
675 .skip_dependencies(&repo.repo_id, "Dependencies are not checked while the repository is archived or deleted.")
676 .await?;
677 continue;
678 }
679 if let Err(error) = self.scan_dependencies(&repo).await {
680 worker::console_error!("security: dependencies of {} not read: {error}", repo.repo_id);
681 }
682 }
683 Ok(())
684 }
685}
686
687/// The fingerprints a push may carry: those someone dismissed (allowed),
688/// and likely test values, which are recorded but never stop a push.
689/// `known` is (fingerprint, id, status) of findings already recorded.
690fn let_through(known: &[(String, String, String)], secrets: &[NewSecret]) -> Vec<String> {
691 let mut allowed: Vec<String> = known
692 .iter()
693 .filter(|(_, _, status)| status == "allowed")
694 .map(|(fingerprint, _, _)| fingerprint.clone())
695 .chain(secrets.iter().filter(|secret| secret.test_value.is_some()).map(|secret| secret.fingerprint.clone()))
696 .collect();
697 allowed.sort();
698 allowed.dedup();
699 allowed
700}
701
702#[event(fetch)]
703async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
704 let Some(method) = rpc_method(&request) else {
705 return Response::error("Not found", 404);
706 };
707 let security = Security::new(&env)?;
708 let body: serde_json::Value = request.json().await?;
709 match method.as_str() {
710 "overview" => reply(&security.overview(args(body)?).await?),
711 "dismiss" => reply(&security.dismiss(args(body)?).await?),
712 "reopen" => reply(&security.reopen(args(body)?).await?),
713 "rescan" => reply(&security.rescan(args(body)?).await?),
714 "set_upkeep" => reply(&security.set_upkeep(args(body)?).await?),
715 "workspace" => reply(&security.workspace(args(body)?).await?),
716 "push_blocked" => reply(&security.push_blocked(args(body)?).await?),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar717 "check_updates" => reply(&security.check_updates(args(body)?).await?),
718 // The security suite.
719 "patterns_for" => reply(&security.patterns_for(args(body)?).await?),
720 "custom_patterns" => reply(&security.custom_patterns(args(body)?).await?),
721 "save_custom_pattern" => reply(&security.save_custom_pattern(args(body)?).await?),
722 "delete_custom_pattern" => reply(&security.delete_custom_pattern(args(body)?).await?),
723 "dry_run_pattern" => reply(&security.dry_run_pattern(args(body)?).await?),
724 "secret_alert" => reply(&security.secret_alert(args(body)?).await?),
725 "bypass" => reply(&security.bypass(args(body)?).await?),
726 "bypass_requests" => reply(&security.bypass_requests(args(body)?).await?),
727 "review_bypass" => reply(&security.review_bypass(args(body)?).await?),
728 "check_validity" => reply(&security.check_validity(args(body)?).await?),
729 "upload_sarif" => reply(&security.upload_sarif(args(body)?).await?),
730 "sarif_status" => reply(&security.sarif_status(args(body)?).await?),
731 "code_scanning" => reply(&security.code_scanning(args(body)?).await?),
732 "code_alert" => reply(&security.code_alert(args(body)?).await?),
733 "set_code_alert_state" => reply(&security.set_code_alert_state(args(body)?).await?),
734 "pull_code_scanning" => reply(&security.pull_code_scanning(args(body)?).await?),
735 "dependency_graph" => reply(&security.dependency_graph(args(body)?).await?),
736 "sbom" => reply(&security.sbom(args(body)?).await?),
737 "dependency_review" => reply(&security.dependency_review(args(body)?).await?),
738 "security_settings" => reply(&security.security_settings(args(body)?).await?),
739 "set_security_settings" => reply(&security.set_security_settings(args(body)?).await?),
740 "workspace_security_settings" => reply(&security.workspace_security_settings(args(body)?).await?),
741 "set_workspace_security_settings" => reply(&security.set_workspace_security_settings(args(body)?).await?),
742 "security_overview" => reply(&security.security_overview(args(body)?).await?),
743 "workspace_alerts" => reply(&security.workspace_alerts(args(body)?).await?),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9744 // Reporting: advisories and private vulnerability reports (advisories.rs).
745 "list_advisories" => reply(&security.list_advisories(args(body)?).await?),
746 "get_advisory" => reply(&security.get_advisory(args(body)?).await?),
747 "create_advisory" => reply(&security.create_advisory(args(body)?).await?),
748 "update_advisory" => reply(&security.update_advisory(args(body)?).await?),
749 "set_advisory_state" => reply(&security.set_advisory_state(args(body)?).await?),
750 "report_vulnerability" => reply(&security.report_vulnerability(args(body)?).await?),
751 "report_settings" => reply(&security.report_settings(args(body)?).await?),
752 "list_vulnerability_reports" => reply(&security.list_vulnerability_reports(args(body)?).await?),
753 "review_vulnerability_report" => reply(&security.review_vulnerability_report(args(body)?).await?),
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths754 _ => Response::error("Unknown method", 404),
755 }
756}
757
758#[event(queue)]
759async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> {
760 let security = Security::new(&env)?;
761 for message in batch.messages()? {
762 let event = message.body();
763 if let Err(error) = security.on_event(event).await {
764 // Scans are idempotent and the sweep catches up, so one failed
765 // event is logged rather than retried.
766 worker::console_error!("security: {} {} failed: {error}", event.kind, event.id);
767 }
768 }
769 Ok(())
770}
771
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar772/// The cron (wrangler.jsonc) that runs version updates that are due.
773const VERSION_UPDATES_CRON: &str = "*/5 * * * *";
774
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths775#[event(scheduled)]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar776async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths777 match Security::new(&env) {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar778 // Version updates run every few minutes, so a schedule's time is kept.
779 Ok(security) if event.cron() == VERSION_UPDATES_CRON => {
780 if let Err(error) = security.version_update_sweep().await {
781 worker::console_error!("security: the version update sweep failed: {error}");
782 }
783 }
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths784 Ok(security) => {
785 if let Err(error) = security.sweep().await {
786 worker::console_error!("security: the sweep failed: {error}");
787 }
788 }
789 Err(error) => worker::console_error!("security: could not start: {error}"),
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 fn secret(fingerprint: &str, test_value: Option<&str>) -> NewSecret {
798 NewSecret {
799 fingerprint: fingerprint.into(),
800 kind: "aws_access_key".into(),
801 path: "a.env".into(),
802 line: 1,
803 commit: "c".into(),
804 preview: "AKIA…".into(),
805 test_value: test_value.map(str::to_owned),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar806 pattern_id: None,
807 pattern_name: None,
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths808 }
809 }
810
811 #[test]
812 fn dismissed_secrets_and_test_values_go_through() {
813 let known = vec![
814 ("allowed".to_owned(), "sec_1".to_owned(), "allowed".to_owned()),
815 ("resolved".to_owned(), "sec_2".to_owned(), "resolved".to_owned()),
816 ("blocked".to_owned(), "sec_3".to_owned(), "blocked".to_owned()),
817 ];
818 let secrets = [secret("allowed", None), secret("resolved", None), secret("example", Some("it says it is an example")), secret("real", None)];
819 assert_eq!(let_through(&known, &secrets), ["allowed", "example"]);
820 }
821
822 #[test]
823 fn a_push_says_when_it_landed_unscanned() {
824 let pushed: Pushed = serde_json::from_value(serde_json::json!({
825 "repoId": "rep_1", "ref": "refs/heads/import", "after": "abc", "defaultBranch": false, "unscanned": true
826 }))
827 .unwrap();
828 assert!(pushed.unscanned && pushed.before.is_none() && pushed.git_ref == "refs/heads/import");
829 let ordinary: Pushed = serde_json::from_value(serde_json::json!({ "repoId": "rep_1", "ref": "refs/heads/main", "after": "abc", "defaultBranch": true })).unwrap();
830 assert!(!ordinary.unscanned);
831 }
832
833 #[test]
834 fn owners_are_told_what_landed_and_what_to_do() {
835 let one = history::landed_secrets_intro("acme", "rocket", "import", 1);
836 assert!(one.contains("A push to import in acme/rocket") && one.contains("a secret that looks real") && one.contains("Rotate"));
837 assert!(history::landed_secrets_intro("acme", "rocket", "main", 3).contains("3 secrets that look real"));
838 }
839
840 #[test]
841 fn dismissing_a_secret_takes_admin_and_a_dependency_write() {
Merge main (membership, two-factor, GitHub repo roles) into tokens842 assert_eq!(Security::capability_for("sec_1"), Capability::SecurityAlerts);
843 assert_eq!(Security::capability_for("vul_1"), Capability::SecurityAlerts);
Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths844 }
845}

This file's history is long; its oldest lines are credited to the oldest commit read.