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.
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1 | //! 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 | ||
| 5 | use std::collections::{BTreeSet, HashMap}; | |
| 6 | ||
| 7 | use g1t_contracts::repos::RepoPath; | |
| 8 | use g1t_contracts::repos::{BlobArgs, BlobView}; | |
| 9 | use g1t_contracts::security::{AlertState, FindLockfilesArgs, Lockfiles, VersionUpdatesState, VulnStatus, Vulnerability}; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 10 | use g1t_contracts::updates::DEPENDABOT_PATHS; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 11 | use g1t_contracts::time::rfc3339; |
| 12 | use g1t_contracts::work::{AddCommentArgs, Issue, IssueDetail, ViewArgs}; | |
| 13 | use g1t_contracts::{Outcome, User}; | |
| 14 | use g1t_kit::now_ms; | |
| 15 | use g1t_scan::lockfiles::{Lockfile, Package, still_locked_check, test_command}; | |
| 16 | use g1t_scan::osv::{self, Advisory}; | |
| 17 | use serde_json::{Value, json}; | |
| 18 | use worker::{Fetch, Headers, Method, Request, RequestInit, Result}; | |
| 19 | ||
| 20 | use crate::Security; | |
| 21 | use crate::store::{RepoRow, VulnRow}; | |
| 22 | use crate::updates; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 23 | use crate::version_updates::Loaded; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 24 | |
| 25 | /// OSV's records are fetched again after this long. | |
| 26 | const 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. | |
| 29 | const UNFIXED_ADVISORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000; | |
| 30 | /// Records fetched per scan, at most; the rest wait for the next one. | |
| 31 | const 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. | |
| 35 | const 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). | |
| 41 | pub 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 | ||
| 48 | async 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. | |
| 71 | fn 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. | |
| 82 | pub(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. | |
| 89 | fn 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() }); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 104 | } |
| 105 | } | |
| 106 | located | |
| 107 | } | |
| 108 | ||
| 109 | fn directory(path: &str) -> &str { | |
| 110 | path.rsplit_once('/').map_or("", |(directory, _)| directory) | |
| 111 | } | |
| 112 | ||
| 113 | impl 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 bar | 115 | pub(crate) async fn query_osv(&self, packages: &[Package]) -> Result<(Vec<Vec<String>>, u32)> { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 116 | 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 bar | 143 | pub(crate) async fn advisories(&self, ids: &BTreeSet<String>) -> Result<(HashMap<String, Value>, u32)> { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 144 | 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 bar | 183 | let files: Lockfiles = g1t_kit::call(&self.repos, "find_lockfiles", &FindLockfilesArgs { repo_id: repo.repo_id.clone(), git_ref: None }).await?; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 184 | 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 bar | 214 | let before: BTreeSet<String> = self.store.open_vulnerabilities(&repo.repo_id).await?.into_iter().map(|row| row.id).collect(); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 215 | 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 branches | 217 | // 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 bar | 223 | if let Err(error) = self.record_graph(repo, &files).await { |
| 224 | worker::console_error!("security: dependency graph of {} not kept: {error}", repo.repo_id); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 225 | } |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 226 | 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 | }; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 233 | // 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?; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 240 | Ok(None) |
| 241 | } | |
| 242 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 243 | /// 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(), | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 252 | 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 bar | 254 | 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)); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 264 | } |
| 265 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 266 | 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() })) | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 278 | } |
| 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 bar | 309 | /// The event that tells of a vulnerability alert. |
| 310 | pub(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 | ||
| 324 | impl 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-7nm8r9 | 326 | /// 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 bar | 329 | async fn vulnerabilities_changed(&self, repo: &RepoRow, before: &BTreeSet<String>) -> Result<()> { |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 330 | use g1t_contracts::security_suite::{AlertType, MAX_SUMMARY_IDS}; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 331 | 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-7nm8r9 | 333 | // 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 bar | 344 | 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-7nm8r9 | 348 | 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 bar | 350 | 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-7nm8r9 | 353 | 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 bar | 355 | } |
| 356 | } | |
| 357 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 358 | fn 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. | |
| 389 | pub(crate) fn issue_text(ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> String { | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 390 | 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" | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 394 | ); |
| 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)); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 403 | } |
| 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)) { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 405 | 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. | |
| 422 | pub(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)] | |
| 443 | mod 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]); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 496 | 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.