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.
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 1 | //! Dependencies: reading a repository's lockfiles, asking OSV about every |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 2 | //! package in them, and recording what is vulnerable. The security updates |
| 3 | //! that fix them are `security_updates`'s. | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 4 | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 5 | use std::collections::{BTreeSet, HashMap}; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 6 | |
| 7 | use g1t_contracts::repos::RepoPath; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 11 | use g1t_contracts::time::rfc3339; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 12 | use g1t_contracts::work::{AddCommentArgs, Issue, IssueDetail, ViewArgs}; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 13 | use g1t_contracts::{Outcome, User}; |
| 14 | use g1t_kit::now_ms; | |
| 15 | use g1t_scan::lockfiles::{Lockfile, Package, still_locked_check, test_command}; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 16 | use g1t_scan::osv::{self, Advisory}; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 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}; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 24 | |
| 25 | /// OSV's records are fetched again after this long. | |
| 26 | const ADVISORY_MAX_AGE_MS: u64 = 7 * 24 * 60 * 60 * 1000; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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; | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 30 | /// Records fetched per scan, at most; the rest wait for the next one. |
| 31 | const MAX_ADVISORY_FETCHES: usize = 150; | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 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; | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 36 | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 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 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 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 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 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 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 81 | /// One package in one lockfile. |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 82 | pub(crate) struct Located { |
| 83 | pub(crate) package: Package, | |
| 84 | pub(crate) lockfile: Lockfile, | |
| 85 | pub(crate) path: String, | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 86 | } |
| 87 | ||
| 88 | /// Every package the lockfiles resolve. A directory with a `go.mod` is read | |
| 89 | /// from it rather than from its `go.sum`, which lists versions not built. | |
| 90 | fn packages(files: &Lockfiles) -> Vec<Located> { | |
| 91 | let go_mods: BTreeSet<&str> = files | |
| 92 | .files | |
| 93 | .iter() | |
| 94 | .filter(|file| file.path.ends_with("go.mod")) | |
| 95 | .map(|file| file.path.trim_end_matches("go.mod")) | |
| 96 | .collect(); | |
| 97 | let mut located = Vec::new(); | |
| 98 | for file in &files.files { | |
| 99 | let Some(lockfile) = Lockfile::for_path(&file.path) else { continue }; | |
| 100 | if lockfile == Lockfile::GoSum && go_mods.contains(file.path.trim_end_matches("go.sum")) { | |
| 101 | continue; | |
| 102 | } | |
| 103 | for package in lockfile.parse(&file.text) { | |
| 104 | located.push(Located { package, lockfile, path: file.path.clone() }); | |
| 105 | } | |
| 106 | } | |
| 107 | located | |
| 108 | } | |
| 109 | ||
| 110 | fn directory(path: &str) -> &str { | |
| 111 | path.rsplit_once('/').map_or("", |(directory, _)| directory) | |
| 112 | } | |
| 113 | ||
| 114 | impl Security { | |
| 115 | /// 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 | 116 | pub(crate) async fn query_osv(&self, packages: &[Package]) -> Result<(Vec<Vec<String>>, u32)> { |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 117 | let mut ids = Vec::with_capacity(packages.len()); |
| 118 | let mut calls = 0; | |
| 119 | for (body, chunk) in osv::batch_bodies(packages).iter().zip(packages.chunks(osv::MAX_BATCH)) { | |
| 120 | calls += 1; | |
| 121 | let answer = osv_call(Method::Post, osv::QUERY_BATCH_URL, Some(body)).await?.unwrap_or(Value::Null); | |
| 122 | let (mut found, more) = osv::read_batch(&answer, chunk.len()); | |
| 123 | // A package with many advisories is paged; fetch the rest. | |
| 124 | for (index, token) in more.into_iter().take(20) { | |
| 125 | let package = &chunk[index]; | |
| 126 | let query = json!({ | |
| 127 | "package": {"name": package.name, "ecosystem": package.ecosystem.osv()}, | |
| 128 | "version": package.version, | |
| 129 | "page_token": token, | |
| 130 | }); | |
| 131 | calls += 1; | |
| 132 | if let Some(page) = osv_call(Method::Post, "https://api.osv.dev/v1/query", Some(&query)).await? { | |
| 133 | found[index].extend( | |
| 134 | page["vulns"].as_array().into_iter().flatten().filter_map(|v| v["id"].as_str().map(str::to_owned)), | |
| 135 | ); | |
| 136 | } | |
| 137 | } | |
| 138 | ids.extend(found); | |
| 139 | } | |
| 140 | Ok((ids, calls)) | |
| 141 | } | |
| 142 | ||
| 143 | /// 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 | 144 | pub(crate) async fn advisories(&self, ids: &BTreeSet<String>) -> Result<(HashMap<String, Value>, u32)> { |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 145 | let fresh_after = rfc3339(now_ms().saturating_sub(ADVISORY_MAX_AGE_MS)); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 146 | let unfixed_fresh_after = rfc3339(now_ms().saturating_sub(UNFIXED_ADVISORY_MAX_AGE_MS)); |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 147 | let mut records = HashMap::new(); |
| 148 | let mut fetched = 0u32; | |
| 149 | for id in ids { | |
| 150 | if let Some(record) = self.store.advisory(id, &fresh_after).await? { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 151 | // One without a fix is asked about again daily; until then, |
| 152 | // or if asking fails below, the kept record stands. | |
| 153 | let fixed = names_a_fix(&record); | |
| 154 | let recent = !fixed && self.store.advisory(id, &unfixed_fresh_after).await?.is_some(); | |
| 155 | if fixed || recent || fetched as usize >= MAX_ADVISORY_FETCHES { | |
| 156 | records.insert(id.clone(), record); | |
| 157 | continue; | |
| 158 | } | |
| 159 | fetched += 1; | |
| 160 | let refreshed = osv_call(Method::Get, &osv::vuln_url(id), None).await.ok().flatten(); | |
| 161 | if let Some(refreshed) = &refreshed { | |
| 162 | self.store.keep_advisory(id, refreshed).await?; | |
| 163 | } | |
| 164 | records.insert(id.clone(), refreshed.unwrap_or(record)); | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 165 | continue; |
| 166 | } | |
| 167 | if fetched as usize >= MAX_ADVISORY_FETCHES { | |
| 168 | continue; | |
| 169 | } | |
| 170 | fetched += 1; | |
| 171 | if let Some(record) = osv_call(Method::Get, &osv::vuln_url(id), None).await? { | |
| 172 | self.store.keep_advisory(id, &record).await?; | |
| 173 | records.insert(id.clone(), record); | |
| 174 | } | |
| 175 | } | |
| 176 | Ok((records, fetched)) | |
| 177 | } | |
| 178 | ||
| 179 | /// Reads a repository's dependencies, records which are vulnerable, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 180 | /// and starts security updates for those with a fix. Also reads |
| 181 | /// `.g1t/dependencies.yml`. Returns what went wrong, for the Security | |
| 182 | /// page, if anything did. | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 183 | 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 | 184 | let files: Lockfiles = g1t_kit::call(&self.repos, "find_lockfiles", &FindLockfilesArgs { repo_id: repo.repo_id.clone(), git_ref: None }).await?; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 185 | let paths: Vec<String> = files.files.iter().map(|file| file.path.clone()).collect(); |
| 186 | let located = packages(&files); | |
| 187 | let unique: Vec<Package> = located.iter().map(|l| l.package.clone()).collect::<BTreeSet<_>>().into_iter().collect(); | |
| 188 | let outcome = async { | |
| 189 | let (ids, calls) = self.query_osv(&unique).await?; | |
| 190 | let by_package: HashMap<&Package, &Vec<String>> = unique.iter().zip(ids.iter()).collect(); | |
| 191 | let wanted: BTreeSet<String> = ids.iter().flatten().cloned().collect(); | |
| 192 | let (records, fetched) = self.advisories(&wanted).await?; | |
| 193 | let mut found = Vec::new(); | |
| 194 | for item in &located { | |
| 195 | for id in by_package.get(&item.package).into_iter().flat_map(|ids| ids.iter()) { | |
| 196 | let Some(advisory) = records.get(id).and_then(|record| osv::read_vuln(record, &item.package)) else { | |
| 197 | continue; | |
| 198 | }; | |
| 199 | found.push(vulnerability(&repo.repo_id, item, &advisory)); | |
| 200 | } | |
| 201 | } | |
| 202 | Ok::<_, worker::Error>((found, calls + fetched)) | |
| 203 | } | |
| 204 | .await; | |
| 205 | let (found, calls) = match outcome { | |
| 206 | Ok(result) => result, | |
| 207 | Err(error) => { | |
| 208 | let problem = format!("The dependencies could not be checked: {error}"); | |
| 209 | self.store | |
| 210 | .set_dependencies_scanned(&repo.repo_id, files.commit.as_deref(), &paths, Some(&problem)) | |
| 211 | .await?; | |
| 212 | return Ok(Some(problem)); | |
| 213 | } | |
| 214 | }; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 215 | let before: BTreeSet<String> = self.store.open_vulnerabilities(&repo.repo_id).await?.into_iter().map(|row| row.id).collect(); |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 216 | self.store.replace_vulnerabilities(&repo.repo_id, &found).await?; |
| 217 | 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 | 218 | // g1t's update pull requests whose alerts are now fixed or dismissed, |
| 219 | // or whose versions the lockfiles already have, close first, so that | |
| 220 | // nothing later in the scan can keep them open. | |
| 221 | if let Err(error) = self.resolve_updates(repo, Some((&located, &paths))).await { | |
| 222 | worker::console_error!("security: updates of {} not resolved: {error}", repo.repo_id); | |
| 223 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 224 | if let Err(error) = self.record_graph(repo, &files).await { |
| 225 | worker::console_error!("security: dependency graph of {} not kept: {error}", repo.repo_id); | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 226 | } |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 227 | self.vulnerabilities_changed(repo, &before).await?; |
| 228 | self.meter(&repo.namespace, 0, 0, calls, dependency_check_cost(calls, found.len())).await?; | |
| 229 | // The dependency update file says how security updates are made too. | |
| 230 | let rules = match files.commit.as_deref() { | |
| 231 | Some(commit) => self.read_version_updates(repo, commit).await?, | |
| 232 | None => None, | |
| 233 | }; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 234 | // 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. | 235 | // repository, nor on a mirror until it is taken over or moved to |
| 236 | // g1t (`g1t_contracts::mirrors`: a standby mirror is an exact copy | |
| 237 | // that opens nothing of its own); ones in flight for packages no | |
| 238 | // longer vulnerable are closed either way. | |
| 239 | let takes_updates = self.takes_updates(repo).await?.is_ok(); | |
| 240 | self.security_updates(repo, repo.upkeep != 0 && takes_updates, rules.as_ref()).await?; | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 241 | Ok(None) |
| 242 | } | |
| 243 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 244 | /// Reads the dependency update file at `commit` and keeps what it |
| 245 | /// says; see `updates`. Its entries' schedules start or move. Returns | |
| 246 | /// it, when it has no problems. | |
| 247 | pub(crate) async fn read_version_updates(&self, repo: &RepoRow, commit: &str) -> Result<Option<Loaded>> { | |
| 248 | let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }; | |
| 249 | let asks: Vec<BlobArgs> = DEPENDABOT_PATHS | |
| 250 | .iter() | |
| 251 | .map(|file| BlobArgs { | |
| 252 | path: path.clone(), | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 253 | viewer: Some(User::system(&repo.namespace)), |
| 254 | git_ref: commit.to_owned(), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 255 | file_path: (*file).to_owned(), |
| 256 | }) | |
| 257 | .collect(); | |
| 258 | let reads = asks.iter().map(|ask| g1t_kit::call::<_, Outcome<BlobView>>(&self.repos, "blob", ask)); | |
| 259 | let mut found = Vec::new(); | |
| 260 | let mut default_branch = None; | |
| 261 | for (file, read) in DEPENDABOT_PATHS.iter().zip(futures_util::future::join_all(reads).await) { | |
| 262 | if let Outcome::Ok(blob) = read? { | |
| 263 | default_branch = Some(blob.repo.default_branch.clone()); | |
| 264 | found.push(((*file).to_owned(), blob.text)); | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 265 | } |
| 266 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 267 | let (mut state, config) = match updates::choose(found) { |
| 268 | Some(((file, text), ignored)) => { | |
| 269 | let (state, config) = updates::state(&file, text.as_deref(), ignored, &repo.repo_id, default_branch.as_deref()); | |
| 270 | (state, config.map(|config| (config, file))) | |
| 271 | } | |
| 272 | None => (VersionUpdatesState::default(), None), | |
| 273 | }; | |
| 274 | state.read_at = Some(crate::store::now()); | |
| 275 | state.commit = Some(commit.to_owned()); | |
| 276 | self.store.set_version_updates(&repo.repo_id, &state).await?; | |
| 277 | self.schedule_entries(repo, config.as_ref().map(|(config, _)| config), default_branch.as_deref()).await?; | |
| 278 | Ok(config.map(|(config, file)| Loaded { config, file, default_branch: default_branch.unwrap_or_default() })) | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 279 | } |
| 280 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 281 | pub(crate) async fn issue(&self, actor: &User, repo: &RepoPath, number: u32) -> Result<Option<Issue>> { |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 282 | let found: Outcome<IssueDetail> = g1t_kit::call( |
| 283 | &self.work, | |
| 284 | "get_issue", | |
| 285 | &ViewArgs { repo: repo.clone(), number, viewer: Some(actor.clone()), after_seq: 0 }, | |
| 286 | ) | |
| 287 | .await?; | |
| 288 | Ok(found.into_result().ok().map(|detail| detail.issue)) | |
| 289 | } | |
| 290 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 291 | pub(crate) async fn comment(&self, actor: &User, repo: &RepoPath, number: u32, body: String) -> Result<()> { |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 292 | let _: Outcome<Value> = g1t_kit::call( |
| 293 | &self.work, | |
| 294 | "add_comment", | |
| 295 | &AddCommentArgs { | |
| 296 | actor: actor.clone(), | |
| 297 | repo: repo.clone(), | |
| 298 | number, | |
| 299 | body, | |
| 300 | path: None, | |
| 301 | line: None, | |
| 302 | verdict: None, | |
| 303 | }, | |
| 304 | ) | |
| 305 | .await?; | |
| 306 | Ok(()) | |
| 307 | } | |
| 308 | } | |
| 309 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 310 | /// The event that tells of a vulnerability alert. |
| 311 | pub(crate) fn vulnerability_event(repo: &RepoRow, vuln: &Vulnerability) -> g1t_contracts::security_suite::SecurityEvent { | |
| 312 | g1t_contracts::security_suite::SecurityEvent { | |
| 313 | repo_id: repo.repo_id.clone(), | |
| 314 | alert_id: vuln.id.clone(), | |
| 315 | alert_type: g1t_contracts::security_suite::AlertType::Vulnerability.as_str().to_owned(), | |
| 316 | severity: vuln.severity.clone(), | |
| 317 | title: format!("{} {} in {}: {}", vuln.package, vuln.version, vuln.manifest, vuln.advisory), | |
| 318 | link: crate::suite::link(repo, &format!("vulnerabilities?finding={}", vuln.id)), | |
| 319 | path: Some(vuln.manifest.clone()), | |
| 320 | state: vuln.state.as_str().to_owned(), | |
| 321 | ..Default::default() | |
| 322 | } | |
| 323 | } | |
| 324 | ||
| 325 | impl Security { | |
| 326 | /// Tells of vulnerability alerts a dependency read opened or fixed, | |
| Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples. | 327 | /// given the ids that were open before it: an event each for webhooks |
| 328 | /// (the first `MAX_SUMMARY_IDS` of each), and one summary of the read | |
| 329 | /// 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 | 330 | async fn vulnerabilities_changed(&self, repo: &RepoRow, before: &BTreeSet<String>) -> Result<()> { |
| Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples. | 331 | 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 | 332 | let after: Vec<VulnRow> = self.store.open_vulnerabilities(&repo.repo_id).await?; |
| 333 | let now: BTreeSet<&str> = after.iter().map(|row| row.id.as_str()).collect(); | |
| Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples. | 334 | // Worst first, as the rows come: the summary's headline is the worst new one. |
| 335 | let opened: Vec<String> = after.iter().map(|row| row.id.clone()).filter(|id| !before.contains(id)).collect(); | |
| 336 | let mut fixed: Vec<String> = Vec::new(); | |
| 337 | for id in before.iter().filter(|id| !now.contains(id.as_str())) { | |
| 338 | if let Some(vuln) = self.store.vulnerability(&repo.repo_id, id).await? | |
| 339 | && vuln.status == VulnStatus::Fixed | |
| 340 | { | |
| 341 | fixed.push(id.clone()); | |
| 342 | } | |
| 343 | } | |
| 344 | 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 | 345 | if let Some(vuln) = self.store.vulnerability(&repo.repo_id, id).await? { |
| 346 | self.alert_event(AlertType::Vulnerability, "created", repo, vulnerability_event(repo, &vuln), None).await; | |
| 347 | } | |
| 348 | } | |
| Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples. | 349 | for id in fixed.iter().take(MAX_SUMMARY_IDS) { |
| 350 | 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 | 351 | self.alert_event(AlertType::Vulnerability, "fixed", repo, vulnerability_event(repo, &vuln), None).await; |
| 352 | } | |
| 353 | } | |
| Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples. | 354 | let changed = crate::summaries::Changed { scan: "dependencies", opened, fixed, ..crate::summaries::Changed::default() }; |
| 355 | self.summarize(repo, AlertType::Vulnerability, changed, None).await | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 356 | } |
| 357 | } | |
| 358 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 359 | fn vulnerability(repo_id: &str, item: &Located, advisory: &Advisory) -> Vulnerability { |
| 360 | Vulnerability { | |
| 361 | id: String::new(), | |
| 362 | repo_id: repo_id.to_owned(), | |
| 363 | ecosystem: item.package.ecosystem.osv().to_owned(), | |
| 364 | package: item.package.name.clone(), | |
| 365 | version: item.package.version.clone(), | |
| 366 | manifest: item.path.clone(), | |
| 367 | advisory: advisory.display_id.clone(), | |
| 368 | osv_id: advisory.id.clone(), | |
| 369 | summary: advisory.summary.clone(), | |
| 370 | severity: advisory.severity.as_str().to_owned(), | |
| 371 | fixed_version: advisory.fixed.clone(), | |
| 372 | status: VulnStatus::Open, | |
| 373 | issue: None, | |
| 374 | found_at: String::new(), | |
| 375 | fixed_at: None, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 376 | state: AlertState::Open, |
| 377 | dismissed_by: None, | |
| 378 | dismissed_reason: None, | |
| 379 | dismissed_comment: None, | |
| 380 | dismissed_at: None, | |
| 381 | update: None, | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 382 | } |
| 383 | } | |
| 384 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 385 | /// The issue's body, for when raising the version is not enough, written |
| 386 | /// for the agent that takes it as much as for a person, ending with what | |
| 387 | /// done means: commands that show no lockfile still resolves a vulnerable | |
| 388 | /// version, and the project's tests. The pull request merges on the | |
| 389 | /// repository's required checks, like any other. | |
| 390 | pub(crate) fn issue_text(ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow], located: &[Located]) -> String { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 391 | let mut body = format!( |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 392 | "`{package}` ({ecosystem}) has known vulnerabilities with a fix in **{target}**, and raising its version alone \ |
| 393 | does not pass this project's checks. Upgrade it to {target} or later everywhere it is locked, and change the \ | |
| 394 | code that depends on it, keeping other changes to what the upgrade needs.\n\n" | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 395 | ); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 396 | body.push_str(&advisory_table(vulns)); |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 397 | let mut checks = Vec::new(); |
| 398 | let mut manifests = BTreeSet::new(); | |
| 399 | let mut tests = BTreeSet::new(); | |
| 400 | for vuln in vulns { | |
| 401 | let Some(item) = located | |
| 402 | .iter() | |
| 403 | .find(|item| item.path == vuln.manifest && item.package.name == vuln.package && item.package.version == vuln.version) | |
| 404 | else { | |
| 405 | continue; | |
| 406 | }; | |
| 407 | if manifests.insert((item.path.clone(), item.package.version.clone())) { | |
| 408 | checks.push(still_locked_check(item.lockfile, &item.path, &item.package.name, &item.package.version)); | |
| 409 | } | |
| 410 | if let Some(test) = test_command(item.lockfile, directory(&item.path)) { | |
| 411 | tests.insert(test); | |
| 412 | } | |
| 413 | } | |
| 414 | let locked: Vec<String> = manifests.iter().map(|(path, version)| format!("`{path}` ({version})")).collect(); | |
| 415 | body.push_str(&format!("\nLocked in: {}.\n", locked.join(", "))); | |
| 416 | body.push_str( | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 417 | "\nIf the fix needs a major upgrade that breaks the build, change the code that depends on it in the same pull request.", |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 418 | ); |
| 419 | checks.extend(tests); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 420 | let mut done = vec!["No lockfile resolves a vulnerable version, and the tests still pass.".to_owned()]; |
| 421 | done.extend(g1t_contracts::work::commands_pass(&checks)); | |
| 422 | let mut body = g1t_contracts::work::with_definition_of_done(&body, &done); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 423 | body.push_str("\n\n---\n_Opened by g1t's security updates. Turn them off for this project on its Security page._"); |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 424 | body |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 425 | } |
| 426 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 427 | /// The advisories a package's vulnerabilities name, as a table. |
| 428 | pub(crate) fn advisory_table(vulns: &[&VulnRow]) -> String { | |
| 429 | let mut table = "| Advisory | Severity | Affected | Fixed in | Summary |\n| --- | --- | --- | --- | --- |\n".to_owned(); | |
| 430 | let mut seen = BTreeSet::new(); | |
| 431 | for vuln in vulns { | |
| 432 | if !seen.insert((vuln.advisory.clone(), vuln.version.clone())) { | |
| 433 | continue; | |
| 434 | } | |
| 435 | table.push_str(&format!( | |
| 436 | "| [{}]({}) | {} | {} | {} | {} |\n", | |
| 437 | vuln.advisory, | |
| 438 | osv::page_url(&vuln.osv_id), | |
| 439 | vuln.severity, | |
| 440 | vuln.version, | |
| 441 | vuln.fixed_version.as_deref().unwrap_or("none yet"), | |
| 442 | vuln.summary.replace('|', "\\|").replace('\n', " "), | |
| 443 | )); | |
| 444 | } | |
| 445 | table | |
| 446 | } | |
| 447 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 448 | #[cfg(test)] |
| 449 | mod tests { | |
| 450 | use super::*; | |
| 451 | use g1t_contracts::security::LockfileText; | |
| 452 | ||
| 453 | #[test] | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 454 | fn scans_cost_their_cpu_and_the_rows_they_write() { |
| 455 | // 10 OSV calls and 3 vulnerabilities: 0.4 of CPU, 15 rows. | |
| 456 | assert_eq!(dependency_check_cost(10, 3), 16); | |
| 457 | assert_eq!(dependency_check_cost(0, 0), 2); | |
| 458 | // A page of 25 commits that read 100 objects and found nothing: | |
| 459 | // 10 of CPU and 2 rows. The old placeholder charged 100. | |
| 460 | assert_eq!(crate::history::history_page_cost(100, 0), 12); | |
| 461 | assert_eq!(crate::history::history_page_cost(0, 1), 3); | |
| 462 | } | |
| 463 | ||
| 464 | #[test] | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 465 | fn go_sum_is_skipped_beside_go_mod() { |
| 466 | let files = Lockfiles { | |
| 467 | commit: None, | |
| 468 | files: vec![ | |
| 469 | LockfileText { path: "go.mod".into(), text: "require golang.org/x/net v0.7.0\n".into() }, | |
| 470 | LockfileText { path: "go.sum".into(), text: "golang.org/x/net v0.1.0 h1:x=\n".into() }, | |
| 471 | LockfileText { path: "tools/go.sum".into(), text: "golang.org/x/text v0.3.0 h1:x=\n".into() }, | |
| 472 | ], | |
| 473 | }; | |
| 474 | let found: Vec<String> = packages(&files).iter().map(|l| format!("{}:{}", l.path, l.package.version)).collect(); | |
| 475 | assert_eq!(found, ["go.mod:v0.7.0", "tools/go.sum:v0.3.0"]); | |
| 476 | } | |
| 477 | ||
| 478 | #[test] | |
| 479 | fn the_issue_names_the_advisories_and_checks_the_lockfile() { | |
| 480 | let row = VulnRow { | |
| 481 | id: "vul_1".into(), | |
| 482 | repo_id: "rep_1".into(), | |
| 483 | ecosystem: "npm".into(), | |
| 484 | package: "lodash".into(), | |
| 485 | version: "4.17.20".into(), | |
| 486 | manifest: "web/package-lock.json".into(), | |
| 487 | osv_id: "GHSA-35jh-r3h4-6jhm".into(), | |
| 488 | advisory: "GHSA-35jh-r3h4-6jhm".into(), | |
| 489 | summary: "Command Injection in lodash".into(), | |
| 490 | severity: "high".into(), | |
| 491 | fixed_version: Some("4.17.21".into()), | |
| 492 | status: "open".into(), | |
| 493 | found_at: "2026-10-04T00:00:00Z".into(), | |
| 494 | fixed_at: None, | |
| 495 | number: None, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 496 | dismiss_reason: None, |
| 497 | dismiss_comment: None, | |
| 498 | dismissed_by: None, | |
| 499 | dismissed_at: None, | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 500 | }; |
| 501 | let located = vec![Located { | |
| 502 | package: Package { ecosystem: g1t_scan::lockfiles::Ecosystem::Npm, name: "lodash".into(), version: "4.17.20".into() }, | |
| 503 | lockfile: Lockfile::PackageLock, | |
| 504 | path: "web/package-lock.json".into(), | |
| 505 | }]; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 506 | let body = issue_text("npm", "lodash", "4.17.21", &[&row], &located); |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 507 | assert!(body.contains("[GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm) | high | 4.17.20 | 4.17.21")); |
| 508 | assert!(body.contains("`web/package-lock.json` (4.17.20)")); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 509 | let (_, done) = body.split_once("## Definition of done\n\n").unwrap(); |
| 510 | let items: Vec<&str> = done.lines().take_while(|line| line.starts_with("- ")).collect(); | |
| 511 | assert_eq!(items.len(), 3); | |
| 512 | assert!(items[1].contains("node_modules/lodash") && items[1].contains("'web/package-lock.json'")); | |
| 513 | assert_eq!(items[2], "- `cd 'web' && npm ci && npm test --if-present` passes."); | |
| 514 | assert!(body.ends_with("on its Security page._")); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 515 | assert!(advisory_table(&[&row]).contains("| [GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm) | high |")); |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 516 | } |
| 517 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.