| 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 | use sha2::{Digest, Sha256}; |
| 10 | |
| 11 | use crate::graph::Dependency; |
| 12 | use 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")] |
| 17 | pub 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)] |
| 25 | pub 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. |
| 32 | pub 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)] |
| 60 | pub 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)] |
| 71 | pub 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. |
| 80 | pub 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. |
| 93 | pub 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)] |
| 107 | pub 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. |
| 117 | pub const HEADING: &str = "### Dependency review"; |
| 118 | |
| 119 | /// The comment on a pull request that changes no dependencies. |
| 120 | pub 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. |
| 126 | pub 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)] |
| 132 | pub 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`. |
| 140 | pub 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 | |
| 169 | impl 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)] |
| 276 | mod 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 | } |