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.

g1t.sh
506 linesCodeBlameRaw

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.

Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01//! Dependencies: reading a repository's lockfiles, asking OSV about every
2//! package in them, and recording what is vulnerable. The security updates
3//! that fix them are `security_updates`'s.
4
5use std::collections::{BTreeSet, HashMap};
6
7use g1t_contracts::repos::RepoPath;
8use g1t_contracts::repos::{BlobArgs, BlobView};
9use g1t_contracts::security::{AlertState, FindLockfilesArgs, Lockfiles, VersionUpdatesState, VulnStatus, Vulnerability};
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar10use g1t_contracts::updates::DEPENDABOT_PATHS;
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.011use g1t_contracts::time::rfc3339;
12use g1t_contracts::work::{AddCommentArgs, Issue, IssueDetail, ViewArgs};
13use g1t_contracts::{Outcome, User};
14use g1t_kit::now_ms;
15use g1t_scan::lockfiles::{Lockfile, Package, still_locked_check, test_command};
16use g1t_scan::osv::{self, Advisory};
17use serde_json::{Value, json};
18use worker::{Fetch, Headers, Method, Request, RequestInit, Result};
19
20use crate::Security;
21use crate::store::{RepoRow, VulnRow};
22use crate::updates;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar23use crate::version_updates::Loaded;
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.024
25/// OSV's records are fetched again after this long.
26const ADVISORY_MAX_AGE_MS: u64 = 7 * 24 * 60 * 60 * 1000;
27/// A record that names no fixed version is fetched again after a day, so
28/// a fix is noticed the day it is published.
29const UNFIXED_ADVISORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
30/// Records fetched per scan, at most; the rest wait for the next one.
31const MAX_ADVISORY_FETCHES: usize = 150;
32/// CPU one call to OSV takes, sending it and reading its answer, in
33/// milliseconds (an estimate, rounded up). OSV itself is free, and a
34/// Worker's outgoing requests are not charged.
35const CPU_MS_PER_OSV_CALL: f64 = 2.0;
36
37/// What a dependency check cost g1t, in millionths of a dollar, rounded
38/// up, at the prices in `history`: the CPU of its OSV calls, and the rows
39/// it writes (an advisory kept for each call at most, each vulnerability
40/// found, where the check stands and the month's usage).
41pub fn dependency_check_cost(calls: u32, found: usize) -> i64 {
42 use crate::history::{MICROS_PER_CPU_MS, MICROS_PER_ROW_WRITTEN};
43 let cpu = f64::from(calls) * CPU_MS_PER_OSV_CALL * MICROS_PER_CPU_MS;
44 let rows = (calls as usize + found + 2) as f64 * MICROS_PER_ROW_WRITTEN;
45 (cpu + rows).ceil() as i64
46}
47
48async fn osv_call(method: Method, url: &str, body: Option<&Value>) -> Result<Option<Value>> {
49 let headers = Headers::new();
50 headers.set("user-agent", "g1t (+https://g1t.sh)")?;
51 headers.set("accept", "application/json")?;
52 if body.is_some() {
53 headers.set("content-type", "application/json")?;
54 }
55 let mut init = RequestInit::new();
56 init.with_method(method).with_headers(headers);
57 if let Some(body) = body {
58 init.with_body(Some(body.to_string().into()));
59 }
60 let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?;
61 if response.status_code() == 404 {
62 return Ok(None);
63 }
64 if !(200..300).contains(&response.status_code()) {
65 return Err(worker::Error::RustError(format!("OSV answered {}", response.status_code())));
66 }
67 Ok(Some(response.json().await?))
68}
69
70/// Whether an OSV record names a version that fixes it, for any package.
71fn names_a_fix(record: &Value) -> bool {
72 record["affected"].as_array().into_iter().flatten().any(|affected| {
73 affected["ranges"]
74 .as_array()
75 .into_iter()
76 .flatten()
77 .any(|range| range["events"].as_array().into_iter().flatten().any(|event| event.get("fixed").is_some()))
78 })
79}
80
81/// One package in one lockfile.
82pub(crate) struct Located {
83 pub(crate) package: Package,
84 pub(crate) path: String,
85}
86
87/// Every package the lockfiles resolve. A directory with a `go.mod` is read
88/// from it rather than from its `go.sum`, which lists versions not built.
89fn packages(files: &Lockfiles) -> Vec<Located> {
90 let go_mods: BTreeSet<&str> = files
91 .files
92 .iter()
93 .filter(|file| file.path.ends_with("go.mod"))
94 .map(|file| file.path.trim_end_matches("go.mod"))
95 .collect();
96 let mut located = Vec::new();
97 for file in &files.files {
98 let Some(lockfile) = Lockfile::for_path(&file.path) else { continue };
99 if lockfile == Lockfile::GoSum && go_mods.contains(file.path.trim_end_matches("go.sum")) {
100 continue;
101 }
102 for package in lockfile.parse(&file.text) {
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.103 located.push(Located { package, path: file.path.clone() });
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0104 }
105 }
106 located
107}
108
109fn directory(path: &str) -> &str {
110 path.rsplit_once('/').map_or("", |(directory, _)| directory)
111}
112
113impl Security {
114 /// The ids of the vulnerabilities affecting each package, from OSV.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar115 pub(crate) async fn query_osv(&self, packages: &[Package]) -> Result<(Vec<Vec<String>>, u32)> {
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0116 let mut ids = Vec::with_capacity(packages.len());
117 let mut calls = 0;
118 for (body, chunk) in osv::batch_bodies(packages).iter().zip(packages.chunks(osv::MAX_BATCH)) {
119 calls += 1;
120 let answer = osv_call(Method::Post, osv::QUERY_BATCH_URL, Some(body)).await?.unwrap_or(Value::Null);
121 let (mut found, more) = osv::read_batch(&answer, chunk.len());
122 // A package with many advisories is paged; fetch the rest.
123 for (index, token) in more.into_iter().take(20) {
124 let package = &chunk[index];
125 let query = json!({
126 "package": {"name": package.name, "ecosystem": package.ecosystem.osv()},
127 "version": package.version,
128 "page_token": token,
129 });
130 calls += 1;
131 if let Some(page) = osv_call(Method::Post, "https://api.osv.dev/v1/query", Some(&query)).await? {
132 found[index].extend(
133 page["vulns"].as_array().into_iter().flatten().filter_map(|v| v["id"].as_str().map(str::to_owned)),
134 );
135 }
136 }
137 ids.extend(found);
138 }
139 Ok((ids, calls))
140 }
141
142 /// OSV's record of each id, from the cache when it is fresh.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar143 pub(crate) async fn advisories(&self, ids: &BTreeSet<String>) -> Result<(HashMap<String, Value>, u32)> {
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0144 let fresh_after = rfc3339(now_ms().saturating_sub(ADVISORY_MAX_AGE_MS));
145 let unfixed_fresh_after = rfc3339(now_ms().saturating_sub(UNFIXED_ADVISORY_MAX_AGE_MS));
146 let mut records = HashMap::new();
147 let mut fetched = 0u32;
148 for id in ids {
149 if let Some(record) = self.store.advisory(id, &fresh_after).await? {
150 // One without a fix is asked about again daily; until then,
151 // or if asking fails below, the kept record stands.
152 let fixed = names_a_fix(&record);
153 let recent = !fixed && self.store.advisory(id, &unfixed_fresh_after).await?.is_some();
154 if fixed || recent || fetched as usize >= MAX_ADVISORY_FETCHES {
155 records.insert(id.clone(), record);
156 continue;
157 }
158 fetched += 1;
159 let refreshed = osv_call(Method::Get, &osv::vuln_url(id), None).await.ok().flatten();
160 if let Some(refreshed) = &refreshed {
161 self.store.keep_advisory(id, refreshed).await?;
162 }
163 records.insert(id.clone(), refreshed.unwrap_or(record));
164 continue;
165 }
166 if fetched as usize >= MAX_ADVISORY_FETCHES {
167 continue;
168 }
169 fetched += 1;
170 if let Some(record) = osv_call(Method::Get, &osv::vuln_url(id), None).await? {
171 self.store.keep_advisory(id, &record).await?;
172 records.insert(id.clone(), record);
173 }
174 }
175 Ok((records, fetched))
176 }
177
178 /// Reads a repository's dependencies, records which are vulnerable,
179 /// and starts security updates for those with a fix. Also reads
180 /// `.g1t/dependencies.yml`. Returns what went wrong, for the Security
181 /// page, if anything did.
182 pub async fn scan_dependencies(&self, repo: &RepoRow) -> Result<Option<String>> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar183 let files: Lockfiles = g1t_kit::call(&self.repos, "find_lockfiles", &FindLockfilesArgs { repo_id: repo.repo_id.clone(), git_ref: None }).await?;
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0184 let paths: Vec<String> = files.files.iter().map(|file| file.path.clone()).collect();
185 let located = packages(&files);
186 let unique: Vec<Package> = located.iter().map(|l| l.package.clone()).collect::<BTreeSet<_>>().into_iter().collect();
187 let outcome = async {
188 let (ids, calls) = self.query_osv(&unique).await?;
189 let by_package: HashMap<&Package, &Vec<String>> = unique.iter().zip(ids.iter()).collect();
190 let wanted: BTreeSet<String> = ids.iter().flatten().cloned().collect();
191 let (records, fetched) = self.advisories(&wanted).await?;
192 let mut found = Vec::new();
193 for item in &located {
194 for id in by_package.get(&item.package).into_iter().flat_map(|ids| ids.iter()) {
195 let Some(advisory) = records.get(id).and_then(|record| osv::read_vuln(record, &item.package)) else {
196 continue;
197 };
198 found.push(vulnerability(&repo.repo_id, item, &advisory));
199 }
200 }
201 Ok::<_, worker::Error>((found, calls + fetched))
202 }
203 .await;
204 let (found, calls) = match outcome {
205 Ok(result) => result,
206 Err(error) => {
207 let problem = format!("The dependencies could not be checked: {error}");
208 self.store
209 .set_dependencies_scanned(&repo.repo_id, files.commit.as_deref(), &paths, Some(&problem))
210 .await?;
211 return Ok(Some(problem));
212 }
213 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar214 let before: BTreeSet<String> = self.store.open_vulnerabilities(&repo.repo_id).await?.into_iter().map(|row| row.id).collect();
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0215 self.store.replace_vulnerabilities(&repo.repo_id, &found).await?;
216 self.store.set_dependencies_scanned(&repo.repo_id, files.commit.as_deref(), &paths, None).await?;
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches217 // g1t's update pull requests whose alerts are now fixed or dismissed,
218 // or whose versions the lockfiles already have, close first, so that
219 // nothing later in the scan can keep them open.
220 if let Err(error) = self.resolve_updates(repo, Some((&located, &paths))).await {
221 worker::console_error!("security: updates of {} not resolved: {error}", repo.repo_id);
222 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar223 if let Err(error) = self.record_graph(repo, &files).await {
224 worker::console_error!("security: dependency graph of {} not kept: {error}", repo.repo_id);
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0225 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar226 self.vulnerabilities_changed(repo, &before).await?;
227 self.meter(&repo.namespace, 0, 0, calls, dependency_check_cost(calls, found.len())).await?;
228 // The dependency update file says how security updates are made too.
229 let rules = match files.commit.as_deref() {
230 Some(commit) => self.read_version_updates(repo, commit).await?,
231 None => None,
232 };
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0233 // No security updates on an archived (read-only) or deleted
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.234 // repository, nor on a mirror until it is taken over or moved to
235 // g1t (`g1t_contracts::mirrors`: a standby mirror is an exact copy
236 // that opens nothing of its own); ones in flight for packages no
237 // longer vulnerable are closed either way.
238 let takes_updates = self.takes_updates(repo).await?.is_ok();
239 self.security_updates(repo, repo.upkeep != 0 && takes_updates, rules.as_ref()).await?;
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0240 Ok(None)
241 }
242
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar243 /// Reads the dependency update file at `commit` and keeps what it
244 /// says; see `updates`. Its entries' schedules start or move. Returns
245 /// it, when it has no problems.
246 pub(crate) async fn read_version_updates(&self, repo: &RepoRow, commit: &str) -> Result<Option<Loaded>> {
247 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
248 let asks: Vec<BlobArgs> = DEPENDABOT_PATHS
249 .iter()
250 .map(|file| BlobArgs {
251 path: path.clone(),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0252 viewer: Some(User::system(&repo.namespace)),
253 git_ref: commit.to_owned(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar254 file_path: (*file).to_owned(),
255 })
256 .collect();
257 let reads = asks.iter().map(|ask| g1t_kit::call::<_, Outcome<BlobView>>(&self.repos, "blob", ask));
258 let mut found = Vec::new();
259 let mut default_branch = None;
260 for (file, read) in DEPENDABOT_PATHS.iter().zip(futures_util::future::join_all(reads).await) {
261 if let Outcome::Ok(blob) = read? {
262 default_branch = Some(blob.repo.default_branch.clone());
263 found.push(((*file).to_owned(), blob.text));
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0264 }
265 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar266 let (mut state, config) = match updates::choose(found) {
267 Some(((file, text), ignored)) => {
268 let (state, config) = updates::state(&file, text.as_deref(), ignored, &repo.repo_id, default_branch.as_deref());
269 (state, config.map(|config| (config, file)))
270 }
271 None => (VersionUpdatesState::default(), None),
272 };
273 state.read_at = Some(crate::store::now());
274 state.commit = Some(commit.to_owned());
275 self.store.set_version_updates(&repo.repo_id, &state).await?;
276 self.schedule_entries(repo, config.as_ref().map(|(config, _)| config), default_branch.as_deref()).await?;
277 Ok(config.map(|(config, file)| Loaded { config, file, default_branch: default_branch.unwrap_or_default() }))
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0278 }
279
280 pub(crate) async fn issue(&self, actor: &User, repo: &RepoPath, number: u32) -> Result<Option<Issue>> {
281 let found: Outcome<IssueDetail> = g1t_kit::call(
282 &self.work,
283 "get_issue",
284 &ViewArgs { repo: repo.clone(), number, viewer: Some(actor.clone()), after_seq: 0 },
285 )
286 .await?;
287 Ok(found.into_result().ok().map(|detail| detail.issue))
288 }
289
290 pub(crate) async fn comment(&self, actor: &User, repo: &RepoPath, number: u32, body: String) -> Result<()> {
291 let _: Outcome<Value> = g1t_kit::call(
292 &self.work,
293 "add_comment",
294 &AddCommentArgs {
295 actor: actor.clone(),
296 repo: repo.clone(),
297 number,
298 body,
299 path: None,
300 line: None,
301 verdict: None,
302 },
303 )
304 .await?;
305 Ok(())
306 }
307}
308
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar309/// The event that tells of a vulnerability alert.
310pub(crate) fn vulnerability_event(repo: &RepoRow, vuln: &Vulnerability) -> g1t_contracts::security_suite::SecurityEvent {
311 g1t_contracts::security_suite::SecurityEvent {
312 repo_id: repo.repo_id.clone(),
313 alert_id: vuln.id.clone(),
314 alert_type: g1t_contracts::security_suite::AlertType::Vulnerability.as_str().to_owned(),
315 severity: vuln.severity.clone(),
316 title: format!("{} {} in {}: {}", vuln.package, vuln.version, vuln.manifest, vuln.advisory),
317 link: crate::suite::link(repo, &format!("vulnerabilities?finding={}", vuln.id)),
318 path: Some(vuln.manifest.clone()),
319 state: vuln.state.as_str().to_owned(),
320 ..Default::default()
321 }
322}
323
324impl Security {
325 /// Tells of vulnerability alerts a dependency read opened or fixed,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9326 /// given the ids that were open before it: an event each for webhooks
327 /// (the first `MAX_SUMMARY_IDS` of each), and one summary of the read
328 /// for the inbox, which carries the counts whatever the number.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar329 async fn vulnerabilities_changed(&self, repo: &RepoRow, before: &BTreeSet<String>) -> Result<()> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9330 use g1t_contracts::security_suite::{AlertType, MAX_SUMMARY_IDS};
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar331 let after: Vec<VulnRow> = self.store.open_vulnerabilities(&repo.repo_id).await?;
332 let now: BTreeSet<&str> = after.iter().map(|row| row.id.as_str()).collect();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9333 // Worst first, as the rows come: the summary's headline is the worst new one.
334 let opened: Vec<String> = after.iter().map(|row| row.id.clone()).filter(|id| !before.contains(id)).collect();
335 let mut fixed: Vec<String> = Vec::new();
336 for id in before.iter().filter(|id| !now.contains(id.as_str())) {
337 if let Some(vuln) = self.store.vulnerability(&repo.repo_id, id).await?
338 && vuln.status == VulnStatus::Fixed
339 {
340 fixed.push(id.clone());
341 }
342 }
343 for id in opened.iter().take(MAX_SUMMARY_IDS) {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar344 if let Some(vuln) = self.store.vulnerability(&repo.repo_id, id).await? {
345 self.alert_event(AlertType::Vulnerability, "created", repo, vulnerability_event(repo, &vuln), None).await;
346 }
347 }
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9348 for id in fixed.iter().take(MAX_SUMMARY_IDS) {
349 if let Some(vuln) = self.store.vulnerability(&repo.repo_id, id).await? {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar350 self.alert_event(AlertType::Vulnerability, "fixed", repo, vulnerability_event(repo, &vuln), None).await;
351 }
352 }
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9353 let changed = crate::summaries::Changed { scan: "dependencies", opened, fixed, ..crate::summaries::Changed::default() };
354 self.summarize(repo, AlertType::Vulnerability, changed, None).await
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar355 }
356}
357
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0358fn vulnerability(repo_id: &str, item: &Located, advisory: &Advisory) -> Vulnerability {
359 Vulnerability {
360 id: String::new(),
361 repo_id: repo_id.to_owned(),
362 ecosystem: item.package.ecosystem.osv().to_owned(),
363 package: item.package.name.clone(),
364 version: item.package.version.clone(),
365 manifest: item.path.clone(),
366 advisory: advisory.display_id.clone(),
367 osv_id: advisory.id.clone(),
368 summary: advisory.summary.clone(),
369 severity: advisory.severity.as_str().to_owned(),
370 fixed_version: advisory.fixed.clone(),
371 status: VulnStatus::Open,
372 issue: None,
373 found_at: String::new(),
374 fixed_at: None,
375 state: AlertState::Open,
376 dismissed_by: None,
377 dismissed_reason: None,
378 dismissed_comment: None,
379 dismissed_at: None,
380 update: None,
381 }
382}
383
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.384/// The issue's body, for when a person asks g1t to fix a vulnerability
385/// alert, written for the agent that takes it as much as for a person,
386/// ending with what done means: commands that show no lockfile still
387/// resolves a vulnerable version, and the project's tests. The pull request
388/// merges on the repository's required checks, like any other.
389pub(crate) fn issue_text(ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> String {
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0390 let mut body = format!(
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.391 "`{package}` ({ecosystem}) has known vulnerabilities with a fix in **{target}**. Upgrade it to {target} or later \
392 everywhere it is locked, and change the code that depends on it where the upgrade needs that, keeping other \
393 changes to what the upgrade needs.\n\n"
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0394 );
395 body.push_str(&advisory_table(vulns));
396 let mut checks = Vec::new();
397 let mut manifests = BTreeSet::new();
398 let mut tests = BTreeSet::new();
399 for vuln in vulns {
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.400 let Some(lockfile) = Lockfile::for_path(&vuln.manifest) else { continue };
401 if manifests.insert((vuln.manifest.clone(), vuln.version.clone())) {
402 checks.push(still_locked_check(lockfile, &vuln.manifest, &vuln.package, &vuln.version));
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0403 }
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.404 if let Some(test) = test_command(lockfile, directory(&vuln.manifest)) {
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0405 tests.insert(test);
406 }
407 }
408 let locked: Vec<String> = manifests.iter().map(|(path, version)| format!("`{path}` ({version})")).collect();
409 body.push_str(&format!("\nLocked in: {}.\n", locked.join(", ")));
410 body.push_str(
411 "\nIf the fix needs a major upgrade that breaks the build, change the code that depends on it in the same pull request.",
412 );
413 checks.extend(tests);
414 let mut done = vec!["No lockfile resolves a vulnerable version, and the tests still pass.".to_owned()];
415 done.extend(g1t_contracts::work::commands_pass(&checks));
416 let mut body = g1t_contracts::work::with_definition_of_done(&body, &done);
417 body.push_str("\n\n---\n_Opened by g1t's security updates. Turn them off for this project on its Security page._");
418 body
419}
420
421/// The advisories a package's vulnerabilities name, as a table.
422pub(crate) fn advisory_table(vulns: &[&VulnRow]) -> String {
423 let mut table = "| Advisory | Severity | Affected | Fixed in | Summary |\n| --- | --- | --- | --- | --- |\n".to_owned();
424 let mut seen = BTreeSet::new();
425 for vuln in vulns {
426 if !seen.insert((vuln.advisory.clone(), vuln.version.clone())) {
427 continue;
428 }
429 table.push_str(&format!(
430 "| [{}]({}) | {} | {} | {} | {} |\n",
431 vuln.advisory,
432 osv::page_url(&vuln.osv_id),
433 vuln.severity,
434 vuln.version,
435 vuln.fixed_version.as_deref().unwrap_or("none yet"),
436 vuln.summary.replace('|', "\\|").replace('\n', " "),
437 ));
438 }
439 table
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use g1t_contracts::security::LockfileText;
446
447 #[test]
448 fn scans_cost_their_cpu_and_the_rows_they_write() {
449 // 10 OSV calls and 3 vulnerabilities: 0.4 of CPU, 15 rows.
450 assert_eq!(dependency_check_cost(10, 3), 16);
451 assert_eq!(dependency_check_cost(0, 0), 2);
452 // A page of 25 commits that read 100 objects and found nothing:
453 // 10 of CPU and 2 rows. The old placeholder charged 100.
454 assert_eq!(crate::history::history_page_cost(100, 0), 12);
455 assert_eq!(crate::history::history_page_cost(0, 1), 3);
456 }
457
458 #[test]
459 fn go_sum_is_skipped_beside_go_mod() {
460 let files = Lockfiles {
461 commit: None,
462 files: vec![
463 LockfileText { path: "go.mod".into(), text: "require golang.org/x/net v0.7.0\n".into() },
464 LockfileText { path: "go.sum".into(), text: "golang.org/x/net v0.1.0 h1:x=\n".into() },
465 LockfileText { path: "tools/go.sum".into(), text: "golang.org/x/text v0.3.0 h1:x=\n".into() },
466 ],
467 };
468 let found: Vec<String> = packages(&files).iter().map(|l| format!("{}:{}", l.path, l.package.version)).collect();
469 assert_eq!(found, ["go.mod:v0.7.0", "tools/go.sum:v0.3.0"]);
470 }
471
472 #[test]
473 fn the_issue_names_the_advisories_and_checks_the_lockfile() {
474 let row = VulnRow {
475 id: "vul_1".into(),
476 repo_id: "rep_1".into(),
477 ecosystem: "npm".into(),
478 package: "lodash".into(),
479 version: "4.17.20".into(),
480 manifest: "web/package-lock.json".into(),
481 osv_id: "GHSA-35jh-r3h4-6jhm".into(),
482 advisory: "GHSA-35jh-r3h4-6jhm".into(),
483 summary: "Command Injection in lodash".into(),
484 severity: "high".into(),
485 fixed_version: Some("4.17.21".into()),
486 status: "open".into(),
487 found_at: "2026-10-04T00:00:00Z".into(),
488 fixed_at: None,
489 number: None,
490 dismiss_reason: None,
491 dismiss_comment: None,
492 dismissed_by: None,
493 dismissed_at: None,
494 };
Security and version updates open no issues on their own: an update whose version cannot be raised fails with why on its alert and is tried again a day later, and one whose required checks fail stays open for a person to decide on; an agent takes one only when a person asks with Fix with g1t. That issue now names the lockfiles the package is locked in and the commands that show it is done, which were left empty ("Locked in: .") because the lockfiles were never passed in.495 let body = issue_text("npm", "lodash", "4.17.21", &[&row]);
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0496 assert!(body.contains("[GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm) | high | 4.17.20 | 4.17.21"));
497 assert!(body.contains("`web/package-lock.json` (4.17.20)"));
498 let (_, done) = body.split_once("## Definition of done\n\n").unwrap();
499 let items: Vec<&str> = done.lines().take_while(|line| line.starts_with("- ")).collect();
500 assert_eq!(items.len(), 3);
501 assert!(items[1].contains("node_modules/lodash") && items[1].contains("'web/package-lock.json'"));
502 assert_eq!(items[2], "- `cd 'web' && npm ci && npm test --if-present` passes.");
503 assert!(body.ends_with("on its Security page._"));
504 assert!(advisory_table(&[&row]).contains("| [GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm) | high |"));
505 }
506}

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