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.
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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 | |
| 17 | //! required checks. Only when code has to change is g1t put on an | |
| 18 | //! issue for it. | |
| 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, | |
| 24 | //! its SBOM and dependency review (`supply_chain`), "Fix with g1t" | |
| 25 | //! (`fixes`), and settings with the workspace's overview (`overview`). | |
| 26 | //! On private repositories its paid parts need the workspace's Security | |
| 27 | //! and quality activation (`suite`); it tells people through events. | |
| 28 | //! | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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; | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 35 | mod deps; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 36 | mod fixes; |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 37 | mod history; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 38 | mod manifests; |
| 39 | mod overview; | |
| 40 | mod patterns; | |
| 41 | mod planning; | |
| 42 | 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 | 43 | mod resolved; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 44 | mod ranges; |
| 45 | mod registries; | |
| 46 | mod schedule; | |
| 47 | mod secret_alerts; | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 48 | mod security_updates; |
| 49 | mod store; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 50 | mod suite; |
| 51 | mod suite_store; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 52 | mod summaries; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 53 | mod supply_chain; |
| 54 | mod timezones; | |
| 55 | mod update_store; | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 56 | mod updates; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 57 | mod version_updates; |
| 58 | mod yaml; | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 59 | |
| 60 | use g1t_contracts::access::{self, Capability}; | |
| 61 | use g1t_contracts::events::{Event, WorkspaceRenamed}; | |
| 62 | use g1t_contracts::security::UPDATE_BRANCH_PREFIX; | |
| 63 | use g1t_contracts::repos::{GetArgs, PathByIdArgs, Repo, RepoPath, RepoStatus, StatusByIdArgs}; | |
| 64 | use g1t_contracts::security::*; | |
| 65 | use g1t_contracts::time::rfc3339; | |
| 66 | use g1t_contracts::{FailureCode, Outcome, User}; | |
| 67 | use g1t_kit::{args, now_ms, reply, rpc_method}; | |
| 68 | use serde::Deserialize; | |
| 69 | use worker::{Context, Env, Fetcher, MessageBatch, Request, Response, Result, ScheduleContext, ScheduledEvent, event}; | |
| 70 | ||
| 71 | use store::{Activity, RepoRow, Store}; | |
| 72 | ||
| 73 | /// Repositories whose history is continued per sweep, and pages each. | |
| 74 | const HISTORIES_PER_SWEEP: u32 = 5; | |
| 75 | const PAGES_PER_SWEEP: u32 = 4; | |
| 76 | /// Repositories whose dependencies are read again per sweep. | |
| 77 | const DEPENDENCIES_PER_SWEEP: u32 = 10; | |
| 78 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 79 | const MAX_REASON_CHARS: usize = 500; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 80 | /// What seeing and dismissing a repository's findings takes: the Write |
| 81 | /// role, as on GitHub, or a security manager of its workspace. Changing its | |
| 82 | /// security settings takes Admin (`ManageSecurity`). | |
| 83 | const SEE_FINDINGS: Capability = Capability::SecurityAlerts; | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 84 | |
| 85 | pub struct Security { | |
| 86 | store: Store, | |
| 87 | identity: Fetcher, | |
| 88 | repos: Fetcher, | |
| 89 | work: Fetcher, | |
| 90 | runner: Fetcher, | |
| 91 | billing: Fetcher, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 92 | actions: Fetcher, |
| 93 | /// The events service: security events for webhooks and the inbox, | |
| 94 | /// and audit entries. Optional so a deployment without the binding | |
| 95 | /// still scans. | |
| 96 | events: Option<Fetcher>, | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 97 | } |
| 98 | ||
| 99 | fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> { | |
| 100 | Outcome::fail(code, message) | |
| 101 | } | |
| 102 | ||
| 103 | /// `git.push`, as far as this service reads it. | |
| 104 | #[derive(Deserialize)] | |
| 105 | #[serde(rename_all = "camelCase")] | |
| 106 | struct Pushed { | |
| 107 | repo_id: String, | |
| 108 | #[serde(default)] | |
| 109 | default_branch: bool, | |
| 110 | #[serde(default, rename = "ref")] | |
| 111 | git_ref: String, | |
| 112 | #[serde(default)] | |
| 113 | before: Option<String>, | |
| 114 | #[serde(default)] | |
| 115 | after: String, | |
| 116 | /// Too large to scan before it was stored. | |
| 117 | #[serde(default)] | |
| 118 | unscanned: bool, | |
| 119 | } | |
| 120 | ||
| 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. | 121 | /// `pull.merged` and `pull.closed`, as far as this service reads them. |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 122 | #[derive(Deserialize)] |
| 123 | #[serde(rename_all = "camelCase")] | |
| 124 | struct PullHappened { | |
| 125 | repo_id: String, | |
| 126 | number: u32, | |
| 127 | } | |
| 128 | ||
| 129 | /// The longest comment a dismissal keeps. | |
| 130 | const MAX_COMMENT_CHARS: usize = MAX_REASON_CHARS; | |
| 131 | /// Activity rows the Security page reads, newest first. | |
| 132 | const ACTIVITY_SHOWN: u32 = 500; | |
| 133 | ||
| 134 | #[derive(Deserialize)] | |
| 135 | #[serde(rename_all = "camelCase")] | |
| 136 | struct Created { | |
| 137 | repo_id: String, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 138 | /// Absent from events older than the field. |
| 139 | #[serde(default)] | |
| 140 | is_private: Option<bool>, | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 141 | } |
| 142 | ||
| 143 | impl Security { | |
| 144 | fn new(env: &Env) -> Result<Self> { | |
| 145 | Ok(Security { | |
| 146 | store: Store { db: env.d1("DB")? }, | |
| 147 | identity: env.service("IDENTITY")?, | |
| 148 | repos: env.service("REPOS")?, | |
| 149 | work: env.service("WORK")?, | |
| 150 | runner: env.service("RUNNER")?, | |
| 151 | billing: env.service("BILLING")?, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 152 | actions: env.service("ACTIONS")?, |
| 153 | events: env.service("EVENTS").ok(), | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 154 | }) |
| 155 | } | |
| 156 | ||
| 157 | /// The repository at `path`, recorded here, if `viewer` may see its | |
| 158 | /// findings (the Write role) and do `capability`. Findings are for those | |
| 159 | /// who can change the code: to anyone else the page does not exist, | |
| 160 | /// public repository or not. | |
| 161 | async fn member_repo( | |
| 162 | &self, | |
| 163 | path: &RepoPath, | |
| 164 | viewer: &Option<User>, | |
| 165 | capability: Capability, | |
| 166 | ) -> Result<Outcome<RepoRow>> { | |
| 167 | let hidden = || fail(FailureCode::NotFound, "Repository not found."); | |
| 168 | if viewer.is_none() { | |
| 169 | return Ok(hidden()); | |
| 170 | } | |
| 171 | let repo: Outcome<Repo> = | |
| 172 | g1t_kit::call(&self.repos, "get", &GetArgs { path: path.clone(), viewer: viewer.clone() }).await?; | |
| 173 | let repo = match repo { | |
| 174 | Outcome::Ok(repo) if repo.fork_of.is_none() => repo, | |
| 175 | _ => return Ok(hidden()), | |
| 176 | }; | |
| 177 | if !access::can(viewer.as_ref(), &repo, SEE_FINDINGS) { | |
| 178 | return Ok(hidden()); | |
| 179 | } | |
| 180 | if !access::can(viewer.as_ref(), &repo, capability) { | |
| 181 | return Ok(fail( | |
| 182 | FailureCode::Forbidden, | |
| 183 | access::needs(capability, &format!("{}/{}", repo.namespace, repo.name)), | |
| 184 | )); | |
| 185 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 186 | let row = self.store.register(&repo.id, &repo.namespace, &repo.name).await?; |
| 187 | self.store.set_private(&repo.id, repo.is_private).await?; | |
| 188 | Ok(Outcome::Ok(row)) | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 189 | } |
| 190 | ||
| 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 | /// Where the repository stands: archived, deleted, a mirror. When repos |
| 192 | /// cannot say, it is taken as an ordinary active repository. | |
| 193 | async fn status(&self, repo_id: &str) -> Result<RepoStatus> { | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 194 | let status: Result<RepoStatus> = |
| 195 | g1t_kit::call(&self.repos, "status_by_id", &StatusByIdArgs { id: repo_id.to_owned() }).await; | |
| 196 | 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. | 197 | Ok(status) => status, |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 198 | Err(error) => { |
| 199 | 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. | 200 | RepoStatus::default() |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 201 | } |
| 202 | }) | |
| 203 | } | |
| 204 | ||
| 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 | /// Whether the repository is neither archived nor deleted. Scanning |
| 206 | /// goes on in a mirror: it reads. | |
| 207 | async fn active(&self, repo_id: &str) -> Result<bool> { | |
| 208 | Ok(self.status(repo_id).await?.active()) | |
| 209 | } | |
| 210 | ||
| 211 | /// Whether g1t may open branches and pull requests here on its own | |
| 212 | /// (security and dependency updates): active, and not a mirror short | |
| 213 | /// of a takeover (`g1t_contracts::mirrors`). `Err` says why not. | |
| 214 | async fn takes_updates(&self, repo: &RepoRow) -> Result<std::result::Result<(), String>> { | |
| 215 | let status = self.status(&repo.repo_id).await?; | |
| 216 | if status.takes_updates() { | |
| 217 | return Ok(Ok(())); | |
| 218 | } | |
| 219 | Ok(Err(match &status.mirror { | |
| 220 | Some(mirror) if status.active() => g1t_contracts::mirrors::updates_wait_message(&repo.namespace, &repo.name, mirror), | |
| 221 | _ => "Updates wait while the repository is archived or deleted.".to_owned(), | |
| 222 | })) | |
| 223 | } | |
| 224 | ||
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 225 | /// Records a repository named in an event, by id. Forks are not |
| 226 | /// recorded: a pull request's findings belong to its repository. | |
| 227 | async fn register_by_id(&self, repo_id: &str) -> Result<Option<RepoRow>> { | |
| 228 | if let Some(row) = self.store.repo(repo_id).await? { | |
| 229 | return Ok(Some(row)); | |
| 230 | } | |
| 231 | let path: Option<RepoPath> = g1t_kit::call(&self.repos, "path_by_id", &PathByIdArgs { id: repo_id.to_owned() }).await?; | |
| 232 | match path { | |
| 233 | Some(path) => Ok(Some(self.store.register(repo_id, &path.namespace, &path.name).await?)), | |
| 234 | None => Ok(None), | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | async fn overview(&self, a: OverviewArgs) -> Result<Outcome<SecurityOverview>> { | |
| 239 | let mut repo = match self.member_repo(&a.repo, &a.viewer, SEE_FINDINGS).await? { | |
| 240 | Outcome::Ok(repo) => repo, | |
| 241 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 242 | }; | |
| 243 | // The first look at a repository reads its dependencies at once; | |
| 244 | // its history is scanned in the background. | |
| 245 | if repo.deps_scanned_at.is_none() { | |
| 246 | self.scan_dependencies(&repo).await?; | |
| 247 | repo = self.store.repo(&repo.repo_id).await?.unwrap_or(repo); | |
| 248 | } | |
| 249 | let (counts, _, _) = self.store.counts(&repo.repo_id).await?; | |
| 250 | Ok(Outcome::Ok(SecurityOverview { | |
| 251 | repo_id: repo.repo_id.clone(), | |
| 252 | counts, | |
| 253 | secret_counts: self.store.secret_counts(&repo.repo_id).await?, | |
| 254 | secrets: self.store.secrets(&repo.repo_id).await?, | |
| 255 | vulnerabilities: self.store.vulnerabilities(&repo.repo_id).await?, | |
| 256 | activity: self.store.activity(&repo.repo_id, ACTIVITY_SHOWN).await?, | |
| 257 | scan: repo.scan_state(), | |
| 258 | upkeep: repo.upkeep != 0, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 259 | version_updates: self.version_updates_view(&repo).await?, |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 260 | })) |
| 261 | } | |
| 262 | ||
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 263 | /// What dismissing or reopening alert `id` takes: Write, for a secret |
| 264 | /// as for a vulnerable dependency, as on GitHub; a security manager may | |
| 265 | /// too. | |
| 266 | fn capability_for(_id: &str) -> Capability { | |
| 267 | SEE_FINDINGS | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 268 | } |
| 269 | ||
| 270 | async fn dismiss(&self, a: DismissArgs) -> Result<Outcome<AlertChange>> { | |
| 271 | let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Self::capability_for(&a.id)).await? { | |
| 272 | Outcome::Ok(repo) => repo, | |
| 273 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 274 | }; | |
| 275 | if !a.actor.verified { | |
| 276 | return Ok(fail(FailureCode::Forbidden, "Confirm your email address first.")); | |
| 277 | } | |
| 278 | let comment: String = a.comment.trim().chars().take(MAX_COMMENT_CHARS).collect(); | |
| 279 | let comment = (!comment.is_empty()).then_some(comment); | |
| 280 | if let Some(finding) = self.store.secret(&repo.repo_id, &a.id).await? { | |
| 281 | if !a.reason.for_secrets() { | |
| 282 | return Ok(fail( | |
| 283 | FailureCode::Invalid, | |
| 284 | "A secret is dismissed as false_positive, used_in_tests, revoked or wont_fix.", | |
| 285 | )); | |
| 286 | } | |
| 287 | if finding.state != AlertState::Open { | |
| 288 | return Ok(fail(FailureCode::Conflict, "This alert is not open. Reopen it first to dismiss it again.")); | |
| 289 | } | |
| 290 | self.store | |
| 291 | .dismiss_secret(&repo.repo_id, &a.id, a.reason, &a.actor.username, comment.as_deref()) | |
| 292 | .await?; | |
| 293 | self.store | |
| 294 | .record(&repo.repo_id, &[Activity { | |
| 295 | alert_id: &a.id, | |
| 296 | action: "dismissed", | |
| 297 | actor: Some(&a.actor.username), | |
| 298 | reason: Some(a.reason), | |
| 299 | comment: comment.as_deref(), | |
| 300 | number: None, | |
| 301 | }]) | |
| 302 | .await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 303 | let secret = self.store.secret(&repo.repo_id, &a.id).await?; |
| 304 | if let Some(secret) = &secret { | |
| 305 | // Revoked is fixed; any other reason, dismissed. | |
| 306 | let action = if a.reason == DismissReason::Revoked { "fixed" } else { "dismissed" }; | |
| 307 | let event = g1t_contracts::security_suite::SecurityEvent { | |
| 308 | reason: Some(a.reason.as_str().to_owned()), | |
| 309 | ..secret_alerts::secret_event(&repo, secret) | |
| 310 | }; | |
| 311 | self.alert_event(g1t_contracts::security_suite::AlertType::SecretScanning, action, &repo, event, Some(a.actor.id.clone())) | |
| 312 | .await; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 313 | let changed = summaries::Changed { |
| 314 | scan: "change", | |
| 315 | fixed: if action == "fixed" { vec![a.id.clone()] } else { Vec::new() }, | |
| 316 | dismissed: u32::from(action == "dismissed"), | |
| 317 | ..summaries::Changed::default() | |
| 318 | }; | |
| 319 | 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 | 320 | } |
| 321 | return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None })); | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 322 | } |
| 323 | let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else { | |
| 324 | return Ok(fail(FailureCode::NotFound, "No such alert.")); | |
| 325 | }; | |
| 326 | if a.reason.for_secrets() { | |
| 327 | return Ok(fail( | |
| 328 | FailureCode::Invalid, | |
| 329 | "A dependency is dismissed as fix_started, no_bandwidth, tolerable_risk, inaccurate or not_used.", | |
| 330 | )); | |
| 331 | } | |
| 332 | if vuln.state != AlertState::Open { | |
| 333 | return Ok(fail(FailureCode::Conflict, "This alert is not open. Reopen it first to dismiss it again.")); | |
| 334 | } | |
| 335 | self.store | |
| 336 | .dismiss_vulnerability(&repo.repo_id, &a.id, a.reason, &a.actor.username, comment.as_deref()) | |
| 337 | .await?; | |
| 338 | self.store | |
| 339 | .record(&repo.repo_id, &[Activity { | |
| 340 | alert_id: &a.id, | |
| 341 | action: "dismissed", | |
| 342 | actor: Some(&a.actor.username), | |
| 343 | reason: Some(a.reason), | |
| 344 | comment: comment.as_deref(), | |
| 345 | number: None, | |
| 346 | }]) | |
| 347 | .await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 348 | let vulnerability = self.store.vulnerability(&repo.repo_id, &a.id).await?; |
| 349 | if let Some(vuln) = &vulnerability { | |
| 350 | let event = g1t_contracts::security_suite::SecurityEvent { reason: Some(a.reason.as_str().to_owned()), ..deps::vulnerability_event(&repo, vuln) }; | |
| 351 | 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 | 352 | let changed = summaries::Changed { scan: "change", dismissed: 1, ..summaries::Changed::default() }; |
| 353 | 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 | 354 | } |
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 355 | // A security update whose alerts are now all fixed or dismissed closes. |
| 356 | if let Err(error) = self.resolve_updates(&repo, None).await { | |
| 357 | worker::console_error!("security: updates of {} not resolved after a dismissal: {error}", repo.repo_id); | |
| 358 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 359 | Ok(Outcome::Ok(AlertChange { secret: None, vulnerability })) |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 360 | } |
| 361 | ||
| 362 | async fn reopen(&self, a: ReopenArgs) -> Result<Outcome<AlertChange>> { | |
| 363 | let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Self::capability_for(&a.id)).await? { | |
| 364 | Outcome::Ok(repo) => repo, | |
| 365 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 366 | }; | |
| 367 | if !a.actor.verified { | |
| 368 | return Ok(fail(FailureCode::Forbidden, "Confirm your email address first.")); | |
| 369 | } | |
| 370 | let reopened = Activity { alert_id: &a.id, action: "reopened", actor: Some(&a.actor.username), reason: None, comment: None, number: None }; | |
| 371 | if let Some(finding) = self.store.secret(&repo.repo_id, &a.id).await? { | |
| 372 | if finding.state == AlertState::Open { | |
| 373 | return Ok(fail(FailureCode::Conflict, "This alert is already open.")); | |
| 374 | } | |
| 375 | // A secret that never landed has nothing to reopen to but blocked. | |
| 376 | let status = if finding.source == "push" && finding.test_value.is_none() { SecretStatus::Blocked } else { SecretStatus::Open }; | |
| 377 | self.store.reopen_secret(&repo.repo_id, &a.id, status).await?; | |
| 378 | self.store.record(&repo.repo_id, &[reopened]).await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 379 | let secret = self.store.secret(&repo.repo_id, &a.id).await?; |
| 380 | if let Some(secret) = &secret { | |
| 381 | self.alert_event( | |
| 382 | g1t_contracts::security_suite::AlertType::SecretScanning, | |
| 383 | "reopened", | |
| 384 | &repo, | |
| 385 | secret_alerts::secret_event(&repo, secret), | |
| 386 | Some(a.actor.id.clone()), | |
| 387 | ) | |
| 388 | .await; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 389 | // The thread's counts move; nobody is told anew of what they reopened. |
| 390 | let changed = summaries::Changed { scan: "change", ..summaries::Changed::default() }; | |
| 391 | 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 | 392 | } |
| 393 | return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None })); | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 394 | } |
| 395 | let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else { | |
| 396 | return Ok(fail(FailureCode::NotFound, "No such alert.")); | |
| 397 | }; | |
| 398 | if vuln.state != AlertState::Dismissed { | |
| 399 | return Ok(fail(FailureCode::Conflict, "Only a dismissed alert can be reopened; a fixed one reopens when it is found again.")); | |
| 400 | } | |
| 401 | self.store.reopen_vulnerability(&repo.repo_id, &a.id).await?; | |
| 402 | self.store.record(&repo.repo_id, &[reopened]).await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 403 | let vulnerability = self.store.vulnerability(&repo.repo_id, &a.id).await?; |
| 404 | if let Some(vuln) = &vulnerability { | |
| 405 | self.alert_event( | |
| 406 | g1t_contracts::security_suite::AlertType::Vulnerability, | |
| 407 | "reopened", | |
| 408 | &repo, | |
| 409 | deps::vulnerability_event(&repo, vuln), | |
| 410 | Some(a.actor.id.clone()), | |
| 411 | ) | |
| 412 | .await; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 413 | let changed = summaries::Changed { scan: "change", ..summaries::Changed::default() }; |
| 414 | 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 | 415 | } |
| 416 | Ok(Outcome::Ok(AlertChange { secret: None, vulnerability })) | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 417 | } |
| 418 | ||
| 419 | async fn rescan(&self, a: RescanArgs) -> Result<Outcome<ScanState>> { | |
| 420 | let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), SEE_FINDINGS).await? { | |
| 421 | Outcome::Ok(repo) => repo, | |
| 422 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 423 | }; | |
| 424 | self.store.restart_history(&repo.repo_id).await?; | |
| 425 | self.scan_dependencies(&repo).await?; | |
| 426 | if let Some(fresh) = self.store.repo(&repo.repo_id).await? { | |
| 427 | self.advance_history(&fresh, 1).await?; | |
| 428 | } | |
| 429 | let state = self.store.repo(&repo.repo_id).await?.map(|row| row.scan_state()).unwrap_or_default(); | |
| 430 | Ok(Outcome::Ok(state)) | |
| 431 | } | |
| 432 | ||
| 433 | async fn set_upkeep(&self, a: SetUpkeepArgs) -> Result<Outcome<bool>> { | |
| 434 | // Whether agents keep its dependencies up to date is one of its | |
| 435 | // settings. | |
| 436 | let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Capability::ManageSettings).await? { | |
| 437 | Outcome::Ok(repo) => repo, | |
| 438 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 439 | }; | |
| 440 | if !a.actor.verified { | |
| 441 | return Ok(fail(FailureCode::Forbidden, "Confirm your email address first.")); | |
| 442 | } | |
| 443 | self.store.set_upkeep(&repo.repo_id, a.enabled, &a.actor.username).await?; | |
| 444 | Ok(Outcome::Ok(a.enabled)) | |
| 445 | } | |
| 446 | ||
| 447 | async fn workspace(&self, a: WorkspaceArgs) -> Result<Outcome<Vec<RepoSecurity>>> { | |
| 448 | let workspace = a.workspace.to_lowercase(); | |
| 449 | if !a.viewer.as_ref().is_some_and(|user| user.is_member(&workspace)) { | |
| 450 | return Ok(fail(FailureCode::NotFound, "Workspace not found.")); | |
| 451 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 452 | // Only the repositories whose findings the viewer may see. The |
| 453 | // Write role is never had through being public, so treating each | |
| 454 | // as private changes nothing. | |
| 455 | let repos: Vec<RepoRow> = self | |
| 456 | .store | |
| 457 | .in_namespace(&workspace) | |
| 458 | .await? | |
| 459 | .into_iter() | |
| 460 | .filter(|repo| { | |
| 461 | let target = access::RepoRef { id: &repo.repo_id, namespace: &workspace, private: true }; | |
| 462 | access::can(a.viewer.as_ref(), target, SEE_FINDINGS) | |
| 463 | }) | |
| 464 | .collect(); | |
| 465 | // Every repository's counts at once, not one after another. | |
| 466 | let counts = futures_util::future::try_join_all(repos.iter().map(|repo| self.store.counts(&repo.repo_id))).await?; | |
| 467 | let list = repos | |
| 468 | .into_iter() | |
| 469 | .zip(counts) | |
| 470 | .map(|(repo, (counts, secrets, vulnerabilities))| RepoSecurity { | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 471 | repo_id: repo.repo_id, |
| 472 | name: repo.name, | |
| 473 | counts, | |
| 474 | secrets, | |
| 475 | vulnerabilities, | |
| 476 | upkeep: repo.upkeep != 0, | |
| 477 | dependencies_scanned_at: repo.deps_scanned_at, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 478 | }) |
| 479 | .collect(); | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 480 | Ok(Outcome::Ok(list)) |
| 481 | } | |
| 482 | ||
| 483 | /// Push protection's question: which of these secrets were allowed? | |
| 484 | /// The others are recorded as blocked, so someone can allow them. | |
| 485 | 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 | 486 | let repo = self.store.register(&a.repo_id, &a.path.namespace, &a.path.name).await?; |
| 487 | if let Some(private) = a.private { | |
| 488 | self.store.set_private(&a.repo_id, private).await?; | |
| 489 | } | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 490 | let fingerprints: Vec<String> = a.secrets.iter().map(|secret| secret.fingerprint.clone()).collect(); |
| 491 | 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 | 492 | let mut allowed = let_through(&known, &a.secrets); |
| 493 | // Bypassed with a reason: let through, whatever the alert says now. | |
| 494 | allowed.extend(self.store.bypassed(&a.repo_id, &fingerprints).await?); | |
| 495 | allowed.sort(); | |
| 496 | allowed.dedup(); | |
| 497 | let all = a.secrets.clone(); | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 498 | let fresh: Vec<NewSecret> = a |
| 499 | .secrets | |
| 500 | .into_iter() | |
| 501 | .filter(|secret| !known.iter().any(|(fingerprint, _, _)| *fingerprint == secret.fingerprint)) | |
| 502 | .collect(); | |
| 503 | // A likely test value goes through, so it lands: open, not blocked. | |
| 504 | let (tests, real): (Vec<NewSecret>, Vec<NewSecret>) = fresh.into_iter().partition(|secret| secret.test_value.is_some()); | |
| 505 | self.store | |
| 506 | .add_secrets(&a.repo_id, &real, SecretStatus::Blocked, "push", a.pusher.as_deref()) | |
| 507 | .await?; | |
| 508 | self.store | |
| 509 | .add_secrets(&a.repo_id, &tests, SecretStatus::Open, "push", a.pusher.as_deref()) | |
| 510 | .await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 511 | let fresh: Vec<String> = real.iter().map(|secret| secret.fingerprint.clone()).collect(); |
| 512 | self.secrets_found(&repo, &all, "push", &fresh, a.pusher.as_deref()).await?; | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 513 | let ids = self |
| 514 | .store | |
| 515 | .known(&a.repo_id, &fingerprints) | |
| 516 | .await? | |
| 517 | .into_iter() | |
| 518 | .filter(|(fingerprint, _, _)| !allowed.contains(fingerprint)) | |
| 519 | .map(|(fingerprint, id, _)| (fingerprint, id)) | |
| 520 | .collect(); | |
| 521 | Ok(PushVerdict { allowed, ids }) | |
| 522 | } | |
| 523 | ||
| 524 | /// What happens on the bus that concerns this service. | |
| 525 | async fn on_event(&self, event: &Event) -> Result<()> { | |
| 526 | match event.kind.as_str() { | |
| 527 | "git.push" => { | |
| 528 | let Ok(pushed) = serde_json::from_value::<Pushed>(event.data.clone()) else { | |
| 529 | return Ok(()); | |
| 530 | }; | |
| 531 | // Too large to scan before it was stored: its new commits | |
| 532 | // are scanned now, on whichever branch. | |
| 533 | if pushed.unscanned | |
| 534 | && !pushed.after.is_empty() | |
| 535 | && let Some(repo) = self.register_by_id(&pushed.repo_id).await? | |
| 536 | { | |
| 537 | let id = self | |
| 538 | .store | |
| 539 | .add_push_scan(&repo.repo_id, &pushed.git_ref, &pushed.after, pushed.before.as_deref(), event.actor.as_deref()) | |
| 540 | .await?; | |
| 541 | self.advance_push_scan(&id, history::PUSH_PAGES_AT_ONCE).await?; | |
| 542 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 543 | // A version update's branch, or a grouped security update's, |
| 544 | // pushed by its sandbox: time for its pull request. | |
| 545 | if !pushed.default_branch | |
| 546 | && let Some(branch) = pushed.git_ref.strip_prefix("refs/heads/") | |
| 547 | && self.update_pull_pushed(&pushed.repo_id, branch, &pushed.after).await? | |
| 548 | { | |
| 549 | return Ok(()); | |
| 550 | } | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 551 | // A security update's branch, pushed by its sandbox: time for |
| 552 | // its pull request. | |
| 553 | if let Some(branch) = pushed.git_ref.strip_prefix("refs/heads/") | |
| 554 | && branch.starts_with(UPDATE_BRANCH_PREFIX) | |
| 555 | { | |
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 556 | self.update_pushed(&pushed.repo_id, branch, &pushed.after).await?; |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 557 | return Ok(()); |
| 558 | } | |
| 559 | if !pushed.default_branch { | |
| 560 | return Ok(()); | |
| 561 | } | |
| 562 | if let Some(repo) = self.register_by_id(&pushed.repo_id).await? { | |
| 563 | self.scan_dependencies(&repo).await?; | |
| 564 | if repo.history != "done" { | |
| 565 | self.advance_history(&repo, 1).await?; | |
| 566 | } | |
| 567 | } | |
| 568 | } | |
| 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. | 569 | "pull.merged" | "pull.closed" => { |
| 570 | if let Ok(happened) = serde_json::from_value::<PullHappened>(event.data.clone()) { | |
| 571 | if !self.update_pull_closed(&event.kind, &happened.repo_id, happened.number).await? { | |
| 572 | self.update_pull_event(&event.kind, &happened.repo_id, happened.number, event.actor.as_deref()).await?; | |
| 573 | } | |
| 574 | // One of g1t's update pull requests closed or merged, by | |
| 575 | // 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 | 576 | self.pull_finished(&happened.repo_id, happened.number).await?; |
| 577 | } | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 578 | } |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 579 | "comment.created" => self.update_comment(event).await?, |
| 580 | // A pull request opened or its head moved: a dependency update | |
| 581 | // file it changes is checked (not on `pull.ready`, which moves | |
| 582 | // nothing), and dependency review runs. | |
| 583 | "pull.opened" | "pull.updated" | "pull.ready" => { | |
| 584 | if event.kind != "pull.ready" { | |
| 585 | self.check_dependabot_file(event).await?; | |
| 586 | } | |
| 587 | if let Ok(happened) = serde_json::from_value::<PullHappened>(event.data.clone()) { | |
| 588 | self.review_pull(&happened.repo_id, happened.number).await?; | |
| 589 | } | |
| 590 | } | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 591 | "repo.created" => { |
| 592 | if let Ok(created) = serde_json::from_value::<Created>(event.data.clone()) { | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 593 | // Recorded with its visibility, so the overview never takes a |
| 594 | // public repository for a private one. | |
| 595 | if self.register_by_id(&created.repo_id).await?.is_some() | |
| 596 | && let Some(private) = created.is_private | |
| 597 | { | |
| 598 | self.store.set_private(&created.repo_id, private).await?; | |
| 599 | } | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 600 | } |
| 601 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 602 | "repo.visibility_changed" => { |
| 603 | if let Ok(changed) = serde_json::from_value::<g1t_contracts::events::RepoVisibilityChanged>(event.data.clone()) | |
| 604 | && self.store.repo(&changed.repo_id).await?.is_some() | |
| 605 | { | |
| 606 | self.store.set_private(&changed.repo_id, changed.is_private).await?; | |
| 607 | } | |
| 608 | } | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 609 | // A repository transferred or renamed: it is recorded under its new path. |
| 610 | "repo.transferred" | "repo.renamed" => { | |
| 611 | if let Some(moved) = g1t_kit::transfer::read(event) { | |
| 612 | // Where it is now, so moves heard out of order end in | |
| 613 | // the same place. | |
| 614 | let now: Option<g1t_contracts::repos::RepoPath> = g1t_kit::call( | |
| 615 | &self.repos, | |
| 616 | "path_by_id", | |
| 617 | &g1t_contracts::repos::PathByIdArgs { id: moved.repo_id.clone() }, | |
| 618 | ) | |
| 619 | .await?; | |
| 620 | let current = now.map_or_else( | |
| 621 | || moved.destination().to_owned(), | |
| 622 | |path| format!("{}/{}", path.namespace, path.name), | |
| 623 | ); | |
| 624 | if let Some((namespace, name)) = current.split_once('/') { | |
| 625 | self.store.moved(&moved.repo_id, namespace, name).await?; | |
| 626 | } | |
| 627 | } | |
| 628 | } | |
| 629 | // A repository purged: everything found in it goes. | |
| 630 | "repo.purged" => { | |
| 631 | 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 | 632 | self.store.purge_advisories(&purged.repo_id).await?; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 633 | self.store.purge_suite(&purged.repo_id).await?; |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 634 | self.store.purge(&purged.repo_id).await?; |
| 635 | } | |
| 636 | } | |
| 637 | "workspace.renamed" => { | |
| 638 | if let Ok(renamed) = serde_json::from_value::<WorkspaceRenamed>(event.data.clone()) { | |
| 639 | self.store.rename_namespace(&renamed.stale_slugs(&renamed.to), &renamed.to).await?; | |
| 640 | } | |
| 641 | } | |
| 642 | _ => {} | |
| 643 | } | |
| 644 | Ok(()) | |
| 645 | } | |
| 646 | ||
| 647 | /// The sweep: continues history scans and scans of pushes that landed | |
| 648 | /// unscanned, catches security updates whose sandbox never pushed, and | |
| 649 | /// reads dependencies that have not been read for a day. | |
| 650 | async fn sweep(&self) -> Result<()> { | |
| 651 | for scan in self.store.pending_push_scans(HISTORIES_PER_SWEEP).await? { | |
| 652 | if let Err(error) = self.advance_push_scan(&scan.id, PAGES_PER_SWEEP).await { | |
| 653 | worker::console_error!("security: push scan {} not continued: {error}", scan.id); | |
| 654 | } | |
| 655 | } | |
| 656 | if let Err(error) = self.stalled_updates().await { | |
| 657 | worker::console_error!("security: stalled security updates not handled: {error}"); | |
| 658 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 659 | if let Err(error) = self.sweep_suite().await { |
| 660 | worker::console_error!("security: daily snapshots and validity checks: {error}"); | |
| 661 | } | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 662 | // Archived and deleted repositories wait; a few more are looked at |
| 663 | // so that they do not hold up the rest. | |
| 664 | let mut histories = 0; | |
| 665 | for repo in self.store.unfinished_histories(HISTORIES_PER_SWEEP * 4).await? { | |
| 666 | if histories == HISTORIES_PER_SWEEP { | |
| 667 | break; | |
| 668 | } | |
| 669 | if !self.active(&repo.repo_id).await? { | |
| 670 | continue; | |
| 671 | } | |
| 672 | histories += 1; | |
| 673 | if let Err(error) = self.advance_history(&repo, PAGES_PER_SWEEP).await { | |
| 674 | worker::console_error!("security: history of {} not scanned: {error}", repo.repo_id); | |
| 675 | } | |
| 676 | } | |
| 677 | let day_ago = rfc3339(now_ms().saturating_sub(DAY_MS)); | |
| 678 | for repo in self.store.stale_dependencies(&day_ago, DEPENDENCIES_PER_SWEEP).await? { | |
| 679 | if !self.active(&repo.repo_id).await? { | |
| 680 | self.store | |
| 681 | .skip_dependencies(&repo.repo_id, "Dependencies are not checked while the repository is archived or deleted.") | |
| 682 | .await?; | |
| 683 | continue; | |
| 684 | } | |
| 685 | if let Err(error) = self.scan_dependencies(&repo).await { | |
| 686 | worker::console_error!("security: dependencies of {} not read: {error}", repo.repo_id); | |
| 687 | } | |
| 688 | } | |
| 689 | Ok(()) | |
| 690 | } | |
| 691 | } | |
| 692 | ||
| 693 | /// The fingerprints a push may carry: those someone dismissed (allowed), | |
| 694 | /// and likely test values, which are recorded but never stop a push. | |
| 695 | /// `known` is (fingerprint, id, status) of findings already recorded. | |
| 696 | fn let_through(known: &[(String, String, String)], secrets: &[NewSecret]) -> Vec<String> { | |
| 697 | let mut allowed: Vec<String> = known | |
| 698 | .iter() | |
| 699 | .filter(|(_, _, status)| status == "allowed") | |
| 700 | .map(|(fingerprint, _, _)| fingerprint.clone()) | |
| 701 | .chain(secrets.iter().filter(|secret| secret.test_value.is_some()).map(|secret| secret.fingerprint.clone())) | |
| 702 | .collect(); | |
| 703 | allowed.sort(); | |
| 704 | allowed.dedup(); | |
| 705 | allowed | |
| 706 | } | |
| 707 | ||
| 708 | #[event(fetch)] | |
| 709 | async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> { | |
| 710 | let Some(method) = rpc_method(&request) else { | |
| 711 | return Response::error("Not found", 404); | |
| 712 | }; | |
| 713 | let security = Security::new(&env)?; | |
| 714 | let body: serde_json::Value = request.json().await?; | |
| 715 | match method.as_str() { | |
| 716 | "overview" => reply(&security.overview(args(body)?).await?), | |
| 717 | "dismiss" => reply(&security.dismiss(args(body)?).await?), | |
| 718 | "reopen" => reply(&security.reopen(args(body)?).await?), | |
| 719 | "rescan" => reply(&security.rescan(args(body)?).await?), | |
| 720 | "set_upkeep" => reply(&security.set_upkeep(args(body)?).await?), | |
| 721 | "workspace" => reply(&security.workspace(args(body)?).await?), | |
| 722 | "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 | 723 | "check_updates" => reply(&security.check_updates(args(body)?).await?), |
| 724 | // The security suite. | |
| 725 | "patterns_for" => reply(&security.patterns_for(args(body)?).await?), | |
| 726 | "custom_patterns" => reply(&security.custom_patterns(args(body)?).await?), | |
| 727 | "save_custom_pattern" => reply(&security.save_custom_pattern(args(body)?).await?), | |
| 728 | "delete_custom_pattern" => reply(&security.delete_custom_pattern(args(body)?).await?), | |
| 729 | "dry_run_pattern" => reply(&security.dry_run_pattern(args(body)?).await?), | |
| 730 | "secret_alert" => reply(&security.secret_alert(args(body)?).await?), | |
| 731 | "bypass" => reply(&security.bypass(args(body)?).await?), | |
| 732 | "bypass_requests" => reply(&security.bypass_requests(args(body)?).await?), | |
| 733 | "review_bypass" => reply(&security.review_bypass(args(body)?).await?), | |
| 734 | "check_validity" => reply(&security.check_validity(args(body)?).await?), | |
| 735 | "upload_sarif" => reply(&security.upload_sarif(args(body)?).await?), | |
| 736 | "sarif_status" => reply(&security.sarif_status(args(body)?).await?), | |
| 737 | "code_scanning" => reply(&security.code_scanning(args(body)?).await?), | |
| 738 | "code_alert" => reply(&security.code_alert(args(body)?).await?), | |
| 739 | "set_code_alert_state" => reply(&security.set_code_alert_state(args(body)?).await?), | |
| 740 | "pull_code_scanning" => reply(&security.pull_code_scanning(args(body)?).await?), | |
| 741 | "fix_alert" => reply(&security.fix_alert(args(body)?).await?), | |
| 742 | "dependency_graph" => reply(&security.dependency_graph(args(body)?).await?), | |
| 743 | "sbom" => reply(&security.sbom(args(body)?).await?), | |
| 744 | "dependency_review" => reply(&security.dependency_review(args(body)?).await?), | |
| 745 | "security_settings" => reply(&security.security_settings(args(body)?).await?), | |
| 746 | "set_security_settings" => reply(&security.set_security_settings(args(body)?).await?), | |
| 747 | "workspace_security_settings" => reply(&security.workspace_security_settings(args(body)?).await?), | |
| 748 | "set_workspace_security_settings" => reply(&security.set_workspace_security_settings(args(body)?).await?), | |
| 749 | "security_overview" => reply(&security.security_overview(args(body)?).await?), | |
| 750 | "workspace_alerts" => reply(&security.workspace_alerts(args(body)?).await?), | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 751 | // Reporting: advisories and private vulnerability reports (advisories.rs). |
| 752 | "list_advisories" => reply(&security.list_advisories(args(body)?).await?), | |
| 753 | "get_advisory" => reply(&security.get_advisory(args(body)?).await?), | |
| 754 | "create_advisory" => reply(&security.create_advisory(args(body)?).await?), | |
| 755 | "update_advisory" => reply(&security.update_advisory(args(body)?).await?), | |
| 756 | "set_advisory_state" => reply(&security.set_advisory_state(args(body)?).await?), | |
| 757 | "report_vulnerability" => reply(&security.report_vulnerability(args(body)?).await?), | |
| 758 | "report_settings" => reply(&security.report_settings(args(body)?).await?), | |
| 759 | "list_vulnerability_reports" => reply(&security.list_vulnerability_reports(args(body)?).await?), | |
| 760 | "review_vulnerability_report" => reply(&security.review_vulnerability_report(args(body)?).await?), | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 761 | _ => Response::error("Unknown method", 404), |
| 762 | } | |
| 763 | } | |
| 764 | ||
| 765 | #[event(queue)] | |
| 766 | async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> { | |
| 767 | let security = Security::new(&env)?; | |
| 768 | for message in batch.messages()? { | |
| 769 | let event = message.body(); | |
| 770 | if let Err(error) = security.on_event(event).await { | |
| 771 | // Scans are idempotent and the sweep catches up, so one failed | |
| 772 | // event is logged rather than retried. | |
| 773 | worker::console_error!("security: {} {} failed: {error}", event.kind, event.id); | |
| 774 | } | |
| 775 | } | |
| 776 | Ok(()) | |
| 777 | } | |
| 778 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 779 | /// The cron (wrangler.jsonc) that runs version updates that are due. |
| 780 | const VERSION_UPDATES_CRON: &str = "*/5 * * * *"; | |
| 781 | ||
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 782 | #[event(scheduled)] |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 783 | async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 784 | match Security::new(&env) { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 785 | // Version updates run every few minutes, so a schedule's time is kept. |
| 786 | Ok(security) if event.cron() == VERSION_UPDATES_CRON => { | |
| 787 | if let Err(error) = security.version_update_sweep().await { | |
| 788 | worker::console_error!("security: the version update sweep failed: {error}"); | |
| 789 | } | |
| 790 | } | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 791 | Ok(security) => { |
| 792 | if let Err(error) = security.sweep().await { | |
| 793 | worker::console_error!("security: the sweep failed: {error}"); | |
| 794 | } | |
| 795 | } | |
| 796 | Err(error) => worker::console_error!("security: could not start: {error}"), | |
| 797 | } | |
| 798 | } | |
| 799 | ||
| 800 | #[cfg(test)] | |
| 801 | mod tests { | |
| 802 | use super::*; | |
| 803 | ||
| 804 | fn secret(fingerprint: &str, test_value: Option<&str>) -> NewSecret { | |
| 805 | NewSecret { | |
| 806 | fingerprint: fingerprint.into(), | |
| 807 | kind: "aws_access_key".into(), | |
| 808 | path: "a.env".into(), | |
| 809 | line: 1, | |
| 810 | commit: "c".into(), | |
| 811 | preview: "AKIA…".into(), | |
| 812 | test_value: test_value.map(str::to_owned), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 813 | pattern_id: None, |
| 814 | pattern_name: None, | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 815 | } |
| 816 | } | |
| 817 | ||
| 818 | #[test] | |
| 819 | fn dismissed_secrets_and_test_values_go_through() { | |
| 820 | let known = vec![ | |
| 821 | ("allowed".to_owned(), "sec_1".to_owned(), "allowed".to_owned()), | |
| 822 | ("resolved".to_owned(), "sec_2".to_owned(), "resolved".to_owned()), | |
| 823 | ("blocked".to_owned(), "sec_3".to_owned(), "blocked".to_owned()), | |
| 824 | ]; | |
| 825 | let secrets = [secret("allowed", None), secret("resolved", None), secret("example", Some("it says it is an example")), secret("real", None)]; | |
| 826 | assert_eq!(let_through(&known, &secrets), ["allowed", "example"]); | |
| 827 | } | |
| 828 | ||
| 829 | #[test] | |
| 830 | fn a_push_says_when_it_landed_unscanned() { | |
| 831 | let pushed: Pushed = serde_json::from_value(serde_json::json!({ | |
| 832 | "repoId": "rep_1", "ref": "refs/heads/import", "after": "abc", "defaultBranch": false, "unscanned": true | |
| 833 | })) | |
| 834 | .unwrap(); | |
| 835 | assert!(pushed.unscanned && pushed.before.is_none() && pushed.git_ref == "refs/heads/import"); | |
| 836 | let ordinary: Pushed = serde_json::from_value(serde_json::json!({ "repoId": "rep_1", "ref": "refs/heads/main", "after": "abc", "defaultBranch": true })).unwrap(); | |
| 837 | assert!(!ordinary.unscanned); | |
| 838 | } | |
| 839 | ||
| 840 | #[test] | |
| 841 | fn owners_are_told_what_landed_and_what_to_do() { | |
| 842 | let one = history::landed_secrets_intro("acme", "rocket", "import", 1); | |
| 843 | assert!(one.contains("A push to import in acme/rocket") && one.contains("a secret that looks real") && one.contains("Rotate")); | |
| 844 | assert!(history::landed_secrets_intro("acme", "rocket", "main", 3).contains("3 secrets that look real")); | |
| 845 | } | |
| 846 | ||
| 847 | #[test] | |
| 848 | fn dismissing_a_secret_takes_admin_and_a_dependency_write() { | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 849 | assert_eq!(Security::capability_for("sec_1"), Capability::SecurityAlerts); |
| 850 | assert_eq!(Security::capability_for("vul_1"), Capability::SecurityAlerts); | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 851 | } |
| 852 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.