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