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.
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 1 | //! 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. | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 19 | //! |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 20 | //! - 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 bar | 26 | //! On private repositories its paid parts need the workspace's Security |
| 27 | //! and quality activation (`suite`); it tells people through events. | |
| 28 | //! | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 29 | //! 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-7nm8r9 | 32 | mod advisories; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 33 | mod code_scanning; |
| 34 | mod config; | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 35 | mod deps; |
| 36 | mod history; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 37 | mod manifests; |
| 38 | mod overview; | |
| 39 | mod patterns; | |
| 40 | mod planning; | |
| 41 | mod pull_text; | |
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 42 | mod resolved; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 43 | mod ranges; |
| 44 | mod registries; | |
| 45 | mod schedule; | |
| 46 | mod secret_alerts; | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 47 | mod security_updates; |
| 48 | mod store; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 49 | mod suite; |
| 50 | mod suite_store; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 51 | mod summaries; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 52 | mod supply_chain; |
| 53 | mod timezones; | |
| 54 | mod update_store; | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 55 | mod updates; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 56 | mod version_updates; |
| 57 | mod yaml; | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 58 | |
| 59 | use g1t_contracts::access::{self, Capability}; | |
| 60 | use g1t_contracts::events::{Event, WorkspaceRenamed}; | |
| 61 | use g1t_contracts::security::UPDATE_BRANCH_PREFIX; | |
| 62 | use g1t_contracts::repos::{GetArgs, PathByIdArgs, Repo, RepoPath, RepoStatus, StatusByIdArgs}; | |
| 63 | use g1t_contracts::security::*; | |
| 64 | use g1t_contracts::time::rfc3339; | |
| 65 | use g1t_contracts::{FailureCode, Outcome, User}; | |
| 66 | use g1t_kit::{args, now_ms, reply, rpc_method}; | |
| 67 | use serde::Deserialize; | |
| 68 | use worker::{Context, Env, Fetcher, MessageBatch, Request, Response, Result, ScheduleContext, ScheduledEvent, event}; | |
| 69 | ||
| 70 | use store::{Activity, RepoRow, Store}; | |
| 71 | ||
| 72 | /// Repositories whose history is continued per sweep, and pages each. | |
| 73 | const HISTORIES_PER_SWEEP: u32 = 5; | |
| 74 | const PAGES_PER_SWEEP: u32 = 4; | |
| 75 | /// Repositories whose dependencies are read again per sweep. | |
| 76 | const DEPENDENCIES_PER_SWEEP: u32 = 10; | |
| 77 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 78 | const MAX_REASON_CHARS: usize = 500; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 79 | /// 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`). | |
| 82 | const SEE_FINDINGS: Capability = Capability::SecurityAlerts; | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 83 | |
| 84 | pub 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 bar | 91 | 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>, | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 96 | } |
| 97 | ||
| 98 | fn 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")] | |
| 105 | struct 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. |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 121 | #[derive(Deserialize)] |
| 122 | #[serde(rename_all = "camelCase")] | |
| 123 | struct PullHappened { | |
| 124 | repo_id: String, | |
| 125 | number: u32, | |
| 126 | } | |
| 127 | ||
| 128 | /// The longest comment a dismissal keeps. | |
| 129 | const MAX_COMMENT_CHARS: usize = MAX_REASON_CHARS; | |
| 130 | /// Activity rows the Security page reads, newest first. | |
| 131 | const ACTIVITY_SHOWN: u32 = 500; | |
| 132 | ||
| 133 | #[derive(Deserialize)] | |
| 134 | #[serde(rename_all = "camelCase")] | |
| 135 | struct Created { | |
| 136 | repo_id: String, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 137 | /// Absent from events older than the field. |
| 138 | #[serde(default)] | |
| 139 | is_private: Option<bool>, | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 140 | } |
| 141 | ||
| 142 | impl 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 bar | 151 | actions: env.service("ACTIONS")?, |
| 152 | events: env.service("EVENTS").ok(), | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 153 | }) |
| 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 bar | 185 | 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)) | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 188 | } |
| 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> { | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 193 | 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, |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 197 | 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() |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 200 | } |
| 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 | ||
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 219 | /// 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 bar | 253 | version_updates: self.version_updates_view(&repo).await?, |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 254 | })) |
| 255 | } | |
| 256 | ||
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 257 | /// 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 | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 262 | } |
| 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 bar | 297 | 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-7nm8r9 | 307 | 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 bar | 314 | } |
| 315 | return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None })); | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 316 | } |
| 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 bar | 342 | 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-7nm8r9 | 346 | 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 bar | 348 | } |
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 349 | // 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 bar | 353 | Ok(Outcome::Ok(AlertChange { secret: None, vulnerability })) |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 354 | } |
| 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 bar | 373 | 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-7nm8r9 | 383 | // 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 bar | 386 | } |
| 387 | return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None })); | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 388 | } |
| 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 bar | 397 | 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-7nm8r9 | 407 | 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 bar | 409 | } |
| 410 | Ok(Outcome::Ok(AlertChange { secret: None, vulnerability })) | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 411 | } |
| 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 audit | 446 | // 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 { | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 465 | 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 audit | 472 | }) |
| 473 | .collect(); | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 474 | 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 bar | 480 | 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 | } | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 484 | 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 bar | 486 | 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(); | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 492 | 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 bar | 505 | 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?; | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 507 | 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 bar | 537 | // 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 | } | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 545 | // 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 branches | 550 | self.update_pushed(&pushed.repo_id, branch, &pushed.after).await?; |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 551 | 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 branches | 570 | self.pull_finished(&happened.repo_id, happened.number).await?; |
| 571 | } | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 572 | } |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 573 | "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 | } | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 585 | "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 audit | 587 | // 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 | } | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 594 | } |
| 595 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 596 | "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 | } | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 603 | // 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-7nm8r9 | 626 | self.store.purge_advisories(&purged.repo_id).await?; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 627 | self.store.purge_suite(&purged.repo_id).await?; |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 628 | 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 bar | 653 | if let Err(error) = self.sweep_suite().await { |
| 654 | worker::console_error!("security: daily snapshots and validity checks: {error}"); | |
| 655 | } | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 656 | // 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. | |
| 690 | fn 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)] | |
| 703 | async 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 bar | 717 | "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-7nm8r9 | 744 | // 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?), | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 754 | _ => Response::error("Unknown method", 404), |
| 755 | } | |
| 756 | } | |
| 757 | ||
| 758 | #[event(queue)] | |
| 759 | async 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 bar | 772 | /// The cron (wrangler.jsonc) that runs version updates that are due. |
| 773 | const VERSION_UPDATES_CRON: &str = "*/5 * * * *"; | |
| 774 | ||
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 775 | #[event(scheduled)] |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 776 | async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 777 | match Security::new(&env) { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 778 | // 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 | } | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 784 | 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)] | |
| 794 | mod 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 bar | 806 | pattern_id: None, |
| 807 | pattern_name: None, | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 808 | } |
| 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 tokens | 842 | assert_eq!(Security::capability_for("sec_1"), Capability::SecurityAlerts); |
| 843 | assert_eq!(Security::capability_for("vul_1"), Capability::SecurityAlerts); | |
| Checkout tries a failed fetch twice more, after 2 s and 5 s | 844 | } |
| 845 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.