Skip to content

g1t/services/security/src/security_updates.rs

799 lines40,982 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
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar31use g1t_contracts::security::UPDATE_BRANCH_PREFIX;
32use g1t_contracts::updates::{BumpPackage, IgnoreCondition, UpdatedDependency, VersionUpdateEntry, VersionUpdatesState};
33use g1t_contracts::work::UpdatePullArgs;
34
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily35use crate::Security;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar36use crate::config::{CommitMessage, Config, Entry, glob, matches};
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily37use crate::deps::{advisory_table, issue_text};
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar38use crate::pull_text;
39use crate::ranges;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily40use crate::store::{Activity, RepoRow, UpdateRow, VulnRow};
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar41use crate::update_store::NewPull;
42use crate::version_updates::{Loaded, package_ecosystem};
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily43
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. \
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent99 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 daily100 ---\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
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar106/// 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
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily211impl 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.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar227 pub(crate) async fn close_with(&self, repo: &RepoRow, number: u32, why: String) -> Result<()> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily228 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 },
241 )
242 .await?;
243 Ok(())
244 }
245
246 /// Records `action` on every open alert of an update's package.
247 async fn note(&self, row: &UpdateRow, action: &str, actor: Option<&str>, number: Option<u32>, comment: Option<&str>) -> Result<()> {
248 let ids = self.store.open_ids(&row.repo_id, &row.ecosystem, &row.package).await?;
249 let activity: Vec<Activity> = ids
250 .iter()
251 .map(|id| Activity { alert_id: id, action, actor, reason: None, comment, number })
252 .collect();
253 self.store.record(&row.repo_id, &activity).await
254 }
255
256 /// After a dependency scan: asks for an update for each vulnerable
257 /// package with a fix (when `enabled`), and supersedes those whose
258 /// package is no longer vulnerable.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar259 ///
260 /// The dependency update file, when there is one, applies as it does to
261 /// version updates: `ignore` and `allow` by name, people's `@g1t ignore`
262 /// comments, `groups` with `applies-to: security-updates` (one pull
263 /// request for each group), and `assignees`, `reviewers` and
264 /// `commit-message`. `open-pull-requests-limit` does not apply.
265 pub async fn security_updates(&self, repo: &RepoRow, enabled: bool, rules: Option<&Loaded>) -> Result<()> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily266 let open = self.store.open_vulnerabilities(&repo.repo_id).await?;
267 let mut by_package: BTreeMap<(String, String), Vec<&VulnRow>> = BTreeMap::new();
268 for vuln in &open {
269 by_package.entry((vuln.ecosystem.clone(), vuln.package.clone())).or_default().push(vuln);
270 }
271 // In flight for a package that is no longer vulnerable: no longer needed.
272 for row in self.store.updates(&repo.repo_id).await? {
273 if !row.state().in_progress() || by_package.contains_key(&(row.ecosystem.clone(), row.package.clone())) {
274 continue;
275 }
276 self.supersede(repo, &row, format!("`{}` is no longer vulnerable here, so this is no longer needed.", row.package))
277 .await?;
278 }
279 if !enabled {
280 return Ok(());
281 }
282 let mut groups: Vec<((String, String), Vec<&VulnRow>)> = by_package
283 .into_iter()
284 .filter(|(_, vulns)| vulns.iter().any(|vuln| vuln.fixed_version.is_some()))
285 .collect();
286 groups.sort_by_key(|(_, vulns)| std::cmp::Reverse(vulns.iter().map(|v| Severity::parse(&v.severity)).max()));
287 let retry_after = rfc3339(now_ms().saturating_sub(RETRY_FAILED_MS));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar288 let comments = self.store.ignores(&repo.repo_id).await?;
289 let mut grouped: BTreeMap<(String, String), Vec<GroupMember>> = BTreeMap::new();
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily290 let mut asked = 0;
291 for ((ecosystem, package), vulns) in groups {
292 if asked >= MAX_NEW_UPDATES {
293 break;
294 }
295 let Some(target) = osv::upgrade_target(vulns.iter().filter_map(|v| v.fixed_version.as_deref())) else {
296 continue;
297 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar298 let manifests: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect();
299 if let Some(entry) = rules.and_then(|loaded| security_entry(&loaded.config, &loaded.default_branch, &ecosystem, &manifests)) {
300 if !security_allowed(entry, &comments, &package, &target) {
301 continue;
302 }
303 let from = lowest(&vulns);
304 if let Some(group) = security_group(entry, &package, &from, &target) {
305 let key = (entry.id(), group);
306 if !grouped.contains_key(&key) {
307 asked += 1;
308 }
309 let manifests = manifests.iter().map(|path| (*path).to_owned()).collect();
310 grouped.entry(key).or_default().push(GroupMember { ecosystem: ecosystem.clone(), package: package.clone(), from, target, manifests });
311 continue;
312 }
313 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily314 match self.store.update(&repo.repo_id, &ecosystem, &package).await? {
315 Some(row) => {
316 if next_step(row.state(), &row.target, &target, &row.updated_at, &retry_after) == Next::Wait {
317 continue;
318 }
319 }
320 None => {
321 // An upgrade issue from before security updates, still
322 // open, is left to the agent on it.
323 if let Some(existing) = self.store.upgrade(&repo.repo_id, &ecosystem, &package).await?
324 && self
325 .issue(&User::system(&repo.namespace), &Self::path_of(repo), existing.number as u32)
326 .await?
327 .is_some_and(|issue| issue.state == g1t_contracts::work::State::Open)
328 {
329 continue;
330 }
331 }
332 }
333 asked += 1;
334 self.request(repo, &ecosystem, &package, &target, &vulns).await?;
335 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar336 for ((entry_id, group), members) in grouped {
337 let Some(loaded) = rules else { continue };
338 let Some(entry) = loaded.config.updates.iter().find(|entry| entry.id() == entry_id) else { continue };
339 self.request_group(repo, loaded, entry, &group, members, &open).await?;
340 }
341 Ok(())
342 }
343
344 /// Asks for one pull request for a `security-updates` group's
345 /// packages, unless one for the same versions is under way or was
346 /// closed.
347 async fn request_group(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry, group: &str, members: Vec<GroupMember>, open: &[VulnRow]) -> Result<()> {
348 let subject = format!("security:{group}");
349 let mut signature: Vec<String> = members.iter().map(|m| format!("{}@{}", m.package, m.target)).collect();
350 signature.sort();
351 let signature = signature.join(",");
352 let existing = self.store.update_pulls(&repo.repo_id).await?;
353 let known = |row: &&crate::update_store::PullRow| row.kind == "security" && row.subject == subject && row.signature == signature;
354 if existing.iter().filter(known).any(|row| row.state().in_progress() || row.state() == UpdateState::Closed) {
355 return Ok(());
356 }
357 let branch = format!("{UPDATE_BRANCH_PREFIX}{}-{}", group.to_lowercase().replace('|', "-"), pull_text::digest(&signature));
358 let vulns: Vec<&VulnRow> = open.iter().filter(|vuln| members.iter().any(|m| m.ecosystem == vuln.ecosystem && m.package == vuln.package)).collect();
359 let count = if members.len() == 1 { "1 security update".to_owned() } else { format!("{} security updates", members.len()) };
360 let title = prefixed(&pull_text::prefix(entry.commit_message.as_ref(), false), format!("Bump the {group} group with {count}"));
361 let body = group_text(group, &members, &vulns, &loaded.file);
362 let mut lockfiles: Vec<String> = members.iter().flat_map(|m| m.manifests.clone()).collect();
363 lockfiles.sort();
364 lockfiles.dedup();
365 let packages: Vec<BumpPackage> = members.iter().map(|m| BumpPackage { package: m.package.clone(), version: m.target.clone() }).collect();
366 let bump = BumpArgs {
367 repo: Self::path_of(repo),
368 ecosystem: members[0].ecosystem.clone(),
369 package: packages[0].package.clone(),
370 version: packages[0].version.clone(),
371 lockfiles,
372 branch: branch.clone(),
373 message: title.clone(),
374 kind: None,
375 packages,
376 strategy: None,
377 force: existing.iter().any(|row| row.branch == branch && row.state().in_progress()),
378 registries: Vec::new(),
379 base: None,
380 };
381 let dependencies: Vec<UpdatedDependency> = members
382 .iter()
383 .map(|m| UpdatedDependency {
384 name: m.package.clone(),
385 from: m.from.clone(),
386 to: m.target.clone(),
387 directory: m.manifests.first().map(|path| format!("/{}", path.rsplit_once('/').map_or("", |(dir, _)| dir))).unwrap_or_else(|| "/".to_owned()),
388 dependency_type: String::new(),
389 update_type: ranges::update_type(&m.from, &m.target),
390 })
391 .collect();
392 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
393 let id = self
394 .store
395 .add_update_pull(&NewPull {
396 repo_id: &repo.repo_id,
397 kind: "security",
398 entry: &entry.id(),
399 ecosystem: &entry.ecosystem,
400 subject: &subject,
401 signature: &signature,
402 group: Some(group),
403 branch: &branch,
404 title: &title,
405 body: &body,
406 dependencies: &dependencies,
407 bump: &bump,
408 assignees: &people(&entry.assignees),
409 reviewers: &people(&entry.reviewers),
410 })
411 .await?;
412 for member in &members {
413 self.store.request_update(&repo.repo_id, &member.ecosystem, &member.package, &member.target, &branch).await?;
414 }
415 let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &bump).await {
416 Ok(Outcome::Ok(_)) => Ok(()),
417 Ok(Outcome::Fail(refused)) => Err(refused.message),
418 Err(error) => Err(format!("the runner could not be reached: {error}")),
419 };
420 for member in &members {
421 let Some(row) = self.store.update(&repo.repo_id, &member.ecosystem, &member.package).await? else { continue };
422 match &started {
423 Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await?,
424 Err(reason) => {
425 let error = format!("g1t could not start the security update: {reason}");
426 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
427 self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await?;
428 }
429 }
430 }
431 if let Err(reason) = started {
432 self.store.set_update_pull(&id, UpdateState::Failed, None, None, Some(&format!("g1t could not start the security update: {reason}"))).await?;
433 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily434 Ok(())
435 }
436
437 /// Asks the runner to make the change on its branch.
438 async fn request(&self, repo: &RepoRow, ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> Result<()> {
439 let branch = update_branch(package, target);
440 let mut lockfiles: Vec<String> = vulns.iter().map(|vuln| vuln.manifest.clone()).collect();
441 lockfiles.sort();
442 lockfiles.dedup();
443 let args = BumpArgs {
444 repo: Self::path_of(repo),
445 ecosystem: ecosystem.to_owned(),
446 package: package.to_owned(),
447 version: target.to_owned(),
448 lockfiles,
449 branch: branch.clone(),
450 message: format!("Upgrade {package} to {target}"),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar451 kind: None,
452 packages: Vec::new(),
453 strategy: None,
454 force: false,
455 registries: Vec::new(),
456 base: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily457 };
458 let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &args).await {
459 Ok(Outcome::Ok(_)) => Ok(()),
460 Ok(Outcome::Fail(refused)) => Err(refused.message),
461 Err(error) => Err(format!("the runner could not be reached: {error}")),
462 };
463 self.store.request_update(&repo.repo_id, ecosystem, package, target, &branch).await?;
464 let Some(row) = self.store.update(&repo.repo_id, ecosystem, package).await? else {
465 return Ok(());
466 };
467 match started {
468 Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await,
469 Err(reason) => {
470 let error = format!("g1t could not start the security update: {reason}");
471 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
472 self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await
473 }
474 }
475 }
476
477 /// A security update's branch was pushed: its pull request opens, and
478 /// an older one for the same package is closed as superseded.
479 pub async fn update_pushed(&self, repo_id: &str, branch: &str) -> Result<()> {
480 let Some(row) = self.store.update_by_branch(repo_id, branch).await? else {
481 return Ok(());
482 };
483 if row.state() != UpdateState::Requested {
484 return Ok(());
485 }
486 let Some(repo) = self.store.repo(repo_id).await? else {
487 return Ok(());
488 };
489 let open = self.store.open_vulnerabilities(repo_id).await?;
490 let vulns: Vec<&VulnRow> = open
491 .iter()
492 .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package)
493 .collect();
494 let system = User::system(&repo.namespace);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar495 // What the dependency update file says for this package's directory.
496 let state = repo.version_updates();
497 let manifests: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect();
498 let settings = security_settings(&state, &row.ecosystem, &manifests);
499 let prefix = settings.map(|entry| pull_text::prefix(commit_message_of(entry).as_ref(), false)).unwrap_or_default();
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily500 let opened: Outcome<Pull> = g1t_kit::call(
501 &self.work,
502 "open_pull",
503 &OpenPullArgs {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar504 actor: system.clone(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily505 repo: Self::path_of(&repo),
506 issue: None,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar507 title: prefixed(&prefix, pull_title(&row.package, &row.target)),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily508 body: pull_text(&row.ecosystem, &row.package, &row.target, &vulns),
509 branch: Some(branch.to_owned()),
510 agent: String::new(),
511 runtime: Runtime::External,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar512 // Security updates are for the default branch.
513 base: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily514 },
515 )
516 .await?;
517 let pull = match opened {
518 Outcome::Ok(pull) => pull,
519 Outcome::Fail(refused) => {
520 let error = format!("The pull request could not be opened: {}", refused.message);
521 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
522 return self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await;
523 }
524 };
525 if let Some(older) = row.pull().filter(|older| *older != pull.number) {
526 self.close_with(&repo, older, format!("Superseded by #{}, which upgrades `{}` to {}.", pull.number, row.package, row.target))
527 .await?;
528 }
529 self.store.set_update(&row, UpdateState::Open, Some(pull.number), None, None).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar530 // Labelled as the entry for its directory says, or `dependencies`
531 // and its ecosystem's label; assigned and in a milestone as it says.
532 let ecosystem = package_ecosystem(&row.ecosystem).unwrap_or(row.ecosystem.as_str());
533 let labels = crate::config::update_labels(settings.and_then(|entry| entry.labels.as_deref()), ecosystem);
534 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
535 let (assignees, reviewers) = settings.map(|entry| (people(&entry.assignees), people(&entry.reviewers))).unwrap_or_default();
536 if !assignees.is_empty() || !reviewers.is_empty() || !labels.is_empty() {
537 let _: Outcome<Value> = g1t_kit::call(
538 &self.work,
539 "update_pull",
540 &UpdatePullArgs {
541 actor: system.clone(),
542 repo: Self::path_of(&repo),
543 number: pull.number,
544 assignees: (!assignees.is_empty()).then_some(assignees),
545 reviewers: (!reviewers.is_empty()).then_some(reviewers),
546 labels: (!labels.is_empty()).then_some(labels),
547 milestone: None,
548 base: None,
549 },
550 )
551 .await?;
552 }
553 if let Some(milestone) = settings.and_then(|entry| entry.milestone) {
554 let _: Outcome<Value> = g1t_kit::call(
555 &self.work,
556 "update_pull",
557 &UpdatePullArgs {
558 actor: system,
559 repo: Self::path_of(&repo),
560 number: pull.number,
561 assignees: None,
562 reviewers: None,
563 labels: None,
564 milestone: Some(milestone),
565 base: None,
566 },
567 )
568 .await?;
569 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily570 self.note(&row, "update_opened", Some(g1t_contracts::system::USERNAME), Some(pull.number), None).await
571 }
572
573 /// A pull request merged, closed or checked: if it is a security
574 /// update's, where the update stands now.
575 pub async fn update_pull_event(&self, kind: &str, repo_id: &str, number: u32, status: Option<&str>, actor: Option<&str>) -> Result<()> {
576 let Some(row) = self.store.update_by_pull(repo_id, number).await? else {
577 return Ok(());
578 };
579 if row.state() != UpdateState::Open {
580 return Ok(());
581 }
582 match kind {
583 "pull.merged" => {
584 self.store.set_update(&row, UpdateState::Merged, Some(number), None, None).await?;
585 self.note(&row, "update_merged", actor, Some(number), None).await
586 }
587 "pull.closed" => {
588 self.store.set_update(&row, UpdateState::Closed, Some(number), None, None).await?;
589 self.note(&row, "update_closed", actor, Some(number), None).await
590 }
591 "checks.completed" if status == Some("failed") => {
592 let Some(repo) = self.store.repo(repo_id).await? else {
593 return Ok(());
594 };
595 self.needs_code(&repo, &row, "Raising the version alone fails this branch's required checks").await
596 }
597 _ => Ok(()),
598 }
599 }
600
601 /// Closes an update that is no longer needed: its pull request, if it
602 /// has one still open (one that merged meanwhile is recorded merged).
603 async fn supersede(&self, repo: &RepoRow, row: &UpdateRow, why: String) -> Result<()> {
604 if let Some(number) = row.pull() {
605 match self.get_pull(repo, number).await? {
606 Some(pull) if pull.status == PullStatus::Merged => {
607 return self.store.set_update(row, UpdateState::Merged, Some(number), row.issue(), None).await;
608 }
609 Some(pull) if matches!(pull.status, PullStatus::Open | PullStatus::Draft) => {
610 self.store.set_update(row, UpdateState::Superseded, Some(number), row.issue(), None).await?;
611 self.close_with(repo, number, why).await?;
612 return self.note(row, "update_superseded", Some(g1t_contracts::system::USERNAME), Some(number), None).await;
613 }
614 _ => {}
615 }
616 }
617 self.store.set_update(row, UpdateState::Superseded, row.pull(), row.issue(), None).await
618 }
619
620 /// 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-agent621 /// work, so g1t gets an issue for it.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily622 pub async fn stalled_updates(&self) -> Result<()> {
623 let before = rfc3339(now_ms().saturating_sub(STALLED_MS));
624 for row in self.store.stalled_updates(&before, 10).await? {
625 let Some(repo) = self.store.repo(&row.repo_id).await? else { continue };
626 if repo.upkeep == 0 || !self.active(&repo.repo_id).await? {
627 self.store
628 .set_update(&row, UpdateState::Failed, row.pull(), None, Some("The version could not be raised in a sandbox."))
629 .await?;
630 continue;
631 }
632 self.needs_code(&repo, &row, "The version could not be raised in a sandbox on its own").await?;
633 }
634 Ok(())
635 }
636
637 /// 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-agent638 /// 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 daily639 async fn needs_code(&self, repo: &RepoRow, row: &UpdateRow, why: &str) -> Result<()> {
640 let path = Self::path_of(repo);
641 let system = User::system(&repo.namespace);
642 let open = self.store.open_vulnerabilities(&repo.repo_id).await?;
643 let vulns: Vec<&VulnRow> = open
644 .iter()
645 .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package)
646 .collect();
647 if vulns.is_empty() {
648 return self.supersede(repo, row, format!("`{}` is no longer vulnerable here.", row.package)).await;
649 }
650 let issue: Outcome<g1t_contracts::work::Issue> = g1t_kit::call(
651 &self.work,
652 "open_issue",
653 &OpenIssueArgs {
654 actor: system.clone(),
655 repo: path.clone(),
656 title: format!("Upgrade {} to {}: needs code changes", row.package, row.target).chars().take(200).collect(),
657 body: issue_text(&row.ecosystem, &row.package, &row.target, &vulns, &[]),
658 labels: vec!["dependencies".to_owned(), "security".to_owned()],
659 checks: Vec::new(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar660 milestone: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily661 },
662 )
663 .await?;
664 let issue = match issue {
665 Outcome::Ok(issue) => issue,
666 Outcome::Fail(refused) => {
667 let error = format!("{why}, and the issue for it could not be opened: {}", refused.message);
668 self.store.set_update(row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
669 return self.note(row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await;
670 }
671 };
672 self.store
673 .set_update(row, UpdateState::NeedsCode, row.pull(), Some(issue.number), Some(why))
674 .await?;
675 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-agent676 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 daily677 .await?;
678 }
679 let started: Outcome<Value> =
680 g1t_kit::call(&self.runner, "run", &json!({ "actor": system, "repo": path, "issue": issue.number })).await?;
681 if let Outcome::Fail(refused) = started {
682 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-agent683 "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 daily684 refused.message
685 ))
686 .await?;
687 }
688 self.note(row, "update_needs_code", Some(g1t_contracts::system::USERNAME), Some(issue.number), Some(why)).await
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar696 fn rules(extra: &str) -> Config {
697 let source = format!(
698 "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"
699 );
700 let found = crate::config::read(&source);
701 assert!(found.problems.is_empty(), "{:?}", found.problems);
702 found.config
703 }
704
705 #[test]
706 fn security_updates_follow_the_file() {
707 let config = rules(
708 " 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",
709 );
710 let entry = security_entry(&config, "main", "npm", &["apps/web/package-lock.json"]).unwrap();
711 assert_eq!(entry.id(), "npm:/,/apps/*");
712 // An entry for another branch never speaks for security updates.
713 assert!(security_entry(&config, "main", "npm", &["legacy/package-lock.json"]).is_none());
714 assert!(security_entry(&config, "main", "crates.io", &["Cargo.lock"]).is_none());
715 assert!(!security_allowed(entry, &[], "left-pad", "1.3.0"));
716 assert!(!security_allowed(entry, &[], "react", "19.0.1"));
717 assert!(security_allowed(entry, &[], "react", "18.3.1"));
718 let comment = IgnoreCondition { ecosystem: "npm".into(), dependency: "Lodash".into(), versions: None, update_type: None, by: "ana".into(), pull: None, at: String::new() };
719 assert!(!security_allowed(entry, &[comment], "lodash", "4.17.21"));
720 assert_eq!(security_group(entry, "@babel/core", "7.0.0", "7.24.1").as_deref(), Some("fixes"));
721 // A major bump is outside the group's update types, and only security groups count.
722 assert_eq!(security_group(entry, "@babel/core", "6.0.0", "7.24.1"), None);
723 assert_eq!(security_group(entry, "lodash", "4.17.20", "4.17.21"), None);
724 let named = rules(" allow:\n - dependency-name: \"lodash\"\n");
725 let entry = security_entry(&named, "main", "npm", &["package-lock.json"]).unwrap();
726 assert!(security_allowed(entry, &[], "lodash", "4.17.21") && !security_allowed(entry, &[], "minimist", "1.2.6"));
727 }
728
729 #[test]
730 fn grouped_security_updates_say_what_they_fix() {
731 let vuln = row("4.17.20", "4.17.21");
732 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()] }];
733 let body = group_text("fixes", &members, &[&vuln], ".github/dependabot.yml");
734 assert!(body.starts_with("Upgrades the fixes group's vulnerable packages"));
735 assert!(body.contains("| `lodash` | 4.17.20 | **4.17.21** |"));
736 assert!(body.contains("GHSA-35jh-r3h4-6jhm") && body.contains("`@g1t rebase`"));
737 assert!(body.ends_with("on its Security page._"));
738 assert_eq!(prefixed("build(deps): ", "Upgrade lodash to 4.17.21".into()), "build(deps): upgrade lodash to 4.17.21");
739 assert_eq!(prefixed("", "Upgrade lodash to 4.17.21".into()), "Upgrade lodash to 4.17.21");
740 }
741
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily742 fn row(version: &str, fixed: &str) -> VulnRow {
743 VulnRow {
744 id: "vul_1".into(),
745 repo_id: "rep_1".into(),
746 ecosystem: "npm".into(),
747 package: "lodash".into(),
748 version: version.into(),
749 manifest: "web/package-lock.json".into(),
750 osv_id: "GHSA-35jh-r3h4-6jhm".into(),
751 advisory: "GHSA-35jh-r3h4-6jhm".into(),
752 summary: "Command Injection in lodash".into(),
753 severity: "high".into(),
754 fixed_version: Some(fixed.into()),
755 status: "open".into(),
756 found_at: "2026-10-04T00:00:00Z".into(),
757 fixed_at: None,
758 number: None,
759 dismiss_reason: None,
760 dismiss_comment: None,
761 dismissed_by: None,
762 dismissed_at: None,
763 }
764 }
765
766 #[test]
767 fn a_newer_fix_asks_again_and_the_same_one_waits() {
768 let retry = "2026-10-05T00:00:00Z";
769 let at = "2026-10-06T00:00:00Z";
770 assert_eq!(next_step(UpdateState::Open, "4.17.20", "4.17.21", at, retry), Next::Request);
771 assert_eq!(next_step(UpdateState::Open, "4.17.21", "4.17.21", at, retry), Next::Wait);
772 assert_eq!(next_step(UpdateState::Requested, "4.17.21", "4.17.21", at, retry), Next::Wait);
773 assert_eq!(next_step(UpdateState::Merged, "4.17.21", "4.17.21", at, retry), Next::Wait);
774 // A person closed it: left alone until a newer fix.
775 assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.21", at, retry), Next::Wait);
776 assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.22", at, retry), Next::Request);
777 // Vulnerable again after it was no longer needed.
778 assert_eq!(next_step(UpdateState::Superseded, "4.17.21", "4.17.21", at, retry), Next::Request);
779 // Failed: tried again a day later.
780 assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", at, retry), Next::Wait);
781 assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", "2026-10-04T00:00:00Z", retry), Next::Request);
782 assert_eq!(next_step(UpdateState::NeedsCode, "4.17.21", "4.17.21", at, retry), Next::Wait);
783 }
784
785 #[test]
786 fn the_pull_request_says_what_it_fixes_and_where() {
787 let a = row("4.17.20", "4.17.21");
788 let mut b = row("4.17.19", "4.17.21");
789 b.manifest = "package-lock.json".into();
790 let body = pull_text("npm", "lodash", "4.17.21", &[&a, &b]);
791 assert!(body.starts_with("Upgrades `lodash` (npm) from 4.17.19, 4.17.20 to **4.17.21**"));
792 assert!(body.contains("[GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)"));
793 assert!(body.contains("Lockfiles changed: `package-lock.json`, `web/package-lock.json`."));
794 assert!(body.contains("required checks"));
795 assert_eq!(pull_title("lodash", "4.17.21"), "Upgrade lodash to 4.17.21");
796 assert_eq!(update_branch("@babel/core", "7.24.1"), "g1t/security/babel-core-7.24.1");
797 assert_eq!(update_branch("golang.org/x/net", "v0.23.0"), "g1t/security/golang.org-x-net-v0.23.0");
798 }
799}