Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 | } |