Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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