Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1 | //! Security updates: for each vulnerable package with a fix, g1t itself |
| 2 | //! opens a pull request that raises its version. | |
| 3 | //! | |
| 4 | //! 1. A dependency scan asks the runner for a `bump` (most severe first): | |
| 5 | //! a sandbox raises the package in each lockfile with the ecosystem's | |
| 6 | //! own tool and pushes that to `g1t/security/<package>-<version>`. | |
| 7 | //! 2. That push (`git.push`) opens the pull request, authored by g1t | |
| 8 | //! (`User::system`). It lands through the branch's required checks like | |
| 9 | //! any other. An older one for the same package is closed as superseded. | |
| 10 | //! 3. When its checks fail because code has to change, or the sandbox | |
| 11 | //! never pushed, the pull request is closed and an issue is opened for | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 12 | //! g1t to work on, started by g1t. That is the only time an agent is |
| 13 | //! used. | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 14 | //! 4. A package no longer vulnerable closes its update as superseded. |
| 15 | //! | |
| 16 | //! Each step is written to the alerts' activity log. | |
| 17 | ||
| 18 | use std::collections::BTreeMap; | |
| 19 | ||
| 20 | use g1t_contracts::repos::RepoPath; | |
| 21 | use g1t_contracts::security::{BumpArgs, UpdateState, update_branch}; | |
| 22 | use g1t_contracts::time::rfc3339; | |
| 23 | use g1t_contracts::work::{OpenIssueArgs, OpenPullArgs, Pull, PullActionArgs, PullDetail, PullStatus, Runtime, ViewArgs}; | |
| 24 | use g1t_contracts::{Outcome, User}; | |
| 25 | use g1t_kit::now_ms; | |
| 26 | use g1t_scan::osv::{self, Severity}; | |
| 27 | use g1t_scan::version; | |
| 28 | use serde_json::{Value, json}; | |
| 29 | use worker::Result; | |
| 30 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 31 | use g1t_contracts::security::UPDATE_BRANCH_PREFIX; |
| 32 | use g1t_contracts::updates::{BumpPackage, IgnoreCondition, UpdatedDependency, VersionUpdateEntry, VersionUpdatesState}; | |
| 33 | use g1t_contracts::work::UpdatePullArgs; | |
| 34 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 35 | use crate::Security; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 36 | use crate::config::{CommitMessage, Config, Entry, glob, matches}; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 37 | use crate::deps::{advisory_table, issue_text}; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 38 | use crate::pull_text; |
| 39 | use crate::ranges; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 40 | use crate::store::{Activity, RepoRow, UpdateRow, VulnRow}; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 41 | use crate::update_store::NewPull; |
| 42 | use crate::version_updates::{Loaded, package_ecosystem}; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 43 | |
| 44 | /// Security updates asked for per scan, most severe first. | |
| 45 | const MAX_NEW_UPDATES: usize = 8; | |
| 46 | /// A sandbox that has not pushed its branch after this long is taken to | |
| 47 | /// have failed. | |
| 48 | const STALLED_MS: u64 = 45 * 60 * 1000; | |
| 49 | /// A failed update is tried again after this long. | |
| 50 | const RETRY_FAILED_MS: u64 = 24 * 60 * 60 * 1000; | |
| 51 | ||
| 52 | /// What to do about a package's existing update, given the version it | |
| 53 | /// should now reach. | |
| 54 | #[derive(Debug, PartialEq, Eq)] | |
| 55 | pub enum Next { | |
| 56 | /// Leave it: in flight, done, or someone closed it. | |
| 57 | Wait, | |
| 58 | /// Ask for a new one. | |
| 59 | Request, | |
| 60 | } | |
| 61 | ||
| 62 | /// Whether a package with an update in `state` to `current`, last changed | |
| 63 | /// at `updated_at`, needs a new one to reach `target`. A higher target | |
| 64 | /// always does; the same one only when the last was superseded (it is | |
| 65 | /// vulnerable again) or failed long enough ago. | |
| 66 | pub fn next_step(state: UpdateState, current: &str, target: &str, updated_at: &str, retry_after: &str) -> Next { | |
| 67 | if version::compare(target, current) == std::cmp::Ordering::Greater { | |
| 68 | return Next::Request; | |
| 69 | } | |
| 70 | match state { | |
| 71 | UpdateState::Superseded => Next::Request, | |
| 72 | UpdateState::Failed if updated_at < retry_after => Next::Request, | |
| 73 | _ => Next::Wait, | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | /// The pull request's title. | |
| 78 | pub fn pull_title(package: &str, target: &str) -> String { | |
| 79 | format!("Upgrade {package} to {target}").chars().take(200).collect() | |
| 80 | } | |
| 81 | ||
| 82 | /// The pull request's body: what it fixes, where, and what happens if | |
| 83 | /// raising the version is not enough. | |
| 84 | pub fn pull_text(ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> String { | |
| 85 | let mut from: Vec<&str> = vulns.iter().map(|vuln| vuln.version.as_str()).collect(); | |
| 86 | from.sort(); | |
| 87 | from.dedup(); | |
| 88 | let mut lockfiles: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect(); | |
| 89 | lockfiles.sort(); | |
| 90 | lockfiles.dedup(); | |
| 91 | let mut body = format!( | |
| 92 | "Upgrades `{package}` ({ecosystem}) from {} to **{target}**, which fixes these known vulnerabilities:\n\n", | |
| 93 | from.join(", ") | |
| 94 | ); | |
| 95 | body.push_str(&advisory_table(vulns)); | |
| 96 | body.push_str(&format!( | |
| 97 | "\nLockfiles changed: {}.\n\n\ | |
| 98 | Only the version changes. This pull request lands through this branch's required checks like any other. \ | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 99 | If they fail because code has to change, g1t closes it and puts g1t on an issue to make the change.\n\n\ |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 100 | ---\n_Opened by g1t's security updates. Turn them off for this project on its Security page._", |
| 101 | lockfiles.iter().map(|path| format!("`{path}`")).collect::<Vec<_>>().join(", ") | |
| 102 | )); | |
| 103 | body | |
| 104 | } | |
| 105 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 106 | /// A package a `security-updates` group gathers. |
| 107 | pub struct GroupMember { | |
| 108 | /// OSV's name. | |
| 109 | pub ecosystem: String, | |
| 110 | pub package: String, | |
| 111 | /// The lowest vulnerable version found. | |
| 112 | pub from: String, | |
| 113 | pub target: String, | |
| 114 | /// The lockfiles that resolve a vulnerable version. | |
| 115 | pub manifests: Vec<String>, | |
| 116 | } | |
| 117 | ||
| 118 | fn lowest(vulns: &[&VulnRow]) -> String { | |
| 119 | vulns.iter().map(|vuln| vuln.version.clone()).min_by(|a, b| version::compare(a, b)).unwrap_or_default() | |
| 120 | } | |
| 121 | ||
| 122 | /// Whether one of the file's directories (`/web`, or a glob) holds the | |
| 123 | /// lockfile at `path`, from the root. | |
| 124 | fn covers(directories: &[String], path: &str) -> bool { | |
| 125 | let directory = format!("/{}", path.rsplit_once('/').map_or("", |(dir, _)| dir)); | |
| 126 | directories.iter().any(|wanted| if wanted.contains(['*', '?']) { glob(wanted, &directory) } else { *wanted == directory }) | |
| 127 | } | |
| 128 | ||
| 129 | /// The `updates` entry that speaks for a vulnerable package: its | |
| 130 | /// ecosystem, a directory holding one of its lockfiles, and no | |
| 131 | /// `target-branch` but the default branch (security updates always go to | |
| 132 | /// the default branch, and such an entry's options are for version | |
| 133 | /// updates only). | |
| 134 | pub fn security_entry<'a>(config: &'a Config, default_branch: &str, osv: &str, manifests: &[&str]) -> Option<&'a Entry> { | |
| 135 | let ecosystem = package_ecosystem(osv)?; | |
| 136 | config.updates.iter().find(|entry| { | |
| 137 | entry.ecosystem == ecosystem | |
| 138 | && entry.target_branch.as_deref().is_none_or(|branch| branch == default_branch) | |
| 139 | && manifests.iter().any(|path| covers(&entry.directories, path)) | |
| 140 | }) | |
| 141 | } | |
| 142 | ||
| 143 | /// Whether the file lets a security update raise `package` to `target`: | |
| 144 | /// not ignored (by name, or for these versions) in the file or by a | |
| 145 | /// comment, and named by `allow` when it names dependencies. | |
| 146 | pub fn security_allowed(entry: &Entry, comments: &[IgnoreCondition], package: &str, target: &str) -> bool { | |
| 147 | let ignored = entry.ignore.iter().filter(|rule| matches(&rule.dependency, package)).any(|rule| { | |
| 148 | (rule.versions.is_empty() && rule.update_types.is_empty()) || rule.versions.iter().any(|versions| ranges::ignored_by(versions, target)) | |
| 149 | }); | |
| 150 | let commented = comments.iter().filter(|c| c.ecosystem == entry.ecosystem && c.dependency.eq_ignore_ascii_case(package)).any(|c| { | |
| 151 | (c.versions.is_none() && c.update_type.is_none()) || c.versions.as_deref().is_some_and(|versions| ranges::ignored_by(versions, target)) | |
| 152 | }); | |
| 153 | let named: Vec<&str> = entry.allow.iter().filter_map(|rule| rule.dependency.as_deref()).collect(); | |
| 154 | let allowed = named.is_empty() || named.iter().any(|pattern| matches(pattern, package)); | |
| 155 | !ignored && !commented && allowed | |
| 156 | } | |
| 157 | ||
| 158 | /// The `security-updates` group that gathers `package`, if any. | |
| 159 | pub fn security_group(entry: &Entry, package: &str, from: &str, target: &str) -> Option<String> { | |
| 160 | let level = ranges::update_level(from, target); | |
| 161 | entry | |
| 162 | .groups | |
| 163 | .iter() | |
| 164 | .filter(|group| group.applies_to == "security-updates") | |
| 165 | .find(|group| { | |
| 166 | (group.patterns.is_empty() || group.patterns.iter().any(|pattern| matches(pattern, package))) | |
| 167 | && !group.exclude_patterns.iter().any(|pattern| matches(pattern, package)) | |
| 168 | && (group.update_types.is_empty() || group.update_types.iter().any(|wanted| wanted == level)) | |
| 169 | }) | |
| 170 | .map(|group| group.name.clone()) | |
| 171 | } | |
| 172 | ||
| 173 | /// The entry, as last read, that speaks for a package's security update. | |
| 174 | pub fn security_settings<'a>(state: &'a VersionUpdatesState, osv: &str, manifests: &[&str]) -> Option<&'a VersionUpdateEntry> { | |
| 175 | let ecosystem = package_ecosystem(osv)?; | |
| 176 | state.updates.iter().find(|entry| { | |
| 177 | entry.ecosystem == ecosystem && entry.target_branch.is_none() && manifests.iter().any(|path| covers(&entry.directories, path)) | |
| 178 | }) | |
| 179 | } | |
| 180 | ||
| 181 | /// An entry's `commit-message`, as last read. | |
| 182 | pub fn commit_message_of(entry: &VersionUpdateEntry) -> Option<CommitMessage> { | |
| 183 | let message = entry.options.get("commit-message")?; | |
| 184 | let text = |key: &str| message.get(key).and_then(Value::as_str).map(str::to_owned); | |
| 185 | Some(CommitMessage { prefix: text("prefix"), prefix_development: text("prefix-development"), scope: text("include").as_deref() == Some("scope") }) | |
| 186 | } | |
| 187 | ||
| 188 | /// A title with the file's commit message prefix, if it has one. | |
| 189 | fn prefixed(prefix: &str, plain: String) -> String { | |
| 190 | let text = if prefix.is_empty() { plain } else { format!("{prefix}{}{}", plain[..1].to_lowercase(), &plain[1..]) }; | |
| 191 | text.chars().take(200).collect() | |
| 192 | } | |
| 193 | ||
| 194 | /// A grouped security update's body. | |
| 195 | pub fn group_text(group: &str, members: &[GroupMember], vulns: &[&VulnRow], file: &str) -> String { | |
| 196 | let mut body = format!("Upgrades the {group} group's vulnerable packages to the versions that fix them:\n\n| Package | From | To |\n| --- | --- | --- |\n"); | |
| 197 | for member in members { | |
| 198 | body.push_str(&format!("| `{}` | {} | **{}** |\n", member.package, member.from, member.target)); | |
| 199 | } | |
| 200 | body.push_str("\nThe known vulnerabilities they fix:\n\n"); | |
| 201 | body.push_str(&advisory_table(vulns)); | |
| 202 | body.push_str(&format!( | |
| 203 | "\nOnly the versions change. This pull request lands through this branch's required checks like any other. \ | |
| 204 | If they fail because code has to change, g1t closes it and puts g1t on an issue to make the change.\n\n{}\n\n---\n\ | |
| 205 | _Opened by g1t's security updates, grouped as `{file}` asks. Turn them off for this project on its Security page._", | |
| 206 | pull_text::COMMANDS | |
| 207 | )); | |
| 208 | body | |
| 209 | } | |
| 210 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 211 | impl Security { |
| 212 | fn path_of(repo: &RepoRow) -> RepoPath { | |
| 213 | RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() } | |
| 214 | } | |
| 215 | ||
| 216 | async fn get_pull(&self, repo: &RepoRow, number: u32) -> Result<Option<Pull>> { | |
| 217 | let found: Outcome<PullDetail> = g1t_kit::call( | |
| 218 | &self.work, | |
| 219 | "get_pull", | |
| 220 | &ViewArgs { repo: Self::path_of(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 }, | |
| 221 | ) | |
| 222 | .await?; | |
| 223 | Ok(found.into_result().ok().map(|detail| detail.pull)) | |
| 224 | } | |
| 225 | ||
| 226 | /// Closes one of g1t's pull requests, saying why first. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 227 | pub(crate) async fn close_with(&self, repo: &RepoRow, number: u32, why: String) -> Result<()> { |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 228 | let system = User::system(&repo.namespace); |
| 229 | self.comment(&system, &Self::path_of(repo), number, why).await?; | |
| 230 | let _: Outcome<Value> = g1t_kit::call( | |
| 231 | &self.work, | |
| 232 | "close_pull", | |
| 233 | &PullActionArgs { | |
| 234 | actor: system, | |
| 235 | repo: Self::path_of(repo), | |
| 236 | number, | |
| 237 | summary: String::new(), | |
| 238 | keep_issue_open: false, | |
| 239 | ignore_checks: false, | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 240 | bypass_rules: false, |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 241 | }, |
| 242 | ) | |
| 243 | .await?; | |
| 244 | Ok(()) | |
| 245 | } | |
| 246 | ||
| 247 | /// Records `action` on every open alert of an update's package. | |
| 248 | async fn note(&self, row: &UpdateRow, action: &str, actor: Option<&str>, number: Option<u32>, comment: Option<&str>) -> Result<()> { | |
| 249 | let ids = self.store.open_ids(&row.repo_id, &row.ecosystem, &row.package).await?; | |
| 250 | let activity: Vec<Activity> = ids | |
| 251 | .iter() | |
| 252 | .map(|id| Activity { alert_id: id, action, actor, reason: None, comment, number }) | |
| 253 | .collect(); | |
| 254 | self.store.record(&row.repo_id, &activity).await | |
| 255 | } | |
| 256 | ||
| 257 | /// After a dependency scan: asks for an update for each vulnerable | |
| 258 | /// package with a fix (when `enabled`), and supersedes those whose | |
| 259 | /// package is no longer vulnerable. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 260 | /// |
| 261 | /// The dependency update file, when there is one, applies as it does to | |
| 262 | /// version updates: `ignore` and `allow` by name, people's `@g1t ignore` | |
| 263 | /// comments, `groups` with `applies-to: security-updates` (one pull | |
| 264 | /// request for each group), and `assignees`, `reviewers` and | |
| 265 | /// `commit-message`. `open-pull-requests-limit` does not apply. | |
| 266 | pub async fn security_updates(&self, repo: &RepoRow, enabled: bool, rules: Option<&Loaded>) -> Result<()> { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 267 | let open = self.store.open_vulnerabilities(&repo.repo_id).await?; |
| 268 | let mut by_package: BTreeMap<(String, String), Vec<&VulnRow>> = BTreeMap::new(); | |
| 269 | for vuln in &open { | |
| 270 | by_package.entry((vuln.ecosystem.clone(), vuln.package.clone())).or_default().push(vuln); | |
| 271 | } | |
| 272 | // In flight for a package that is no longer vulnerable: no longer needed. | |
| 273 | for row in self.store.updates(&repo.repo_id).await? { | |
| 274 | if !row.state().in_progress() || by_package.contains_key(&(row.ecosystem.clone(), row.package.clone())) { | |
| 275 | continue; | |
| 276 | } | |
| 277 | self.supersede(repo, &row, format!("`{}` is no longer vulnerable here, so this is no longer needed.", row.package)) | |
| 278 | .await?; | |
| 279 | } | |
| 280 | if !enabled { | |
| 281 | return Ok(()); | |
| 282 | } | |
| 283 | let mut groups: Vec<((String, String), Vec<&VulnRow>)> = by_package | |
| 284 | .into_iter() | |
| 285 | .filter(|(_, vulns)| vulns.iter().any(|vuln| vuln.fixed_version.is_some())) | |
| 286 | .collect(); | |
| 287 | groups.sort_by_key(|(_, vulns)| std::cmp::Reverse(vulns.iter().map(|v| Severity::parse(&v.severity)).max())); | |
| 288 | let retry_after = rfc3339(now_ms().saturating_sub(RETRY_FAILED_MS)); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 289 | let comments = self.store.ignores(&repo.repo_id).await?; |
| 290 | let mut grouped: BTreeMap<(String, String), Vec<GroupMember>> = BTreeMap::new(); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 291 | let mut asked = 0; |
| 292 | for ((ecosystem, package), vulns) in groups { | |
| 293 | if asked >= MAX_NEW_UPDATES { | |
| 294 | break; | |
| 295 | } | |
| 296 | let Some(target) = osv::upgrade_target(vulns.iter().filter_map(|v| v.fixed_version.as_deref())) else { | |
| 297 | continue; | |
| 298 | }; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 299 | let manifests: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect(); |
| 300 | if let Some(entry) = rules.and_then(|loaded| security_entry(&loaded.config, &loaded.default_branch, &ecosystem, &manifests)) { | |
| 301 | if !security_allowed(entry, &comments, &package, &target) { | |
| 302 | continue; | |
| 303 | } | |
| 304 | let from = lowest(&vulns); | |
| 305 | if let Some(group) = security_group(entry, &package, &from, &target) { | |
| 306 | let key = (entry.id(), group); | |
| 307 | if !grouped.contains_key(&key) { | |
| 308 | asked += 1; | |
| 309 | } | |
| 310 | let manifests = manifests.iter().map(|path| (*path).to_owned()).collect(); | |
| 311 | grouped.entry(key).or_default().push(GroupMember { ecosystem: ecosystem.clone(), package: package.clone(), from, target, manifests }); | |
| 312 | continue; | |
| 313 | } | |
| 314 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 315 | match self.store.update(&repo.repo_id, &ecosystem, &package).await? { |
| 316 | Some(row) => { | |
| 317 | if next_step(row.state(), &row.target, &target, &row.updated_at, &retry_after) == Next::Wait { | |
| 318 | continue; | |
| 319 | } | |
| 320 | } | |
| 321 | None => { | |
| 322 | // An upgrade issue from before security updates, still | |
| 323 | // open, is left to the agent on it. | |
| 324 | if let Some(existing) = self.store.upgrade(&repo.repo_id, &ecosystem, &package).await? | |
| 325 | && self | |
| 326 | .issue(&User::system(&repo.namespace), &Self::path_of(repo), existing.number as u32) | |
| 327 | .await? | |
| 328 | .is_some_and(|issue| issue.state == g1t_contracts::work::State::Open) | |
| 329 | { | |
| 330 | continue; | |
| 331 | } | |
| 332 | } | |
| 333 | } | |
| 334 | asked += 1; | |
| 335 | self.request(repo, &ecosystem, &package, &target, &vulns).await?; | |
| 336 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 337 | for ((entry_id, group), members) in grouped { |
| 338 | let Some(loaded) = rules else { continue }; | |
| 339 | let Some(entry) = loaded.config.updates.iter().find(|entry| entry.id() == entry_id) else { continue }; | |
| 340 | self.request_group(repo, loaded, entry, &group, members, &open).await?; | |
| 341 | } | |
| 342 | Ok(()) | |
| 343 | } | |
| 344 | ||
| 345 | /// Asks for one pull request for a `security-updates` group's | |
| 346 | /// packages, unless one for the same versions is under way or was | |
| 347 | /// closed. | |
| 348 | async fn request_group(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry, group: &str, members: Vec<GroupMember>, open: &[VulnRow]) -> Result<()> { | |
| 349 | let subject = format!("security:{group}"); | |
| 350 | let mut signature: Vec<String> = members.iter().map(|m| format!("{}@{}", m.package, m.target)).collect(); | |
| 351 | signature.sort(); | |
| 352 | let signature = signature.join(","); | |
| 353 | let existing = self.store.update_pulls(&repo.repo_id).await?; | |
| 354 | let known = |row: &&crate::update_store::PullRow| row.kind == "security" && row.subject == subject && row.signature == signature; | |
| 355 | if existing.iter().filter(known).any(|row| row.state().in_progress() || row.state() == UpdateState::Closed) { | |
| 356 | return Ok(()); | |
| 357 | } | |
| 358 | let branch = format!("{UPDATE_BRANCH_PREFIX}{}-{}", group.to_lowercase().replace('|', "-"), pull_text::digest(&signature)); | |
| 359 | let vulns: Vec<&VulnRow> = open.iter().filter(|vuln| members.iter().any(|m| m.ecosystem == vuln.ecosystem && m.package == vuln.package)).collect(); | |
| 360 | let count = if members.len() == 1 { "1 security update".to_owned() } else { format!("{} security updates", members.len()) }; | |
| 361 | let title = prefixed(&pull_text::prefix(entry.commit_message.as_ref(), false), format!("Bump the {group} group with {count}")); | |
| 362 | let body = group_text(group, &members, &vulns, &loaded.file); | |
| 363 | let mut lockfiles: Vec<String> = members.iter().flat_map(|m| m.manifests.clone()).collect(); | |
| 364 | lockfiles.sort(); | |
| 365 | lockfiles.dedup(); | |
| 366 | let packages: Vec<BumpPackage> = members.iter().map(|m| BumpPackage { package: m.package.clone(), version: m.target.clone() }).collect(); | |
| 367 | let bump = BumpArgs { | |
| 368 | repo: Self::path_of(repo), | |
| 369 | ecosystem: members[0].ecosystem.clone(), | |
| 370 | package: packages[0].package.clone(), | |
| 371 | version: packages[0].version.clone(), | |
| 372 | lockfiles, | |
| 373 | branch: branch.clone(), | |
| 374 | message: title.clone(), | |
| 375 | kind: None, | |
| 376 | packages, | |
| 377 | strategy: None, | |
| 378 | force: existing.iter().any(|row| row.branch == branch && row.state().in_progress()), | |
| 379 | registries: Vec::new(), | |
| 380 | base: None, | |
| 381 | }; | |
| 382 | let dependencies: Vec<UpdatedDependency> = members | |
| 383 | .iter() | |
| 384 | .map(|m| UpdatedDependency { | |
| 385 | name: m.package.clone(), | |
| 386 | from: m.from.clone(), | |
| 387 | to: m.target.clone(), | |
| 388 | directory: m.manifests.first().map(|path| format!("/{}", path.rsplit_once('/').map_or("", |(dir, _)| dir))).unwrap_or_else(|| "/".to_owned()), | |
| 389 | dependency_type: String::new(), | |
| 390 | update_type: ranges::update_type(&m.from, &m.target), | |
| 391 | }) | |
| 392 | .collect(); | |
| 393 | let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() }; | |
| 394 | let id = self | |
| 395 | .store | |
| 396 | .add_update_pull(&NewPull { | |
| 397 | repo_id: &repo.repo_id, | |
| 398 | kind: "security", | |
| 399 | entry: &entry.id(), | |
| 400 | ecosystem: &entry.ecosystem, | |
| 401 | subject: &subject, | |
| 402 | signature: &signature, | |
| 403 | group: Some(group), | |
| 404 | branch: &branch, | |
| 405 | title: &title, | |
| 406 | body: &body, | |
| 407 | dependencies: &dependencies, | |
| 408 | bump: &bump, | |
| 409 | assignees: &people(&entry.assignees), | |
| 410 | reviewers: &people(&entry.reviewers), | |
| 411 | }) | |
| 412 | .await?; | |
| 413 | for member in &members { | |
| 414 | self.store.request_update(&repo.repo_id, &member.ecosystem, &member.package, &member.target, &branch).await?; | |
| 415 | } | |
| 416 | let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &bump).await { | |
| 417 | Ok(Outcome::Ok(_)) => Ok(()), | |
| 418 | Ok(Outcome::Fail(refused)) => Err(refused.message), | |
| 419 | Err(error) => Err(format!("the runner could not be reached: {error}")), | |
| 420 | }; | |
| 421 | for member in &members { | |
| 422 | let Some(row) = self.store.update(&repo.repo_id, &member.ecosystem, &member.package).await? else { continue }; | |
| 423 | match &started { | |
| 424 | Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await?, | |
| 425 | Err(reason) => { | |
| 426 | let error = format!("g1t could not start the security update: {reason}"); | |
| 427 | self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?; | |
| 428 | self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await?; | |
| 429 | } | |
| 430 | } | |
| 431 | } | |
| 432 | if let Err(reason) = started { | |
| 433 | self.store.set_update_pull(&id, UpdateState::Failed, None, None, Some(&format!("g1t could not start the security update: {reason}"))).await?; | |
| 434 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 435 | Ok(()) |
| 436 | } | |
| 437 | ||
| 438 | /// Asks the runner to make the change on its branch. | |
| 439 | async fn request(&self, repo: &RepoRow, ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> Result<()> { | |
| 440 | let branch = update_branch(package, target); | |
| 441 | let mut lockfiles: Vec<String> = vulns.iter().map(|vuln| vuln.manifest.clone()).collect(); | |
| 442 | lockfiles.sort(); | |
| 443 | lockfiles.dedup(); | |
| 444 | let args = BumpArgs { | |
| 445 | repo: Self::path_of(repo), | |
| 446 | ecosystem: ecosystem.to_owned(), | |
| 447 | package: package.to_owned(), | |
| 448 | version: target.to_owned(), | |
| 449 | lockfiles, | |
| 450 | branch: branch.clone(), | |
| 451 | message: format!("Upgrade {package} to {target}"), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 452 | kind: None, |
| 453 | packages: Vec::new(), | |
| 454 | strategy: None, | |
| 455 | force: false, | |
| 456 | registries: Vec::new(), | |
| 457 | base: None, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 458 | }; |
| 459 | let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &args).await { | |
| 460 | Ok(Outcome::Ok(_)) => Ok(()), | |
| 461 | Ok(Outcome::Fail(refused)) => Err(refused.message), | |
| 462 | Err(error) => Err(format!("the runner could not be reached: {error}")), | |
| 463 | }; | |
| 464 | self.store.request_update(&repo.repo_id, ecosystem, package, target, &branch).await?; | |
| 465 | let Some(row) = self.store.update(&repo.repo_id, ecosystem, package).await? else { | |
| 466 | return Ok(()); | |
| 467 | }; | |
| 468 | match started { | |
| 469 | Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await, | |
| 470 | Err(reason) => { | |
| 471 | let error = format!("g1t could not start the security update: {reason}"); | |
| 472 | self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?; | |
| 473 | self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await | |
| 474 | } | |
| 475 | } | |
| 476 | } | |
| 477 | ||
| 478 | /// A security update's branch was pushed: its pull request opens, and | |
| 479 | /// an older one for the same package is closed as superseded. | |
| 480 | pub async fn update_pushed(&self, repo_id: &str, branch: &str) -> Result<()> { | |
| 481 | let Some(row) = self.store.update_by_branch(repo_id, branch).await? else { | |
| 482 | return Ok(()); | |
| 483 | }; | |
| 484 | if row.state() != UpdateState::Requested { | |
| 485 | return Ok(()); | |
| 486 | } | |
| 487 | let Some(repo) = self.store.repo(repo_id).await? else { | |
| 488 | return Ok(()); | |
| 489 | }; | |
| 490 | let open = self.store.open_vulnerabilities(repo_id).await?; | |
| 491 | let vulns: Vec<&VulnRow> = open | |
| 492 | .iter() | |
| 493 | .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package) | |
| 494 | .collect(); | |
| 495 | let system = User::system(&repo.namespace); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 496 | // What the dependency update file says for this package's directory. |
| 497 | let state = repo.version_updates(); | |
| 498 | let manifests: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect(); | |
| 499 | let settings = security_settings(&state, &row.ecosystem, &manifests); | |
| 500 | let prefix = settings.map(|entry| pull_text::prefix(commit_message_of(entry).as_ref(), false)).unwrap_or_default(); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 501 | let opened: Outcome<Pull> = g1t_kit::call( |
| 502 | &self.work, | |
| 503 | "open_pull", | |
| 504 | &OpenPullArgs { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 505 | actor: system.clone(), |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 506 | repo: Self::path_of(&repo), |
| 507 | issue: None, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 508 | title: prefixed(&prefix, pull_title(&row.package, &row.target)), |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 509 | body: pull_text(&row.ecosystem, &row.package, &row.target, &vulns), |
| 510 | branch: Some(branch.to_owned()), | |
| 511 | agent: String::new(), | |
| 512 | runtime: Runtime::External, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 513 | // Security updates are for the default branch. |
| 514 | base: None, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 515 | }, |
| 516 | ) | |
| 517 | .await?; | |
| 518 | let pull = match opened { | |
| 519 | Outcome::Ok(pull) => pull, | |
| 520 | Outcome::Fail(refused) => { | |
| 521 | let error = format!("The pull request could not be opened: {}", refused.message); | |
| 522 | self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?; | |
| 523 | return self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await; | |
| 524 | } | |
| 525 | }; | |
| 526 | if let Some(older) = row.pull().filter(|older| *older != pull.number) { | |
| 527 | self.close_with(&repo, older, format!("Superseded by #{}, which upgrades `{}` to {}.", pull.number, row.package, row.target)) | |
| 528 | .await?; | |
| 529 | } | |
| 530 | self.store.set_update(&row, UpdateState::Open, Some(pull.number), None, None).await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 531 | // Labelled as the entry for its directory says, or `dependencies` |
| 532 | // and its ecosystem's label; assigned and in a milestone as it says. | |
| 533 | let ecosystem = package_ecosystem(&row.ecosystem).unwrap_or(row.ecosystem.as_str()); | |
| 534 | let labels = crate::config::update_labels(settings.and_then(|entry| entry.labels.as_deref()), ecosystem); | |
| 535 | let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() }; | |
| 536 | let (assignees, reviewers) = settings.map(|entry| (people(&entry.assignees), people(&entry.reviewers))).unwrap_or_default(); | |
| 537 | if !assignees.is_empty() || !reviewers.is_empty() || !labels.is_empty() { | |
| 538 | let _: Outcome<Value> = g1t_kit::call( | |
| 539 | &self.work, | |
| 540 | "update_pull", | |
| 541 | &UpdatePullArgs { | |
| 542 | actor: system.clone(), | |
| 543 | repo: Self::path_of(&repo), | |
| 544 | number: pull.number, | |
| 545 | assignees: (!assignees.is_empty()).then_some(assignees), | |
| 546 | reviewers: (!reviewers.is_empty()).then_some(reviewers), | |
| 547 | labels: (!labels.is_empty()).then_some(labels), | |
| 548 | milestone: None, | |
| 549 | base: None, | |
| 550 | }, | |
| 551 | ) | |
| 552 | .await?; | |
| 553 | } | |
| 554 | if let Some(milestone) = settings.and_then(|entry| entry.milestone) { | |
| 555 | let _: Outcome<Value> = g1t_kit::call( | |
| 556 | &self.work, | |
| 557 | "update_pull", | |
| 558 | &UpdatePullArgs { | |
| 559 | actor: system, | |
| 560 | repo: Self::path_of(&repo), | |
| 561 | number: pull.number, | |
| 562 | assignees: None, | |
| 563 | reviewers: None, | |
| 564 | labels: None, | |
| 565 | milestone: Some(milestone), | |
| 566 | base: None, | |
| 567 | }, | |
| 568 | ) | |
| 569 | .await?; | |
| 570 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 571 | self.note(&row, "update_opened", Some(g1t_contracts::system::USERNAME), Some(pull.number), None).await |
| 572 | } | |
| 573 | ||
| 574 | /// A pull request merged, closed or checked: if it is a security | |
| 575 | /// update's, where the update stands now. | |
| 576 | pub async fn update_pull_event(&self, kind: &str, repo_id: &str, number: u32, status: Option<&str>, actor: Option<&str>) -> Result<()> { | |
| 577 | let Some(row) = self.store.update_by_pull(repo_id, number).await? else { | |
| 578 | return Ok(()); | |
| 579 | }; | |
| 580 | if row.state() != UpdateState::Open { | |
| 581 | return Ok(()); | |
| 582 | } | |
| 583 | match kind { | |
| 584 | "pull.merged" => { | |
| 585 | self.store.set_update(&row, UpdateState::Merged, Some(number), None, None).await?; | |
| 586 | self.note(&row, "update_merged", actor, Some(number), None).await | |
| 587 | } | |
| 588 | "pull.closed" => { | |
| 589 | self.store.set_update(&row, UpdateState::Closed, Some(number), None, None).await?; | |
| 590 | self.note(&row, "update_closed", actor, Some(number), None).await | |
| 591 | } | |
| 592 | "checks.completed" if status == Some("failed") => { | |
| 593 | let Some(repo) = self.store.repo(repo_id).await? else { | |
| 594 | return Ok(()); | |
| 595 | }; | |
| 596 | self.needs_code(&repo, &row, "Raising the version alone fails this branch's required checks").await | |
| 597 | } | |
| 598 | _ => Ok(()), | |
| 599 | } | |
| 600 | } | |
| 601 | ||
| 602 | /// Closes an update that is no longer needed: its pull request, if it | |
| 603 | /// has one still open (one that merged meanwhile is recorded merged). | |
| 604 | async fn supersede(&self, repo: &RepoRow, row: &UpdateRow, why: String) -> Result<()> { | |
| 605 | if let Some(number) = row.pull() { | |
| 606 | match self.get_pull(repo, number).await? { | |
| 607 | Some(pull) if pull.status == PullStatus::Merged => { | |
| 608 | return self.store.set_update(row, UpdateState::Merged, Some(number), row.issue(), None).await; | |
| 609 | } | |
| 610 | Some(pull) if matches!(pull.status, PullStatus::Open | PullStatus::Draft) => { | |
| 611 | self.store.set_update(row, UpdateState::Superseded, Some(number), row.issue(), None).await?; | |
| 612 | self.close_with(repo, number, why).await?; | |
| 613 | return self.note(row, "update_superseded", Some(g1t_contracts::system::USERNAME), Some(number), None).await; | |
| 614 | } | |
| 615 | _ => {} | |
| 616 | } | |
| 617 | } | |
| 618 | self.store.set_update(row, UpdateState::Superseded, row.pull(), row.issue(), None).await | |
| 619 | } | |
| 620 | ||
| 621 | /// Updates whose sandbox never pushed: raising the version did not | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 622 | /// work, so g1t gets an issue for it. |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 623 | pub async fn stalled_updates(&self) -> Result<()> { |
| 624 | let before = rfc3339(now_ms().saturating_sub(STALLED_MS)); | |
| 625 | for row in self.store.stalled_updates(&before, 10).await? { | |
| 626 | let Some(repo) = self.store.repo(&row.repo_id).await? else { continue }; | |
| 627 | if repo.upkeep == 0 || !self.active(&repo.repo_id).await? { | |
| 628 | self.store | |
| 629 | .set_update(&row, UpdateState::Failed, row.pull(), None, Some("The version could not be raised in a sandbox.")) | |
| 630 | .await?; | |
| 631 | continue; | |
| 632 | } | |
| 633 | self.needs_code(&repo, &row, "The version could not be raised in a sandbox on its own").await?; | |
| 634 | } | |
| 635 | Ok(()) | |
| 636 | } | |
| 637 | ||
| 638 | /// Raising the version is not enough: closes g1t's pull request, opens | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 639 | /// an issue for the change, and puts g1t to work on it. |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 640 | async fn needs_code(&self, repo: &RepoRow, row: &UpdateRow, why: &str) -> Result<()> { |
| 641 | let path = Self::path_of(repo); | |
| 642 | let system = User::system(&repo.namespace); | |
| 643 | let open = self.store.open_vulnerabilities(&repo.repo_id).await?; | |
| 644 | let vulns: Vec<&VulnRow> = open | |
| 645 | .iter() | |
| 646 | .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package) | |
| 647 | .collect(); | |
| 648 | if vulns.is_empty() { | |
| 649 | return self.supersede(repo, row, format!("`{}` is no longer vulnerable here.", row.package)).await; | |
| 650 | } | |
| 651 | let issue: Outcome<g1t_contracts::work::Issue> = g1t_kit::call( | |
| 652 | &self.work, | |
| 653 | "open_issue", | |
| 654 | &OpenIssueArgs { | |
| 655 | actor: system.clone(), | |
| 656 | repo: path.clone(), | |
| 657 | title: format!("Upgrade {} to {}: needs code changes", row.package, row.target).chars().take(200).collect(), | |
| 658 | body: issue_text(&row.ecosystem, &row.package, &row.target, &vulns, &[]), | |
| 659 | labels: vec!["dependencies".to_owned(), "security".to_owned()], | |
| 660 | checks: Vec::new(), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 661 | milestone: None, |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 662 | }, |
| 663 | ) | |
| 664 | .await?; | |
| 665 | let issue = match issue { | |
| 666 | Outcome::Ok(issue) => issue, | |
| 667 | Outcome::Fail(refused) => { | |
| 668 | let error = format!("{why}, and the issue for it could not be opened: {}", refused.message); | |
| 669 | self.store.set_update(row, UpdateState::Failed, row.pull(), None, Some(&error)).await?; | |
| 670 | return self.note(row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await; | |
| 671 | } | |
| 672 | }; | |
| 673 | self.store | |
| 674 | .set_update(row, UpdateState::NeedsCode, row.pull(), Some(issue.number), Some(why)) | |
| 675 | .await?; | |
| 676 | if let Some(number) = row.pull() { | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 677 | self.close_with(repo, number, format!("{why}, so code has to change too. g1t is making the change in #{}.", issue.number)) |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 678 | .await?; |
| 679 | } | |
| 680 | let started: Outcome<Value> = | |
| 681 | g1t_kit::call(&self.runner, "run", &json!({ "actor": system, "repo": path, "issue": issue.number })).await?; | |
| 682 | if let Outcome::Fail(refused) = started { | |
| 683 | self.comment(&system, &path, issue.number, format!( | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 684 | "g1t could not put an agent on this upgrade: {}\n\nAssign it to g1t once agents can run here, or upgrade it by hand.", |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 685 | refused.message |
| 686 | )) | |
| 687 | .await?; | |
| 688 | } | |
| 689 | self.note(row, "update_needs_code", Some(g1t_contracts::system::USERNAME), Some(issue.number), Some(why)).await | |
| 690 | } | |
| 691 | } | |
| 692 | ||
| 693 | #[cfg(test)] | |
| 694 | mod tests { | |
| 695 | use super::*; | |
| 696 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 697 | fn rules(extra: &str) -> Config { |
| 698 | let source = format!( | |
| 699 | "version: 2\nupdates:\n - package-ecosystem: npm\n directories: [\"/\", \"/apps/*\"]\n schedule: {{interval: weekly}}\n{extra} - package-ecosystem: npm\n directory: /legacy\n target-branch: develop\n schedule: {{interval: weekly}}\n" | |
| 700 | ); | |
| 701 | let found = crate::config::read(&source); | |
| 702 | assert!(found.problems.is_empty(), "{:?}", found.problems); | |
| 703 | found.config | |
| 704 | } | |
| 705 | ||
| 706 | #[test] | |
| 707 | fn security_updates_follow_the_file() { | |
| 708 | let config = rules( | |
| 709 | " ignore:\n - dependency-name: left-pad\n - dependency-name: react\n versions: [\">=19\"]\n groups:\n fixes:\n applies-to: security-updates\n patterns: [\"@babel/*\"]\n update-types: [patch, minor]\n minor:\n patterns: [\"*\"]\n", | |
| 710 | ); | |
| 711 | let entry = security_entry(&config, "main", "npm", &["apps/web/package-lock.json"]).unwrap(); | |
| 712 | assert_eq!(entry.id(), "npm:/,/apps/*"); | |
| 713 | // An entry for another branch never speaks for security updates. | |
| 714 | assert!(security_entry(&config, "main", "npm", &["legacy/package-lock.json"]).is_none()); | |
| 715 | assert!(security_entry(&config, "main", "crates.io", &["Cargo.lock"]).is_none()); | |
| 716 | assert!(!security_allowed(entry, &[], "left-pad", "1.3.0")); | |
| 717 | assert!(!security_allowed(entry, &[], "react", "19.0.1")); | |
| 718 | assert!(security_allowed(entry, &[], "react", "18.3.1")); | |
| 719 | let comment = IgnoreCondition { ecosystem: "npm".into(), dependency: "Lodash".into(), versions: None, update_type: None, by: "ana".into(), pull: None, at: String::new() }; | |
| 720 | assert!(!security_allowed(entry, &[comment], "lodash", "4.17.21")); | |
| 721 | assert_eq!(security_group(entry, "@babel/core", "7.0.0", "7.24.1").as_deref(), Some("fixes")); | |
| 722 | // A major bump is outside the group's update types, and only security groups count. | |
| 723 | assert_eq!(security_group(entry, "@babel/core", "6.0.0", "7.24.1"), None); | |
| 724 | assert_eq!(security_group(entry, "lodash", "4.17.20", "4.17.21"), None); | |
| 725 | let named = rules(" allow:\n - dependency-name: \"lodash\"\n"); | |
| 726 | let entry = security_entry(&named, "main", "npm", &["package-lock.json"]).unwrap(); | |
| 727 | assert!(security_allowed(entry, &[], "lodash", "4.17.21") && !security_allowed(entry, &[], "minimist", "1.2.6")); | |
| 728 | } | |
| 729 | ||
| 730 | #[test] | |
| 731 | fn grouped_security_updates_say_what_they_fix() { | |
| 732 | let vuln = row("4.17.20", "4.17.21"); | |
| 733 | let members = vec![GroupMember { ecosystem: "npm".into(), package: "lodash".into(), from: "4.17.20".into(), target: "4.17.21".into(), manifests: vec!["package-lock.json".into()] }]; | |
| 734 | let body = group_text("fixes", &members, &[&vuln], ".github/dependabot.yml"); | |
| 735 | assert!(body.starts_with("Upgrades the fixes group's vulnerable packages")); | |
| 736 | assert!(body.contains("| `lodash` | 4.17.20 | **4.17.21** |")); | |
| 737 | assert!(body.contains("GHSA-35jh-r3h4-6jhm") && body.contains("`@g1t rebase`")); | |
| 738 | assert!(body.ends_with("on its Security page._")); | |
| 739 | assert_eq!(prefixed("build(deps): ", "Upgrade lodash to 4.17.21".into()), "build(deps): upgrade lodash to 4.17.21"); | |
| 740 | assert_eq!(prefixed("", "Upgrade lodash to 4.17.21".into()), "Upgrade lodash to 4.17.21"); | |
| 741 | } | |
| 742 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 743 | fn row(version: &str, fixed: &str) -> VulnRow { |
| 744 | VulnRow { | |
| 745 | id: "vul_1".into(), | |
| 746 | repo_id: "rep_1".into(), | |
| 747 | ecosystem: "npm".into(), | |
| 748 | package: "lodash".into(), | |
| 749 | version: version.into(), | |
| 750 | manifest: "web/package-lock.json".into(), | |
| 751 | osv_id: "GHSA-35jh-r3h4-6jhm".into(), | |
| 752 | advisory: "GHSA-35jh-r3h4-6jhm".into(), | |
| 753 | summary: "Command Injection in lodash".into(), | |
| 754 | severity: "high".into(), | |
| 755 | fixed_version: Some(fixed.into()), | |
| 756 | status: "open".into(), | |
| 757 | found_at: "2026-10-04T00:00:00Z".into(), | |
| 758 | fixed_at: None, | |
| 759 | number: None, | |
| 760 | dismiss_reason: None, | |
| 761 | dismiss_comment: None, | |
| 762 | dismissed_by: None, | |
| 763 | dismissed_at: None, | |
| 764 | } | |
| 765 | } | |
| 766 | ||
| 767 | #[test] | |
| 768 | fn a_newer_fix_asks_again_and_the_same_one_waits() { | |
| 769 | let retry = "2026-10-05T00:00:00Z"; | |
| 770 | let at = "2026-10-06T00:00:00Z"; | |
| 771 | assert_eq!(next_step(UpdateState::Open, "4.17.20", "4.17.21", at, retry), Next::Request); | |
| 772 | assert_eq!(next_step(UpdateState::Open, "4.17.21", "4.17.21", at, retry), Next::Wait); | |
| 773 | assert_eq!(next_step(UpdateState::Requested, "4.17.21", "4.17.21", at, retry), Next::Wait); | |
| 774 | assert_eq!(next_step(UpdateState::Merged, "4.17.21", "4.17.21", at, retry), Next::Wait); | |
| 775 | // A person closed it: left alone until a newer fix. | |
| 776 | assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.21", at, retry), Next::Wait); | |
| 777 | assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.22", at, retry), Next::Request); | |
| 778 | // Vulnerable again after it was no longer needed. | |
| 779 | assert_eq!(next_step(UpdateState::Superseded, "4.17.21", "4.17.21", at, retry), Next::Request); | |
| 780 | // Failed: tried again a day later. | |
| 781 | assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", at, retry), Next::Wait); | |
| 782 | assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", "2026-10-04T00:00:00Z", retry), Next::Request); | |
| 783 | assert_eq!(next_step(UpdateState::NeedsCode, "4.17.21", "4.17.21", at, retry), Next::Wait); | |
| 784 | } | |
| 785 | ||
| 786 | #[test] | |
| 787 | fn the_pull_request_says_what_it_fixes_and_where() { | |
| 788 | let a = row("4.17.20", "4.17.21"); | |
| 789 | let mut b = row("4.17.19", "4.17.21"); | |
| 790 | b.manifest = "package-lock.json".into(); | |
| 791 | let body = pull_text("npm", "lodash", "4.17.21", &[&a, &b]); | |
| 792 | assert!(body.starts_with("Upgrades `lodash` (npm) from 4.17.19, 4.17.20 to **4.17.21**")); | |
| 793 | assert!(body.contains("[GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)")); | |
| 794 | assert!(body.contains("Lockfiles changed: `package-lock.json`, `web/package-lock.json`.")); | |
| 795 | assert!(body.contains("required checks")); | |
| 796 | assert_eq!(pull_title("lodash", "4.17.21"), "Upgrade lodash to 4.17.21"); | |
| 797 | assert_eq!(update_branch("@babel/core", "7.24.1"), "g1t/security/babel-core-7.24.1"); | |
| 798 | assert_eq!(update_branch("golang.org/x/net", "v0.23.0"), "g1t/security/golang.org-x-net-v0.23.0"); | |
| 799 | } | |
| 800 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.