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