1use std::collections::{BTreeMap, HashSet};
2use std::fmt;
3use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
4use std::path::Path;
5use thiserror::Error;
6
7use crate::advisory::{Advisory, Criticality, Database, DatabaseError};
8use crate::lockfile::{self, Lockfile, Source};
9use crate::version::Version;
10
11#[derive(Debug)]
13pub enum ScanResult {
14 InsecureSource(InsecureSource),
15 UnpatchedGem(Box<UnpatchedGem>),
16 VulnerableRuby(Box<VulnerableRuby>),
17}
18
19#[derive(Debug, Clone)]
21pub struct InsecureSource {
22 pub source: String,
24}
25
26impl fmt::Display for InsecureSource {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 write!(f, "Insecure Source URI found: {}", self.source)
29 }
30}
31
32#[derive(Debug)]
34pub struct UnpatchedGem {
35 pub name: String,
37 pub version: String,
39 pub advisory: Advisory,
41}
42
43impl fmt::Display for UnpatchedGem {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(f, "{} ({}): {}", self.name, self.version, self.advisory.id)
46 }
47}
48
49#[derive(Debug)]
51pub struct VulnerableRuby {
52 pub engine: String,
54 pub version: String,
56 pub advisory: Advisory,
58}
59
60impl fmt::Display for VulnerableRuby {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 write!(
63 f,
64 "{} ({}): {}",
65 self.engine, self.version, self.advisory.id
66 )
67 }
68}
69
70#[derive(Debug)]
72pub struct Remediation {
73 pub name: String,
75 pub version: String,
77 pub advisories: Vec<Advisory>,
79}
80
81#[derive(Debug)]
83pub struct Report {
84 pub insecure_sources: Vec<InsecureSource>,
85 pub unpatched_gems: Vec<UnpatchedGem>,
86 pub vulnerable_rubies: Vec<VulnerableRuby>,
87 pub version_parse_errors: usize,
89 pub advisory_load_errors: usize,
91}
92
93impl Report {
94 pub fn vulnerable(&self) -> bool {
96 !self.insecure_sources.is_empty()
97 || !self.unpatched_gems.is_empty()
98 || !self.vulnerable_rubies.is_empty()
99 }
100
101 pub fn count(&self) -> usize {
103 self.insecure_sources.len() + self.unpatched_gems.len() + self.vulnerable_rubies.len()
104 }
105
106 pub fn remediations(&self) -> Vec<Remediation> {
111 let mut by_name: BTreeMap<&str, (&str, Vec<&Advisory>)> = BTreeMap::new();
112
113 for gem in &self.unpatched_gems {
114 let entry = by_name
115 .entry(&gem.name)
116 .or_insert((&gem.version, Vec::new()));
117 if !entry.1.iter().any(|a| a.id == gem.advisory.id) {
119 entry.1.push(&gem.advisory);
120 }
121 }
122
123 by_name
124 .into_iter()
125 .map(|(name, (version, advisories))| Remediation {
126 name: name.to_string(),
127 version: version.to_string(),
128 advisories: advisories.into_iter().cloned().collect(),
129 })
130 .collect()
131 }
132}
133
134#[derive(Debug, Default)]
136pub struct ScanOptions {
137 pub ignore: HashSet<String>,
139 pub severity: Option<Criticality>,
141 pub strict: bool,
143}
144
145impl ScanOptions {
146 fn should_report(&self, advisory: &Advisory) -> bool {
148 if !self.ignore.is_empty() {
149 let identifiers: HashSet<String> = advisory.identifiers().into_iter().collect();
150 if !self.ignore.is_disjoint(&identifiers) {
151 return false;
152 }
153 }
154 if let Some(threshold) = &self.severity {
155 match advisory.criticality() {
156 Some(crit) if crit >= *threshold => {}
157 _ => return false,
158 }
159 }
160 true
161 }
162}
163
164#[derive(Debug, Error)]
165pub enum ScanError {
166 #[error("Gemfile.lock not found: {0}")]
167 LockfileNotFound(String),
168 #[error("failed to parse Gemfile.lock: {0}")]
169 LockfileParse(String),
170 #[error("database error: {0}")]
171 Database(#[from] DatabaseError),
172 #[error("IO error: {0}")]
173 Io(#[from] std::io::Error),
174}
175
176pub struct Scanner {
178 lockfile: Lockfile,
179 database: Database,
180}
181
182impl Scanner {
183 pub fn new(lockfile_path: &Path, database: Database) -> Result<Self, ScanError> {
185 let content = std::fs::read_to_string(lockfile_path)
186 .map_err(|_| ScanError::LockfileNotFound(lockfile_path.display().to_string()))?;
187
188 let lockfile =
189 lockfile::parse(&content).map_err(|e| ScanError::LockfileParse(e.to_string()))?;
190
191 Ok(Scanner { lockfile, database })
192 }
193
194 pub fn from_lockfile(lockfile: Lockfile, database: Database) -> Self {
196 Scanner { lockfile, database }
197 }
198
199 pub fn scan(&self, options: &ScanOptions) -> Report {
201 let insecure_sources = self.scan_sources();
202 let (unpatched_gems, version_parse_errors, advisory_load_errors) = self.scan_specs(options);
203 let (vulnerable_rubies, ruby_advisory_errors) = self.scan_ruby(options);
204
205 Report {
206 insecure_sources,
207 unpatched_gems,
208 vulnerable_rubies,
209 version_parse_errors,
210 advisory_load_errors: advisory_load_errors + ruby_advisory_errors,
211 }
212 }
213
214 pub fn scan_sources(&self) -> Vec<InsecureSource> {
216 let mut results = Vec::new();
217
218 for source in &self.lockfile.sources {
219 match source {
220 Source::Git(git) => {
221 if is_insecure_uri(&git.remote) && !is_internal_source(&git.remote) {
222 results.push(InsecureSource {
223 source: git.remote.clone(),
224 });
225 }
226 }
227 Source::Rubygems(gem) => {
228 if gem.remote.starts_with("http://") && !is_internal_source(&gem.remote) {
229 results.push(InsecureSource {
230 source: gem.remote.clone(),
231 });
232 }
233 }
234 Source::Path(_) => {
235 }
237 }
238 }
239
240 results
241 }
242
243 pub fn scan_specs(&self, options: &ScanOptions) -> (Vec<UnpatchedGem>, usize, usize) {
247 let mut results = Vec::new();
248 let mut version_parse_errors: usize = 0;
249 let mut advisory_load_errors: usize = 0;
250
251 let mut seen = HashSet::new();
253
254 for spec in &self.lockfile.specs {
255 let key = (&spec.name, &spec.version);
256 if !seen.insert(key) {
257 continue;
258 }
259
260 let version = match Version::parse(&spec.version) {
261 Ok(v) => v,
262 Err(_) => {
263 version_parse_errors += 1;
264 if options.strict {
265 eprintln!(
266 "warning: failed to parse version '{}' for gem '{}'",
267 spec.version, spec.name
268 );
269 }
270 continue;
271 }
272 };
273
274 let (advisories, load_errors) = self.database.check_gem(&spec.name, &version);
275 advisory_load_errors += load_errors;
276
277 for advisory in advisories {
278 if !options.should_report(&advisory) {
279 continue;
280 }
281
282 results.push(UnpatchedGem {
283 name: spec.name.clone(),
284 version: spec.version.clone(),
285 advisory,
286 });
287 }
288 }
289
290 results.sort_by(|a, b| b.advisory.criticality().cmp(&a.advisory.criticality()));
292
293 (results, version_parse_errors, advisory_load_errors)
294 }
295
296 pub fn scan_ruby(&self, options: &ScanOptions) -> (Vec<VulnerableRuby>, usize) {
300 let ruby_version = match self.lockfile.parsed_ruby_version() {
301 Some(rv) => rv,
302 None => return (Vec::new(), 0),
303 };
304
305 let version = match Version::parse(&ruby_version.version) {
306 Ok(v) => v,
307 Err(_) => return (Vec::new(), 0),
308 };
309
310 let (advisories, load_errors) = self.database.check_ruby(&ruby_version.engine, &version);
311
312 let mut results = Vec::new();
313 for advisory in advisories {
314 if !options.should_report(&advisory) {
315 continue;
316 }
317
318 results.push(VulnerableRuby {
319 engine: ruby_version.engine.clone(),
320 version: ruby_version.version.clone(),
321 advisory,
322 });
323 }
324
325 results.sort_by(|a, b| b.advisory.criticality().cmp(&a.advisory.criticality()));
327
328 (results, load_errors)
329 }
330}
331
332fn is_insecure_uri(uri: &str) -> bool {
334 uri.starts_with("git://") || uri.starts_with("http://")
335}
336
337const INTERNAL_IPV4_RANGES: &[(Ipv4Addr, u32)] = &[
339 (Ipv4Addr::new(10, 0, 0, 0), 8),
340 (Ipv4Addr::new(172, 16, 0, 0), 12),
341 (Ipv4Addr::new(192, 168, 0, 0), 16),
342 (Ipv4Addr::new(127, 0, 0, 0), 8),
343];
344
345fn ipv4_in_cidr(addr: Ipv4Addr, network: Ipv4Addr, prefix_len: u32) -> bool {
347 let addr_bits = u32::from(addr);
348 let net_bits = u32::from(network);
349 let mask = if prefix_len == 0 {
350 0
351 } else {
352 !0u32 << (32 - prefix_len)
353 };
354 (addr_bits & mask) == (net_bits & mask)
355}
356
357fn is_internal_ip(ip: IpAddr) -> bool {
359 match ip {
360 IpAddr::V4(v4) => INTERNAL_IPV4_RANGES
361 .iter()
362 .any(|(net, prefix)| ipv4_in_cidr(v4, *net, *prefix)),
363 IpAddr::V6(v6) => {
364 v6 == Ipv6Addr::LOCALHOST
366 || (v6.octets()[0] & 0xfe) == 0xfc
368 }
369 }
370}
371
372fn is_internal_source(uri: &str) -> bool {
374 let host = extract_host(uri);
375 match host {
376 Some(h) => is_internal_host(&h),
377 None => false,
378 }
379}
380
381fn extract_host(uri: &str) -> Option<String> {
383 let after_scheme = uri.split("://").nth(1)?;
385 let host_port = after_scheme.split('/').next()?;
386 let host = host_port.split(':').next()?;
387 let host = if let Some(at_pos) = host.rfind('@') {
389 &host[at_pos + 1..]
390 } else {
391 host
392 };
393 if host.is_empty() {
394 None
395 } else {
396 Some(host.to_string())
397 }
398}
399
400fn is_internal_host(host: &str) -> bool {
402 if let Ok(ip) = host.parse::<IpAddr>() {
404 return is_internal_ip(ip);
405 }
406
407 let sock_addr = format!("{}:0", host);
409 match sock_addr.to_socket_addrs() {
410 Ok(addrs) => {
411 let addrs: Vec<_> = addrs.collect();
412 !addrs.is_empty() && addrs.iter().all(|a| is_internal_ip(a.ip()))
413 }
414 Err(_) => false,
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use crate::lockfile;
422 use std::path::PathBuf;
423
424 fn fixtures_dir() -> PathBuf {
425 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
426 }
427
428 fn mock_database() -> Database {
429 let db_dir = fixtures_dir().join("mock_db");
431 let gem_dir = db_dir.join("gems").join("test");
432 if !gem_dir.exists() {
433 std::fs::create_dir_all(&gem_dir).unwrap();
434 std::fs::copy(
435 fixtures_dir().join("advisory/CVE-2020-1234.yml"),
436 gem_dir.join("CVE-2020-1234.yml"),
437 )
438 .unwrap();
439 }
440 Database::open(&db_dir).unwrap()
441 }
442
443 fn local_database() -> Option<Database> {
444 let path = Database::default_path();
445 if path.join("gems").is_dir() {
446 Database::open(&path).ok()
447 } else {
448 None
449 }
450 }
451
452 #[test]
455 fn git_protocol_is_insecure() {
456 assert!(is_insecure_uri("git://github.com/foo/bar.git"));
457 }
458
459 #[test]
460 fn http_is_insecure() {
461 assert!(is_insecure_uri("http://rubygems.org/"));
462 }
463
464 #[test]
465 fn https_is_secure() {
466 assert!(!is_insecure_uri("https://rubygems.org/"));
467 }
468
469 #[test]
470 fn ssh_is_secure() {
471 assert!(!is_insecure_uri("git@github.com:foo/bar.git"));
472 }
473
474 #[test]
477 fn extract_host_from_git_uri() {
478 assert_eq!(
479 extract_host("git://github.com/rails/jquery-rails.git"),
480 Some("github.com".to_string())
481 );
482 }
483
484 #[test]
485 fn extract_host_from_http_uri() {
486 assert_eq!(
487 extract_host("http://rubygems.org/"),
488 Some("rubygems.org".to_string())
489 );
490 }
491
492 #[test]
493 fn extract_host_with_port() {
494 assert_eq!(
495 extract_host("http://gems.example.com:8080/"),
496 Some("gems.example.com".to_string())
497 );
498 }
499
500 #[test]
501 fn extract_host_with_user() {
502 assert_eq!(
503 extract_host("http://user@gems.example.com/"),
504 Some("gems.example.com".to_string())
505 );
506 }
507
508 #[test]
511 fn localhost_is_internal() {
512 assert!(is_internal_ip("127.0.0.1".parse().unwrap()));
513 assert!(is_internal_ip("127.0.0.42".parse().unwrap()));
514 }
515
516 #[test]
517 fn rfc1918_10_is_internal() {
518 assert!(is_internal_ip("10.0.0.1".parse().unwrap()));
519 assert!(is_internal_ip("10.255.255.255".parse().unwrap()));
520 }
521
522 #[test]
523 fn rfc1918_172_is_internal() {
524 assert!(is_internal_ip("172.16.0.1".parse().unwrap()));
525 assert!(is_internal_ip("172.31.255.255".parse().unwrap()));
526 }
527
528 #[test]
529 fn rfc1918_192_is_internal() {
530 assert!(is_internal_ip("192.168.0.1".parse().unwrap()));
531 assert!(is_internal_ip("192.168.255.255".parse().unwrap()));
532 }
533
534 #[test]
535 fn public_ip_is_not_internal() {
536 assert!(!is_internal_ip("8.8.8.8".parse().unwrap()));
537 assert!(!is_internal_ip("1.1.1.1".parse().unwrap()));
538 }
539
540 #[test]
541 fn ipv6_loopback_is_internal() {
542 assert!(is_internal_ip("::1".parse().unwrap()));
543 }
544
545 #[test]
546 fn ipv6_unique_local_is_internal() {
547 assert!(is_internal_ip("fc00::1".parse().unwrap()));
548 assert!(is_internal_ip("fd12:3456:789a::1".parse().unwrap()));
549 }
550
551 #[test]
554 fn internal_http_source() {
555 assert!(is_internal_source("http://192.168.1.1/gems/"));
556 assert!(is_internal_source("http://10.0.0.1:8080/"));
557 assert!(is_internal_source("http://127.0.0.1/"));
558 }
559
560 #[test]
561 fn external_http_source() {
562 assert!(!is_internal_source("http://rubygems.org/"));
563 }
564
565 #[test]
566 fn localhost_name_is_internal() {
567 assert!(is_internal_source("http://localhost/"));
568 }
569
570 #[test]
573 fn scan_secure_sources() {
574 let input = include_str!("../tests/fixtures/secure/Gemfile.lock");
575 let lockfile = lockfile::parse(input).unwrap();
576 let db = mock_database();
577 let scanner = Scanner::from_lockfile(lockfile, db);
578
579 let insecure = scanner.scan_sources();
580 assert!(
581 insecure.is_empty(),
582 "secure lockfile should have no insecure sources"
583 );
584 }
585
586 #[test]
587 fn scan_insecure_sources() {
588 let input = include_str!("../tests/fixtures/insecure_sources/Gemfile.lock");
589 let lockfile = lockfile::parse(input).unwrap();
590 let db = mock_database();
591 let scanner = Scanner::from_lockfile(lockfile, db);
592
593 let insecure = scanner.scan_sources();
594 assert_eq!(insecure.len(), 2);
595
596 let sources: Vec<&str> = insecure.iter().map(|s| s.source.as_str()).collect();
597 assert!(sources.contains(&"git://github.com/rails/jquery-rails.git"));
598 assert!(sources.contains(&"http://rubygems.org/"));
599 }
600
601 #[test]
604 fn scan_specs_with_mock_db() {
605 let input = include_str!("../tests/fixtures/secure/Gemfile.lock");
608 let lockfile = lockfile::parse(input).unwrap();
609 let db = mock_database();
610 let scanner = Scanner::from_lockfile(lockfile, db);
611
612 let opts = ScanOptions::default();
613 let (vulns, _, _) = scanner.scan_specs(&opts);
614 assert!(vulns.is_empty());
615 }
616
617 #[test]
620 fn scan_unpatched_gems_with_real_db() {
621 if let Some(db) = local_database() {
622 let input = include_str!("../tests/fixtures/unpatched_gems/Gemfile.lock");
623 let lockfile = lockfile::parse(input).unwrap();
624 let scanner = Scanner::from_lockfile(lockfile, db);
625
626 let opts = ScanOptions::default();
627 let report = scanner.scan(&opts);
628
629 assert!(
631 !report.unpatched_gems.is_empty(),
632 "expected vulnerabilities for unpatched_gems fixture"
633 );
634
635 let has_activerecord = report
637 .unpatched_gems
638 .iter()
639 .any(|v| v.name == "activerecord");
640 assert!(has_activerecord, "expected activerecord vulnerability");
641 }
642 }
643
644 #[test]
645 fn scan_secure_lockfile_with_real_db() {
646 if let Some(db) = local_database() {
647 let input = include_str!("../tests/fixtures/secure/Gemfile.lock");
648 let lockfile = lockfile::parse(input).unwrap();
649 let scanner = Scanner::from_lockfile(lockfile, db);
650
651 let insecure = scanner.scan_sources();
652 assert!(insecure.is_empty());
653 }
654 }
655
656 #[test]
657 fn scan_with_ignore_list() {
658 if let Some(db) = local_database() {
659 let input = include_str!("../tests/fixtures/unpatched_gems/Gemfile.lock");
660 let lockfile = lockfile::parse(input).unwrap();
661 let scanner = Scanner::from_lockfile(lockfile, db);
662
663 let all_opts = ScanOptions::default();
665 let (all_vulns, _, _) = scanner.scan_specs(&all_opts);
666
667 if let Some(first_vuln) = all_vulns.first() {
668 let mut ignore = HashSet::new();
670 for id in first_vuln.advisory.identifiers() {
671 ignore.insert(id);
672 }
673 let filtered_opts = ScanOptions {
674 ignore,
675 ..Default::default()
676 };
677 let (filtered_vulns, _, _) = scanner.scan_specs(&filtered_opts);
678
679 assert!(
680 filtered_vulns.len() < all_vulns.len(),
681 "ignore list should reduce vulnerability count"
682 );
683 }
684 }
685 }
686
687 #[test]
690 fn report_vulnerable_when_issues_found() {
691 let report = Report {
692 insecure_sources: vec![InsecureSource {
693 source: "http://rubygems.org/".to_string(),
694 }],
695 unpatched_gems: vec![],
696 vulnerable_rubies: vec![],
697 version_parse_errors: 0,
698 advisory_load_errors: 0,
699 };
700 assert!(report.vulnerable());
701 assert_eq!(report.count(), 1);
702 }
703
704 #[test]
705 fn report_not_vulnerable_when_clean() {
706 let report = Report {
707 insecure_sources: vec![],
708 unpatched_gems: vec![],
709 vulnerable_rubies: vec![],
710 version_parse_errors: 0,
711 advisory_load_errors: 0,
712 };
713 assert!(!report.vulnerable());
714 assert_eq!(report.count(), 0);
715 }
716
717 #[test]
720 fn remediations_empty_for_clean_report() {
721 let report = Report {
722 insecure_sources: vec![],
723 unpatched_gems: vec![],
724 vulnerable_rubies: vec![],
725 version_parse_errors: 0,
726 advisory_load_errors: 0,
727 };
728 assert!(report.remediations().is_empty());
729 }
730
731 #[test]
732 fn remediations_groups_by_gem_name() {
733 use crate::advisory::Advisory;
734
735 let yaml1 =
736 "---\ngem: test\ncve: 2020-1111\ncvss_v3: 9.0\npatched_versions:\n - \">= 1.0.0\"\n";
737 let yaml2 =
738 "---\ngem: test\ncve: 2020-2222\ncvss_v3: 7.0\npatched_versions:\n - \">= 1.2.0\"\n";
739 let yaml3 =
740 "---\ngem: other\ncve: 2020-3333\ncvss_v3: 5.0\npatched_versions:\n - \">= 2.0.0\"\n";
741 let adv1 = Advisory::from_yaml(yaml1, Path::new("CVE-2020-1111.yml")).unwrap();
742 let adv2 = Advisory::from_yaml(yaml2, Path::new("CVE-2020-2222.yml")).unwrap();
743 let adv3 = Advisory::from_yaml(yaml3, Path::new("CVE-2020-3333.yml")).unwrap();
744
745 let report = Report {
746 insecure_sources: vec![],
747 unpatched_gems: vec![
748 UnpatchedGem {
749 name: "test".to_string(),
750 version: "0.5.0".to_string(),
751 advisory: adv1,
752 },
753 UnpatchedGem {
754 name: "test".to_string(),
755 version: "0.5.0".to_string(),
756 advisory: adv2,
757 },
758 UnpatchedGem {
759 name: "other".to_string(),
760 version: "1.0.0".to_string(),
761 advisory: adv3,
762 },
763 ],
764 vulnerable_rubies: vec![],
765 version_parse_errors: 0,
766 advisory_load_errors: 0,
767 };
768
769 let remediations = report.remediations();
770 assert_eq!(remediations.len(), 2);
771
772 assert_eq!(remediations[0].name, "other");
774 assert_eq!(remediations[0].version, "1.0.0");
775 assert_eq!(remediations[0].advisories.len(), 1);
776
777 assert_eq!(remediations[1].name, "test");
778 assert_eq!(remediations[1].version, "0.5.0");
779 assert_eq!(remediations[1].advisories.len(), 2);
780 }
781
782 #[test]
783 fn remediations_deduplicates_advisories() {
784 use crate::advisory::Advisory;
785
786 let yaml =
787 "---\ngem: test\ncve: 2020-1111\ncvss_v3: 9.0\npatched_versions:\n - \">= 1.0.0\"\n";
788 let adv1 = Advisory::from_yaml(yaml, Path::new("CVE-2020-1111.yml")).unwrap();
789 let adv2 = Advisory::from_yaml(yaml, Path::new("CVE-2020-1111.yml")).unwrap();
790
791 let report = Report {
792 insecure_sources: vec![],
793 unpatched_gems: vec![
794 UnpatchedGem {
795 name: "test".to_string(),
796 version: "0.5.0".to_string(),
797 advisory: adv1,
798 },
799 UnpatchedGem {
800 name: "test".to_string(),
801 version: "0.5.0".to_string(),
802 advisory: adv2,
803 },
804 ],
805 vulnerable_rubies: vec![],
806 version_parse_errors: 0,
807 advisory_load_errors: 0,
808 };
809
810 let remediations = report.remediations();
811 assert_eq!(remediations.len(), 1);
812 assert_eq!(remediations[0].advisories.len(), 1);
813 }
814
815 #[test]
818 fn insecure_source_display() {
819 let src = InsecureSource {
820 source: "http://rubygems.org/".to_string(),
821 };
822 assert_eq!(
823 src.to_string(),
824 "Insecure Source URI found: http://rubygems.org/"
825 );
826 }
827
828 #[test]
829 fn unpatched_gem_display() {
830 use crate::advisory::Advisory;
831 let yaml =
832 "---\ngem: test\ncve: 2020-1234\ncvss_v3: 9.0\npatched_versions:\n - \">= 1.0\"\n";
833 let advisory = Advisory::from_yaml(yaml, Path::new("CVE-2020-1234.yml")).unwrap();
834 let gem = UnpatchedGem {
835 name: "test".to_string(),
836 version: "0.5.0".to_string(),
837 advisory,
838 };
839 assert_eq!(gem.to_string(), "test (0.5.0): CVE-2020-1234");
840 }
841
842 #[test]
843 fn vulnerable_ruby_display() {
844 use crate::advisory::Advisory;
845 let yaml = "---\nengine: ruby\ncve: 2021-31810\ncvss_v3: 5.9\npatched_versions:\n - \">= 3.0.2\"\n";
846 let advisory = Advisory::from_yaml(yaml, Path::new("CVE-2021-31810.yml")).unwrap();
847 let ruby = VulnerableRuby {
848 engine: "ruby".to_string(),
849 version: "2.6.0".to_string(),
850 advisory,
851 };
852 assert_eq!(ruby.to_string(), "ruby (2.6.0): CVE-2021-31810");
853 }
854
855 #[test]
858 fn scan_error_lockfile_not_found_display() {
859 let err = ScanError::LockfileNotFound("/tmp/missing".to_string());
860 assert!(err.to_string().contains("Gemfile.lock not found"));
861 assert!(err.to_string().contains("/tmp/missing"));
862 }
863
864 #[test]
865 fn scan_error_lockfile_parse_display() {
866 let err = ScanError::LockfileParse("bad content".to_string());
867 assert!(err.to_string().contains("failed to parse Gemfile.lock"));
868 }
869
870 #[test]
871 fn scan_error_io_display() {
872 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
873 let err = ScanError::Io(io_err);
874 assert!(err.to_string().contains("IO error"));
875 }
876
877 #[test]
880 fn ipv4_in_cidr_prefix_zero_matches_any() {
881 assert!(ipv4_in_cidr(
883 Ipv4Addr::new(8, 8, 8, 8),
884 Ipv4Addr::new(0, 0, 0, 0),
885 0
886 ));
887 assert!(ipv4_in_cidr(
888 Ipv4Addr::new(192, 168, 1, 1),
889 Ipv4Addr::new(0, 0, 0, 0),
890 0
891 ));
892 }
893
894 #[test]
895 fn ipv4_in_cidr_prefix_32_exact_match() {
896 assert!(ipv4_in_cidr(
897 Ipv4Addr::new(10, 0, 0, 1),
898 Ipv4Addr::new(10, 0, 0, 1),
899 32
900 ));
901 assert!(!ipv4_in_cidr(
902 Ipv4Addr::new(10, 0, 0, 2),
903 Ipv4Addr::new(10, 0, 0, 1),
904 32
905 ));
906 }
907
908 #[test]
911 fn extract_host_no_scheme() {
912 assert_eq!(extract_host("not-a-url"), None);
913 }
914
915 #[test]
916 fn extract_host_empty_host() {
917 assert_eq!(extract_host("http:///path"), None);
918 }
919
920 #[test]
923 fn scan_specs_tracks_version_parse_errors() {
924 let input = "\
925GEM
926 remote: https://rubygems.org/
927 specs:
928 badgem (!!!invalid!!!)
929
930PLATFORMS
931 ruby
932
933DEPENDENCIES
934 badgem
935";
936 let lockfile = lockfile::parse(input).unwrap();
937 let db = mock_database();
938 let scanner = Scanner::from_lockfile(lockfile, db);
939
940 let opts = ScanOptions::default();
941 let (_, version_parse_errors, _) = scanner.scan_specs(&opts);
942 assert!(
943 version_parse_errors > 0,
944 "expected version parse errors for invalid version"
945 );
946 }
947
948 #[test]
949 fn scan_specs_strict_mode_prints_warning() {
950 let input = "\
951GEM
952 remote: https://rubygems.org/
953 specs:
954 badgem (!!!invalid!!!)
955
956PLATFORMS
957 ruby
958
959DEPENDENCIES
960 badgem
961";
962 let lockfile = lockfile::parse(input).unwrap();
963 let db = mock_database();
964 let scanner = Scanner::from_lockfile(lockfile, db);
965
966 let opts = ScanOptions {
967 strict: true,
968 ..Default::default()
969 };
970 let (_, version_parse_errors, _) = scanner.scan_specs(&opts);
971 assert!(version_parse_errors > 0);
972 }
973
974 #[test]
977 fn scan_path_source_is_safe() {
978 let input = "\
979PATH
980 remote: .
981 specs:
982 my_gem (0.1.0)
983
984GEM
985 remote: https://rubygems.org/
986 specs:
987 rack (2.0.0)
988
989PLATFORMS
990 ruby
991
992DEPENDENCIES
993 my_gem!
994 rack
995";
996 let lockfile = lockfile::parse(input).unwrap();
997 let db = mock_database();
998 let scanner = Scanner::from_lockfile(lockfile, db);
999
1000 let insecure = scanner.scan_sources();
1001 assert!(insecure.is_empty(), "PATH sources should be safe");
1002 }
1003
1004 #[test]
1007 fn scan_ruby_detects_vulnerable_version() {
1008 let input = include_str!("../tests/fixtures/vulnerable_ruby/Gemfile.lock");
1009 let lockfile = lockfile::parse(input).unwrap();
1010 let db = mock_database();
1011 let scanner = Scanner::from_lockfile(lockfile, db);
1012
1013 let opts = ScanOptions::default();
1014 let (vulns, _) = scanner.scan_ruby(&opts);
1015 assert_eq!(vulns.len(), 1);
1016 assert_eq!(vulns[0].engine, "ruby");
1017 assert_eq!(vulns[0].version, "2.6.0");
1018 assert_eq!(vulns[0].advisory.id, "CVE-2021-31810");
1019 }
1020
1021 #[test]
1022 fn scan_ruby_no_ruby_version_section() {
1023 let input = include_str!("../tests/fixtures/secure/Gemfile.lock");
1024 let lockfile = lockfile::parse(input).unwrap();
1025 let db = mock_database();
1026 let scanner = Scanner::from_lockfile(lockfile, db);
1027
1028 let opts = ScanOptions::default();
1029 let (vulns, _) = scanner.scan_ruby(&opts);
1030 assert!(vulns.is_empty());
1031 }
1032
1033 #[test]
1034 fn scan_ruby_respects_ignore_list() {
1035 let input = include_str!("../tests/fixtures/vulnerable_ruby/Gemfile.lock");
1036 let lockfile = lockfile::parse(input).unwrap();
1037 let db = mock_database();
1038 let scanner = Scanner::from_lockfile(lockfile, db);
1039
1040 let mut ignore = HashSet::new();
1041 ignore.insert("CVE-2021-31810".to_string());
1042 let opts = ScanOptions {
1043 ignore,
1044 ..Default::default()
1045 };
1046 let (vulns, _) = scanner.scan_ruby(&opts);
1047 assert!(vulns.is_empty());
1048 }
1049
1050 #[test]
1051 fn scan_ruby_respects_severity_filter() {
1052 let input = include_str!("../tests/fixtures/vulnerable_ruby/Gemfile.lock");
1053 let lockfile = lockfile::parse(input).unwrap();
1054 let db = mock_database();
1055 let scanner = Scanner::from_lockfile(lockfile, db);
1056
1057 let opts = ScanOptions {
1059 severity: Some(Criticality::High),
1060 ..Default::default()
1061 };
1062 let (vulns, _) = scanner.scan_ruby(&opts);
1063 assert!(vulns.is_empty());
1064 }
1065
1066 #[test]
1067 fn scan_full_includes_ruby_vulnerabilities() {
1068 let input = include_str!("../tests/fixtures/vulnerable_ruby/Gemfile.lock");
1069 let lockfile = lockfile::parse(input).unwrap();
1070 let db = mock_database();
1071 let scanner = Scanner::from_lockfile(lockfile, db);
1072
1073 let opts = ScanOptions::default();
1074 let report = scanner.scan(&opts);
1075 assert!(report.vulnerable());
1076 assert_eq!(report.vulnerable_rubies.len(), 1);
1077 }
1078
1079 #[test]
1080 fn scan_ruby_severity_threshold_met() {
1081 let input = include_str!("../tests/fixtures/vulnerable_ruby/Gemfile.lock");
1083 let lockfile = lockfile::parse(input).unwrap();
1084 let db = mock_database();
1085 let scanner = Scanner::from_lockfile(lockfile, db);
1086
1087 let opts = ScanOptions {
1088 severity: Some(Criticality::Medium),
1089 ..Default::default()
1090 };
1091 let (vulns, _) = scanner.scan_ruby(&opts);
1092 assert_eq!(vulns.len(), 1);
1093 }
1094
1095 #[test]
1096 fn scan_ruby_unparseable_version() {
1097 let input = "\
1098GEM
1099 remote: https://rubygems.org/
1100 specs:
1101 rack (2.0.0)
1102
1103PLATFORMS
1104 ruby
1105
1106DEPENDENCIES
1107 rack
1108
1109RUBY VERSION
1110 ruby !!!invalid!!!
1111";
1112 let lockfile = lockfile::parse(input).unwrap();
1113 let db = mock_database();
1114 let scanner = Scanner::from_lockfile(lockfile, db);
1115
1116 let opts = ScanOptions::default();
1117 let (vulns, _) = scanner.scan_ruby(&opts);
1118 assert!(vulns.is_empty());
1119 }
1120
1121 #[test]
1122 fn should_report_ignore_nonmatching() {
1123 let mut ignore = HashSet::new();
1124 ignore.insert("CVE-9999-0000".to_string());
1125 let opts = ScanOptions {
1126 ignore,
1127 ..Default::default()
1128 };
1129 let yaml =
1130 "---\ngem: test\ncve: 2020-1234\ncvss_v3: 9.0\npatched_versions:\n - \">= 1.0\"\n";
1131 let advisory =
1132 crate::advisory::Advisory::from_yaml(yaml, Path::new("CVE-2020-1234.yml")).unwrap();
1133 assert!(opts.should_report(&advisory));
1134 }
1135
1136 #[test]
1137 fn report_count_includes_ruby_vulns() {
1138 let input = include_str!("../tests/fixtures/vulnerable_ruby/Gemfile.lock");
1139 let lockfile = lockfile::parse(input).unwrap();
1140 let db = mock_database();
1141 let scanner = Scanner::from_lockfile(lockfile, db);
1142
1143 let opts = ScanOptions::default();
1144 let report = scanner.scan(&opts);
1145 assert!(report.count() >= 1);
1146 }
1147}