| 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 | |
| 6 | use std::collections::{BTreeMap, BTreeSet}; |
| 7 | |
| 8 | use serde::{Deserialize, Serialize}; |
| 9 | |
| 10 | use crate::graph::Dependency; |
| 11 | use 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")] |
| 16 | pub 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)] |
| 24 | pub 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. |
| 31 | pub 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)] |
| 59 | pub 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)] |
| 70 | pub 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. |
| 79 | pub 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. |
| 92 | pub 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)] |
| 106 | pub 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)] |
| 116 | pub 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`. |
| 124 | pub 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 | |
| 153 | impl 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)] |
| 261 | mod 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 | } |