| 1 | //! The supply chain: a repository's dependency graph, read from the same |
| 2 | //! lockfiles as its vulnerability alerts; the graph as an SPDX SBOM; and |
| 3 | //! dependency review, which compares a pull request's dependencies with |
| 4 | //! its base and fails the `Dependency review` check when it adds a package |
| 5 | //! with a known vulnerability at or above the repository's threshold, or a |
| 6 | //! license the repository does not allow. |
| 7 | |
| 8 | use std::collections::{BTreeMap, BTreeSet, HashMap}; |
| 9 | |
| 10 | use g1t_contracts::access::Capability; |
| 11 | use g1t_contracts::repos::{Comparison, FileDiff, LineKind, RepoPath}; |
| 12 | use g1t_contracts::security::{FindLockfilesArgs, Lockfiles}; |
| 13 | use g1t_contracts::security_suite::{ |
| 14 | DEPENDENCY_REVIEW_CHECK, DependencyGraph, DependencyGraphArgs, DependencyReview, DependencyReviewArgs, GraphDependency, |
| 15 | GraphManifest, PaidFeature, PullScanningArgs, RepoSecuritySettings, ReviewChange, ReviewVulnerability, SbomArgs, |
| 16 | }; |
| 17 | use g1t_contracts::work::{AddCommentArgs, Comment, CommentActionArgs, PullStatus}; |
| 18 | use g1t_contracts::{FailureCode, Outcome, User}; |
| 19 | use g1t_scan::graph::{self, Dependency, Relationship}; |
| 20 | use g1t_scan::lockfiles::{Ecosystem, Lockfile, Package}; |
| 21 | use g1t_scan::osv::{self, Severity}; |
| 22 | use g1t_scan::review::{self, ChangeKind, Finding, Policy, Verdict}; |
| 23 | use g1t_scan::sbom; |
| 24 | use serde_json::Value; |
| 25 | use worker::Result; |
| 26 | |
| 27 | use crate::Security; |
| 28 | use crate::store::RepoRow; |
| 29 | use crate::suite_store::{DependencyRow, PullCheckRow}; |
| 30 | |
| 31 | /// Added packages a review asks OSV about, at most; the rest are listed |
| 32 | /// without. |
| 33 | const MAX_REVIEWED: usize = 500; |
| 34 | |
| 35 | /// Every package `files` resolve, with what each lockfile says of it. A |
| 36 | /// directory with a `go.mod` is read from it rather than its `go.sum`. |
| 37 | pub fn graph_of(files: &Lockfiles) -> Vec<Dependency> { |
| 38 | let go_mods: BTreeSet<&str> = files |
| 39 | .files |
| 40 | .iter() |
| 41 | .filter(|file| file.path.ends_with("go.mod")) |
| 42 | .map(|file| file.path.trim_end_matches("go.mod")) |
| 43 | .collect(); |
| 44 | let mut deps = Vec::new(); |
| 45 | for file in &files.files { |
| 46 | let Some(lockfile) = Lockfile::for_path(&file.path) else { continue }; |
| 47 | if lockfile == Lockfile::GoSum && go_mods.contains(file.path.trim_end_matches("go.sum")) { |
| 48 | continue; |
| 49 | } |
| 50 | deps.extend(graph::dependencies(lockfile, &file.path, &file.text)); |
| 51 | } |
| 52 | deps |
| 53 | } |
| 54 | |
| 55 | fn row_dependency(row: &DependencyRow) -> Option<Dependency> { |
| 56 | Some(Dependency { |
| 57 | package: Package { ecosystem: Ecosystem::parse(&row.ecosystem)?, name: row.name.clone(), version: row.version.clone() }, |
| 58 | manifest: row.manifest.clone(), |
| 59 | relationship: Relationship::parse(&row.relationship), |
| 60 | development: row.development != 0, |
| 61 | license: row.license.clone(), |
| 62 | }) |
| 63 | } |
| 64 | |
| 65 | /// The policy a repository's settings make. |
| 66 | pub fn policy_of(fail_on: &str, deny: &[String]) -> Policy { |
| 67 | Policy { |
| 68 | fail_on: match fail_on { |
| 69 | "none" => None, |
| 70 | other => Some(Severity::parse(other)).filter(|severity| *severity != Severity::Unknown), |
| 71 | }, |
| 72 | deny_licenses: deny.iter().map(|id| id.trim().to_owned()).filter(|id| !id.is_empty()).collect(), |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// The review as the API and the pull request page show it. |
| 77 | pub fn review_view(base: &str, head: &str, verdict: &Verdict, policy: &Policy) -> DependencyReview { |
| 78 | let change = |kind: &str, dep: &Dependency, findings: &[Finding], failing: &[Finding], denied: bool| ReviewChange { |
| 79 | change_type: kind.to_owned(), |
| 80 | manifest: dep.manifest.clone(), |
| 81 | ecosystem: dep.package.ecosystem.osv().to_owned(), |
| 82 | name: dep.package.name.clone(), |
| 83 | version: dep.package.version.clone(), |
| 84 | relationship: dep.relationship.as_str().to_owned(), |
| 85 | development: dep.development, |
| 86 | license: dep.license.clone(), |
| 87 | purl: dep.purl(), |
| 88 | vulnerabilities: findings |
| 89 | .iter() |
| 90 | .map(|finding| ReviewVulnerability { |
| 91 | advisory: finding.advisory.clone(), |
| 92 | osv_id: finding.osv_id.clone(), |
| 93 | summary: finding.summary.clone(), |
| 94 | severity: finding.severity.as_str().to_owned(), |
| 95 | fixed_version: finding.fixed.clone(), |
| 96 | url: osv::page_url(&finding.osv_id), |
| 97 | }) |
| 98 | .collect(), |
| 99 | denied_license: denied, |
| 100 | failing: !failing.is_empty() || denied, |
| 101 | }; |
| 102 | let mut changes: Vec<ReviewChange> = verdict |
| 103 | .added |
| 104 | .iter() |
| 105 | .map(|reviewed| change("added", &reviewed.dependency, &reviewed.findings, &reviewed.failing, reviewed.denied_license)) |
| 106 | .collect(); |
| 107 | changes.extend(verdict.removed.iter().map(|dep| change("removed", dep, &[], &[], false))); |
| 108 | DependencyReview { |
| 109 | base: base.to_owned(), |
| 110 | head: head.to_owned(), |
| 111 | changes, |
| 112 | passed: verdict.passed, |
| 113 | headline: verdict.headline(), |
| 114 | fail_on: policy.fail_on.map_or("none", |severity| severity.as_str()).to_owned(), |
| 115 | deny_licenses: policy.deny_licenses.clone(), |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /// Whether a comparison touches any lockfile: if not, nothing in the |
| 120 | /// dependency graph can have changed. |
| 121 | pub fn touches_lockfiles(paths: &[String]) -> bool { |
| 122 | paths.iter().any(|path| Lockfile::for_path(path).is_some()) |
| 123 | } |
| 124 | |
| 125 | impl Security { |
| 126 | /// Keeps the dependency graph the lockfiles describe. Called with the |
| 127 | /// lockfiles each dependency read fetched. |
| 128 | pub(crate) async fn record_graph(&self, repo: &RepoRow, files: &Lockfiles) -> Result<()> { |
| 129 | self.store.replace_dependencies(&repo.repo_id, &graph_of(files)).await |
| 130 | } |
| 131 | |
| 132 | async fn lockfiles_at(&self, repo_id: &str, git_ref: Option<&str>) -> Result<Lockfiles> { |
| 133 | g1t_kit::call(&self.repos, "find_lockfiles", &FindLockfilesArgs { repo_id: repo_id.to_owned(), git_ref: git_ref.map(str::to_owned) }).await |
| 134 | } |
| 135 | |
| 136 | pub(crate) async fn dependency_graph(&self, a: DependencyGraphArgs) -> Result<Outcome<DependencyGraph>> { |
| 137 | let mut repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? { |
| 138 | Outcome::Ok(repo) => repo, |
| 139 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 140 | }; |
| 141 | if repo.deps_scanned_at.is_none() { |
| 142 | self.scan_dependencies(&repo).await?; |
| 143 | repo = self.store.repo(&repo.repo_id).await?.unwrap_or(repo); |
| 144 | } |
| 145 | let rows = self.store.dependencies(&repo.repo_id).await?; |
| 146 | let mut vulnerable: HashMap<(String, String, String), u32> = HashMap::new(); |
| 147 | for vuln in self.store.open_vulnerabilities(&repo.repo_id).await? { |
| 148 | *vulnerable.entry((vuln.ecosystem, vuln.package, vuln.version)).or_default() += 1; |
| 149 | } |
| 150 | let mut manifests: BTreeMap<String, GraphManifest> = BTreeMap::new(); |
| 151 | let mut dependencies = Vec::with_capacity(rows.len()); |
| 152 | for row in &rows { |
| 153 | let Some(dep) = row_dependency(row) else { continue }; |
| 154 | let manifest = manifests.entry(row.manifest.clone()).or_insert_with(|| GraphManifest { |
| 155 | path: row.manifest.clone(), |
| 156 | ecosystem: row.ecosystem.clone(), |
| 157 | dependencies: 0, |
| 158 | direct: 0, |
| 159 | }); |
| 160 | manifest.dependencies += 1; |
| 161 | manifest.direct += u32::from(dep.relationship == Relationship::Direct); |
| 162 | dependencies.push(GraphDependency { |
| 163 | vulnerabilities: vulnerable.get(&(row.ecosystem.clone(), row.name.clone(), row.version.clone())).copied().unwrap_or(0), |
| 164 | purl: dep.purl(), |
| 165 | ecosystem: row.ecosystem.clone(), |
| 166 | name: row.name.clone(), |
| 167 | version: row.version.clone(), |
| 168 | manifest: row.manifest.clone(), |
| 169 | relationship: row.relationship.clone(), |
| 170 | development: row.development != 0, |
| 171 | license: row.license.clone(), |
| 172 | }); |
| 173 | } |
| 174 | // Lockfiles with nothing in them still show. |
| 175 | for path in repo.scan_state().lockfiles { |
| 176 | manifests.entry(path.clone()).or_insert_with(|| GraphManifest { |
| 177 | ecosystem: Lockfile::for_path(&path).map(|lockfile| lockfile.ecosystem().osv().to_owned()).unwrap_or_default(), |
| 178 | path, |
| 179 | dependencies: 0, |
| 180 | direct: 0, |
| 181 | }); |
| 182 | } |
| 183 | Ok(Outcome::Ok(DependencyGraph { commit: self.store.deps_commit(&repo.repo_id).await?, manifests: manifests.into_values().collect(), dependencies })) |
| 184 | } |
| 185 | |
| 186 | pub(crate) async fn sbom(&self, a: SbomArgs) -> Result<Outcome<Value>> { |
| 187 | let graph = match self.dependency_graph(DependencyGraphArgs { viewer: a.viewer, repo: a.repo.clone() }).await? { |
| 188 | Outcome::Ok(graph) => graph, |
| 189 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 190 | }; |
| 191 | let deps: Vec<Dependency> = graph |
| 192 | .dependencies |
| 193 | .iter() |
| 194 | .filter_map(|dep| { |
| 195 | Some(Dependency { |
| 196 | package: Package { ecosystem: Ecosystem::parse(&dep.ecosystem)?, name: dep.name.clone(), version: dep.version.clone() }, |
| 197 | manifest: dep.manifest.clone(), |
| 198 | relationship: Relationship::parse(&dep.relationship), |
| 199 | development: dep.development, |
| 200 | license: dep.license.clone(), |
| 201 | }) |
| 202 | }) |
| 203 | .collect(); |
| 204 | let full_name = format!("{}/{}", a.repo.namespace, a.repo.name); |
| 205 | let url = format!("https://g1t.sh/{full_name}"); |
| 206 | let unique = g1t_contracts::new_id("sbom", g1t_kit::now_ms()); |
| 207 | let created = format!("{}Z", &crate::store::now()[..19]); |
| 208 | Ok(Outcome::Ok(sbom::spdx( |
| 209 | &sbom::Subject { full_name: &full_name, url: &url, commit: graph.commit.as_deref(), unique: &unique, created: &created }, |
| 210 | &deps, |
| 211 | ))) |
| 212 | } |
| 213 | |
| 214 | /// Asks OSV about the packages a change adds. |
| 215 | async fn findings_for(&self, added: &[&Dependency]) -> Result<BTreeMap<String, Vec<Finding>>> { |
| 216 | let packages: Vec<Package> = added.iter().map(|dep| dep.package.clone()).collect::<BTreeSet<_>>().into_iter().take(MAX_REVIEWED).collect(); |
| 217 | if packages.is_empty() { |
| 218 | return Ok(BTreeMap::new()); |
| 219 | } |
| 220 | let (ids, _) = self.query_osv(&packages).await?; |
| 221 | let wanted: BTreeSet<String> = ids.iter().flatten().cloned().collect(); |
| 222 | let (records, _) = self.advisories(&wanted).await?; |
| 223 | let mut found = BTreeMap::new(); |
| 224 | for (package, ids) in packages.iter().zip(ids) { |
| 225 | let findings: Vec<Finding> = ids |
| 226 | .iter() |
| 227 | .filter_map(|id| osv::read_vuln(records.get(id)?, package)) |
| 228 | .map(|advisory| Finding { |
| 229 | advisory: advisory.display_id, |
| 230 | osv_id: advisory.id, |
| 231 | summary: advisory.summary, |
| 232 | severity: advisory.severity, |
| 233 | fixed: advisory.fixed, |
| 234 | }) |
| 235 | .collect(); |
| 236 | if !findings.is_empty() { |
| 237 | found.insert(graph::purl(package.ecosystem, &package.name, &package.version), findings); |
| 238 | } |
| 239 | } |
| 240 | Ok(found) |
| 241 | } |
| 242 | |
| 243 | /// Reviews the change from `base` (in `base_repo`) to `head` (in |
| 244 | /// `head_repo`, a fork's for a pull request from one). |
| 245 | async fn review(&self, base_repo: &str, base: &str, head_repo: &str, head: &str, policy: &Policy) -> Result<(Verdict, DependencyReview)> { |
| 246 | let (before, after) = (self.lockfiles_at(base_repo, Some(base)).await?, self.lockfiles_at(head_repo, Some(head)).await?); |
| 247 | let changes = review::diff(&graph_of(&before), &graph_of(&after)); |
| 248 | let added: Vec<&Dependency> = changes.iter().filter(|change| change.kind == ChangeKind::Added).map(|change| &change.dependency).collect(); |
| 249 | let findings = self.findings_for(&added).await?; |
| 250 | let verdict = review::judge(&changes, &findings, policy); |
| 251 | let view = review_view(base, head, &verdict, policy); |
| 252 | Ok((verdict, view)) |
| 253 | } |
| 254 | |
| 255 | pub(crate) async fn dependency_review(&self, a: DependencyReviewArgs) -> Result<Outcome<DependencyReview>> { |
| 256 | let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? { |
| 257 | Outcome::Ok(repo) => repo, |
| 258 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 259 | }; |
| 260 | if let Some(refusal) = self.gate(&repo, PaidFeature::DependencyReview).await? { |
| 261 | return Ok(refusal); |
| 262 | } |
| 263 | let (settings, _) = self.store.repo_settings(&repo.repo_id).await?; |
| 264 | let policy = policy_of(&settings.review_fail_on, &settings.review_deny_licenses); |
| 265 | let (_, view) = self.review(&repo.repo_id, &a.base, &repo.repo_id, &a.head, &policy).await?; |
| 266 | Ok(Outcome::Ok(view)) |
| 267 | } |
| 268 | |
| 269 | /// The `Dependency review` check on a pull request whose head moved. |
| 270 | pub(crate) async fn review_pull(&self, repo_id: &str, number: u32) -> Result<()> { |
| 271 | let Some(repo) = self.register_by_id(repo_id).await? else { return Ok(()) }; |
| 272 | let (settings, _) = self.store.repo_settings(&repo.repo_id).await?; |
| 273 | if !settings.dependency_review || !self.entitled(&repo).await? { |
| 274 | return Ok(()); |
| 275 | } |
| 276 | self.check_pull(&repo, &settings, number, false).await.map(|_| ()) |
| 277 | } |
| 278 | |
| 279 | /// `rerun_dependency_review`: the `Dependency review` check run again on |
| 280 | /// a pull request's head, asked for by someone who may start runs. Its |
| 281 | /// comment is changed in place. Returns `Outcome<DependencyReview>`. |
| 282 | pub(crate) async fn rerun_dependency_review(&self, a: PullScanningArgs) -> Result<Outcome<DependencyReview>> { |
| 283 | let repo = match self.member_repo(&a.repo, &a.viewer, Capability::Run).await? { |
| 284 | Outcome::Ok(repo) => repo, |
| 285 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 286 | }; |
| 287 | if let Some(refusal) = self.gate(&repo, PaidFeature::DependencyReview).await? { |
| 288 | return Ok(refusal); |
| 289 | } |
| 290 | let (settings, _) = self.store.repo_settings(&repo.repo_id).await?; |
| 291 | if !settings.dependency_review { |
| 292 | return Ok(Outcome::fail(FailureCode::Conflict, "Dependency review is off for this repository. Turn it on in its Security settings.")); |
| 293 | } |
| 294 | Ok(match self.check_pull(&repo, &settings, a.number, true).await? { |
| 295 | Some(view) => Outcome::Ok(view), |
| 296 | None => Outcome::fail(FailureCode::NotFound, "No open pull request with that number has a head to review."), |
| 297 | }) |
| 298 | } |
| 299 | |
| 300 | /// Reviews a pull request's head and reports the check, unless its head |
| 301 | /// was reviewed already. A head whose lockfile changes are the ones |
| 302 | /// reviewed before (the base merged into it, say) takes that result |
| 303 | /// without another review, and its comment is left alone. `again` |
| 304 | /// reviews it whatever was reviewed before. |
| 305 | async fn check_pull(&self, repo: &RepoRow, settings: &RepoSecuritySettings, number: u32, again: bool) -> Result<Option<DependencyReview>> { |
| 306 | let Some(detail) = self.pull_detail(repo, number).await? else { return Ok(None) }; |
| 307 | let pull = &detail.pull; |
| 308 | // Asked for again: only while it is open. |
| 309 | if again && pull.status != PullStatus::Open { |
| 310 | return Ok(None); |
| 311 | } |
| 312 | let Some(head) = pull.head_commit.clone() else { return Ok(None) }; |
| 313 | let previous = self.store.pull_check(&repo.repo_id, number, "review").await?; |
| 314 | let previous_view = |row: &PullCheckRow| serde_json::from_str::<DependencyReview>(&row.detail).unwrap_or_default(); |
| 315 | if !again |
| 316 | && let Some(previous) = &previous |
| 317 | && previous.commit_sha == head |
| 318 | { |
| 319 | return Ok(Some(previous_view(previous))); |
| 320 | } |
| 321 | let Some(comparison) = self.pull_comparison(repo, pull).await? else { return Ok(None) }; |
| 322 | let policy = policy_of(&settings.review_fail_on, &settings.review_deny_licenses); |
| 323 | let key = review_key(&comparison, &policy); |
| 324 | if !again |
| 325 | && let Some(previous) = &previous |
| 326 | && key.is_some() |
| 327 | && stored_key(&previous.commented) == key |
| 328 | { |
| 329 | let mut view = previous_view(previous); |
| 330 | view.head = head.clone(); |
| 331 | self.store |
| 332 | .set_pull_check(&repo.repo_id, number, "review", &head, &previous.state, &previous.description, &serde_json::to_value(&view)?, &memo(key.as_deref())) |
| 333 | .await?; |
| 334 | self.set_status(repo, &head, DEPENDENCY_REVIEW_CHECK, &previous.state, &previous.description, number).await; |
| 335 | return Ok(Some(view)); |
| 336 | } |
| 337 | let paths: Vec<String> = comparison.files.iter().map(|file| file.path.clone()).collect(); |
| 338 | let (state, description, view, summary) = if !touches_lockfiles(&paths) { |
| 339 | let view = DependencyReview { |
| 340 | head: head.clone(), |
| 341 | passed: true, |
| 342 | headline: "No dependency changes".to_owned(), |
| 343 | fail_on: policy.fail_on.map_or("none", |severity| severity.as_str()).to_owned(), |
| 344 | deny_licenses: policy.deny_licenses.clone(), |
| 345 | ..DependencyReview::default() |
| 346 | }; |
| 347 | ("success", view.headline.clone(), view, review::unchanged_summary()) |
| 348 | } else { |
| 349 | let base = comparison.base.clone().unwrap_or_else(|| "HEAD".to_owned()); |
| 350 | let head_repo = pull.source_id().to_owned(); |
| 351 | let (verdict, view) = self.review(&repo.repo_id, &base, &head_repo, &head, &policy).await?; |
| 352 | let summary = verdict.summary(&policy); |
| 353 | (if verdict.passed { "success" } else { "failure" }, verdict.headline(), view, summary) |
| 354 | }; |
| 355 | self.store |
| 356 | .set_pull_check(&repo.repo_id, number, "review", &head, state, &description, &serde_json::to_value(&view)?, &memo(key.as_deref())) |
| 357 | .await?; |
| 358 | self.set_status(repo, &head, DEPENDENCY_REVIEW_CHECK, state, &description, number).await; |
| 359 | if settings.review_comment { |
| 360 | self.keep_review_comment(repo, number, &detail.comments, summary, !view.changes.is_empty()).await; |
| 361 | } |
| 362 | Ok(Some(view)) |
| 363 | } |
| 364 | |
| 365 | /// The pull request's one dependency review comment: changed in place |
| 366 | /// when the review says something new, left alone when it says the |
| 367 | /// same, and added only for a review with changes to show. |
| 368 | async fn keep_review_comment(&self, repo: &RepoRow, number: u32, comments: &[Comment], body: String, changes: bool) { |
| 369 | let actor = User::system(&repo.namespace); |
| 370 | let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }; |
| 371 | let done: Result<Outcome<Value>> = match comment_plan(our_comment(comments), &body, changes) { |
| 372 | CommentPlan::Leave => return, |
| 373 | CommentPlan::Edit(comment_id) => { |
| 374 | g1t_kit::call(&self.work, "edit_comment", &CommentActionArgs { actor, repo: path, comment_id, body }).await |
| 375 | } |
| 376 | CommentPlan::Add => { |
| 377 | g1t_kit::call(&self.work, "add_comment", &AddCommentArgs { actor, repo: path, number, body, path: None, line: None, verdict: None }) |
| 378 | .await |
| 379 | } |
| 380 | }; |
| 381 | match done { |
| 382 | Ok(Outcome::Fail(failure)) => worker::console_error!("security: dependency review comment on #{number}: {}", failure.message), |
| 383 | Err(error) => worker::console_error!("security: dependency review comment on #{number}: {error}"), |
| 384 | Ok(Outcome::Ok(_)) => {} |
| 385 | } |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | /// What a review of a pull request depends on, as a key: the lines each |
| 390 | /// lockfile changes (not where they are, which moves when the base is |
| 391 | /// merged in) and the policy. None when a lockfile's lines are not all |
| 392 | /// listed, so the review has to be made to know. |
| 393 | pub fn review_key(comparison: &Comparison, policy: &Policy) -> Option<String> { |
| 394 | if comparison.truncated { |
| 395 | return None; |
| 396 | } |
| 397 | let mut lockfiles: Vec<&FileDiff> = comparison.files.iter().filter(|file| Lockfile::for_path(&file.path).is_some()).collect(); |
| 398 | if lockfiles.iter().any(|file| file.binary || file.omitted) { |
| 399 | return None; |
| 400 | } |
| 401 | lockfiles.sort_by(|a, b| a.path.cmp(&b.path)); |
| 402 | let mut text = format!( |
| 403 | "fail_on {}\ndeny {}\n", |
| 404 | policy.fail_on.map_or("none", |severity| severity.as_str()), |
| 405 | policy.deny_licenses.join(",") |
| 406 | ); |
| 407 | for file in lockfiles { |
| 408 | text.push_str(&format!("file {} {:?}\n", file.path, file.status)); |
| 409 | for line in file.hunks.iter().flat_map(|hunk| &hunk.lines) { |
| 410 | match line.kind { |
| 411 | LineKind::Add => text.push('+'), |
| 412 | LineKind::Delete => text.push('-'), |
| 413 | LineKind::Context => continue, |
| 414 | } |
| 415 | text.push_str(&line.text); |
| 416 | text.push('\n'); |
| 417 | } |
| 418 | } |
| 419 | Some(review::key(&text)) |
| 420 | } |
| 421 | |
| 422 | /// The review row's memo (`pull_checks.commented`): the key of what was |
| 423 | /// reviewed, if there was one. |
| 424 | fn memo(key: Option<&str>) -> Vec<String> { |
| 425 | key.map(|key| vec![format!("key:{key}")]).unwrap_or_default() |
| 426 | } |
| 427 | |
| 428 | fn stored_key(commented: &str) -> Option<String> { |
| 429 | serde_json::from_str::<Vec<String>>(commented) |
| 430 | .ok()? |
| 431 | .into_iter() |
| 432 | .find_map(|entry| entry.strip_prefix("key:").map(str::to_owned)) |
| 433 | } |
| 434 | |
| 435 | /// g1t's dependency review comment on a pull request: the first comment g1t |
| 436 | /// wrote that starts with the review's heading. |
| 437 | pub fn our_comment(comments: &[Comment]) -> Option<&Comment> { |
| 438 | comments |
| 439 | .iter() |
| 440 | .find(|comment| g1t_contracts::system::is_system_id(&comment.author.id) && comment.body.trim_start().starts_with(review::HEADING)) |
| 441 | } |
| 442 | |
| 443 | /// What to do with the review comment. |
| 444 | #[derive(Debug, PartialEq, Eq)] |
| 445 | pub enum CommentPlan { |
| 446 | Leave, |
| 447 | Edit(String), |
| 448 | Add, |
| 449 | } |
| 450 | |
| 451 | pub fn comment_plan(existing: Option<&Comment>, body: &str, changes: bool) -> CommentPlan { |
| 452 | match existing { |
| 453 | Some(comment) if comment.body.trim() == body.trim() => CommentPlan::Leave, |
| 454 | Some(comment) => CommentPlan::Edit(comment.id.clone()), |
| 455 | None if changes => CommentPlan::Add, |
| 456 | None => CommentPlan::Leave, |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | #[cfg(test)] |
| 461 | mod tests { |
| 462 | use super::*; |
| 463 | use g1t_contracts::security::LockfileText; |
| 464 | |
| 465 | #[test] |
| 466 | fn the_graph_reads_every_lockfile_and_skips_go_sum_beside_go_mod() { |
| 467 | let files = Lockfiles { |
| 468 | commit: None, |
| 469 | files: vec![ |
| 470 | LockfileText { path: "go.mod".into(), text: "require golang.org/x/net v0.7.0 // indirect\n".into() }, |
| 471 | LockfileText { path: "go.sum".into(), text: "golang.org/x/net v0.1.0 h1:x=\n".into() }, |
| 472 | LockfileText { |
| 473 | path: "web/package-lock.json".into(), |
| 474 | text: r#"{"packages":{"":{"dependencies":{"lodash":"^4"}},"node_modules/lodash":{"version":"4.17.21","license":"MIT"}}}"#.into(), |
| 475 | }, |
| 476 | ], |
| 477 | }; |
| 478 | let deps: Vec<String> = graph_of(&files) |
| 479 | .iter() |
| 480 | .map(|dep| format!("{} {} {}", dep.manifest, dep.package.name, dep.relationship.as_str())) |
| 481 | .collect(); |
| 482 | assert_eq!(deps, ["go.mod golang.org/x/net transitive", "web/package-lock.json lodash direct"]); |
| 483 | } |
| 484 | |
| 485 | #[test] |
| 486 | fn the_policy_comes_from_the_settings() { |
| 487 | assert_eq!(policy_of("high", &[" GPL-3.0-only ".into(), "".into()]).fail_on, Some(Severity::High)); |
| 488 | assert_eq!(policy_of("high", &[" GPL-3.0-only ".into(), "".into()]).deny_licenses, ["GPL-3.0-only"]); |
| 489 | assert_eq!(policy_of("none", &[]).fail_on, None); |
| 490 | assert_eq!(policy_of("moderate", &[]).fail_on, Some(Severity::Medium)); |
| 491 | } |
| 492 | |
| 493 | #[test] |
| 494 | fn only_lockfile_changes_need_a_review() { |
| 495 | assert!(!touches_lockfiles(&["src/app.ts".into(), "README.md".into()])); |
| 496 | assert!(touches_lockfiles(&["src/app.ts".into(), "web/package-lock.json".into()])); |
| 497 | } |
| 498 | |
| 499 | fn lockfile(path: &str, lines: &[(LineKind, u32, &str)]) -> FileDiff { |
| 500 | use g1t_contracts::repos::{DiffLine, FileStatus, Hunk}; |
| 501 | FileDiff { |
| 502 | path: path.into(), |
| 503 | status: FileStatus::Modified, |
| 504 | additions: 0, |
| 505 | deletions: 0, |
| 506 | binary: false, |
| 507 | hunks: vec![Hunk { |
| 508 | lines: lines |
| 509 | .iter() |
| 510 | .map(|(kind, at, text)| DiffLine { kind: *kind, old: Some(*at), new: Some(*at), text: (*text).into() }) |
| 511 | .collect(), |
| 512 | }], |
| 513 | omitted: false, |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | fn compared(files: Vec<FileDiff>) -> Comparison { |
| 518 | Comparison { base: Some("base".into()), head: "head".into(), files, truncated: false } |
| 519 | } |
| 520 | |
| 521 | #[test] |
| 522 | fn the_base_merged_in_keeps_the_review_key_and_a_lockfile_change_moves_it() { |
| 523 | let policy = policy_of("high", &[]); |
| 524 | let bump = [(LineKind::Context, 10, "\"lodash\": {"), (LineKind::Delete, 11, "\"version\": \"4.17.21\""), (LineKind::Add, 11, "\"version\": \"4.17.20\"")]; |
| 525 | let first = review_key(&compared(vec![lockfile("package-lock.json", &bump)]), &policy).unwrap(); |
| 526 | // The base merged in: other files, and the same lockfile lines |
| 527 | // further down with other context around them. |
| 528 | let moved = [(LineKind::Context, 40, "\"lodash\": { "), (LineKind::Delete, 41, "\"version\": \"4.17.21\""), (LineKind::Add, 41, "\"version\": \"4.17.20\"")]; |
| 529 | let mut caught_up = vec![lockfile("package-lock.json", &moved), lockfile("src/app.ts", &[(LineKind::Add, 1, "x")])]; |
| 530 | caught_up.reverse(); |
| 531 | assert_eq!(review_key(&compared(caught_up), &policy).as_deref(), Some(first.as_str())); |
| 532 | // Another version, or another policy, is another review. |
| 533 | let other = [(LineKind::Delete, 11, "\"version\": \"4.17.21\""), (LineKind::Add, 11, "\"version\": \"4.17.19\"")]; |
| 534 | assert_ne!(review_key(&compared(vec![lockfile("package-lock.json", &other)]), &policy).unwrap(), first); |
| 535 | assert_ne!(review_key(&compared(vec![lockfile("package-lock.json", &bump)]), &policy_of("critical", &[])).unwrap(), first); |
| 536 | // No lockfile changed: one key for that too. |
| 537 | assert!(review_key(&compared(vec![]), &policy).is_some()); |
| 538 | // Lines not listed: no key, so the review is made. |
| 539 | let mut omitted = lockfile("package-lock.json", &bump); |
| 540 | omitted.omitted = true; |
| 541 | assert_eq!(review_key(&compared(vec![omitted]), &policy), None); |
| 542 | let mut truncated = compared(vec![lockfile("package-lock.json", &bump)]); |
| 543 | truncated.truncated = true; |
| 544 | assert_eq!(review_key(&truncated, &policy), None); |
| 545 | } |
| 546 | |
| 547 | #[test] |
| 548 | fn the_review_key_is_kept_in_the_row() { |
| 549 | let commented = serde_json::to_string(&memo(Some("abc"))).unwrap(); |
| 550 | assert_eq!(stored_key(&commented).as_deref(), Some("abc")); |
| 551 | assert_eq!(stored_key("[]"), None); |
| 552 | assert!(memo(None).is_empty()); |
| 553 | } |
| 554 | |
| 555 | fn comment(id: &str, author: User, body: &str) -> Comment { |
| 556 | Comment { |
| 557 | id: id.into(), |
| 558 | kind: Default::default(), |
| 559 | author, |
| 560 | body: body.into(), |
| 561 | path: None, |
| 562 | line: None, |
| 563 | verdict: None, |
| 564 | created_at: String::new(), |
| 565 | edited_at: None, |
| 566 | agent: None, |
| 567 | acting_for: None, |
| 568 | advisory: false, |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | #[test] |
| 573 | fn one_comment_is_kept_and_changed_only_when_the_review_says_something_new() { |
| 574 | let person = User { id: "usr_1".into(), username: "ana".into(), ..User::default() }; |
| 575 | let ours = review::unchanged_summary(); |
| 576 | let comments = vec![ |
| 577 | comment("cmt_1", person.clone(), &ours), |
| 578 | comment("cmt_2", User::system("acme"), "Something else g1t said."), |
| 579 | comment("cmt_3", User::system("acme"), &ours), |
| 580 | comment("cmt_4", User::system("acme"), &ours), |
| 581 | ]; |
| 582 | // A person quoting it is not it; the first of g1t's is. |
| 583 | assert_eq!(our_comment(&comments).map(|found| found.id.as_str()), Some("cmt_3")); |
| 584 | let found = our_comment(&comments); |
| 585 | // The same review: left alone, however its text ends. |
| 586 | assert_eq!(comment_plan(found, &format!("{ours}\n"), true), CommentPlan::Leave); |
| 587 | // Something new: that comment changes, none is added. |
| 588 | assert_eq!(comment_plan(found, "### Dependency review\n\n**Failed.**", true), CommentPlan::Edit("cmt_3".into())); |
| 589 | assert_eq!(comment_plan(found, &ours, false), CommentPlan::Leave); |
| 590 | // None yet: one is added for a review with changes only. |
| 591 | assert_eq!(comment_plan(None, "### Dependency review\n\n**Passed.**", true), CommentPlan::Add); |
| 592 | assert_eq!(comment_plan(None, &ours, false), CommentPlan::Leave); |
| 593 | assert!(our_comment(&comments[..2]).is_none()); |
| 594 | } |
| 595 | |
| 596 | #[test] |
| 597 | fn the_review_view_marks_what_fails() { |
| 598 | let dep = |version: &str| Dependency { |
| 599 | package: Package { ecosystem: Ecosystem::Npm, name: "lodash".into(), version: version.into() }, |
| 600 | manifest: "package-lock.json".into(), |
| 601 | relationship: Relationship::Direct, |
| 602 | development: false, |
| 603 | license: Some("MIT".into()), |
| 604 | }; |
| 605 | let changes = review::diff(&[dep("4.17.21")], &[dep("4.17.20")]); |
| 606 | let findings = BTreeMap::from([( |
| 607 | "pkg:npm/lodash@4.17.20".to_owned(), |
| 608 | vec![Finding { |
| 609 | advisory: "GHSA-35jh-r3h4-6jhm".into(), |
| 610 | osv_id: "GHSA-35jh-r3h4-6jhm".into(), |
| 611 | summary: "Command Injection in lodash".into(), |
| 612 | severity: Severity::High, |
| 613 | fixed: Some("4.17.21".into()), |
| 614 | }], |
| 615 | )]); |
| 616 | let policy = policy_of("high", &[]); |
| 617 | let verdict = review::judge(&changes, &findings, &policy); |
| 618 | let view = review_view("abc", "def", &verdict, &policy); |
| 619 | assert!(!view.passed); |
| 620 | assert_eq!(view.changes.len(), 2); |
| 621 | let added = view.changes.iter().find(|change| change.change_type == "added").unwrap(); |
| 622 | assert!(added.failing && added.vulnerabilities[0].url.ends_with("GHSA-35jh-r3h4-6jhm")); |
| 623 | assert_eq!(view.fail_on, "high"); |
| 624 | } |
| 625 | } |