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.

367 linesCodeBlameRaw
1//! Dependency review: what a pull request changes in the dependency graph,
2//! and whether that is allowed. The packages it adds (or moves to another
3//! version) are checked for known vulnerabilities and, when the repository
4//! lists licenses it does not allow, for those.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::graph::Dependency;
12use crate::osv::Severity;
13
14/// How a package changed between the base and the head.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum ChangeKind {
18 Added,
19 Removed,
20}
21
22/// One package at one version, added or removed in one lockfile. A version
23/// change is a removal of the old and an addition of the new.
24#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
25pub struct Change {
26 pub kind: ChangeKind,
27 pub dependency: Dependency,
28}
29
30/// The packages `head` has that `base` does not, and the other way round,
31/// per lockfile, sorted by lockfile, name and version.
32pub fn diff(base: &[Dependency], head: &[Dependency]) -> Vec<Change> {
33 let key = |dep: &Dependency| (dep.manifest.clone(), dep.package.ecosystem, dep.package.name.clone(), dep.package.version.clone());
34 let before: BTreeMap<_, &Dependency> = base.iter().map(|dep| (key(dep), dep)).collect();
35 let after: BTreeMap<_, &Dependency> = head.iter().map(|dep| (key(dep), dep)).collect();
36 let mut changes: Vec<Change> = after
37 .iter()
38 .filter(|(key, _)| !before.contains_key(*key))
39 .map(|(_, dep)| Change { kind: ChangeKind::Added, dependency: (*dep).clone() })
40 .chain(
41 before
42 .iter()
43 .filter(|(key, _)| !after.contains_key(*key))
44 .map(|(_, dep)| Change { kind: ChangeKind::Removed, dependency: (*dep).clone() }),
45 )
46 .collect();
47 changes.sort_by(|a, b| {
48 (&a.dependency.manifest, &a.dependency.package.name, &a.dependency.package.version, a.kind).cmp(&(
49 &b.dependency.manifest,
50 &b.dependency.package.name,
51 &b.dependency.package.version,
52 b.kind,
53 ))
54 });
55 changes
56}
57
58/// A known vulnerability in an added package.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Finding {
61 /// The id people know it by.
62 pub advisory: String,
63 pub osv_id: String,
64 pub summary: String,
65 pub severity: Severity,
66 pub fixed: Option<String>,
67}
68
69/// What the repository asks of a review.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct Policy {
72 /// The lowest severity that fails the check; `None` never fails on
73 /// vulnerabilities.
74 pub fail_on: Option<Severity>,
75 /// SPDX license ids that fail the check when an added package has one.
76 pub deny_licenses: Vec<String>,
77}
78
79/// How bad a severity is, higher worse.
80pub fn rank(severity: Severity) -> u8 {
81 match severity {
82 Severity::Critical => 4,
83 Severity::High => 3,
84 Severity::Medium => 2,
85 Severity::Low => 1,
86 Severity::Unknown => 0,
87 }
88}
89
90/// Whether `license` (an SPDX expression) names any denied id. `MIT OR
91/// GPL-3.0` names GPL-3.0; denying applies when any choice is denied only
92/// if no other choice is allowed, so an `OR` with an allowed side passes.
93pub fn denied_license(license: &str, deny: &[String]) -> bool {
94 if deny.is_empty() {
95 return false;
96 }
97 let denied = |id: &str| {
98 let id = id.trim().trim_matches(['(', ')']);
99 deny.iter().any(|deny| deny.eq_ignore_ascii_case(id))
100 };
101 // Any alternative entirely free of denied ids makes it acceptable.
102 !license.split(" OR ").any(|alternative| !alternative.split(" AND ").any(&denied))
103}
104
105/// The verdict on one added package.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct Reviewed {
108 pub dependency: Dependency,
109 pub findings: Vec<Finding>,
110 /// Its findings at or above the policy's severity.
111 pub failing: Vec<Finding>,
112 pub denied_license: bool,
113}
114
115/// How the pull request comment starts: g1t finds its own comment again
116/// by it, to change it rather than add another.
117pub const HEADING: &str = "### Dependency review";
118
119/// The comment on a pull request that changes no dependencies.
120pub fn unchanged_summary() -> String {
121 format!("{HEADING}\n\nThis pull request changes no dependencies.\n")
122}
123
124/// A short, stable key for `text`: what a review was of, so that the same
125/// change is not reviewed twice.
126pub fn key(text: &str) -> String {
127 Sha256::digest(text.as_bytes())[..16].iter().map(|byte| format!("{byte:02x}")).collect()
128}
129
130/// The whole review.
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct Verdict {
133 pub added: Vec<Reviewed>,
134 pub removed: Vec<Dependency>,
135 pub passed: bool,
136}
137
138/// Judges `changes` with the vulnerabilities found for each added package
139/// (`findings`, by purl) under `policy`.
140pub fn judge(changes: &[Change], findings: &BTreeMap<String, Vec<Finding>>, policy: &Policy) -> Verdict {
141 let mut added = Vec::new();
142 let mut removed = Vec::new();
143 for change in changes {
144 match change.kind {
145 ChangeKind::Removed => removed.push(change.dependency.clone()),
146 ChangeKind::Added => {
147 let found = findings.get(&change.dependency.purl()).cloned().unwrap_or_default();
148 let failing: Vec<Finding> = match policy.fail_on {
149 Some(threshold) => found
150 .iter()
151 .filter(|finding| finding.severity != Severity::Unknown && rank(finding.severity) >= rank(threshold))
152 .cloned()
153 .collect(),
154 None => Vec::new(),
155 };
156 let denied = change
157 .dependency
158 .license
159 .as_deref()
160 .is_some_and(|license| denied_license(license, &policy.deny_licenses));
161 added.push(Reviewed { dependency: change.dependency.clone(), findings: found, failing, denied_license: denied });
162 }
163 }
164 }
165 let passed = added.iter().all(|reviewed| reviewed.failing.is_empty() && !reviewed.denied_license);
166 Verdict { added, removed, passed }
167}
168
169impl Verdict {
170 /// One line for the check's description.
171 pub fn headline(&self) -> String {
172 let vulnerable = self.added.iter().filter(|reviewed| !reviewed.failing.is_empty()).count();
173 let licensed = self.added.iter().filter(|reviewed| reviewed.denied_license).count();
174 let changed = self.added.len() + self.removed.len();
175 if changed == 0 {
176 return "No dependency changes".to_owned();
177 }
178 let mut problems = Vec::new();
179 if vulnerable > 0 {
180 problems.push(format!("{vulnerable} vulnerable {}", if vulnerable == 1 { "package" } else { "packages" }));
181 }
182 if licensed > 0 {
183 problems.push(format!("{licensed} with a license not allowed"));
184 }
185 if problems.is_empty() {
186 let known = self.added.iter().filter(|reviewed| !reviewed.findings.is_empty()).count();
187 return if known > 0 {
188 format!("{changed} dependency changes; {known} below the severity that fails")
189 } else {
190 format!("{changed} dependency changes, none vulnerable")
191 };
192 }
193 format!("Adds {}", problems.join(" and "))
194 }
195
196 /// The pull request comment: what changed, per lockfile, and why the
197 /// check failed if it did. Markdown.
198 pub fn summary(&self, policy: &Policy) -> String {
199 if self.added.is_empty() && self.removed.is_empty() {
200 return unchanged_summary();
201 }
202 let mut text = format!("{HEADING}\n\n");
203 text.push_str(if self.passed { "**Passed.** " } else { "**Failed.** " });
204 text.push_str(&self.headline());
205 text.push_str(".\n\n");
206 let manifests: BTreeSet<&str> = self
207 .added
208 .iter()
209 .map(|reviewed| reviewed.dependency.manifest.as_str())
210 .chain(self.removed.iter().map(|dep| dep.manifest.as_str()))
211 .collect();
212 for manifest in manifests {
213 text.push_str(&format!("**`{manifest}`**\n\n| Change | Package | Version | Relationship | License | Vulnerabilities |\n| --- | --- | --- | --- | --- | --- |\n"));
214 for reviewed in self.added.iter().filter(|reviewed| reviewed.dependency.manifest == manifest) {
215 let dep = &reviewed.dependency;
216 let vulns = if reviewed.findings.is_empty() {
217 "none known".to_owned()
218 } else {
219 reviewed
220 .findings
221 .iter()
222 .map(|finding| {
223 let fails = reviewed.failing.contains(finding);
224 format!(
225 "{}[{}]({}) {}{}",
226 if fails { "**" } else { "" },
227 finding.advisory,
228 crate::osv::page_url(&finding.osv_id),
229 finding.severity.as_str(),
230 if fails { "**" } else { "" }
231 )
232 })
233 .collect::<Vec<_>>()
234 .join(", ")
235 };
236 let license = match (&dep.license, reviewed.denied_license) {
237 (Some(license), true) => format!("**{license}** (not allowed)"),
238 (Some(license), false) => license.clone(),
239 (None, _) => "unknown".to_owned(),
240 };
241 text.push_str(&format!(
242 "| Added | `{}` | {} | {}{} | {} | {} |\n",
243 dep.package.name,
244 dep.package.version,
245 dep.relationship.as_str(),
246 if dep.development { ", development" } else { "" },
247 license,
248 vulns
249 ));
250 }
251 for dep in self.removed.iter().filter(|dep| dep.manifest == manifest) {
252 text.push_str(&format!(
253 "| Removed | `{}` | {} | {} | {} | |\n",
254 dep.package.name,
255 dep.package.version,
256 dep.relationship.as_str(),
257 dep.license.as_deref().unwrap_or("unknown")
258 ));
259 }
260 text.push('\n');
261 }
262 let threshold = match policy.fail_on {
263 Some(severity) => format!("vulnerabilities of {} severity or higher", severity.as_str()),
264 None => "no vulnerability severity".to_owned(),
265 };
266 text.push_str(&format!("This check fails on {threshold}"));
267 if !policy.deny_licenses.is_empty() {
268 text.push_str(&format!(" and on these licenses: {}", policy.deny_licenses.join(", ")));
269 }
270 text.push_str(". Change it in the repository's Security settings.\n");
271 text
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::graph::Relationship;
279 use crate::lockfiles::{Ecosystem, Package};
280
281 fn dep(name: &str, version: &str, license: Option<&str>) -> Dependency {
282 Dependency {
283 package: Package { ecosystem: Ecosystem::Npm, name: name.into(), version: version.into() },
284 manifest: "package-lock.json".into(),
285 relationship: Relationship::Direct,
286 development: false,
287 license: license.map(str::to_owned),
288 }
289 }
290
291 fn finding(severity: Severity) -> Finding {
292 Finding {
293 advisory: "GHSA-35jh-r3h4-6jhm".into(),
294 osv_id: "GHSA-35jh-r3h4-6jhm".into(),
295 summary: "Command Injection in lodash".into(),
296 severity,
297 fixed: Some("4.17.21".into()),
298 }
299 }
300
301 #[test]
302 fn a_version_change_is_a_removal_and_an_addition() {
303 let base = [dep("lodash", "4.17.21", None), dep("left-pad", "1.3.0", None), dep("ms", "2.1.2", None)];
304 let head = [dep("lodash", "4.17.20", None), dep("ms", "2.1.2", None), dep("chalk", "5.0.0", None)];
305 let changes: Vec<String> = diff(&base, &head)
306 .iter()
307 .map(|change| format!("{:?} {}@{}", change.kind, change.dependency.package.name, change.dependency.package.version))
308 .collect();
309 assert_eq!(changes, ["Added chalk@5.0.0", "Removed left-pad@1.3.0", "Added lodash@4.17.20", "Removed lodash@4.17.21"]);
310 assert!(diff(&head, &head).is_empty());
311 }
312
313 #[test]
314 fn every_summary_starts_with_the_heading_g1t_finds_it_by() {
315 let policy = Policy { fail_on: Some(Severity::High), deny_licenses: Vec::new() };
316 let none = judge(&[], &BTreeMap::new(), &policy);
317 assert_eq!(none.summary(&policy), unchanged_summary());
318 let some = judge(&diff(&[], &[dep("chalk", "5.0.0", None)]), &BTreeMap::new(), &policy);
319 assert!(unchanged_summary().starts_with(HEADING) && some.summary(&policy).starts_with(&format!("{HEADING}\n\n**Passed.**")));
320 assert_eq!(key("a"), key("a"));
321 assert_ne!(key("a"), key("b"));
322 assert_eq!(key("a").len(), 32);
323 }
324
325 #[test]
326 fn the_review_fails_at_the_configured_severity() {
327 let changes = diff(&[dep("lodash", "4.17.21", None)], &[dep("lodash", "4.17.20", None)]);
328 let findings = BTreeMap::from([("pkg:npm/lodash@4.17.20".to_owned(), vec![finding(Severity::High)])]);
329 let strict = Policy { fail_on: Some(Severity::Medium), deny_licenses: Vec::new() };
330 let verdict = judge(&changes, &findings, &strict);
331 assert!(!verdict.passed);
332 assert_eq!(verdict.headline(), "Adds 1 vulnerable package");
333 let summary = verdict.summary(&strict);
334 assert!(summary.contains("**Failed.**") && summary.contains("**[GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm) high**"));
335 assert!(summary.contains("| Removed | `lodash` | 4.17.21 |"));
336 // Critical only: a high finding is shown but passes.
337 let lenient = Policy { fail_on: Some(Severity::Critical), deny_licenses: Vec::new() };
338 let verdict = judge(&changes, &findings, &lenient);
339 assert!(verdict.passed);
340 assert_eq!(verdict.headline(), "2 dependency changes; 1 below the severity that fails");
341 // Off: never fails on vulnerabilities.
342 assert!(judge(&changes, &findings, &Policy { fail_on: None, deny_licenses: Vec::new() }).passed);
343 }
344
345 #[test]
346 fn denied_licenses_fail_unless_an_alternative_is_allowed() {
347 let deny = vec!["GPL-3.0-only".to_owned(), "AGPL-3.0-only".to_owned()];
348 assert!(denied_license("GPL-3.0-only", &deny));
349 assert!(denied_license("MIT AND GPL-3.0-only", &deny));
350 assert!(!denied_license("MIT OR GPL-3.0-only", &deny));
351 assert!(!denied_license("(MIT)", &deny));
352 assert!(!denied_license("GPL-3.0-only", &[]));
353 let changes = diff(&[], &[dep("copyleft", "1.0.0", Some("AGPL-3.0-only"))]);
354 let policy = Policy { fail_on: Some(Severity::High), deny_licenses: deny };
355 let verdict = judge(&changes, &BTreeMap::new(), &policy);
356 assert!(!verdict.passed);
357 assert_eq!(verdict.headline(), "Adds 1 with a license not allowed");
358 assert!(verdict.summary(&policy).contains("**AGPL-3.0-only** (not allowed)"));
359 }
360
361 #[test]
362 fn nothing_changed_says_so() {
363 let verdict = judge(&[], &BTreeMap::new(), &Policy { fail_on: Some(Severity::High), deny_licenses: Vec::new() });
364 assert!(verdict.passed);
365 assert_eq!(verdict.headline(), "No dependency changes");
366 }
367}