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.

g1t.sh
625 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: 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.
393pub 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.
424fn memo(key: Option<&str>) -> Vec<String> {
425 key.map(|key| vec![format!("key:{key}")]).unwrap_or_default()
426}
427
428fn 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.
437pub 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)]
445pub enum CommentPlan {
446 Leave,
447 Edit(String),
448 Add,
449}
450
451pub 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)]
461mod 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}