Skip to main content

gem_audit/
scanner.rs

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/// A scan result: an insecure source, an unpatched gem, or a vulnerable Ruby version.
12#[derive(Debug)]
13pub enum ScanResult {
14    InsecureSource(InsecureSource),
15    UnpatchedGem(Box<UnpatchedGem>),
16    VulnerableRuby(Box<VulnerableRuby>),
17}
18
19/// An insecure gem source (`git://` or `http://`).
20#[derive(Debug, Clone)]
21pub struct InsecureSource {
22    /// The insecure URI string.
23    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/// A gem with a known vulnerability.
33#[derive(Debug)]
34pub struct UnpatchedGem {
35    /// The gem name.
36    pub name: String,
37    /// The installed version.
38    pub version: String,
39    /// The advisory describing the vulnerability.
40    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/// A Ruby interpreter version with a known vulnerability.
50#[derive(Debug)]
51pub struct VulnerableRuby {
52    /// The Ruby engine (e.g., "ruby", "jruby").
53    pub engine: String,
54    /// The installed version.
55    pub version: String,
56    /// The advisory describing the vulnerability.
57    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/// A grouped remediation suggestion for a single gem.
71#[derive(Debug)]
72pub struct Remediation {
73    /// The gem name.
74    pub name: String,
75    /// The currently installed version.
76    pub version: String,
77    /// All advisories affecting this gem (deduplicated by advisory ID).
78    pub advisories: Vec<Advisory>,
79}
80
81/// Aggregated scan report.
82#[derive(Debug)]
83pub struct Report {
84    pub insecure_sources: Vec<InsecureSource>,
85    pub unpatched_gems: Vec<UnpatchedGem>,
86    pub vulnerable_rubies: Vec<VulnerableRuby>,
87    /// Number of gem versions that failed to parse.
88    pub version_parse_errors: usize,
89    /// Number of advisory YAML files that failed to load.
90    pub advisory_load_errors: usize,
91}
92
93impl Report {
94    /// Returns true if any vulnerabilities were found.
95    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    /// Total number of issues found.
102    pub fn count(&self) -> usize {
103        self.insecure_sources.len() + self.unpatched_gems.len() + self.vulnerable_rubies.len()
104    }
105
106    /// Group unpatched gems into remediation suggestions.
107    ///
108    /// Groups vulnerabilities by gem name, deduplicates advisories (by ID),
109    /// and collects the union of all patched_versions across advisories.
110    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            // Deduplicate advisories by ID
118            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/// Scanner configuration options.
135#[derive(Debug, Default)]
136pub struct ScanOptions {
137    /// Advisory IDs to ignore (e.g., "CVE-2020-1234", "GHSA-aaaa-bbbb-cccc").
138    pub ignore: HashSet<String>,
139    /// Minimum severity threshold: only report advisories at or above this level.
140    pub severity: Option<Criticality>,
141    /// Treat parse/load warnings as significant (tracked in report error counters).
142    pub strict: bool,
143}
144
145impl ScanOptions {
146    /// Check whether an advisory should be reported based on ignore list and severity threshold.
147    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
176/// The main scanner that audits a Gemfile.lock for security issues.
177pub struct Scanner {
178    lockfile: Lockfile,
179    database: Database,
180}
181
182impl Scanner {
183    /// Create a new scanner from a lockfile path and database.
184    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    /// Create a scanner from an already-parsed lockfile and database.
195    pub fn from_lockfile(lockfile: Lockfile, database: Database) -> Self {
196        Scanner { lockfile, database }
197    }
198
199    /// Run a full scan and produce a report.
200    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    /// Scan gem sources for insecure protocols (`git://`, `http://`).
215    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                    // Local paths are always considered safe
236                }
237            }
238        }
239
240        results
241    }
242
243    /// Scan gem specs against the advisory database.
244    ///
245    /// Returns `(unpatched_gems, version_parse_errors, advisory_load_errors)`.
246    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        // Deduplicate: only check each gem name+version once (skip platform variants)
252        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        // Sort by criticality descending (Critical first, None/Unknown last)
291        results.sort_by(|a, b| b.advisory.criticality().cmp(&a.advisory.criticality()));
292
293        (results, version_parse_errors, advisory_load_errors)
294    }
295
296    /// Scan the Ruby interpreter version against the advisory database.
297    ///
298    /// Returns `(vulnerable_rubies, advisory_load_errors)`.
299    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        // Sort by criticality descending
326        results.sort_by(|a, b| b.advisory.criticality().cmp(&a.advisory.criticality()));
327
328        (results, load_errors)
329    }
330}
331
332/// Check if a URI uses an insecure protocol.
333fn is_insecure_uri(uri: &str) -> bool {
334    uri.starts_with("git://") || uri.starts_with("http://")
335}
336
337/// RFC 1918 / RFC 4193 / RFC 6890 internal IP ranges.
338const 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
345/// Check if an IPv4 address is in a CIDR range.
346fn 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
357/// Check if an IP address is internal/private.
358fn 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            // ::1 (loopback)
365            v6 == Ipv6Addr::LOCALHOST
366                // fc00::/7 (unique local)
367                || (v6.octets()[0] & 0xfe) == 0xfc
368        }
369    }
370}
371
372/// Check if a source URI points to an internal/private host.
373fn 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
381/// Extract the hostname from a URI string.
382fn extract_host(uri: &str) -> Option<String> {
383    // Handle git:// , http:// , https://
384    let after_scheme = uri.split("://").nth(1)?;
385    let host_port = after_scheme.split('/').next()?;
386    let host = host_port.split(':').next()?;
387    // Strip user@ prefix
388    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
400/// Check if a hostname resolves to only internal IPs.
401fn is_internal_host(host: &str) -> bool {
402    // Try parsing as IP address first
403    if let Ok(ip) = host.parse::<IpAddr>() {
404        return is_internal_ip(ip);
405    }
406
407    // Try DNS resolution
408    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        // Use the mock_db fixture; create it if it doesn't exist
430        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    // ========== URI Security ==========
453
454    #[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    // ========== Host Extraction ==========
475
476    #[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    // ========== Internal IP Detection ==========
509
510    #[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    // ========== Internal Source Detection ==========
552
553    #[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    // ========== Source Scanning ==========
571
572    #[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    // ========== Spec Scanning (with mock DB) ==========
602
603    #[test]
604    fn scan_specs_with_mock_db() {
605        // The mock DB has one advisory for gem "test" - our lockfiles
606        // don't contain "test" gem, so no vulnerabilities expected
607        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    // ========== Full Scan with Real DB ==========
618
619    #[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            // activerecord 3.2.10 should have known vulnerabilities
630            assert!(
631                !report.unpatched_gems.is_empty(),
632                "expected vulnerabilities for unpatched_gems fixture"
633            );
634
635            // Verify at least one vulnerability is for activerecord
636            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            // First get all vulnerabilities
664            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                // Now ignore the first advisory
669                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    // ========== Report ==========
688
689    #[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    // ========== Remediations ==========
718
719    #[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        // BTreeMap orders alphabetically
773        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    // ========== Display Impls ==========
816
817    #[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    // ========== ScanError Display ==========
856
857    #[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    // ========== ipv4_in_cidr edge cases ==========
878
879    #[test]
880    fn ipv4_in_cidr_prefix_zero_matches_any() {
881        // prefix_len = 0 means any address matches
882        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    // ========== extract_host edge cases ==========
909
910    #[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    // ========== Version parse error tracking ==========
921
922    #[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    // ========== Path source scanning ==========
975
976    #[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    // ========== Ruby Version Scanning ==========
1005
1006    #[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        // CVE-2021-31810 has cvss_v3=5.9 (Medium), filter for High should exclude it
1058        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        // CVE-2021-31810 has cvss_v3=5.9 (Medium), filter for Medium should include it
1082        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}