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
850 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.

Topics sit in About on the files pages, under the description1//! 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.
Topics sit in About on the files pages, under the description19//!
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//!
Topics sit in About on the files pages, under the description29//! 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;
Topics sit in About on the files pages, under the description35mod 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;
Topics sit in About on the files pages, under the description47mod 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;
Topics sit in About on the files pages, under the description55mod updates;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar56mod version_updates;
57mod yaml;
Topics sit in About on the files pages, under the description58
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;
Topics sit in About on the files pages, under the description83
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>,
Topics sit in About on the files pages, under the description96}
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.
Topics sit in About on the files pages, under the description121#[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>,
Topics sit in About on the files pages, under the description140}
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(),
Topics sit in About on the files pages, under the description153 })
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))
Topics sit in About on the files pages, under the description188 }
189
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.190 /// Where the repository stands: archived, deleted, a mirror. When repos
191 /// cannot say, it is taken as an ordinary active repository.
192 async fn status(&self, repo_id: &str) -> Result<RepoStatus> {
Topics sit in About on the files pages, under the description193 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,
Topics sit in About on the files pages, under the description197 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()
Topics sit in About on the files pages, under the description200 }
201 })
202 }
203
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.204 /// Whether the repository is neither archived nor deleted. Scanning
205 /// goes on in a mirror: it reads.
206 async fn active(&self, repo_id: &str) -> Result<bool> {
207 Ok(self.status(repo_id).await?.active())
208 }
209
210 /// Whether g1t may open branches and pull requests here on its own
211 /// (security and dependency updates): active, and not a mirror short
212 /// of a takeover (`g1t_contracts::mirrors`). `Err` says why not.
213 async fn takes_updates(&self, repo: &RepoRow) -> Result<std::result::Result<(), String>> {
214 let status = self.status(&repo.repo_id).await?;
215 if status.takes_updates() {
216 return Ok(Ok(()));
217 }
218 Ok(Err(match &status.mirror {
219 Some(mirror) if status.active() => g1t_contracts::mirrors::updates_wait_message(&repo.namespace, &repo.name, mirror),
220 _ => "Updates wait while the repository is archived or deleted.".to_owned(),
221 }))
222 }
223
Topics sit in About on the files pages, under the description224 /// Records a repository named in an event, by id. Forks are not
225 /// recorded: a pull request's findings belong to its repository.
226 async fn register_by_id(&self, repo_id: &str) -> Result<Option<RepoRow>> {
227 if let Some(row) = self.store.repo(repo_id).await? {
228 return Ok(Some(row));
229 }
230 let path: Option<RepoPath> = g1t_kit::call(&self.repos, "path_by_id", &PathByIdArgs { id: repo_id.to_owned() }).await?;
231 match path {
232 Some(path) => Ok(Some(self.store.register(repo_id, &path.namespace, &path.name).await?)),
233 None => Ok(None),
234 }
235 }
236
237 async fn overview(&self, a: OverviewArgs) -> Result<Outcome<SecurityOverview>> {
238 let mut repo = match self.member_repo(&a.repo, &a.viewer, SEE_FINDINGS).await? {
239 Outcome::Ok(repo) => repo,
240 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
241 };
242 // The first look at a repository reads its dependencies at once;
243 // its history is scanned in the background.
244 if repo.deps_scanned_at.is_none() {
245 self.scan_dependencies(&repo).await?;
246 repo = self.store.repo(&repo.repo_id).await?.unwrap_or(repo);
247 }
248 let (counts, _, _) = self.store.counts(&repo.repo_id).await?;
249 Ok(Outcome::Ok(SecurityOverview {
250 repo_id: repo.repo_id.clone(),
251 counts,
252 secret_counts: self.store.secret_counts(&repo.repo_id).await?,
253 secrets: self.store.secrets(&repo.repo_id).await?,
254 vulnerabilities: self.store.vulnerabilities(&repo.repo_id).await?,
255 activity: self.store.activity(&repo.repo_id, ACTIVITY_SHOWN).await?,
256 scan: repo.scan_state(),
257 upkeep: repo.upkeep != 0,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar258 version_updates: self.version_updates_view(&repo).await?,
Topics sit in About on the files pages, under the description259 }))
260 }
261
Merge main (membership, two-factor, GitHub repo roles) into tokens262 /// What dismissing or reopening alert `id` takes: Write, for a secret
263 /// as for a vulnerable dependency, as on GitHub; a security manager may
264 /// too.
265 fn capability_for(_id: &str) -> Capability {
266 SEE_FINDINGS
Topics sit in About on the files pages, under the description267 }
268
269 async fn dismiss(&self, a: DismissArgs) -> Result<Outcome<AlertChange>> {
270 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Self::capability_for(&a.id)).await? {
271 Outcome::Ok(repo) => repo,
272 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
273 };
274 if !a.actor.verified {
275 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
276 }
277 let comment: String = a.comment.trim().chars().take(MAX_COMMENT_CHARS).collect();
278 let comment = (!comment.is_empty()).then_some(comment);
279 if let Some(finding) = self.store.secret(&repo.repo_id, &a.id).await? {
280 if !a.reason.for_secrets() {
281 return Ok(fail(
282 FailureCode::Invalid,
283 "A secret is dismissed as false_positive, used_in_tests, revoked or wont_fix.",
284 ));
285 }
286 if finding.state != AlertState::Open {
287 return Ok(fail(FailureCode::Conflict, "This alert is not open. Reopen it first to dismiss it again."));
288 }
289 self.store
290 .dismiss_secret(&repo.repo_id, &a.id, a.reason, &a.actor.username, comment.as_deref())
291 .await?;
292 self.store
293 .record(&repo.repo_id, &[Activity {
294 alert_id: &a.id,
295 action: "dismissed",
296 actor: Some(&a.actor.username),
297 reason: Some(a.reason),
298 comment: comment.as_deref(),
299 number: None,
300 }])
301 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar302 let secret = self.store.secret(&repo.repo_id, &a.id).await?;
303 if let Some(secret) = &secret {
304 // Revoked is fixed; any other reason, dismissed.
305 let action = if a.reason == DismissReason::Revoked { "fixed" } else { "dismissed" };
306 let event = g1t_contracts::security_suite::SecurityEvent {
307 reason: Some(a.reason.as_str().to_owned()),
308 ..secret_alerts::secret_event(&repo, secret)
309 };
310 self.alert_event(g1t_contracts::security_suite::AlertType::SecretScanning, action, &repo, event, Some(a.actor.id.clone()))
311 .await;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9312 let changed = summaries::Changed {
313 scan: "change",
314 fixed: if action == "fixed" { vec![a.id.clone()] } else { Vec::new() },
315 dismissed: u32::from(action == "dismissed"),
316 ..summaries::Changed::default()
317 };
318 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 bar319 }
320 return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None }));
Topics sit in About on the files pages, under the description321 }
322 let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else {
323 return Ok(fail(FailureCode::NotFound, "No such alert."));
324 };
325 if a.reason.for_secrets() {
326 return Ok(fail(
327 FailureCode::Invalid,
328 "A dependency is dismissed as fix_started, no_bandwidth, tolerable_risk, inaccurate or not_used.",
329 ));
330 }
331 if vuln.state != AlertState::Open {
332 return Ok(fail(FailureCode::Conflict, "This alert is not open. Reopen it first to dismiss it again."));
333 }
334 self.store
335 .dismiss_vulnerability(&repo.repo_id, &a.id, a.reason, &a.actor.username, comment.as_deref())
336 .await?;
337 self.store
338 .record(&repo.repo_id, &[Activity {
339 alert_id: &a.id,
340 action: "dismissed",
341 actor: Some(&a.actor.username),
342 reason: Some(a.reason),
343 comment: comment.as_deref(),
344 number: None,
345 }])
346 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar347 let vulnerability = self.store.vulnerability(&repo.repo_id, &a.id).await?;
348 if let Some(vuln) = &vulnerability {
349 let event = g1t_contracts::security_suite::SecurityEvent { reason: Some(a.reason.as_str().to_owned()), ..deps::vulnerability_event(&repo, vuln) };
350 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-7nm8r9351 let changed = summaries::Changed { scan: "change", dismissed: 1, ..summaries::Changed::default() };
352 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 bar353 }
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches354 // A security update whose alerts are now all fixed or dismissed closes.
355 if let Err(error) = self.resolve_updates(&repo, None).await {
356 worker::console_error!("security: updates of {} not resolved after a dismissal: {error}", repo.repo_id);
357 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar358 Ok(Outcome::Ok(AlertChange { secret: None, vulnerability }))
Topics sit in About on the files pages, under the description359 }
360
361 async fn reopen(&self, a: ReopenArgs) -> Result<Outcome<AlertChange>> {
362 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Self::capability_for(&a.id)).await? {
363 Outcome::Ok(repo) => repo,
364 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
365 };
366 if !a.actor.verified {
367 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
368 }
369 let reopened = Activity { alert_id: &a.id, action: "reopened", actor: Some(&a.actor.username), reason: None, comment: None, number: None };
370 if let Some(finding) = self.store.secret(&repo.repo_id, &a.id).await? {
371 if finding.state == AlertState::Open {
372 return Ok(fail(FailureCode::Conflict, "This alert is already open."));
373 }
374 // A secret that never landed has nothing to reopen to but blocked.
375 let status = if finding.source == "push" && finding.test_value.is_none() { SecretStatus::Blocked } else { SecretStatus::Open };
376 self.store.reopen_secret(&repo.repo_id, &a.id, status).await?;
377 self.store.record(&repo.repo_id, &[reopened]).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar378 let secret = self.store.secret(&repo.repo_id, &a.id).await?;
379 if let Some(secret) = &secret {
380 self.alert_event(
381 g1t_contracts::security_suite::AlertType::SecretScanning,
382 "reopened",
383 &repo,
384 secret_alerts::secret_event(&repo, secret),
385 Some(a.actor.id.clone()),
386 )
387 .await;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9388 // The thread's counts move; nobody is told anew of what they reopened.
389 let changed = summaries::Changed { scan: "change", ..summaries::Changed::default() };
390 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 bar391 }
392 return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None }));
Topics sit in About on the files pages, under the description393 }
394 let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else {
395 return Ok(fail(FailureCode::NotFound, "No such alert."));
396 };
397 if vuln.state != AlertState::Dismissed {
398 return Ok(fail(FailureCode::Conflict, "Only a dismissed alert can be reopened; a fixed one reopens when it is found again."));
399 }
400 self.store.reopen_vulnerability(&repo.repo_id, &a.id).await?;
401 self.store.record(&repo.repo_id, &[reopened]).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar402 let vulnerability = self.store.vulnerability(&repo.repo_id, &a.id).await?;
403 if let Some(vuln) = &vulnerability {
404 self.alert_event(
405 g1t_contracts::security_suite::AlertType::Vulnerability,
406 "reopened",
407 &repo,
408 deps::vulnerability_event(&repo, vuln),
409 Some(a.actor.id.clone()),
410 )
411 .await;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9412 let changed = summaries::Changed { scan: "change", ..summaries::Changed::default() };
413 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 bar414 }
415 Ok(Outcome::Ok(AlertChange { secret: None, vulnerability }))
Topics sit in About on the files pages, under the description416 }
417
418 async fn rescan(&self, a: RescanArgs) -> Result<Outcome<ScanState>> {
419 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), SEE_FINDINGS).await? {
420 Outcome::Ok(repo) => repo,
421 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
422 };
423 self.store.restart_history(&repo.repo_id).await?;
424 self.scan_dependencies(&repo).await?;
425 if let Some(fresh) = self.store.repo(&repo.repo_id).await? {
426 self.advance_history(&fresh, 1).await?;
427 }
428 let state = self.store.repo(&repo.repo_id).await?.map(|row| row.scan_state()).unwrap_or_default();
429 Ok(Outcome::Ok(state))
430 }
431
432 async fn set_upkeep(&self, a: SetUpkeepArgs) -> Result<Outcome<bool>> {
433 // Whether agents keep its dependencies up to date is one of its
434 // settings.
435 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Capability::ManageSettings).await? {
436 Outcome::Ok(repo) => repo,
437 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
438 };
439 if !a.actor.verified {
440 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
441 }
442 self.store.set_upkeep(&repo.repo_id, a.enabled, &a.actor.username).await?;
443 Ok(Outcome::Ok(a.enabled))
444 }
445
446 async fn workspace(&self, a: WorkspaceArgs) -> Result<Outcome<Vec<RepoSecurity>>> {
447 let workspace = a.workspace.to_lowercase();
448 if !a.viewer.as_ref().is_some_and(|user| user.is_member(&workspace)) {
449 return Ok(fail(FailureCode::NotFound, "Workspace not found."));
450 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit451 // Only the repositories whose findings the viewer may see. The
452 // Write role is never had through being public, so treating each
453 // as private changes nothing.
454 let repos: Vec<RepoRow> = self
455 .store
456 .in_namespace(&workspace)
457 .await?
458 .into_iter()
459 .filter(|repo| {
460 let target = access::RepoRef { id: &repo.repo_id, namespace: &workspace, private: true };
461 access::can(a.viewer.as_ref(), target, SEE_FINDINGS)
462 })
463 .collect();
464 // Every repository's counts at once, not one after another.
465 let counts = futures_util::future::try_join_all(repos.iter().map(|repo| self.store.counts(&repo.repo_id))).await?;
466 let list = repos
467 .into_iter()
468 .zip(counts)
469 .map(|(repo, (counts, secrets, vulnerabilities))| RepoSecurity {
Topics sit in About on the files pages, under the description470 repo_id: repo.repo_id,
471 name: repo.name,
472 counts,
473 secrets,
474 vulnerabilities,
475 upkeep: repo.upkeep != 0,
476 dependencies_scanned_at: repo.deps_scanned_at,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit477 })
478 .collect();
Topics sit in About on the files pages, under the description479 Ok(Outcome::Ok(list))
480 }
481
482 /// Push protection's question: which of these secrets were allowed?
483 /// The others are recorded as blocked, so someone can allow them.
484 async fn push_blocked(&self, a: PushBlockedArgs) -> Result<PushVerdict> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar485 let repo = self.store.register(&a.repo_id, &a.path.namespace, &a.path.name).await?;
486 if let Some(private) = a.private {
487 self.store.set_private(&a.repo_id, private).await?;
488 }
Topics sit in About on the files pages, under the description489 let fingerprints: Vec<String> = a.secrets.iter().map(|secret| secret.fingerprint.clone()).collect();
490 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 bar491 let mut allowed = let_through(&known, &a.secrets);
492 // Bypassed with a reason: let through, whatever the alert says now.
493 allowed.extend(self.store.bypassed(&a.repo_id, &fingerprints).await?);
494 allowed.sort();
495 allowed.dedup();
496 let all = a.secrets.clone();
Topics sit in About on the files pages, under the description497 let fresh: Vec<NewSecret> = a
498 .secrets
499 .into_iter()
500 .filter(|secret| !known.iter().any(|(fingerprint, _, _)| *fingerprint == secret.fingerprint))
501 .collect();
502 // A likely test value goes through, so it lands: open, not blocked.
503 let (tests, real): (Vec<NewSecret>, Vec<NewSecret>) = fresh.into_iter().partition(|secret| secret.test_value.is_some());
504 self.store
505 .add_secrets(&a.repo_id, &real, SecretStatus::Blocked, "push", a.pusher.as_deref())
506 .await?;
507 self.store
508 .add_secrets(&a.repo_id, &tests, SecretStatus::Open, "push", a.pusher.as_deref())
509 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar510 let fresh: Vec<String> = real.iter().map(|secret| secret.fingerprint.clone()).collect();
511 self.secrets_found(&repo, &all, "push", &fresh, a.pusher.as_deref()).await?;
Topics sit in About on the files pages, under the description512 let ids = self
513 .store
514 .known(&a.repo_id, &fingerprints)
515 .await?
516 .into_iter()
517 .filter(|(fingerprint, _, _)| !allowed.contains(fingerprint))
518 .map(|(fingerprint, id, _)| (fingerprint, id))
519 .collect();
520 Ok(PushVerdict { allowed, ids })
521 }
522
523 /// What happens on the bus that concerns this service.
524 async fn on_event(&self, event: &Event) -> Result<()> {
525 match event.kind.as_str() {
526 "git.push" => {
527 let Ok(pushed) = serde_json::from_value::<Pushed>(event.data.clone()) else {
528 return Ok(());
529 };
530 // Too large to scan before it was stored: its new commits
531 // are scanned now, on whichever branch.
532 if pushed.unscanned
533 && !pushed.after.is_empty()
534 && let Some(repo) = self.register_by_id(&pushed.repo_id).await?
535 {
536 let id = self
537 .store
538 .add_push_scan(&repo.repo_id, &pushed.git_ref, &pushed.after, pushed.before.as_deref(), event.actor.as_deref())
539 .await?;
540 self.advance_push_scan(&id, history::PUSH_PAGES_AT_ONCE).await?;
541 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar542 // A version update's branch, or a grouped security update's,
543 // pushed by its sandbox: time for its pull request.
544 if !pushed.default_branch
545 && let Some(branch) = pushed.git_ref.strip_prefix("refs/heads/")
546 && self.update_pull_pushed(&pushed.repo_id, branch, &pushed.after).await?
547 {
548 return Ok(());
549 }
Topics sit in About on the files pages, under the description550 // A security update's branch, pushed by its sandbox: time for
551 // its pull request.
552 if let Some(branch) = pushed.git_ref.strip_prefix("refs/heads/")
553 && branch.starts_with(UPDATE_BRANCH_PREFIX)
554 {
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches555 self.update_pushed(&pushed.repo_id, branch, &pushed.after).await?;
Topics sit in About on the files pages, under the description556 return Ok(());
557 }
558 if !pushed.default_branch {
559 return Ok(());
560 }
561 if let Some(repo) = self.register_by_id(&pushed.repo_id).await? {
562 self.scan_dependencies(&repo).await?;
563 if repo.history != "done" {
564 self.advance_history(&repo, 1).await?;
565 }
566 }
567 }
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.568 "pull.merged" | "pull.closed" => {
569 if let Ok(happened) = serde_json::from_value::<PullHappened>(event.data.clone()) {
570 if !self.update_pull_closed(&event.kind, &happened.repo_id, happened.number).await? {
571 self.update_pull_event(&event.kind, &happened.repo_id, happened.number, event.actor.as_deref()).await?;
572 }
573 // One of g1t's update pull requests closed or merged, by
574 // 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 branches575 self.pull_finished(&happened.repo_id, happened.number).await?;
576 }
Topics sit in About on the files pages, under the description577 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar578 "comment.created" => self.update_comment(event).await?,
579 // A pull request opened or its head moved: a dependency update
580 // file it changes is checked (not on `pull.ready`, which moves
581 // nothing), and dependency review runs.
582 "pull.opened" | "pull.updated" | "pull.ready" => {
583 if event.kind != "pull.ready" {
584 self.check_dependabot_file(event).await?;
585 }
586 if let Ok(happened) = serde_json::from_value::<PullHappened>(event.data.clone()) {
587 self.review_pull(&happened.repo_id, happened.number).await?;
588 }
589 }
Topics sit in About on the files pages, under the description590 "repo.created" => {
591 if let Ok(created) = serde_json::from_value::<Created>(event.data.clone()) {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit592 // Recorded with its visibility, so the overview never takes a
593 // public repository for a private one.
594 if self.register_by_id(&created.repo_id).await?.is_some()
595 && let Some(private) = created.is_private
596 {
597 self.store.set_private(&created.repo_id, private).await?;
598 }
Topics sit in About on the files pages, under the description599 }
600 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar601 "repo.visibility_changed" => {
602 if let Ok(changed) = serde_json::from_value::<g1t_contracts::events::RepoVisibilityChanged>(event.data.clone())
603 && self.store.repo(&changed.repo_id).await?.is_some()
604 {
605 self.store.set_private(&changed.repo_id, changed.is_private).await?;
606 }
607 }
Topics sit in About on the files pages, under the description608 // A repository transferred or renamed: it is recorded under its new path.
609 "repo.transferred" | "repo.renamed" => {
610 if let Some(moved) = g1t_kit::transfer::read(event) {
611 // Where it is now, so moves heard out of order end in
612 // the same place.
613 let now: Option<g1t_contracts::repos::RepoPath> = g1t_kit::call(
614 &self.repos,
615 "path_by_id",
616 &g1t_contracts::repos::PathByIdArgs { id: moved.repo_id.clone() },
617 )
618 .await?;
619 let current = now.map_or_else(
620 || moved.destination().to_owned(),
621 |path| format!("{}/{}", path.namespace, path.name),
622 );
623 if let Some((namespace, name)) = current.split_once('/') {
624 self.store.moved(&moved.repo_id, namespace, name).await?;
625 }
626 }
627 }
628 // A repository purged: everything found in it goes.
629 "repo.purged" => {
630 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-7nm8r9631 self.store.purge_advisories(&purged.repo_id).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar632 self.store.purge_suite(&purged.repo_id).await?;
Topics sit in About on the files pages, under the description633 self.store.purge(&purged.repo_id).await?;
634 }
635 }
636 "workspace.renamed" => {
637 if let Ok(renamed) = serde_json::from_value::<WorkspaceRenamed>(event.data.clone()) {
638 self.store.rename_namespace(&renamed.stale_slugs(&renamed.to), &renamed.to).await?;
639 }
640 }
641 _ => {}
642 }
643 Ok(())
644 }
645
646 /// The sweep: continues history scans and scans of pushes that landed
647 /// unscanned, catches security updates whose sandbox never pushed, and
648 /// reads dependencies that have not been read for a day.
649 async fn sweep(&self) -> Result<()> {
650 for scan in self.store.pending_push_scans(HISTORIES_PER_SWEEP).await? {
651 if let Err(error) = self.advance_push_scan(&scan.id, PAGES_PER_SWEEP).await {
652 worker::console_error!("security: push scan {} not continued: {error}", scan.id);
653 }
654 }
655 if let Err(error) = self.stalled_updates().await {
656 worker::console_error!("security: stalled security updates not handled: {error}");
657 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar658 if let Err(error) = self.sweep_suite().await {
659 worker::console_error!("security: daily snapshots and validity checks: {error}");
660 }
Topics sit in About on the files pages, under the description661 // Archived and deleted repositories wait; a few more are looked at
662 // so that they do not hold up the rest.
663 let mut histories = 0;
664 for repo in self.store.unfinished_histories(HISTORIES_PER_SWEEP * 4).await? {
665 if histories == HISTORIES_PER_SWEEP {
666 break;
667 }
668 if !self.active(&repo.repo_id).await? {
669 continue;
670 }
671 histories += 1;
672 if let Err(error) = self.advance_history(&repo, PAGES_PER_SWEEP).await {
673 worker::console_error!("security: history of {} not scanned: {error}", repo.repo_id);
674 }
675 }
676 let day_ago = rfc3339(now_ms().saturating_sub(DAY_MS));
677 for repo in self.store.stale_dependencies(&day_ago, DEPENDENCIES_PER_SWEEP).await? {
678 if !self.active(&repo.repo_id).await? {
679 self.store
680 .skip_dependencies(&repo.repo_id, "Dependencies are not checked while the repository is archived or deleted.")
681 .await?;
682 continue;
683 }
684 if let Err(error) = self.scan_dependencies(&repo).await {
685 worker::console_error!("security: dependencies of {} not read: {error}", repo.repo_id);
686 }
687 }
688 Ok(())
689 }
690}
691
692/// The fingerprints a push may carry: those someone dismissed (allowed),
693/// and likely test values, which are recorded but never stop a push.
694/// `known` is (fingerprint, id, status) of findings already recorded.
695fn let_through(known: &[(String, String, String)], secrets: &[NewSecret]) -> Vec<String> {
696 let mut allowed: Vec<String> = known
697 .iter()
698 .filter(|(_, _, status)| status == "allowed")
699 .map(|(fingerprint, _, _)| fingerprint.clone())
700 .chain(secrets.iter().filter(|secret| secret.test_value.is_some()).map(|secret| secret.fingerprint.clone()))
701 .collect();
702 allowed.sort();
703 allowed.dedup();
704 allowed
705}
706
707#[event(fetch)]
708async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
709 let Some(method) = rpc_method(&request) else {
710 return Response::error("Not found", 404);
711 };
712 let security = Security::new(&env)?;
713 let body: serde_json::Value = request.json().await?;
714 match method.as_str() {
715 "overview" => reply(&security.overview(args(body)?).await?),
716 "dismiss" => reply(&security.dismiss(args(body)?).await?),
717 "reopen" => reply(&security.reopen(args(body)?).await?),
718 "rescan" => reply(&security.rescan(args(body)?).await?),
719 "set_upkeep" => reply(&security.set_upkeep(args(body)?).await?),
720 "workspace" => reply(&security.workspace(args(body)?).await?),
721 "push_blocked" => reply(&security.push_blocked(args(body)?).await?),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar722 "check_updates" => reply(&security.check_updates(args(body)?).await?),
723 // The security suite.
724 "patterns_for" => reply(&security.patterns_for(args(body)?).await?),
725 "custom_patterns" => reply(&security.custom_patterns(args(body)?).await?),
726 "save_custom_pattern" => reply(&security.save_custom_pattern(args(body)?).await?),
727 "delete_custom_pattern" => reply(&security.delete_custom_pattern(args(body)?).await?),
728 "dry_run_pattern" => reply(&security.dry_run_pattern(args(body)?).await?),
729 "secret_alert" => reply(&security.secret_alert(args(body)?).await?),
730 "bypass" => reply(&security.bypass(args(body)?).await?),
731 "bypass_requests" => reply(&security.bypass_requests(args(body)?).await?),
732 "review_bypass" => reply(&security.review_bypass(args(body)?).await?),
733 "check_validity" => reply(&security.check_validity(args(body)?).await?),
734 "upload_sarif" => reply(&security.upload_sarif(args(body)?).await?),
735 "sarif_status" => reply(&security.sarif_status(args(body)?).await?),
736 "code_scanning" => reply(&security.code_scanning(args(body)?).await?),
737 "code_alert" => reply(&security.code_alert(args(body)?).await?),
738 "set_code_alert_state" => reply(&security.set_code_alert_state(args(body)?).await?),
739 "pull_code_scanning" => reply(&security.pull_code_scanning(args(body)?).await?),
740 "dependency_graph" => reply(&security.dependency_graph(args(body)?).await?),
741 "sbom" => reply(&security.sbom(args(body)?).await?),
742 "dependency_review" => reply(&security.dependency_review(args(body)?).await?),
743 "security_settings" => reply(&security.security_settings(args(body)?).await?),
744 "set_security_settings" => reply(&security.set_security_settings(args(body)?).await?),
745 "workspace_security_settings" => reply(&security.workspace_security_settings(args(body)?).await?),
746 "set_workspace_security_settings" => reply(&security.set_workspace_security_settings(args(body)?).await?),
747 "security_overview" => reply(&security.security_overview(args(body)?).await?),
748 "workspace_alerts" => reply(&security.workspace_alerts(args(body)?).await?),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9749 // Reporting: advisories and private vulnerability reports (advisories.rs).
750 "list_advisories" => reply(&security.list_advisories(args(body)?).await?),
751 "get_advisory" => reply(&security.get_advisory(args(body)?).await?),
752 "create_advisory" => reply(&security.create_advisory(args(body)?).await?),
753 "update_advisory" => reply(&security.update_advisory(args(body)?).await?),
754 "set_advisory_state" => reply(&security.set_advisory_state(args(body)?).await?),
755 "report_vulnerability" => reply(&security.report_vulnerability(args(body)?).await?),
756 "report_settings" => reply(&security.report_settings(args(body)?).await?),
757 "list_vulnerability_reports" => reply(&security.list_vulnerability_reports(args(body)?).await?),
758 "review_vulnerability_report" => reply(&security.review_vulnerability_report(args(body)?).await?),
Topics sit in About on the files pages, under the description759 _ => Response::error("Unknown method", 404),
760 }
761}
762
763#[event(queue)]
764async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> {
765 let security = Security::new(&env)?;
766 for message in batch.messages()? {
767 let event = message.body();
768 if let Err(error) = security.on_event(event).await {
769 // Scans are idempotent and the sweep catches up, so one failed
770 // event is logged rather than retried.
771 worker::console_error!("security: {} {} failed: {error}", event.kind, event.id);
772 }
773 }
774 Ok(())
775}
776
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar777/// The cron (wrangler.jsonc) that runs version updates that are due.
778const VERSION_UPDATES_CRON: &str = "*/5 * * * *";
779
Topics sit in About on the files pages, under the description780#[event(scheduled)]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar781async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
Topics sit in About on the files pages, under the description782 match Security::new(&env) {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar783 // Version updates run every few minutes, so a schedule's time is kept.
784 Ok(security) if event.cron() == VERSION_UPDATES_CRON => {
785 if let Err(error) = security.version_update_sweep().await {
786 worker::console_error!("security: the version update sweep failed: {error}");
787 }
788 }
Topics sit in About on the files pages, under the description789 Ok(security) => {
790 if let Err(error) = security.sweep().await {
791 worker::console_error!("security: the sweep failed: {error}");
792 }
793 }
794 Err(error) => worker::console_error!("security: could not start: {error}"),
795 }
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 fn secret(fingerprint: &str, test_value: Option<&str>) -> NewSecret {
803 NewSecret {
804 fingerprint: fingerprint.into(),
805 kind: "aws_access_key".into(),
806 path: "a.env".into(),
807 line: 1,
808 commit: "c".into(),
809 preview: "AKIA…".into(),
810 test_value: test_value.map(str::to_owned),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar811 pattern_id: None,
812 pattern_name: None,
Topics sit in About on the files pages, under the description813 }
814 }
815
816 #[test]
817 fn dismissed_secrets_and_test_values_go_through() {
818 let known = vec![
819 ("allowed".to_owned(), "sec_1".to_owned(), "allowed".to_owned()),
820 ("resolved".to_owned(), "sec_2".to_owned(), "resolved".to_owned()),
821 ("blocked".to_owned(), "sec_3".to_owned(), "blocked".to_owned()),
822 ];
823 let secrets = [secret("allowed", None), secret("resolved", None), secret("example", Some("it says it is an example")), secret("real", None)];
824 assert_eq!(let_through(&known, &secrets), ["allowed", "example"]);
825 }
826
827 #[test]
828 fn a_push_says_when_it_landed_unscanned() {
829 let pushed: Pushed = serde_json::from_value(serde_json::json!({
830 "repoId": "rep_1", "ref": "refs/heads/import", "after": "abc", "defaultBranch": false, "unscanned": true
831 }))
832 .unwrap();
833 assert!(pushed.unscanned && pushed.before.is_none() && pushed.git_ref == "refs/heads/import");
834 let ordinary: Pushed = serde_json::from_value(serde_json::json!({ "repoId": "rep_1", "ref": "refs/heads/main", "after": "abc", "defaultBranch": true })).unwrap();
835 assert!(!ordinary.unscanned);
836 }
837
838 #[test]
839 fn owners_are_told_what_landed_and_what_to_do() {
840 let one = history::landed_secrets_intro("acme", "rocket", "import", 1);
841 assert!(one.contains("A push to import in acme/rocket") && one.contains("a secret that looks real") && one.contains("Rotate"));
842 assert!(history::landed_secrets_intro("acme", "rocket", "main", 3).contains("3 secrets that look real"));
843 }
844
845 #[test]
846 fn dismissing_a_secret_takes_admin_and_a_dependency_write() {
Merge main (membership, two-factor, GitHub repo roles) into tokens847 assert_eq!(Security::capability_for("sec_1"), Capability::SecurityAlerts);
848 assert_eq!(Security::capability_for("vul_1"), Capability::SecurityAlerts);
Topics sit in About on the files pages, under the description849 }
850}

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