Skip to content

g1t/services/security/src/security_updates.rs

800 lines41,019 bytesCodeBlame
1//! 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
12//! g1t to work on, started by g1t. That is the only time an agent is
13//! used.
14//! 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 g1t_contracts::security::UPDATE_BRANCH_PREFIX;
32use g1t_contracts::updates::{BumpPackage, IgnoreCondition, UpdatedDependency, VersionUpdateEntry, VersionUpdatesState};
33use g1t_contracts::work::UpdatePullArgs;
34
35use crate::Security;
36use crate::config::{CommitMessage, Config, Entry, glob, matches};
37use crate::deps::{advisory_table, issue_text};
38use crate::pull_text;
39use crate::ranges;
40use crate::store::{Activity, RepoRow, UpdateRow, VulnRow};
41use crate::update_store::NewPull;
42use crate::version_updates::{Loaded, package_ecosystem};
43
44/// Security updates asked for per scan, most severe first.
45const MAX_NEW_UPDATES: usize = 8;
46/// A sandbox that has not pushed its branch after this long is taken to
47/// have failed.
48const STALLED_MS: u64 = 45 * 60 * 1000;
49/// A failed update is tried again after this long.
50const RETRY_FAILED_MS: u64 = 24 * 60 * 60 * 1000;
51
52/// What to do about a package's existing update, given the version it
53/// should now reach.
54#[derive(Debug, PartialEq, Eq)]
55pub enum Next {
56 /// Leave it: in flight, done, or someone closed it.
57 Wait,
58 /// Ask for a new one.
59 Request,
60}
61
62/// Whether a package with an update in `state` to `current`, last changed
63/// at `updated_at`, needs a new one to reach `target`. A higher target
64/// always does; the same one only when the last was superseded (it is
65/// vulnerable again) or failed long enough ago.
66pub fn next_step(state: UpdateState, current: &str, target: &str, updated_at: &str, retry_after: &str) -> Next {
67 if version::compare(target, current) == std::cmp::Ordering::Greater {
68 return Next::Request;
69 }
70 match state {
71 UpdateState::Superseded => Next::Request,
72 UpdateState::Failed if updated_at < retry_after => Next::Request,
73 _ => Next::Wait,
74 }
75}
76
77/// The pull request's title.
78pub fn pull_title(package: &str, target: &str) -> String {
79 format!("Upgrade {package} to {target}").chars().take(200).collect()
80}
81
82/// The pull request's body: what it fixes, where, and what happens if
83/// raising the version is not enough.
84pub fn pull_text(ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> String {
85 let mut from: Vec<&str> = vulns.iter().map(|vuln| vuln.version.as_str()).collect();
86 from.sort();
87 from.dedup();
88 let mut lockfiles: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect();
89 lockfiles.sort();
90 lockfiles.dedup();
91 let mut body = format!(
92 "Upgrades `{package}` ({ecosystem}) from {} to **{target}**, which fixes these known vulnerabilities:\n\n",
93 from.join(", ")
94 );
95 body.push_str(&advisory_table(vulns));
96 body.push_str(&format!(
97 "\nLockfiles changed: {}.\n\n\
98 Only the version changes. This pull request lands through this branch's required checks like any other. \
99 If they fail because code has to change, g1t closes it and puts g1t on an issue to make the change.\n\n\
100 ---\n_Opened by g1t's security updates. Turn them off for this project on its Security page._",
101 lockfiles.iter().map(|path| format!("`{path}`")).collect::<Vec<_>>().join(", ")
102 ));
103 body
104}
105
106/// A package a `security-updates` group gathers.
107pub struct GroupMember {
108 /// OSV's name.
109 pub ecosystem: String,
110 pub package: String,
111 /// The lowest vulnerable version found.
112 pub from: String,
113 pub target: String,
114 /// The lockfiles that resolve a vulnerable version.
115 pub manifests: Vec<String>,
116}
117
118fn lowest(vulns: &[&VulnRow]) -> String {
119 vulns.iter().map(|vuln| vuln.version.clone()).min_by(|a, b| version::compare(a, b)).unwrap_or_default()
120}
121
122/// Whether one of the file's directories (`/web`, or a glob) holds the
123/// lockfile at `path`, from the root.
124fn covers(directories: &[String], path: &str) -> bool {
125 let directory = format!("/{}", path.rsplit_once('/').map_or("", |(dir, _)| dir));
126 directories.iter().any(|wanted| if wanted.contains(['*', '?']) { glob(wanted, &directory) } else { *wanted == directory })
127}
128
129/// The `updates` entry that speaks for a vulnerable package: its
130/// ecosystem, a directory holding one of its lockfiles, and no
131/// `target-branch` but the default branch (security updates always go to
132/// the default branch, and such an entry's options are for version
133/// updates only).
134pub fn security_entry<'a>(config: &'a Config, default_branch: &str, osv: &str, manifests: &[&str]) -> Option<&'a Entry> {
135 let ecosystem = package_ecosystem(osv)?;
136 config.updates.iter().find(|entry| {
137 entry.ecosystem == ecosystem
138 && entry.target_branch.as_deref().is_none_or(|branch| branch == default_branch)
139 && manifests.iter().any(|path| covers(&entry.directories, path))
140 })
141}
142
143/// Whether the file lets a security update raise `package` to `target`:
144/// not ignored (by name, or for these versions) in the file or by a
145/// comment, and named by `allow` when it names dependencies.
146pub fn security_allowed(entry: &Entry, comments: &[IgnoreCondition], package: &str, target: &str) -> bool {
147 let ignored = entry.ignore.iter().filter(|rule| matches(&rule.dependency, package)).any(|rule| {
148 (rule.versions.is_empty() && rule.update_types.is_empty()) || rule.versions.iter().any(|versions| ranges::ignored_by(versions, target))
149 });
150 let commented = comments.iter().filter(|c| c.ecosystem == entry.ecosystem && c.dependency.eq_ignore_ascii_case(package)).any(|c| {
151 (c.versions.is_none() && c.update_type.is_none()) || c.versions.as_deref().is_some_and(|versions| ranges::ignored_by(versions, target))
152 });
153 let named: Vec<&str> = entry.allow.iter().filter_map(|rule| rule.dependency.as_deref()).collect();
154 let allowed = named.is_empty() || named.iter().any(|pattern| matches(pattern, package));
155 !ignored && !commented && allowed
156}
157
158/// The `security-updates` group that gathers `package`, if any.
159pub fn security_group(entry: &Entry, package: &str, from: &str, target: &str) -> Option<String> {
160 let level = ranges::update_level(from, target);
161 entry
162 .groups
163 .iter()
164 .filter(|group| group.applies_to == "security-updates")
165 .find(|group| {
166 (group.patterns.is_empty() || group.patterns.iter().any(|pattern| matches(pattern, package)))
167 && !group.exclude_patterns.iter().any(|pattern| matches(pattern, package))
168 && (group.update_types.is_empty() || group.update_types.iter().any(|wanted| wanted == level))
169 })
170 .map(|group| group.name.clone())
171}
172
173/// The entry, as last read, that speaks for a package's security update.
174pub fn security_settings<'a>(state: &'a VersionUpdatesState, osv: &str, manifests: &[&str]) -> Option<&'a VersionUpdateEntry> {
175 let ecosystem = package_ecosystem(osv)?;
176 state.updates.iter().find(|entry| {
177 entry.ecosystem == ecosystem && entry.target_branch.is_none() && manifests.iter().any(|path| covers(&entry.directories, path))
178 })
179}
180
181/// An entry's `commit-message`, as last read.
182pub fn commit_message_of(entry: &VersionUpdateEntry) -> Option<CommitMessage> {
183 let message = entry.options.get("commit-message")?;
184 let text = |key: &str| message.get(key).and_then(Value::as_str).map(str::to_owned);
185 Some(CommitMessage { prefix: text("prefix"), prefix_development: text("prefix-development"), scope: text("include").as_deref() == Some("scope") })
186}
187
188/// A title with the file's commit message prefix, if it has one.
189fn prefixed(prefix: &str, plain: String) -> String {
190 let text = if prefix.is_empty() { plain } else { format!("{prefix}{}{}", plain[..1].to_lowercase(), &plain[1..]) };
191 text.chars().take(200).collect()
192}
193
194/// A grouped security update's body.
195pub fn group_text(group: &str, members: &[GroupMember], vulns: &[&VulnRow], file: &str) -> String {
196 let mut body = format!("Upgrades the {group} group's vulnerable packages to the versions that fix them:\n\n| Package | From | To |\n| --- | --- | --- |\n");
197 for member in members {
198 body.push_str(&format!("| `{}` | {} | **{}** |\n", member.package, member.from, member.target));
199 }
200 body.push_str("\nThe known vulnerabilities they fix:\n\n");
201 body.push_str(&advisory_table(vulns));
202 body.push_str(&format!(
203 "\nOnly the versions change. This pull request lands through this branch's required checks like any other. \
204 If they fail because code has to change, g1t closes it and puts g1t on an issue to make the change.\n\n{}\n\n---\n\
205 _Opened by g1t's security updates, grouped as `{file}` asks. Turn them off for this project on its Security page._",
206 pull_text::COMMANDS
207 ));
208 body
209}
210
211impl Security {
212 fn path_of(repo: &RepoRow) -> RepoPath {
213 RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }
214 }
215
216 async fn get_pull(&self, repo: &RepoRow, number: u32) -> Result<Option<Pull>> {
217 let found: Outcome<PullDetail> = g1t_kit::call(
218 &self.work,
219 "get_pull",
220 &ViewArgs { repo: Self::path_of(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 },
221 )
222 .await?;
223 Ok(found.into_result().ok().map(|detail| detail.pull))
224 }
225
226 /// Closes one of g1t's pull requests, saying why first.
227 pub(crate) async fn close_with(&self, repo: &RepoRow, number: u32, why: String) -> Result<()> {
228 let system = User::system(&repo.namespace);
229 self.comment(&system, &Self::path_of(repo), number, why).await?;
230 let _: Outcome<Value> = g1t_kit::call(
231 &self.work,
232 "close_pull",
233 &PullActionArgs {
234 actor: system,
235 repo: Self::path_of(repo),
236 number,
237 summary: String::new(),
238 keep_issue_open: false,
239 ignore_checks: false,
240 bypass_rules: false,
241 },
242 )
243 .await?;
244 Ok(())
245 }
246
247 /// Records `action` on every open alert of an update's package.
248 async fn note(&self, row: &UpdateRow, action: &str, actor: Option<&str>, number: Option<u32>, comment: Option<&str>) -> Result<()> {
249 let ids = self.store.open_ids(&row.repo_id, &row.ecosystem, &row.package).await?;
250 let activity: Vec<Activity> = ids
251 .iter()
252 .map(|id| Activity { alert_id: id, action, actor, reason: None, comment, number })
253 .collect();
254 self.store.record(&row.repo_id, &activity).await
255 }
256
257 /// After a dependency scan: asks for an update for each vulnerable
258 /// package with a fix (when `enabled`), and supersedes those whose
259 /// package is no longer vulnerable.
260 ///
261 /// The dependency update file, when there is one, applies as it does to
262 /// version updates: `ignore` and `allow` by name, people's `@g1t ignore`
263 /// comments, `groups` with `applies-to: security-updates` (one pull
264 /// request for each group), and `assignees`, `reviewers` and
265 /// `commit-message`. `open-pull-requests-limit` does not apply.
266 pub async fn security_updates(&self, repo: &RepoRow, enabled: bool, rules: Option<&Loaded>) -> Result<()> {
267 let open = self.store.open_vulnerabilities(&repo.repo_id).await?;
268 let mut by_package: BTreeMap<(String, String), Vec<&VulnRow>> = BTreeMap::new();
269 for vuln in &open {
270 by_package.entry((vuln.ecosystem.clone(), vuln.package.clone())).or_default().push(vuln);
271 }
272 // In flight for a package that is no longer vulnerable: no longer needed.
273 for row in self.store.updates(&repo.repo_id).await? {
274 if !row.state().in_progress() || by_package.contains_key(&(row.ecosystem.clone(), row.package.clone())) {
275 continue;
276 }
277 self.supersede(repo, &row, format!("`{}` is no longer vulnerable here, so this is no longer needed.", row.package))
278 .await?;
279 }
280 if !enabled {
281 return Ok(());
282 }
283 let mut groups: Vec<((String, String), Vec<&VulnRow>)> = by_package
284 .into_iter()
285 .filter(|(_, vulns)| vulns.iter().any(|vuln| vuln.fixed_version.is_some()))
286 .collect();
287 groups.sort_by_key(|(_, vulns)| std::cmp::Reverse(vulns.iter().map(|v| Severity::parse(&v.severity)).max()));
288 let retry_after = rfc3339(now_ms().saturating_sub(RETRY_FAILED_MS));
289 let comments = self.store.ignores(&repo.repo_id).await?;
290 let mut grouped: BTreeMap<(String, String), Vec<GroupMember>> = BTreeMap::new();
291 let mut asked = 0;
292 for ((ecosystem, package), vulns) in groups {
293 if asked >= MAX_NEW_UPDATES {
294 break;
295 }
296 let Some(target) = osv::upgrade_target(vulns.iter().filter_map(|v| v.fixed_version.as_deref())) else {
297 continue;
298 };
299 let manifests: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect();
300 if let Some(entry) = rules.and_then(|loaded| security_entry(&loaded.config, &loaded.default_branch, &ecosystem, &manifests)) {
301 if !security_allowed(entry, &comments, &package, &target) {
302 continue;
303 }
304 let from = lowest(&vulns);
305 if let Some(group) = security_group(entry, &package, &from, &target) {
306 let key = (entry.id(), group);
307 if !grouped.contains_key(&key) {
308 asked += 1;
309 }
310 let manifests = manifests.iter().map(|path| (*path).to_owned()).collect();
311 grouped.entry(key).or_default().push(GroupMember { ecosystem: ecosystem.clone(), package: package.clone(), from, target, manifests });
312 continue;
313 }
314 }
315 match self.store.update(&repo.repo_id, &ecosystem, &package).await? {
316 Some(row) => {
317 if next_step(row.state(), &row.target, &target, &row.updated_at, &retry_after) == Next::Wait {
318 continue;
319 }
320 }
321 None => {
322 // An upgrade issue from before security updates, still
323 // open, is left to the agent on it.
324 if let Some(existing) = self.store.upgrade(&repo.repo_id, &ecosystem, &package).await?
325 && self
326 .issue(&User::system(&repo.namespace), &Self::path_of(repo), existing.number as u32)
327 .await?
328 .is_some_and(|issue| issue.state == g1t_contracts::work::State::Open)
329 {
330 continue;
331 }
332 }
333 }
334 asked += 1;
335 self.request(repo, &ecosystem, &package, &target, &vulns).await?;
336 }
337 for ((entry_id, group), members) in grouped {
338 let Some(loaded) = rules else { continue };
339 let Some(entry) = loaded.config.updates.iter().find(|entry| entry.id() == entry_id) else { continue };
340 self.request_group(repo, loaded, entry, &group, members, &open).await?;
341 }
342 Ok(())
343 }
344
345 /// Asks for one pull request for a `security-updates` group's
346 /// packages, unless one for the same versions is under way or was
347 /// closed.
348 async fn request_group(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry, group: &str, members: Vec<GroupMember>, open: &[VulnRow]) -> Result<()> {
349 let subject = format!("security:{group}");
350 let mut signature: Vec<String> = members.iter().map(|m| format!("{}@{}", m.package, m.target)).collect();
351 signature.sort();
352 let signature = signature.join(",");
353 let existing = self.store.update_pulls(&repo.repo_id).await?;
354 let known = |row: &&crate::update_store::PullRow| row.kind == "security" && row.subject == subject && row.signature == signature;
355 if existing.iter().filter(known).any(|row| row.state().in_progress() || row.state() == UpdateState::Closed) {
356 return Ok(());
357 }
358 let branch = format!("{UPDATE_BRANCH_PREFIX}{}-{}", group.to_lowercase().replace('|', "-"), pull_text::digest(&signature));
359 let vulns: Vec<&VulnRow> = open.iter().filter(|vuln| members.iter().any(|m| m.ecosystem == vuln.ecosystem && m.package == vuln.package)).collect();
360 let count = if members.len() == 1 { "1 security update".to_owned() } else { format!("{} security updates", members.len()) };
361 let title = prefixed(&pull_text::prefix(entry.commit_message.as_ref(), false), format!("Bump the {group} group with {count}"));
362 let body = group_text(group, &members, &vulns, &loaded.file);
363 let mut lockfiles: Vec<String> = members.iter().flat_map(|m| m.manifests.clone()).collect();
364 lockfiles.sort();
365 lockfiles.dedup();
366 let packages: Vec<BumpPackage> = members.iter().map(|m| BumpPackage { package: m.package.clone(), version: m.target.clone() }).collect();
367 let bump = BumpArgs {
368 repo: Self::path_of(repo),
369 ecosystem: members[0].ecosystem.clone(),
370 package: packages[0].package.clone(),
371 version: packages[0].version.clone(),
372 lockfiles,
373 branch: branch.clone(),
374 message: title.clone(),
375 kind: None,
376 packages,
377 strategy: None,
378 force: existing.iter().any(|row| row.branch == branch && row.state().in_progress()),
379 registries: Vec::new(),
380 base: None,
381 };
382 let dependencies: Vec<UpdatedDependency> = members
383 .iter()
384 .map(|m| UpdatedDependency {
385 name: m.package.clone(),
386 from: m.from.clone(),
387 to: m.target.clone(),
388 directory: m.manifests.first().map(|path| format!("/{}", path.rsplit_once('/').map_or("", |(dir, _)| dir))).unwrap_or_else(|| "/".to_owned()),
389 dependency_type: String::new(),
390 update_type: ranges::update_type(&m.from, &m.target),
391 })
392 .collect();
393 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
394 let id = self
395 .store
396 .add_update_pull(&NewPull {
397 repo_id: &repo.repo_id,
398 kind: "security",
399 entry: &entry.id(),
400 ecosystem: &entry.ecosystem,
401 subject: &subject,
402 signature: &signature,
403 group: Some(group),
404 branch: &branch,
405 title: &title,
406 body: &body,
407 dependencies: &dependencies,
408 bump: &bump,
409 assignees: &people(&entry.assignees),
410 reviewers: &people(&entry.reviewers),
411 })
412 .await?;
413 for member in &members {
414 self.store.request_update(&repo.repo_id, &member.ecosystem, &member.package, &member.target, &branch).await?;
415 }
416 let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &bump).await {
417 Ok(Outcome::Ok(_)) => Ok(()),
418 Ok(Outcome::Fail(refused)) => Err(refused.message),
419 Err(error) => Err(format!("the runner could not be reached: {error}")),
420 };
421 for member in &members {
422 let Some(row) = self.store.update(&repo.repo_id, &member.ecosystem, &member.package).await? else { continue };
423 match &started {
424 Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await?,
425 Err(reason) => {
426 let error = format!("g1t could not start the security update: {reason}");
427 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
428 self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await?;
429 }
430 }
431 }
432 if let Err(reason) = started {
433 self.store.set_update_pull(&id, UpdateState::Failed, None, None, Some(&format!("g1t could not start the security update: {reason}"))).await?;
434 }
435 Ok(())
436 }
437
438 /// Asks the runner to make the change on its branch.
439 async fn request(&self, repo: &RepoRow, ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> Result<()> {
440 let branch = update_branch(package, target);
441 let mut lockfiles: Vec<String> = vulns.iter().map(|vuln| vuln.manifest.clone()).collect();
442 lockfiles.sort();
443 lockfiles.dedup();
444 let args = BumpArgs {
445 repo: Self::path_of(repo),
446 ecosystem: ecosystem.to_owned(),
447 package: package.to_owned(),
448 version: target.to_owned(),
449 lockfiles,
450 branch: branch.clone(),
451 message: format!("Upgrade {package} to {target}"),
452 kind: None,
453 packages: Vec::new(),
454 strategy: None,
455 force: false,
456 registries: Vec::new(),
457 base: None,
458 };
459 let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &args).await {
460 Ok(Outcome::Ok(_)) => Ok(()),
461 Ok(Outcome::Fail(refused)) => Err(refused.message),
462 Err(error) => Err(format!("the runner could not be reached: {error}")),
463 };
464 self.store.request_update(&repo.repo_id, ecosystem, package, target, &branch).await?;
465 let Some(row) = self.store.update(&repo.repo_id, ecosystem, package).await? else {
466 return Ok(());
467 };
468 match started {
469 Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await,
470 Err(reason) => {
471 let error = format!("g1t could not start the security update: {reason}");
472 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
473 self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await
474 }
475 }
476 }
477
478 /// A security update's branch was pushed: its pull request opens, and
479 /// an older one for the same package is closed as superseded.
480 pub async fn update_pushed(&self, repo_id: &str, branch: &str) -> Result<()> {
481 let Some(row) = self.store.update_by_branch(repo_id, branch).await? else {
482 return Ok(());
483 };
484 if row.state() != UpdateState::Requested {
485 return Ok(());
486 }
487 let Some(repo) = self.store.repo(repo_id).await? else {
488 return Ok(());
489 };
490 let open = self.store.open_vulnerabilities(repo_id).await?;
491 let vulns: Vec<&VulnRow> = open
492 .iter()
493 .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package)
494 .collect();
495 let system = User::system(&repo.namespace);
496 // What the dependency update file says for this package's directory.
497 let state = repo.version_updates();
498 let manifests: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect();
499 let settings = security_settings(&state, &row.ecosystem, &manifests);
500 let prefix = settings.map(|entry| pull_text::prefix(commit_message_of(entry).as_ref(), false)).unwrap_or_default();
501 let opened: Outcome<Pull> = g1t_kit::call(
502 &self.work,
503 "open_pull",
504 &OpenPullArgs {
505 actor: system.clone(),
506 repo: Self::path_of(&repo),
507 issue: None,
508 title: prefixed(&prefix, pull_title(&row.package, &row.target)),
509 body: pull_text(&row.ecosystem, &row.package, &row.target, &vulns),
510 branch: Some(branch.to_owned()),
511 agent: String::new(),
512 runtime: Runtime::External,
513 // Security updates are for the default branch.
514 base: None,
515 },
516 )
517 .await?;
518 let pull = match opened {
519 Outcome::Ok(pull) => pull,
520 Outcome::Fail(refused) => {
521 let error = format!("The pull request could not be opened: {}", refused.message);
522 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
523 return self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await;
524 }
525 };
526 if let Some(older) = row.pull().filter(|older| *older != pull.number) {
527 self.close_with(&repo, older, format!("Superseded by #{}, which upgrades `{}` to {}.", pull.number, row.package, row.target))
528 .await?;
529 }
530 self.store.set_update(&row, UpdateState::Open, Some(pull.number), None, None).await?;
531 // Labelled as the entry for its directory says, or `dependencies`
532 // and its ecosystem's label; assigned and in a milestone as it says.
533 let ecosystem = package_ecosystem(&row.ecosystem).unwrap_or(row.ecosystem.as_str());
534 let labels = crate::config::update_labels(settings.and_then(|entry| entry.labels.as_deref()), ecosystem);
535 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
536 let (assignees, reviewers) = settings.map(|entry| (people(&entry.assignees), people(&entry.reviewers))).unwrap_or_default();
537 if !assignees.is_empty() || !reviewers.is_empty() || !labels.is_empty() {
538 let _: Outcome<Value> = g1t_kit::call(
539 &self.work,
540 "update_pull",
541 &UpdatePullArgs {
542 actor: system.clone(),
543 repo: Self::path_of(&repo),
544 number: pull.number,
545 assignees: (!assignees.is_empty()).then_some(assignees),
546 reviewers: (!reviewers.is_empty()).then_some(reviewers),
547 labels: (!labels.is_empty()).then_some(labels),
548 milestone: None,
549 base: None,
550 },
551 )
552 .await?;
553 }
554 if let Some(milestone) = settings.and_then(|entry| entry.milestone) {
555 let _: Outcome<Value> = g1t_kit::call(
556 &self.work,
557 "update_pull",
558 &UpdatePullArgs {
559 actor: system,
560 repo: Self::path_of(&repo),
561 number: pull.number,
562 assignees: None,
563 reviewers: None,
564 labels: None,
565 milestone: Some(milestone),
566 base: None,
567 },
568 )
569 .await?;
570 }
571 self.note(&row, "update_opened", Some(g1t_contracts::system::USERNAME), Some(pull.number), None).await
572 }
573
574 /// A pull request merged, closed or checked: if it is a security
575 /// update's, where the update stands now.
576 pub async fn update_pull_event(&self, kind: &str, repo_id: &str, number: u32, status: Option<&str>, actor: Option<&str>) -> Result<()> {
577 let Some(row) = self.store.update_by_pull(repo_id, number).await? else {
578 return Ok(());
579 };
580 if row.state() != UpdateState::Open {
581 return Ok(());
582 }
583 match kind {
584 "pull.merged" => {
585 self.store.set_update(&row, UpdateState::Merged, Some(number), None, None).await?;
586 self.note(&row, "update_merged", actor, Some(number), None).await
587 }
588 "pull.closed" => {
589 self.store.set_update(&row, UpdateState::Closed, Some(number), None, None).await?;
590 self.note(&row, "update_closed", actor, Some(number), None).await
591 }
592 "checks.completed" if status == Some("failed") => {
593 let Some(repo) = self.store.repo(repo_id).await? else {
594 return Ok(());
595 };
596 self.needs_code(&repo, &row, "Raising the version alone fails this branch's required checks").await
597 }
598 _ => Ok(()),
599 }
600 }
601
602 /// Closes an update that is no longer needed: its pull request, if it
603 /// has one still open (one that merged meanwhile is recorded merged).
604 async fn supersede(&self, repo: &RepoRow, row: &UpdateRow, why: String) -> Result<()> {
605 if let Some(number) = row.pull() {
606 match self.get_pull(repo, number).await? {
607 Some(pull) if pull.status == PullStatus::Merged => {
608 return self.store.set_update(row, UpdateState::Merged, Some(number), row.issue(), None).await;
609 }
610 Some(pull) if matches!(pull.status, PullStatus::Open | PullStatus::Draft) => {
611 self.store.set_update(row, UpdateState::Superseded, Some(number), row.issue(), None).await?;
612 self.close_with(repo, number, why).await?;
613 return self.note(row, "update_superseded", Some(g1t_contracts::system::USERNAME), Some(number), None).await;
614 }
615 _ => {}
616 }
617 }
618 self.store.set_update(row, UpdateState::Superseded, row.pull(), row.issue(), None).await
619 }
620
621 /// Updates whose sandbox never pushed: raising the version did not
622 /// work, so g1t gets an issue for it.
623 pub async fn stalled_updates(&self) -> Result<()> {
624 let before = rfc3339(now_ms().saturating_sub(STALLED_MS));
625 for row in self.store.stalled_updates(&before, 10).await? {
626 let Some(repo) = self.store.repo(&row.repo_id).await? else { continue };
627 if repo.upkeep == 0 || !self.active(&repo.repo_id).await? {
628 self.store
629 .set_update(&row, UpdateState::Failed, row.pull(), None, Some("The version could not be raised in a sandbox."))
630 .await?;
631 continue;
632 }
633 self.needs_code(&repo, &row, "The version could not be raised in a sandbox on its own").await?;
634 }
635 Ok(())
636 }
637
638 /// Raising the version is not enough: closes g1t's pull request, opens
639 /// an issue for the change, and puts g1t to work on it.
640 async fn needs_code(&self, repo: &RepoRow, row: &UpdateRow, why: &str) -> Result<()> {
641 let path = Self::path_of(repo);
642 let system = User::system(&repo.namespace);
643 let open = self.store.open_vulnerabilities(&repo.repo_id).await?;
644 let vulns: Vec<&VulnRow> = open
645 .iter()
646 .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package)
647 .collect();
648 if vulns.is_empty() {
649 return self.supersede(repo, row, format!("`{}` is no longer vulnerable here.", row.package)).await;
650 }
651 let issue: Outcome<g1t_contracts::work::Issue> = g1t_kit::call(
652 &self.work,
653 "open_issue",
654 &OpenIssueArgs {
655 actor: system.clone(),
656 repo: path.clone(),
657 title: format!("Upgrade {} to {}: needs code changes", row.package, row.target).chars().take(200).collect(),
658 body: issue_text(&row.ecosystem, &row.package, &row.target, &vulns, &[]),
659 labels: vec!["dependencies".to_owned(), "security".to_owned()],
660 checks: Vec::new(),
661 milestone: None,
662 },
663 )
664 .await?;
665 let issue = match issue {
666 Outcome::Ok(issue) => issue,
667 Outcome::Fail(refused) => {
668 let error = format!("{why}, and the issue for it could not be opened: {}", refused.message);
669 self.store.set_update(row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
670 return self.note(row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await;
671 }
672 };
673 self.store
674 .set_update(row, UpdateState::NeedsCode, row.pull(), Some(issue.number), Some(why))
675 .await?;
676 if let Some(number) = row.pull() {
677 self.close_with(repo, number, format!("{why}, so code has to change too. g1t is making the change in #{}.", issue.number))
678 .await?;
679 }
680 let started: Outcome<Value> =
681 g1t_kit::call(&self.runner, "run", &json!({ "actor": system, "repo": path, "issue": issue.number })).await?;
682 if let Outcome::Fail(refused) = started {
683 self.comment(&system, &path, issue.number, format!(
684 "g1t could not put an agent on this upgrade: {}\n\nAssign it to g1t once agents can run here, or upgrade it by hand.",
685 refused.message
686 ))
687 .await?;
688 }
689 self.note(row, "update_needs_code", Some(g1t_contracts::system::USERNAME), Some(issue.number), Some(why)).await
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 fn rules(extra: &str) -> Config {
698 let source = format!(
699 "version: 2\nupdates:\n - package-ecosystem: npm\n directories: [\"/\", \"/apps/*\"]\n schedule: {{interval: weekly}}\n{extra} - package-ecosystem: npm\n directory: /legacy\n target-branch: develop\n schedule: {{interval: weekly}}\n"
700 );
701 let found = crate::config::read(&source);
702 assert!(found.problems.is_empty(), "{:?}", found.problems);
703 found.config
704 }
705
706 #[test]
707 fn security_updates_follow_the_file() {
708 let config = rules(
709 " ignore:\n - dependency-name: left-pad\n - dependency-name: react\n versions: [\">=19\"]\n groups:\n fixes:\n applies-to: security-updates\n patterns: [\"@babel/*\"]\n update-types: [patch, minor]\n minor:\n patterns: [\"*\"]\n",
710 );
711 let entry = security_entry(&config, "main", "npm", &["apps/web/package-lock.json"]).unwrap();
712 assert_eq!(entry.id(), "npm:/,/apps/*");
713 // An entry for another branch never speaks for security updates.
714 assert!(security_entry(&config, "main", "npm", &["legacy/package-lock.json"]).is_none());
715 assert!(security_entry(&config, "main", "crates.io", &["Cargo.lock"]).is_none());
716 assert!(!security_allowed(entry, &[], "left-pad", "1.3.0"));
717 assert!(!security_allowed(entry, &[], "react", "19.0.1"));
718 assert!(security_allowed(entry, &[], "react", "18.3.1"));
719 let comment = IgnoreCondition { ecosystem: "npm".into(), dependency: "Lodash".into(), versions: None, update_type: None, by: "ana".into(), pull: None, at: String::new() };
720 assert!(!security_allowed(entry, &[comment], "lodash", "4.17.21"));
721 assert_eq!(security_group(entry, "@babel/core", "7.0.0", "7.24.1").as_deref(), Some("fixes"));
722 // A major bump is outside the group's update types, and only security groups count.
723 assert_eq!(security_group(entry, "@babel/core", "6.0.0", "7.24.1"), None);
724 assert_eq!(security_group(entry, "lodash", "4.17.20", "4.17.21"), None);
725 let named = rules(" allow:\n - dependency-name: \"lodash\"\n");
726 let entry = security_entry(&named, "main", "npm", &["package-lock.json"]).unwrap();
727 assert!(security_allowed(entry, &[], "lodash", "4.17.21") && !security_allowed(entry, &[], "minimist", "1.2.6"));
728 }
729
730 #[test]
731 fn grouped_security_updates_say_what_they_fix() {
732 let vuln = row("4.17.20", "4.17.21");
733 let members = vec![GroupMember { ecosystem: "npm".into(), package: "lodash".into(), from: "4.17.20".into(), target: "4.17.21".into(), manifests: vec!["package-lock.json".into()] }];
734 let body = group_text("fixes", &members, &[&vuln], ".github/dependabot.yml");
735 assert!(body.starts_with("Upgrades the fixes group's vulnerable packages"));
736 assert!(body.contains("| `lodash` | 4.17.20 | **4.17.21** |"));
737 assert!(body.contains("GHSA-35jh-r3h4-6jhm") && body.contains("`@g1t rebase`"));
738 assert!(body.ends_with("on its Security page._"));
739 assert_eq!(prefixed("build(deps): ", "Upgrade lodash to 4.17.21".into()), "build(deps): upgrade lodash to 4.17.21");
740 assert_eq!(prefixed("", "Upgrade lodash to 4.17.21".into()), "Upgrade lodash to 4.17.21");
741 }
742
743 fn row(version: &str, fixed: &str) -> VulnRow {
744 VulnRow {
745 id: "vul_1".into(),
746 repo_id: "rep_1".into(),
747 ecosystem: "npm".into(),
748 package: "lodash".into(),
749 version: version.into(),
750 manifest: "web/package-lock.json".into(),
751 osv_id: "GHSA-35jh-r3h4-6jhm".into(),
752 advisory: "GHSA-35jh-r3h4-6jhm".into(),
753 summary: "Command Injection in lodash".into(),
754 severity: "high".into(),
755 fixed_version: Some(fixed.into()),
756 status: "open".into(),
757 found_at: "2026-10-04T00:00:00Z".into(),
758 fixed_at: None,
759 number: None,
760 dismiss_reason: None,
761 dismiss_comment: None,
762 dismissed_by: None,
763 dismissed_at: None,
764 }
765 }
766
767 #[test]
768 fn a_newer_fix_asks_again_and_the_same_one_waits() {
769 let retry = "2026-10-05T00:00:00Z";
770 let at = "2026-10-06T00:00:00Z";
771 assert_eq!(next_step(UpdateState::Open, "4.17.20", "4.17.21", at, retry), Next::Request);
772 assert_eq!(next_step(UpdateState::Open, "4.17.21", "4.17.21", at, retry), Next::Wait);
773 assert_eq!(next_step(UpdateState::Requested, "4.17.21", "4.17.21", at, retry), Next::Wait);
774 assert_eq!(next_step(UpdateState::Merged, "4.17.21", "4.17.21", at, retry), Next::Wait);
775 // A person closed it: left alone until a newer fix.
776 assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.21", at, retry), Next::Wait);
777 assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.22", at, retry), Next::Request);
778 // Vulnerable again after it was no longer needed.
779 assert_eq!(next_step(UpdateState::Superseded, "4.17.21", "4.17.21", at, retry), Next::Request);
780 // Failed: tried again a day later.
781 assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", at, retry), Next::Wait);
782 assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", "2026-10-04T00:00:00Z", retry), Next::Request);
783 assert_eq!(next_step(UpdateState::NeedsCode, "4.17.21", "4.17.21", at, retry), Next::Wait);
784 }
785
786 #[test]
787 fn the_pull_request_says_what_it_fixes_and_where() {
788 let a = row("4.17.20", "4.17.21");
789 let mut b = row("4.17.19", "4.17.21");
790 b.manifest = "package-lock.json".into();
791 let body = pull_text("npm", "lodash", "4.17.21", &[&a, &b]);
792 assert!(body.starts_with("Upgrades `lodash` (npm) from 4.17.19, 4.17.20 to **4.17.21**"));
793 assert!(body.contains("[GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)"));
794 assert!(body.contains("Lockfiles changed: `package-lock.json`, `web/package-lock.json`."));
795 assert!(body.contains("required checks"));
796 assert_eq!(pull_title("lodash", "4.17.21"), "Upgrade lodash to 4.17.21");
797 assert_eq!(update_branch("@babel/core", "7.24.1"), "g1t/security/babel-core-7.24.1");
798 assert_eq!(update_branch("golang.org/x/net", "v0.23.0"), "g1t/security/golang.org-x-net-v0.23.0");
799 }
800}