Skip to content

g1t/crates/scan/src/review.rs

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