Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

636 linesCodeBlameRaw
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
8use std::collections::{BTreeMap, BTreeSet, HashMap};
9
10use g1t_contracts::access::Capability;
11use g1t_contracts::repos::{Comparison, FileDiff, LineKind, RepoPath};
12use g1t_contracts::security::{FindLockfilesArgs, Lockfiles};
13use g1t_contracts::security_suite::{
14 DEPENDENCY_REVIEW_CHECK, DependencyGraph, DependencyGraphArgs, DependencyReview, DependencyReviewArgs, GraphDependency,
15 GraphManifest, PaidFeature, PullScanningArgs, RepoSecuritySettings, ReviewChange, ReviewVulnerability, SbomArgs,
16};
17use g1t_contracts::work::{AddCommentArgs, Comment, CommentActionArgs, PullStatus};
18use g1t_contracts::{FailureCode, Outcome, User};
19use g1t_scan::graph::{self, Dependency, Relationship};
20use g1t_scan::lockfiles::{Ecosystem, Lockfile, Package};
21use g1t_scan::osv::{self, Severity};
22use g1t_scan::review::{self, ChangeKind, Finding, Policy, Verdict};
23use g1t_scan::sbom;
24use serde_json::Value;
25use worker::Result;
26
27use crate::Security;
28use crate::store::RepoRow;
29use crate::suite_store::{DependencyRow, PullCheckRow};
30
31/// Added packages a review asks OSV about, at most; the rest are listed
32/// without.
33const 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`.
37pub 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
55fn 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.
66pub 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.
77pub 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.
121pub fn touches_lockfiles(paths: &[String]) -> bool {
122 paths.iter().any(|path| Lockfile::for_path(path).is_some())
123}
124
125impl 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: Some(path), workspace: String::new(), comment_id, body }).await
375 }
376 CommentPlan::Add => {
377 g1t_kit::call(&self.work, "add_comment", &AddCommentArgs {
378 actor,
379 repo: Some(path),
380 workspace: String::new(),
381 number,
382 body,
383 path: None,
384 line: None,
385 verdict: None,
386 },
387 )
388 .await
389 }
390 };
391 match done {
392 Ok(Outcome::Fail(failure)) => worker::console_error!("security: dependency review comment on #{number}: {}", failure.message),
393 Err(error) => worker::console_error!("security: dependency review comment on #{number}: {error}"),
394 Ok(Outcome::Ok(_)) => {}
395 }
396 }
397}
398
399/// What a review of a pull request depends on, as a key: the lines each
400/// lockfile changes (not where they are, which moves when the base is
401/// merged in) and the policy. None when a lockfile's lines are not all
402/// listed, so the review has to be made to know.
403pub fn review_key(comparison: &Comparison, policy: &Policy) -> Option<String> {
404 if comparison.truncated {
405 return None;
406 }
407 let mut lockfiles: Vec<&FileDiff> = comparison.files.iter().filter(|file| Lockfile::for_path(&file.path).is_some()).collect();
408 if lockfiles.iter().any(|file| file.binary || file.omitted) {
409 return None;
410 }
411 lockfiles.sort_by(|a, b| a.path.cmp(&b.path));
412 let mut text = format!(
413 "fail_on {}\ndeny {}\n",
414 policy.fail_on.map_or("none", |severity| severity.as_str()),
415 policy.deny_licenses.join(",")
416 );
417 for file in lockfiles {
418 text.push_str(&format!("file {} {:?}\n", file.path, file.status));
419 for line in file.hunks.iter().flat_map(|hunk| &hunk.lines) {
420 match line.kind {
421 LineKind::Add => text.push('+'),
422 LineKind::Delete => text.push('-'),
423 LineKind::Context => continue,
424 }
425 text.push_str(&line.text);
426 text.push('\n');
427 }
428 }
429 Some(review::key(&text))
430}
431
432/// The review row's memo (`pull_checks.commented`): the key of what was
433/// reviewed, if there was one.
434fn memo(key: Option<&str>) -> Vec<String> {
435 key.map(|key| vec![format!("key:{key}")]).unwrap_or_default()
436}
437
438fn stored_key(commented: &str) -> Option<String> {
439 serde_json::from_str::<Vec<String>>(commented)
440 .ok()?
441 .into_iter()
442 .find_map(|entry| entry.strip_prefix("key:").map(str::to_owned))
443}
444
445/// g1t's dependency review comment on a pull request: the first comment g1t
446/// wrote that starts with the review's heading.
447pub fn our_comment(comments: &[Comment]) -> Option<&Comment> {
448 comments
449 .iter()
450 .find(|comment| g1t_contracts::system::is_system_id(&comment.author.id) && comment.body.trim_start().starts_with(review::HEADING))
451}
452
453/// What to do with the review comment.
454#[derive(Debug, PartialEq, Eq)]
455pub enum CommentPlan {
456 Leave,
457 Edit(String),
458 Add,
459}
460
461pub fn comment_plan(existing: Option<&Comment>, body: &str, changes: bool) -> CommentPlan {
462 match existing {
463 Some(comment) if comment.body.trim() == body.trim() => CommentPlan::Leave,
464 Some(comment) => CommentPlan::Edit(comment.id.clone()),
465 None if changes => CommentPlan::Add,
466 None => CommentPlan::Leave,
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use g1t_contracts::security::LockfileText;
474
475 #[test]
476 fn the_graph_reads_every_lockfile_and_skips_go_sum_beside_go_mod() {
477 let files = Lockfiles {
478 commit: None,
479 files: vec![
480 LockfileText { path: "go.mod".into(), text: "require golang.org/x/net v0.7.0 // indirect\n".into() },
481 LockfileText { path: "go.sum".into(), text: "golang.org/x/net v0.1.0 h1:x=\n".into() },
482 LockfileText {
483 path: "web/package-lock.json".into(),
484 text: r#"{"packages":{"":{"dependencies":{"lodash":"^4"}},"node_modules/lodash":{"version":"4.17.21","license":"MIT"}}}"#.into(),
485 },
486 ],
487 };
488 let deps: Vec<String> = graph_of(&files)
489 .iter()
490 .map(|dep| format!("{} {} {}", dep.manifest, dep.package.name, dep.relationship.as_str()))
491 .collect();
492 assert_eq!(deps, ["go.mod golang.org/x/net transitive", "web/package-lock.json lodash direct"]);
493 }
494
495 #[test]
496 fn the_policy_comes_from_the_settings() {
497 assert_eq!(policy_of("high", &[" GPL-3.0-only ".into(), "".into()]).fail_on, Some(Severity::High));
498 assert_eq!(policy_of("high", &[" GPL-3.0-only ".into(), "".into()]).deny_licenses, ["GPL-3.0-only"]);
499 assert_eq!(policy_of("none", &[]).fail_on, None);
500 assert_eq!(policy_of("moderate", &[]).fail_on, Some(Severity::Medium));
501 }
502
503 #[test]
504 fn only_lockfile_changes_need_a_review() {
505 assert!(!touches_lockfiles(&["src/app.ts".into(), "README.md".into()]));
506 assert!(touches_lockfiles(&["src/app.ts".into(), "web/package-lock.json".into()]));
507 }
508
509 fn lockfile(path: &str, lines: &[(LineKind, u32, &str)]) -> FileDiff {
510 use g1t_contracts::repos::{DiffLine, FileStatus, Hunk};
511 FileDiff {
512 path: path.into(),
513 status: FileStatus::Modified,
514 additions: 0,
515 deletions: 0,
516 binary: false,
517 hunks: vec![Hunk {
518 lines: lines
519 .iter()
520 .map(|(kind, at, text)| DiffLine { kind: *kind, old: Some(*at), new: Some(*at), text: (*text).into() })
521 .collect(),
522 }],
523 omitted: false,
524 }
525 }
526
527 fn compared(files: Vec<FileDiff>) -> Comparison {
528 Comparison { base: Some("base".into()), head: "head".into(), files, truncated: false }
529 }
530
531 #[test]
532 fn the_base_merged_in_keeps_the_review_key_and_a_lockfile_change_moves_it() {
533 let policy = policy_of("high", &[]);
534 let bump = [(LineKind::Context, 10, "\"lodash\": {"), (LineKind::Delete, 11, "\"version\": \"4.17.21\""), (LineKind::Add, 11, "\"version\": \"4.17.20\"")];
535 let first = review_key(&compared(vec![lockfile("package-lock.json", &bump)]), &policy).unwrap();
536 // The base merged in: other files, and the same lockfile lines
537 // further down with other context around them.
538 let moved = [(LineKind::Context, 40, "\"lodash\": { "), (LineKind::Delete, 41, "\"version\": \"4.17.21\""), (LineKind::Add, 41, "\"version\": \"4.17.20\"")];
539 let mut caught_up = vec![lockfile("package-lock.json", &moved), lockfile("src/app.ts", &[(LineKind::Add, 1, "x")])];
540 caught_up.reverse();
541 assert_eq!(review_key(&compared(caught_up), &policy).as_deref(), Some(first.as_str()));
542 // Another version, or another policy, is another review.
543 let other = [(LineKind::Delete, 11, "\"version\": \"4.17.21\""), (LineKind::Add, 11, "\"version\": \"4.17.19\"")];
544 assert_ne!(review_key(&compared(vec![lockfile("package-lock.json", &other)]), &policy).unwrap(), first);
545 assert_ne!(review_key(&compared(vec![lockfile("package-lock.json", &bump)]), &policy_of("critical", &[])).unwrap(), first);
546 // No lockfile changed: one key for that too.
547 assert!(review_key(&compared(vec![]), &policy).is_some());
548 // Lines not listed: no key, so the review is made.
549 let mut omitted = lockfile("package-lock.json", &bump);
550 omitted.omitted = true;
551 assert_eq!(review_key(&compared(vec![omitted]), &policy), None);
552 let mut truncated = compared(vec![lockfile("package-lock.json", &bump)]);
553 truncated.truncated = true;
554 assert_eq!(review_key(&truncated, &policy), None);
555 }
556
557 #[test]
558 fn the_review_key_is_kept_in_the_row() {
559 let commented = serde_json::to_string(&memo(Some("abc"))).unwrap();
560 assert_eq!(stored_key(&commented).as_deref(), Some("abc"));
561 assert_eq!(stored_key("[]"), None);
562 assert!(memo(None).is_empty());
563 }
564
565 fn comment(id: &str, author: User, body: &str) -> Comment {
566 Comment {
567 id: id.into(),
568 kind: Default::default(),
569 author,
570 body: body.into(),
571 path: None,
572 line: None,
573 verdict: None,
574 created_at: String::new(),
575 edited_at: None,
576 agent: None,
577 acting_for: None,
578 advisory: false,
579 reactions: Vec::new(),
580 }
581 }
582
583 #[test]
584 fn one_comment_is_kept_and_changed_only_when_the_review_says_something_new() {
585 let person = User { id: "usr_1".into(), username: "ana".into(), ..User::default() };
586 let ours = review::unchanged_summary();
587 let comments = vec![
588 comment("cmt_1", person.clone(), &ours),
589 comment("cmt_2", User::system("acme"), "Something else g1t said."),
590 comment("cmt_3", User::system("acme"), &ours),
591 comment("cmt_4", User::system("acme"), &ours),
592 ];
593 // A person quoting it is not it; the first of g1t's is.
594 assert_eq!(our_comment(&comments).map(|found| found.id.as_str()), Some("cmt_3"));
595 let found = our_comment(&comments);
596 // The same review: left alone, however its text ends.
597 assert_eq!(comment_plan(found, &format!("{ours}\n"), true), CommentPlan::Leave);
598 // Something new: that comment changes, none is added.
599 assert_eq!(comment_plan(found, "### Dependency review\n\n**Failed.**", true), CommentPlan::Edit("cmt_3".into()));
600 assert_eq!(comment_plan(found, &ours, false), CommentPlan::Leave);
601 // None yet: one is added for a review with changes only.
602 assert_eq!(comment_plan(None, "### Dependency review\n\n**Passed.**", true), CommentPlan::Add);
603 assert_eq!(comment_plan(None, &ours, false), CommentPlan::Leave);
604 assert!(our_comment(&comments[..2]).is_none());
605 }
606
607 #[test]
608 fn the_review_view_marks_what_fails() {
609 let dep = |version: &str| Dependency {
610 package: Package { ecosystem: Ecosystem::Npm, name: "lodash".into(), version: version.into() },
611 manifest: "package-lock.json".into(),
612 relationship: Relationship::Direct,
613 development: false,
614 license: Some("MIT".into()),
615 };
616 let changes = review::diff(&[dep("4.17.21")], &[dep("4.17.20")]);
617 let findings = BTreeMap::from([(
618 "pkg:npm/lodash@4.17.20".to_owned(),
619 vec![Finding {
620 advisory: "GHSA-35jh-r3h4-6jhm".into(),
621 osv_id: "GHSA-35jh-r3h4-6jhm".into(),
622 summary: "Command Injection in lodash".into(),
623 severity: Severity::High,
624 fixed: Some("4.17.21".into()),
625 }],
626 )]);
627 let policy = policy_of("high", &[]);
628 let verdict = review::judge(&changes, &findings, &policy);
629 let view = review_view("abc", "def", &verdict, &policy);
630 assert!(!view.passed);
631 assert_eq!(view.changes.len(), 2);
632 let added = view.changes.iter().find(|change| change.change_type == "added").unwrap();
633 assert!(added.failing && added.vulnerabilities[0].url.ends_with("GHSA-35jh-r3h4-6jhm"));
634 assert_eq!(view.fail_on, "high");
635 }
636}