Skip to content

g1t/services/security/src/security_updates.rs

800 lines41,019 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,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge240 bypass_rules: false,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily241 },
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.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar260 ///
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<()> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily267 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));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar289 let comments = self.store.ignores(&repo.repo_id).await?;
290 let mut grouped: BTreeMap<(String, String), Vec<GroupMember>> = BTreeMap::new();
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily291 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 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar299 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 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily315 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 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar337 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 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily435 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}"),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar452 kind: None,
453 packages: Vec::new(),
454 strategy: None,
455 force: false,
456 registries: Vec::new(),
457 base: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily458 };
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);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar496 // 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();
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily501 let opened: Outcome<Pull> = g1t_kit::call(
502 &self.work,
503 "open_pull",
504 &OpenPullArgs {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar505 actor: system.clone(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily506 repo: Self::path_of(&repo),
507 issue: None,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar508 title: prefixed(&prefix, pull_title(&row.package, &row.target)),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily509 body: pull_text(&row.ecosystem, &row.package, &row.target, &vulns),
510 branch: Some(branch.to_owned()),
511 agent: String::new(),
512 runtime: Runtime::External,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar513 // Security updates are for the default branch.
514 base: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily515 },
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?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar531 // 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 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily571 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
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent622 /// work, so g1t gets an issue for it.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily623 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
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent639 /// 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 daily640 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(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar661 milestone: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily662 },
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() {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent677 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 daily678 .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!(
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent684 "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 daily685 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
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar697 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
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily743 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}

This file's history is long; its oldest lines are credited to the oldest commit read.