g1t/services/security/src/security_updates.rs

463 lines22,050 bytesCodeBlame

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.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1//! Security updates: for each vulnerable package with a fix, g1t itself
2//! opens a pull request that raises its version.
3//!
4//! 1. A dependency scan asks the runner for a `bump` (most severe first):
5//! a sandbox raises the package in each lockfile with the ecosystem's
6//! own tool and pushes that to `g1t/security/<package>-<version>`.
7//! 2. That push (`git.push`) opens the pull request, authored by g1t
8//! (`User::system`). It lands through the branch's required checks like
9//! any other. An older one for the same package is closed as superseded.
10//! 3. When its checks fail because code has to change, or the sandbox
11//! never pushed, the pull request is closed and an issue is opened for
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent12//! g1t to work on, started by g1t. That is the only time an agent is
13//! used.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily14//! 4. A package no longer vulnerable closes its update as superseded.
15//!
16//! Each step is written to the alerts' activity log.
17
18use std::collections::BTreeMap;
19
20use g1t_contracts::repos::RepoPath;
21use g1t_contracts::security::{BumpArgs, UpdateState, update_branch};
22use g1t_contracts::time::rfc3339;
23use g1t_contracts::work::{OpenIssueArgs, OpenPullArgs, Pull, PullActionArgs, PullDetail, PullStatus, Runtime, ViewArgs};
24use g1t_contracts::{Outcome, User};
25use g1t_kit::now_ms;
26use g1t_scan::osv::{self, Severity};
27use g1t_scan::version;
28use serde_json::{Value, json};
29use worker::Result;
30
31use crate::Security;
32use crate::deps::{advisory_table, issue_text};
33use crate::store::{Activity, RepoRow, UpdateRow, VulnRow};
34
35/// Security updates asked for per scan, most severe first.
36const MAX_NEW_UPDATES: usize = 8;
37/// A sandbox that has not pushed its branch after this long is taken to
38/// have failed.
39const STALLED_MS: u64 = 45 * 60 * 1000;
40/// A failed update is tried again after this long.
41const RETRY_FAILED_MS: u64 = 24 * 60 * 60 * 1000;
42
43/// What to do about a package's existing update, given the version it
44/// should now reach.
45#[derive(Debug, PartialEq, Eq)]
46pub enum Next {
47 /// Leave it: in flight, done, or someone closed it.
48 Wait,
49 /// Ask for a new one.
50 Request,
51}
52
53/// Whether a package with an update in `state` to `current`, last changed
54/// at `updated_at`, needs a new one to reach `target`. A higher target
55/// always does; the same one only when the last was superseded (it is
56/// vulnerable again) or failed long enough ago.
57pub fn next_step(state: UpdateState, current: &str, target: &str, updated_at: &str, retry_after: &str) -> Next {
58 if version::compare(target, current) == std::cmp::Ordering::Greater {
59 return Next::Request;
60 }
61 match state {
62 UpdateState::Superseded => Next::Request,
63 UpdateState::Failed if updated_at < retry_after => Next::Request,
64 _ => Next::Wait,
65 }
66}
67
68/// The pull request's title.
69pub fn pull_title(package: &str, target: &str) -> String {
70 format!("Upgrade {package} to {target}").chars().take(200).collect()
71}
72
73/// The pull request's body: what it fixes, where, and what happens if
74/// raising the version is not enough.
75pub fn pull_text(ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> String {
76 let mut from: Vec<&str> = vulns.iter().map(|vuln| vuln.version.as_str()).collect();
77 from.sort();
78 from.dedup();
79 let mut lockfiles: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect();
80 lockfiles.sort();
81 lockfiles.dedup();
82 let mut body = format!(
83 "Upgrades `{package}` ({ecosystem}) from {} to **{target}**, which fixes these known vulnerabilities:\n\n",
84 from.join(", ")
85 );
86 body.push_str(&advisory_table(vulns));
87 body.push_str(&format!(
88 "\nLockfiles changed: {}.\n\n\
89 Only the version changes. This pull request lands through this branch's required checks like any other. \
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent90 If they fail because code has to change, g1t closes it and puts g1t on an issue to make the change.\n\n\
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily91 ---\n_Opened by g1t's security updates. Turn them off for this project on its Security page._",
92 lockfiles.iter().map(|path| format!("`{path}`")).collect::<Vec<_>>().join(", ")
93 ));
94 body
95}
96
97impl Security {
98 fn path_of(repo: &RepoRow) -> RepoPath {
99 RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }
100 }
101
102 async fn get_pull(&self, repo: &RepoRow, number: u32) -> Result<Option<Pull>> {
103 let found: Outcome<PullDetail> = g1t_kit::call(
104 &self.work,
105 "get_pull",
106 &ViewArgs { repo: Self::path_of(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 },
107 )
108 .await?;
109 Ok(found.into_result().ok().map(|detail| detail.pull))
110 }
111
112 /// Closes one of g1t's pull requests, saying why first.
113 async fn close_with(&self, repo: &RepoRow, number: u32, why: String) -> Result<()> {
114 let system = User::system(&repo.namespace);
115 self.comment(&system, &Self::path_of(repo), number, why).await?;
116 let _: Outcome<Value> = g1t_kit::call(
117 &self.work,
118 "close_pull",
119 &PullActionArgs {
120 actor: system,
121 repo: Self::path_of(repo),
122 number,
123 summary: String::new(),
124 keep_issue_open: false,
125 ignore_checks: false,
126 },
127 )
128 .await?;
129 Ok(())
130 }
131
132 /// Records `action` on every open alert of an update's package.
133 async fn note(&self, row: &UpdateRow, action: &str, actor: Option<&str>, number: Option<u32>, comment: Option<&str>) -> Result<()> {
134 let ids = self.store.open_ids(&row.repo_id, &row.ecosystem, &row.package).await?;
135 let activity: Vec<Activity> = ids
136 .iter()
137 .map(|id| Activity { alert_id: id, action, actor, reason: None, comment, number })
138 .collect();
139 self.store.record(&row.repo_id, &activity).await
140 }
141
142 /// After a dependency scan: asks for an update for each vulnerable
143 /// package with a fix (when `enabled`), and supersedes those whose
144 /// package is no longer vulnerable.
145 pub async fn security_updates(&self, repo: &RepoRow, enabled: bool) -> Result<()> {
146 let open = self.store.open_vulnerabilities(&repo.repo_id).await?;
147 let mut by_package: BTreeMap<(String, String), Vec<&VulnRow>> = BTreeMap::new();
148 for vuln in &open {
149 by_package.entry((vuln.ecosystem.clone(), vuln.package.clone())).or_default().push(vuln);
150 }
151 // In flight for a package that is no longer vulnerable: no longer needed.
152 for row in self.store.updates(&repo.repo_id).await? {
153 if !row.state().in_progress() || by_package.contains_key(&(row.ecosystem.clone(), row.package.clone())) {
154 continue;
155 }
156 self.supersede(repo, &row, format!("`{}` is no longer vulnerable here, so this is no longer needed.", row.package))
157 .await?;
158 }
159 if !enabled {
160 return Ok(());
161 }
162 let mut groups: Vec<((String, String), Vec<&VulnRow>)> = by_package
163 .into_iter()
164 .filter(|(_, vulns)| vulns.iter().any(|vuln| vuln.fixed_version.is_some()))
165 .collect();
166 groups.sort_by_key(|(_, vulns)| std::cmp::Reverse(vulns.iter().map(|v| Severity::parse(&v.severity)).max()));
167 let retry_after = rfc3339(now_ms().saturating_sub(RETRY_FAILED_MS));
168 let mut asked = 0;
169 for ((ecosystem, package), vulns) in groups {
170 if asked >= MAX_NEW_UPDATES {
171 break;
172 }
173 let Some(target) = osv::upgrade_target(vulns.iter().filter_map(|v| v.fixed_version.as_deref())) else {
174 continue;
175 };
176 match self.store.update(&repo.repo_id, &ecosystem, &package).await? {
177 Some(row) => {
178 if next_step(row.state(), &row.target, &target, &row.updated_at, &retry_after) == Next::Wait {
179 continue;
180 }
181 }
182 None => {
183 // An upgrade issue from before security updates, still
184 // open, is left to the agent on it.
185 if let Some(existing) = self.store.upgrade(&repo.repo_id, &ecosystem, &package).await?
186 && self
187 .issue(&User::system(&repo.namespace), &Self::path_of(repo), existing.number as u32)
188 .await?
189 .is_some_and(|issue| issue.state == g1t_contracts::work::State::Open)
190 {
191 continue;
192 }
193 }
194 }
195 asked += 1;
196 self.request(repo, &ecosystem, &package, &target, &vulns).await?;
197 }
198 Ok(())
199 }
200
201 /// Asks the runner to make the change on its branch.
202 async fn request(&self, repo: &RepoRow, ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> Result<()> {
203 let branch = update_branch(package, target);
204 let mut lockfiles: Vec<String> = vulns.iter().map(|vuln| vuln.manifest.clone()).collect();
205 lockfiles.sort();
206 lockfiles.dedup();
207 let args = BumpArgs {
208 repo: Self::path_of(repo),
209 ecosystem: ecosystem.to_owned(),
210 package: package.to_owned(),
211 version: target.to_owned(),
212 lockfiles,
213 branch: branch.clone(),
214 message: format!("Upgrade {package} to {target}"),
215 };
216 let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &args).await {
217 Ok(Outcome::Ok(_)) => Ok(()),
218 Ok(Outcome::Fail(refused)) => Err(refused.message),
219 Err(error) => Err(format!("the runner could not be reached: {error}")),
220 };
221 self.store.request_update(&repo.repo_id, ecosystem, package, target, &branch).await?;
222 let Some(row) = self.store.update(&repo.repo_id, ecosystem, package).await? else {
223 return Ok(());
224 };
225 match started {
226 Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await,
227 Err(reason) => {
228 let error = format!("g1t could not start the security update: {reason}");
229 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
230 self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await
231 }
232 }
233 }
234
235 /// A security update's branch was pushed: its pull request opens, and
236 /// an older one for the same package is closed as superseded.
237 pub async fn update_pushed(&self, repo_id: &str, branch: &str) -> Result<()> {
238 let Some(row) = self.store.update_by_branch(repo_id, branch).await? else {
239 return Ok(());
240 };
241 if row.state() != UpdateState::Requested {
242 return Ok(());
243 }
244 let Some(repo) = self.store.repo(repo_id).await? else {
245 return Ok(());
246 };
247 let open = self.store.open_vulnerabilities(repo_id).await?;
248 let vulns: Vec<&VulnRow> = open
249 .iter()
250 .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package)
251 .collect();
252 let system = User::system(&repo.namespace);
253 let opened: Outcome<Pull> = g1t_kit::call(
254 &self.work,
255 "open_pull",
256 &OpenPullArgs {
257 actor: system,
258 repo: Self::path_of(&repo),
259 issue: None,
260 title: pull_title(&row.package, &row.target),
261 body: pull_text(&row.ecosystem, &row.package, &row.target, &vulns),
262 branch: Some(branch.to_owned()),
263 agent: String::new(),
264 runtime: Runtime::External,
265 },
266 )
267 .await?;
268 let pull = match opened {
269 Outcome::Ok(pull) => pull,
270 Outcome::Fail(refused) => {
271 let error = format!("The pull request could not be opened: {}", refused.message);
272 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
273 return self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await;
274 }
275 };
276 if let Some(older) = row.pull().filter(|older| *older != pull.number) {
277 self.close_with(&repo, older, format!("Superseded by #{}, which upgrades `{}` to {}.", pull.number, row.package, row.target))
278 .await?;
279 }
280 self.store.set_update(&row, UpdateState::Open, Some(pull.number), None, None).await?;
281 self.note(&row, "update_opened", Some(g1t_contracts::system::USERNAME), Some(pull.number), None).await
282 }
283
284 /// A pull request merged, closed or checked: if it is a security
285 /// update's, where the update stands now.
286 pub async fn update_pull_event(&self, kind: &str, repo_id: &str, number: u32, status: Option<&str>, actor: Option<&str>) -> Result<()> {
287 let Some(row) = self.store.update_by_pull(repo_id, number).await? else {
288 return Ok(());
289 };
290 if row.state() != UpdateState::Open {
291 return Ok(());
292 }
293 match kind {
294 "pull.merged" => {
295 self.store.set_update(&row, UpdateState::Merged, Some(number), None, None).await?;
296 self.note(&row, "update_merged", actor, Some(number), None).await
297 }
298 "pull.closed" => {
299 self.store.set_update(&row, UpdateState::Closed, Some(number), None, None).await?;
300 self.note(&row, "update_closed", actor, Some(number), None).await
301 }
302 "checks.completed" if status == Some("failed") => {
303 let Some(repo) = self.store.repo(repo_id).await? else {
304 return Ok(());
305 };
306 self.needs_code(&repo, &row, "Raising the version alone fails this branch's required checks").await
307 }
308 _ => Ok(()),
309 }
310 }
311
312 /// Closes an update that is no longer needed: its pull request, if it
313 /// has one still open (one that merged meanwhile is recorded merged).
314 async fn supersede(&self, repo: &RepoRow, row: &UpdateRow, why: String) -> Result<()> {
315 if let Some(number) = row.pull() {
316 match self.get_pull(repo, number).await? {
317 Some(pull) if pull.status == PullStatus::Merged => {
318 return self.store.set_update(row, UpdateState::Merged, Some(number), row.issue(), None).await;
319 }
320 Some(pull) if matches!(pull.status, PullStatus::Open | PullStatus::Draft) => {
321 self.store.set_update(row, UpdateState::Superseded, Some(number), row.issue(), None).await?;
322 self.close_with(repo, number, why).await?;
323 return self.note(row, "update_superseded", Some(g1t_contracts::system::USERNAME), Some(number), None).await;
324 }
325 _ => {}
326 }
327 }
328 self.store.set_update(row, UpdateState::Superseded, row.pull(), row.issue(), None).await
329 }
330
331 /// Updates whose sandbox never pushed: raising the version did not
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent332 /// work, so g1t gets an issue for it.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily333 pub async fn stalled_updates(&self) -> Result<()> {
334 let before = rfc3339(now_ms().saturating_sub(STALLED_MS));
335 for row in self.store.stalled_updates(&before, 10).await? {
336 let Some(repo) = self.store.repo(&row.repo_id).await? else { continue };
337 if repo.upkeep == 0 || !self.active(&repo.repo_id).await? {
338 self.store
339 .set_update(&row, UpdateState::Failed, row.pull(), None, Some("The version could not be raised in a sandbox."))
340 .await?;
341 continue;
342 }
343 self.needs_code(&repo, &row, "The version could not be raised in a sandbox on its own").await?;
344 }
345 Ok(())
346 }
347
348 /// Raising the version is not enough: closes g1t's pull request, opens
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent349 /// an issue for the change, and puts g1t to work on it.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily350 async fn needs_code(&self, repo: &RepoRow, row: &UpdateRow, why: &str) -> Result<()> {
351 let path = Self::path_of(repo);
352 let system = User::system(&repo.namespace);
353 let open = self.store.open_vulnerabilities(&repo.repo_id).await?;
354 let vulns: Vec<&VulnRow> = open
355 .iter()
356 .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package)
357 .collect();
358 if vulns.is_empty() {
359 return self.supersede(repo, row, format!("`{}` is no longer vulnerable here.", row.package)).await;
360 }
361 let issue: Outcome<g1t_contracts::work::Issue> = g1t_kit::call(
362 &self.work,
363 "open_issue",
364 &OpenIssueArgs {
365 actor: system.clone(),
366 repo: path.clone(),
367 title: format!("Upgrade {} to {}: needs code changes", row.package, row.target).chars().take(200).collect(),
368 body: issue_text(&row.ecosystem, &row.package, &row.target, &vulns, &[]),
369 labels: vec!["dependencies".to_owned(), "security".to_owned()],
370 checks: Vec::new(),
371 },
372 )
373 .await?;
374 let issue = match issue {
375 Outcome::Ok(issue) => issue,
376 Outcome::Fail(refused) => {
377 let error = format!("{why}, and the issue for it could not be opened: {}", refused.message);
378 self.store.set_update(row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
379 return self.note(row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await;
380 }
381 };
382 self.store
383 .set_update(row, UpdateState::NeedsCode, row.pull(), Some(issue.number), Some(why))
384 .await?;
385 if let Some(number) = row.pull() {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent386 self.close_with(repo, number, format!("{why}, so code has to change too. g1t is making the change in #{}.", issue.number))
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily387 .await?;
388 }
389 let started: Outcome<Value> =
390 g1t_kit::call(&self.runner, "run", &json!({ "actor": system, "repo": path, "issue": issue.number })).await?;
391 if let Outcome::Fail(refused) = started {
392 self.comment(&system, &path, issue.number, format!(
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent393 "g1t could not put an agent on this upgrade: {}\n\nAssign it to g1t once agents can run here, or upgrade it by hand.",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily394 refused.message
395 ))
396 .await?;
397 }
398 self.note(row, "update_needs_code", Some(g1t_contracts::system::USERNAME), Some(issue.number), Some(why)).await
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 fn row(version: &str, fixed: &str) -> VulnRow {
407 VulnRow {
408 id: "vul_1".into(),
409 repo_id: "rep_1".into(),
410 ecosystem: "npm".into(),
411 package: "lodash".into(),
412 version: version.into(),
413 manifest: "web/package-lock.json".into(),
414 osv_id: "GHSA-35jh-r3h4-6jhm".into(),
415 advisory: "GHSA-35jh-r3h4-6jhm".into(),
416 summary: "Command Injection in lodash".into(),
417 severity: "high".into(),
418 fixed_version: Some(fixed.into()),
419 status: "open".into(),
420 found_at: "2026-10-04T00:00:00Z".into(),
421 fixed_at: None,
422 number: None,
423 dismiss_reason: None,
424 dismiss_comment: None,
425 dismissed_by: None,
426 dismissed_at: None,
427 }
428 }
429
430 #[test]
431 fn a_newer_fix_asks_again_and_the_same_one_waits() {
432 let retry = "2026-10-05T00:00:00Z";
433 let at = "2026-10-06T00:00:00Z";
434 assert_eq!(next_step(UpdateState::Open, "4.17.20", "4.17.21", at, retry), Next::Request);
435 assert_eq!(next_step(UpdateState::Open, "4.17.21", "4.17.21", at, retry), Next::Wait);
436 assert_eq!(next_step(UpdateState::Requested, "4.17.21", "4.17.21", at, retry), Next::Wait);
437 assert_eq!(next_step(UpdateState::Merged, "4.17.21", "4.17.21", at, retry), Next::Wait);
438 // A person closed it: left alone until a newer fix.
439 assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.21", at, retry), Next::Wait);
440 assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.22", at, retry), Next::Request);
441 // Vulnerable again after it was no longer needed.
442 assert_eq!(next_step(UpdateState::Superseded, "4.17.21", "4.17.21", at, retry), Next::Request);
443 // Failed: tried again a day later.
444 assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", at, retry), Next::Wait);
445 assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", "2026-10-04T00:00:00Z", retry), Next::Request);
446 assert_eq!(next_step(UpdateState::NeedsCode, "4.17.21", "4.17.21", at, retry), Next::Wait);
447 }
448
449 #[test]
450 fn the_pull_request_says_what_it_fixes_and_where() {
451 let a = row("4.17.20", "4.17.21");
452 let mut b = row("4.17.19", "4.17.21");
453 b.manifest = "package-lock.json".into();
454 let body = pull_text("npm", "lodash", "4.17.21", &[&a, &b]);
455 assert!(body.starts_with("Upgrades `lodash` (npm) from 4.17.19, 4.17.20 to **4.17.21**"));
456 assert!(body.contains("[GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)"));
457 assert!(body.contains("Lockfiles changed: `package-lock.json`, `web/package-lock.json`."));
458 assert!(body.contains("required checks"));
459 assert_eq!(pull_title("lodash", "4.17.21"), "Upgrade lodash to 4.17.21");
460 assert_eq!(update_branch("@babel/core", "7.24.1"), "g1t/security/babel-core-7.24.1");
461 assert_eq!(update_branch("golang.org/x/net", "v0.23.0"), "g1t/security/golang.org-x-net-v0.23.0");
462 }
463}