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.
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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. | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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. | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 12 | //! - Dependencies. On every push to a default branch, and daily, the |
| 13 | //! lockfiles are read and every package checked against OSV. Each | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 17 | //! required checks. Only when code has to change is g1t put on an |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 18 | //! issue for it. |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | //! | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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; | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 35 | mod deps; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 36 | mod fixes; |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 48 | mod security_updates; |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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; | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 59 | |
| 60 | use g1t_contracts::access::{self, Capability}; | |
| 61 | use g1t_contracts::events::{Event, WorkspaceRenamed}; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 62 | use g1t_contracts::security::UPDATE_BRANCH_PREFIX; |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 63 | use g1t_contracts::repos::{GetArgs, PathByIdArgs, Repo, RepoPath, RepoStatus, StatusByIdArgs}; |
| 64 | use g1t_contracts::security::*; | |
| 65 | use g1t_contracts::time::rfc3339; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 66 | use g1t_contracts::{FailureCode, Outcome, User}; |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 71 | use store::{Activity, RepoRow, Store}; |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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; | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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>, | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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, | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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. |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 122 | #[derive(Deserialize)] |
| 123 | #[serde(rename_all = "camelCase")] | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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")] | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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>, | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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(), | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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)) | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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> { | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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, |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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() |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | ||
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 253 | secret_counts: self.store.secret_counts(&repo.repo_id).await?, |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 254 | secrets: self.store.secrets(&repo.repo_id).await?, |
| 255 | vulnerabilities: self.store.vulnerabilities(&repo.repo_id).await?, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 256 | activity: self.store.activity(&repo.repo_id, ACTIVITY_SHOWN).await?, |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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?, |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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? { | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 })); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 322 | } |
| 323 | let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else { | |
| 324 | return Ok(fail(FailureCode::NotFound, "No such alert.")); | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 325 | }; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 326 | if a.reason.for_secrets() { |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 327 | return Ok(fail( |
| 328 | FailureCode::Invalid, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 329 | "A dependency is dismissed as fix_started, no_bandwidth, tolerable_risk, inaccurate or not_used.", |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 330 | )); |
| 331 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 })) |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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)), | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 366 | }; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 })); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 394 | } |
| 395 | let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else { | |
| 396 | return Ok(fail(FailureCode::NotFound, "No such alert.")); | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 397 | }; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 })) | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 { | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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(); | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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(); | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 498 | let fresh: Vec<NewSecret> = a |
| 499 | .secrets | |
| 500 | .into_iter() | |
| 501 | .filter(|secret| !known.iter().any(|(fingerprint, _, _)| *fingerprint == secret.fingerprint)) | |
| 502 | .collect(); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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()); | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 505 | self.store |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 506 | .add_secrets(&a.repo_id, &real, SecretStatus::Blocked, "push", a.pusher.as_deref()) |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 507 | .await?; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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?; | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | }; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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?; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 557 | return Ok(()); |
| 558 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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?; |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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. | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 650 | async fn sweep(&self) -> Result<()> { |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 | ||
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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?), | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 717 | "dismiss" => reply(&security.dismiss(args(body)?).await?), |
| 718 | "reopen" => reply(&security.reopen(args(body)?).await?), | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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?), | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | ||
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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) { |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 851 | } |
| 852 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.