1use crate::fingerprint::fingerprint;
11use crate::installed::Installed;
12use crate::known::normalize_dist;
13use crate::version::{matches_spec, specs_intersect};
14use camino::Utf8Path;
15use mollify_types::{Action, Category, Confidence, Finding, Location, Severity};
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct Advisory {
21 pub id: String,
22 pub package: String,
23 #[serde(default)]
25 pub specs: Vec<String>,
26 #[serde(default)]
27 pub summary: String,
28 #[serde(default)]
29 pub aliases: Vec<String>,
30 #[serde(default)]
31 pub severity: Option<String>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35struct AdvisoryDb {
36 #[serde(default)]
37 advisories: Vec<Advisory>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PinnedDep {
43 pub name: String,
44 pub version: String,
45 pub source: camino::Utf8PathBuf,
46 pub line: u32,
47}
48
49pub fn load_db(db_path: &Utf8Path) -> Option<Vec<Advisory>> {
51 let text = std::fs::read_to_string(db_path).ok()?;
52 let db: AdvisoryDb = serde_json::from_str(&text).ok()?;
53 Some(db.advisories)
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct DeclaredRange {
60 pub name: String,
61 pub spec: String,
62 pub source: camino::Utf8PathBuf,
63 pub line: u32,
64}
65
66pub fn analyze(root: &Utf8Path, advisories: &[Advisory]) -> Vec<Finding> {
69 let pins = collect_pins(root);
70 let pinned: rustc_hash::FxHashSet<String> = pins.iter().map(|p| p.name.clone()).collect();
71 let ranges = collect_declared_ranges(root);
72 let installed = crate::installed::discover(root);
73
74 let mut findings = analyze_pins(&pins, advisories);
75 findings.extend(analyze_declared(
76 &ranges,
77 advisories,
78 installed.as_ref(),
79 &pinned,
80 ));
81 findings.sort_by(|a, b| {
82 a.location
83 .path
84 .cmp(&b.location.path)
85 .then(a.location.line.cmp(&b.location.line))
86 .then(a.reason.cmp(&b.reason))
87 });
88 findings.dedup_by(|a, b| a.fingerprint == b.fingerprint);
89 findings
90}
91
92pub fn analyze_declared(
97 ranges: &[DeclaredRange],
98 advisories: &[Advisory],
99 installed: Option<&Installed>,
100 pinned: &rustc_hash::FxHashSet<String>,
101) -> Vec<Finding> {
102 let mut findings = Vec::new();
103 let mut seen: rustc_hash::FxHashSet<(String, String, String)> =
104 rustc_hash::FxHashSet::default();
105 for dep in ranges {
106 if pinned.contains(&dep.name) {
107 continue; }
109 let installed_ver = installed.and_then(|i| i.versions.get(&dep.name).cloned());
110 for adv in advisories {
111 if normalize_dist(&adv.package) != dep.name {
112 continue;
113 }
114 let alias = adv
115 .aliases
116 .iter()
117 .find(|a| a.starts_with("CVE-"))
118 .cloned()
119 .unwrap_or_else(|| adv.id.clone());
120 let summary = if adv.summary.is_empty() {
121 String::new()
122 } else {
123 format!(" — {}", adv.summary)
124 };
125
126 let (matched, version_key, confidence, reason) = if let Some(ver) = &installed_ver {
127 let hit = adv.specs.is_empty() || adv.specs.iter().any(|s| matches_spec(ver, s));
129 (
130 hit,
131 ver.clone(),
132 Confidence::Certain,
133 format!(
134 "`{}` {ver} (installed, declared `{}`) is affected by {alias}{summary}",
135 dep.name, dep.spec
136 ),
137 )
138 } else if dep.spec.is_empty() {
139 (false, String::new(), Confidence::Uncertain, String::new())
140 } else {
141 let hit = adv.specs.iter().any(|s| specs_intersect(&dep.spec, s));
143 (
144 hit,
145 dep.spec.clone(),
146 Confidence::Uncertain,
147 format!(
148 "declared range `{} {}` permits a version affected by {alias}{summary}; pin or constrain above the fix",
149 dep.name, dep.spec
150 ),
151 )
152 };
153 if !matched {
154 continue;
155 }
156 if !seen.insert((dep.name.clone(), version_key.clone(), alias.clone())) {
157 continue;
158 }
159 let rule = "vulnerable-dependency";
160 findings.push(Finding {
161 fingerprint: fingerprint(rule, &[&dep.name, &version_key, &adv.id]),
162 rule: rule.into(),
163 category: Category::Security,
164 severity: Severity::Warn,
165 confidence,
166 attribution: None,
167 reason,
168 location: Location {
169 path: dep.source.clone(),
170 line: dep.line,
171 column: 0,
172 end_line: None,
173 },
174 actions: vec![Action {
175 kind: "upgrade-dependency".into(),
176 description: format!(
177 "Constrain `{}` out of the affected range for {} ({alias}).",
178 dep.name, adv.id
179 ),
180 auto_fixable: false,
181 suppression_comment: Some("# mollify: ignore[vulnerable-dependency]".into()),
182 }],
183 });
184 }
185 }
186 findings
187}
188
189pub fn analyze_pins(pins: &[PinnedDep], advisories: &[Advisory]) -> Vec<Finding> {
191 let mut findings = Vec::new();
192 let mut seen: rustc_hash::FxHashSet<(String, String, String)> =
195 rustc_hash::FxHashSet::default();
196 for pin in pins {
197 for adv in advisories {
198 if normalize_dist(&adv.package) != pin.name {
199 continue;
200 }
201 let hit =
204 adv.specs.is_empty() || adv.specs.iter().any(|s| matches_spec(&pin.version, s));
205 if !hit {
206 continue;
207 }
208 let rule = "vulnerable-dependency";
209 let alias = adv
210 .aliases
211 .iter()
212 .find(|a| a.starts_with("CVE-"))
213 .cloned()
214 .unwrap_or_else(|| adv.id.clone());
215 if !seen.insert((pin.name.clone(), pin.version.clone(), alias.clone())) {
216 continue; }
218 let summary = if adv.summary.is_empty() {
219 String::new()
220 } else {
221 format!(" — {}", adv.summary)
222 };
223 findings.push(Finding {
224 fingerprint: fingerprint(rule, &[&pin.name, &pin.version, &adv.id]),
225 rule: rule.into(),
226 category: Category::Security,
227 severity: Severity::Warn,
228 confidence: Confidence::Certain,
229 attribution: None,
230 reason: format!(
231 "`{}` {} is affected by {alias}{summary}",
232 pin.name, pin.version
233 ),
234 location: Location {
235 path: pin.source.clone(),
236 line: pin.line,
237 column: 0,
238 end_line: None,
239 },
240 actions: vec![Action {
241 kind: "upgrade-dependency".into(),
242 description: format!(
243 "Upgrade `{}` out of the affected range for {} ({alias}).",
244 pin.name, adv.id
245 ),
246 auto_fixable: false,
247 suppression_comment: Some("# mollify: ignore[vulnerable-dependency]".into()),
248 }],
249 });
250 }
251 }
252 findings.sort_by(|a, b| {
253 a.location
254 .path
255 .cmp(&b.location.path)
256 .then(a.reason.cmp(&b.reason))
257 });
258 findings.dedup_by(|a, b| a.fingerprint == b.fingerprint);
259 findings
260}
261
262pub fn collect_pins(root: &Utf8Path) -> Vec<PinnedDep> {
264 let mut pins = Vec::new();
265 for entry in std::fs::read_dir(root).into_iter().flatten().flatten() {
267 let name = entry.file_name();
268 let name = name.to_string_lossy();
269 if name.starts_with("requirements") && name.ends_with(".txt") {
270 if let Ok(p) = camino::Utf8PathBuf::from_path_buf(entry.path()) {
271 parse_requirements(&p, &mut pins);
272 }
273 }
274 }
275 for lock in ["poetry.lock", "uv.lock"] {
277 let p = root.join(lock);
278 if p.exists() {
279 parse_toml_lock(&p, &mut pins);
280 }
281 }
282 pins.sort_by(|a, b| a.name.cmp(&b.name).then(a.version.cmp(&b.version)));
283 pins.dedup();
284 pins
285}
286
287fn parse_requirements(path: &Utf8Path, out: &mut Vec<PinnedDep>) {
288 let Ok(text) = std::fs::read_to_string(path) else {
289 return;
290 };
291 for (i, raw) in text.lines().enumerate() {
292 let line = raw.split('#').next().unwrap_or("").trim();
293 if line.is_empty() || line.starts_with('-') {
294 continue;
295 }
296 let Some((name_part, rest)) = line.split_once("==") else {
298 continue;
299 };
300 let name = normalize_dist(name_part.split('[').next().unwrap_or(name_part).trim());
301 let version = rest
302 .split([';', ' ', ','])
303 .next()
304 .unwrap_or("")
305 .trim()
306 .to_string();
307 if !name.is_empty() && !version.is_empty() {
308 out.push(PinnedDep {
309 name,
310 version,
311 source: path.to_owned(),
312 line: i as u32 + 1,
313 });
314 }
315 }
316}
317
318fn parse_toml_lock(path: &Utf8Path, out: &mut Vec<PinnedDep>) {
319 let Ok(text) = std::fs::read_to_string(path) else {
320 return;
321 };
322 let Ok(table) = text.parse::<toml::Table>() else {
323 return;
324 };
325 let Some(pkgs) = table.get("package").and_then(|p| p.as_array()) else {
326 return;
327 };
328 for pkg in pkgs {
329 let (Some(name), Some(version)) = (
330 pkg.get("name").and_then(|v| v.as_str()),
331 pkg.get("version").and_then(|v| v.as_str()),
332 ) else {
333 continue;
334 };
335 out.push(PinnedDep {
336 name: normalize_dist(name),
337 version: version.to_string(),
338 source: path.to_owned(),
339 line: 1,
340 });
341 }
342}
343
344pub fn collect_declared_ranges(root: &Utf8Path) -> Vec<DeclaredRange> {
347 let mut out = Vec::new();
348 for entry in std::fs::read_dir(root).into_iter().flatten().flatten() {
349 let name = entry.file_name();
350 let name = name.to_string_lossy();
351 if name.starts_with("requirements") && name.ends_with(".txt") {
352 if let Ok(p) = camino::Utf8PathBuf::from_path_buf(entry.path()) {
353 if let Ok(text) = std::fs::read_to_string(&p) {
354 for (i, raw) in text.lines().enumerate() {
355 let line = raw.split('#').next().unwrap_or("").trim();
356 if line.is_empty() || line.starts_with('-') {
357 continue;
358 }
359 if let Some((name, spec)) = split_requirement(line) {
360 out.push(DeclaredRange {
361 name,
362 spec,
363 source: p.clone(),
364 line: i as u32 + 1,
365 });
366 }
367 }
368 }
369 }
370 }
371 }
372 let pp = root.join("pyproject.toml");
373 if pp.exists() {
374 parse_pyproject_ranges(&pp, &mut out);
375 }
376 out.sort_by(|a, b| a.name.cmp(&b.name).then(a.spec.cmp(&b.spec)));
377 out.dedup();
378 out
379}
380
381fn split_requirement(line: &str) -> Option<(String, String)> {
384 let line = line.split(';').next().unwrap_or("").trim();
385 if line.is_empty() {
386 return None;
387 }
388 match line.find(['<', '>', '=', '!', '~']) {
389 Some(pos) => {
390 let name = line[..pos].split('[').next().unwrap_or("").trim();
391 let spec = line[pos..].trim().to_string();
392 if name.is_empty() {
393 None
394 } else {
395 Some((normalize_dist(name), spec))
396 }
397 }
398 None => {
399 let name = line.split('[').next().unwrap_or("").trim();
400 if name.is_empty() {
401 None
402 } else {
403 Some((normalize_dist(name), String::new()))
404 }
405 }
406 }
407}
408
409fn parse_pyproject_ranges(path: &Utf8Path, out: &mut Vec<DeclaredRange>) {
410 let Ok(text) = std::fs::read_to_string(path) else {
411 return;
412 };
413 let Ok(table) = text.parse::<toml::Table>() else {
414 return;
415 };
416 if let Some(deps) = table
418 .get("project")
419 .and_then(|p| p.get("dependencies"))
420 .and_then(|d| d.as_array())
421 {
422 for d in deps {
423 if let Some(s) = d.as_str() {
424 if let Some((name, spec)) = split_requirement(s) {
425 out.push(DeclaredRange {
426 name,
427 spec,
428 source: path.to_owned(),
429 line: 1,
430 });
431 }
432 }
433 }
434 }
435 if let Some(deps) = table
437 .get("tool")
438 .and_then(|t| t.get("poetry"))
439 .and_then(|p| p.get("dependencies"))
440 .and_then(|d| d.as_table())
441 {
442 for (name, val) in deps {
443 if name.eq_ignore_ascii_case("python") {
444 continue;
445 }
446 let raw = val.as_str().map(|s| s.to_string()).or_else(|| {
447 val.get("version")
448 .and_then(|v| v.as_str())
449 .map(String::from)
450 });
451 if let Some(raw) = raw {
452 out.push(DeclaredRange {
453 name: normalize_dist(name),
454 spec: poetry_to_pep440(&raw),
455 source: path.to_owned(),
456 line: 1,
457 });
458 }
459 }
460 }
461}
462
463fn poetry_to_pep440(spec: &str) -> String {
466 let s = spec.trim();
467 if s == "*" || s.is_empty() {
468 return String::new();
469 }
470 if let Some(rest) = s.strip_prefix('^') {
471 let parts: Vec<u64> = rest
473 .split('.')
474 .map(|p| p.parse::<u64>().unwrap_or(0))
475 .collect();
476 if parts.is_empty() {
477 return String::new();
478 }
479 let upper = caret_upper(&parts);
480 return format!(">={rest},<{upper}");
481 }
482 if let Some(rest) = s.strip_prefix('~') {
483 let parts: Vec<u64> = rest
485 .split('.')
486 .map(|p| p.parse::<u64>().unwrap_or(0))
487 .collect();
488 let upper = match parts.len() {
489 0 => return String::new(),
490 1 => format!("{}", parts[0] + 1),
491 _ => format!("{}.{}", parts[0], parts[1] + 1),
492 };
493 return format!(">={rest},<{upper}");
494 }
495 s.to_string()
497}
498
499fn caret_upper(parts: &[u64]) -> String {
501 for (i, &p) in parts.iter().enumerate() {
502 if p != 0 {
503 let mut bumped = parts[..=i].to_vec();
504 bumped[i] += 1;
505 for b in bumped.iter_mut().skip(i + 1) {
506 *b = 0;
507 }
508 return bumped
509 .iter()
510 .map(|n| n.to_string())
511 .collect::<Vec<_>>()
512 .join(".");
513 }
514 }
515 let mut v = parts.to_vec();
517 if let Some(last) = v.last_mut() {
518 *last += 1;
519 }
520 v.iter()
521 .map(|n| n.to_string())
522 .collect::<Vec<_>>()
523 .join(".")
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use camino::Utf8PathBuf;
530
531 fn temp(tag: &str) -> Utf8PathBuf {
532 let base =
533 std::env::temp_dir().join(format!("mollify-core-sc-{}-{tag}", std::process::id()));
534 let _ = std::fs::remove_dir_all(&base);
535 std::fs::create_dir_all(&base).unwrap();
536 Utf8PathBuf::from_path_buf(base).unwrap()
537 }
538
539 fn adv(id: &str, pkg: &str, specs: &[&str]) -> Advisory {
540 Advisory {
541 id: id.into(),
542 package: pkg.into(),
543 specs: specs.iter().map(|s| s.to_string()).collect(),
544 summary: "test advisory".into(),
545 aliases: vec!["CVE-2020-00000".into()],
546 severity: Some("high".into()),
547 }
548 }
549
550 #[test]
551 fn flags_pinned_vulnerable_version() {
552 let pins = vec![
553 PinnedDep {
554 name: "jinja2".into(),
555 version: "2.4.1".into(),
556 source: "requirements.txt".into(),
557 line: 3,
558 },
559 PinnedDep {
560 name: "jinja2".into(),
561 version: "3.1.5".into(),
562 source: "requirements.txt".into(),
563 line: 4,
564 },
565 ];
566 let advisories = vec![adv("PYSEC-1", "Jinja2", &["<2.11.3"])];
567 let f = analyze_pins(&pins, &advisories);
568 assert_eq!(f.len(), 1, "got {f:?}");
569 assert!(f[0].reason.contains("2.4.1"));
570 assert!(f[0].reason.contains("CVE-2020-00000"));
571 }
572
573 #[test]
574 fn parses_requirements_and_lock() {
575 let d = temp("pins");
576 std::fs::write(
577 d.join("requirements.txt"),
578 "# comment\nDjango==3.2.0\nrequests>=2.0 # range, skipped\nflask==2.0.1 ; python_version>='3.7'\n",
579 )
580 .unwrap();
581 std::fs::write(
582 d.join("poetry.lock"),
583 "[[package]]\nname = \"urllib3\"\nversion = \"1.26.4\"\n",
584 )
585 .unwrap();
586 let pins = collect_pins(&d);
587 assert!(pins
588 .iter()
589 .any(|p| p.name == "django" && p.version == "3.2.0"));
590 assert!(pins
591 .iter()
592 .any(|p| p.name == "flask" && p.version == "2.0.1"));
593 assert!(pins
594 .iter()
595 .any(|p| p.name == "urllib3" && p.version == "1.26.4"));
596 assert!(
597 !pins.iter().any(|p| p.name == "requests"),
598 "ranges not pinned"
599 );
600 std::fs::remove_dir_all(&d).ok();
601 }
602
603 #[test]
604 fn loads_db_from_disk() {
605 let d = temp("db");
606 let db = d.join("adv.json");
607 std::fs::write(
608 &db,
609 r#"{"schema":"mollify-advisories/1","advisories":[{"id":"PYSEC-9","package":"flask","specs":["<2.0.0"],"summary":"x","aliases":["CVE-1"]}]}"#,
610 )
611 .unwrap();
612 let advisories = load_db(&db).unwrap();
613 assert_eq!(advisories.len(), 1);
614 assert_eq!(advisories[0].package, "flask");
615 std::fs::remove_dir_all(&d).ok();
616 }
617
618 #[test]
619 fn flags_declared_range_that_permits_vulnerable() {
620 let ranges = vec![DeclaredRange {
621 name: "jinja2".into(),
622 spec: ">=2.0".into(),
623 source: "requirements.txt".into(),
624 line: 2,
625 }];
626 let advisories = vec![adv("PYSEC-1", "Jinja2", &["<2.11.3"])];
627 let f = analyze_declared(&ranges, &advisories, None, &Default::default());
628 assert_eq!(f.len(), 1, "got {f:?}");
629 assert!(matches!(f[0].confidence, Confidence::Uncertain));
630 assert!(f[0].reason.contains("permits"), "{}", f[0].reason);
631 }
632
633 #[test]
634 fn declared_range_above_fix_is_clean() {
635 let ranges = vec![DeclaredRange {
636 name: "jinja2".into(),
637 spec: ">=2.11.3".into(),
638 source: "requirements.txt".into(),
639 line: 2,
640 }];
641 let advisories = vec![adv("PYSEC-1", "Jinja2", &["<2.11.3"])];
642 let f = analyze_declared(&ranges, &advisories, None, &Default::default());
643 assert!(
644 f.is_empty(),
645 "range entirely above the fix should be clean: {f:?}"
646 );
647 }
648
649 #[test]
650 fn installed_version_resolves_range_precisely() {
651 let mut versions = rustc_hash::FxHashMap::default();
652 versions.insert("jinja2".to_string(), "2.4.1".to_string());
653 let inst = Installed {
654 versions,
655 ..Default::default()
656 };
657 let ranges = vec![DeclaredRange {
658 name: "jinja2".into(),
659 spec: ">=2.0".into(),
660 source: "pyproject.toml".into(),
661 line: 1,
662 }];
663 let advisories = vec![adv("PYSEC-1", "Jinja2", &["<2.11.3"])];
664 let f = analyze_declared(&ranges, &advisories, Some(&inst), &Default::default());
665 assert_eq!(f.len(), 1, "got {f:?}");
666 assert!(matches!(f[0].confidence, Confidence::Certain));
667 assert!(f[0].reason.contains("2.4.1") && f[0].reason.contains("installed"));
668 }
669
670 #[test]
671 fn collects_declared_ranges_from_requirements_and_pyproject() {
672 let d = temp("ranges");
673 std::fs::write(d.join("requirements.txt"), "requests>=2.0,<3\nflask\n").unwrap();
674 std::fs::write(
675 d.join("pyproject.toml"),
676 "[project]\ndependencies = [\"urllib3>=1.0\"]\n[tool.poetry.dependencies]\npython = \"^3.9\"\nclick = \"^8.1\"\n",
677 )
678 .unwrap();
679 let r = collect_declared_ranges(&d);
680 assert!(r
681 .iter()
682 .any(|x| x.name == "requests" && x.spec == ">=2.0,<3"));
683 assert!(r.iter().any(|x| x.name == "flask" && x.spec.is_empty()));
684 assert!(r.iter().any(|x| x.name == "urllib3" && x.spec == ">=1.0"));
685 assert!(r.iter().any(|x| x.name == "click" && x.spec == ">=8.1,<9"));
687 assert!(!r.iter().any(|x| x.name == "python"));
688 std::fs::remove_dir_all(&d).ok();
689 }
690}