Skip to main content

gem_audit/scanner/
mod.rs

1mod network;
2mod report;
3
4pub use network::{is_insecure_uri, is_internal_source};
5pub use report::{InsecureSource, Remediation, Report, ScanResult, UnpatchedGem, VulnerableRuby};
6
7use std::collections::HashSet;
8use std::path::Path;
9use thiserror::Error;
10
11use crate::advisory::{Advisory, Criticality, Database, DatabaseError};
12use crate::lockfile::{self, Lockfile, Source};
13use crate::version::Version;
14
15/// Scanner configuration options.
16#[derive(Debug, Default)]
17pub struct ScanOptions {
18    /// Advisory IDs to ignore (e.g., "CVE-2020-1234", "GHSA-aaaa-bbbb-cccc").
19    pub ignore: HashSet<String>,
20    /// Minimum severity threshold: only report advisories at or above this level.
21    pub severity: Option<Criticality>,
22    /// Treat parse/load warnings as significant (tracked in report error counters).
23    pub strict: bool,
24}
25
26impl ScanOptions {
27    /// Check whether an advisory should be reported based on ignore list and severity threshold.
28    fn should_report(&self, advisory: &Advisory) -> bool {
29        if !self.ignore.is_empty() {
30            let identifiers: HashSet<String> = advisory.identifiers().into_iter().collect();
31            if !self.ignore.is_disjoint(&identifiers) {
32                return false;
33            }
34        }
35        if let Some(threshold) = &self.severity {
36            match advisory.criticality() {
37                Some(crit) if crit >= *threshold => {}
38                _ => return false,
39            }
40        }
41        true
42    }
43}
44
45#[derive(Debug, Error)]
46pub enum ScanError {
47    #[error("Gemfile.lock not found: {0}")]
48    LockfileNotFound(String),
49    #[error("failed to parse Gemfile.lock: {0}")]
50    LockfileParse(String),
51    #[error("database error: {0}")]
52    Database(#[from] DatabaseError),
53    #[error("IO error: {0}")]
54    Io(#[from] std::io::Error),
55}
56
57/// The main scanner that audits a Gemfile.lock for security issues.
58pub struct Scanner {
59    lockfile: Lockfile,
60    database: Database,
61}
62
63impl Scanner {
64    /// Create a new scanner from a lockfile path and database.
65    pub fn new(lockfile_path: &Path, database: Database) -> Result<Self, ScanError> {
66        let content = std::fs::read_to_string(lockfile_path)
67            .map_err(|_| ScanError::LockfileNotFound(lockfile_path.display().to_string()))?;
68
69        let lockfile =
70            lockfile::parse(&content).map_err(|e| ScanError::LockfileParse(e.to_string()))?;
71
72        Ok(Scanner { lockfile, database })
73    }
74
75    /// Create a scanner from an already-parsed lockfile and database.
76    pub fn from_lockfile(lockfile: Lockfile, database: Database) -> Self {
77        Scanner { lockfile, database }
78    }
79
80    /// Run a full scan and produce a report.
81    pub fn scan(&self, options: &ScanOptions) -> Report {
82        let insecure_sources = self.scan_sources();
83        let (unpatched_gems, version_parse_errors, advisory_load_errors) = self.scan_specs(options);
84        let (vulnerable_rubies, ruby_advisory_errors) = self.scan_ruby(options);
85
86        Report {
87            insecure_sources,
88            unpatched_gems,
89            vulnerable_rubies,
90            version_parse_errors,
91            advisory_load_errors: advisory_load_errors + ruby_advisory_errors,
92        }
93    }
94
95    /// Scan gem sources for insecure protocols (`git://`, `http://`).
96    pub fn scan_sources(&self) -> Vec<InsecureSource> {
97        let mut results = Vec::new();
98
99        for source in &self.lockfile.sources {
100            match source {
101                Source::Git(git) => {
102                    if is_insecure_uri(&git.remote) && !is_internal_source(&git.remote) {
103                        results.push(InsecureSource {
104                            source: git.remote.clone(),
105                        });
106                    }
107                }
108                Source::Rubygems(gem) => {
109                    if gem.remote.starts_with("http://") && !is_internal_source(&gem.remote) {
110                        results.push(InsecureSource {
111                            source: gem.remote.clone(),
112                        });
113                    }
114                }
115                Source::Path(_) => {
116                    // Local paths are always considered safe
117                }
118            }
119        }
120
121        results
122    }
123
124    /// Scan gem specs against the advisory database.
125    ///
126    /// Returns `(unpatched_gems, version_parse_errors, advisory_load_errors)`.
127    pub fn scan_specs(&self, options: &ScanOptions) -> (Vec<UnpatchedGem>, usize, usize) {
128        let mut results = Vec::new();
129        let mut version_parse_errors: usize = 0;
130        let mut advisory_load_errors: usize = 0;
131
132        // Deduplicate: only check each gem name+version once (skip platform variants)
133        let mut seen = HashSet::new();
134
135        for spec in &self.lockfile.specs {
136            let key = (&spec.name, &spec.version);
137            if !seen.insert(key) {
138                continue;
139            }
140
141            let version = match Version::parse(&spec.version) {
142                Ok(v) => v,
143                Err(_) => {
144                    version_parse_errors += 1;
145                    if options.strict {
146                        eprintln!(
147                            "warning: failed to parse version '{}' for gem '{}'",
148                            spec.version, spec.name
149                        );
150                    }
151                    continue;
152                }
153            };
154
155            let (advisories, load_errors) = self.database.check_gem(&spec.name, &version);
156            advisory_load_errors += load_errors;
157
158            for advisory in advisories {
159                if !options.should_report(&advisory) {
160                    continue;
161                }
162
163                results.push(UnpatchedGem {
164                    name: spec.name.clone(),
165                    version: spec.version.clone(),
166                    advisory,
167                });
168            }
169        }
170
171        // Sort by criticality descending (Critical first, None/Unknown last)
172        results.sort_by(|a, b| b.advisory.criticality().cmp(&a.advisory.criticality()));
173
174        (results, version_parse_errors, advisory_load_errors)
175    }
176
177    /// Scan the Ruby interpreter version against the advisory database.
178    ///
179    /// Returns `(vulnerable_rubies, advisory_load_errors)`.
180    pub fn scan_ruby(&self, options: &ScanOptions) -> (Vec<VulnerableRuby>, usize) {
181        let ruby_version = match self.lockfile.parsed_ruby_version() {
182            Some(rv) => rv,
183            None => return (Vec::new(), 0),
184        };
185
186        let version = match Version::parse(&ruby_version.version) {
187            Ok(v) => v,
188            Err(_) => return (Vec::new(), 0),
189        };
190
191        let (advisories, load_errors) = self.database.check_ruby(&ruby_version.engine, &version);
192
193        let mut results = Vec::new();
194        for advisory in advisories {
195            if !options.should_report(&advisory) {
196                continue;
197            }
198
199            results.push(VulnerableRuby {
200                engine: ruby_version.engine.clone(),
201                version: ruby_version.version.clone(),
202                advisory,
203            });
204        }
205
206        // Sort by criticality descending
207        results.sort_by(|a, b| b.advisory.criticality().cmp(&a.advisory.criticality()));
208
209        (results, load_errors)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::path::PathBuf;
217
218    fn fixtures_dir() -> PathBuf {
219        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
220    }
221
222    fn mock_database() -> Database {
223        let db_dir = fixtures_dir().join("mock_db");
224        let gem_dir = db_dir.join("gems").join("test");
225        if !gem_dir.exists() {
226            std::fs::create_dir_all(&gem_dir).unwrap();
227            std::fs::copy(
228                fixtures_dir().join("advisory/CVE-2020-1234.yml"),
229                gem_dir.join("CVE-2020-1234.yml"),
230            )
231            .unwrap();
232        }
233        Database::open(&db_dir).unwrap()
234    }
235
236    fn local_database() -> Option<Database> {
237        let path = Database::default_path();
238        if path.join("gems").is_dir() {
239            Database::open(&path).ok()
240        } else {
241            None
242        }
243    }
244
245    // ========== Source Scanning ==========
246
247    #[test]
248    fn scan_secure_sources() {
249        let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
250        let lockfile = lockfile::parse(input).unwrap();
251        let db = mock_database();
252        let scanner = Scanner::from_lockfile(lockfile, db);
253
254        let insecure = scanner.scan_sources();
255        assert!(
256            insecure.is_empty(),
257            "secure lockfile should have no insecure sources"
258        );
259    }
260
261    #[test]
262    fn scan_insecure_sources() {
263        let input = include_str!("../../tests/fixtures/insecure_sources/Gemfile.lock");
264        let lockfile = lockfile::parse(input).unwrap();
265        let db = mock_database();
266        let scanner = Scanner::from_lockfile(lockfile, db);
267
268        let insecure = scanner.scan_sources();
269        assert_eq!(insecure.len(), 2);
270
271        let sources: Vec<&str> = insecure.iter().map(|s| s.source.as_str()).collect();
272        assert!(sources.contains(&"git://github.com/rails/jquery-rails.git"));
273        assert!(sources.contains(&"http://rubygems.org/"));
274    }
275
276    // ========== Spec Scanning (with mock DB) ==========
277
278    #[test]
279    fn scan_specs_with_mock_db() {
280        let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
281        let lockfile = lockfile::parse(input).unwrap();
282        let db = mock_database();
283        let scanner = Scanner::from_lockfile(lockfile, db);
284
285        let opts = ScanOptions::default();
286        let (vulns, _, _) = scanner.scan_specs(&opts);
287        assert!(vulns.is_empty());
288    }
289
290    // ========== Full Scan with Real DB ==========
291
292    #[test]
293    fn scan_unpatched_gems_with_real_db() {
294        if let Some(db) = local_database() {
295            let input = include_str!("../../tests/fixtures/unpatched_gems/Gemfile.lock");
296            let lockfile = lockfile::parse(input).unwrap();
297            let scanner = Scanner::from_lockfile(lockfile, db);
298
299            let opts = ScanOptions::default();
300            let report = scanner.scan(&opts);
301
302            assert!(
303                !report.unpatched_gems.is_empty(),
304                "expected vulnerabilities for unpatched_gems fixture"
305            );
306
307            let has_activerecord = report
308                .unpatched_gems
309                .iter()
310                .any(|v| v.name == "activerecord");
311            assert!(has_activerecord, "expected activerecord vulnerability");
312        }
313    }
314
315    #[test]
316    fn scan_secure_lockfile_with_real_db() {
317        if let Some(db) = local_database() {
318            let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
319            let lockfile = lockfile::parse(input).unwrap();
320            let scanner = Scanner::from_lockfile(lockfile, db);
321
322            let insecure = scanner.scan_sources();
323            assert!(insecure.is_empty());
324        }
325    }
326
327    #[test]
328    fn scan_with_ignore_list() {
329        if let Some(db) = local_database() {
330            let input = include_str!("../../tests/fixtures/unpatched_gems/Gemfile.lock");
331            let lockfile = lockfile::parse(input).unwrap();
332            let scanner = Scanner::from_lockfile(lockfile, db);
333
334            let all_opts = ScanOptions::default();
335            let (all_vulns, _, _) = scanner.scan_specs(&all_opts);
336
337            if let Some(first_vuln) = all_vulns.first() {
338                let mut ignore = HashSet::new();
339                for id in first_vuln.advisory.identifiers() {
340                    ignore.insert(id);
341                }
342                let filtered_opts = ScanOptions {
343                    ignore,
344                    ..Default::default()
345                };
346                let (filtered_vulns, _, _) = scanner.scan_specs(&filtered_opts);
347
348                assert!(
349                    filtered_vulns.len() < all_vulns.len(),
350                    "ignore list should reduce vulnerability count"
351                );
352            }
353        }
354    }
355
356    // ========== ScanError Display ==========
357
358    #[test]
359    fn scan_error_lockfile_not_found_display() {
360        let err = ScanError::LockfileNotFound("/tmp/missing".to_string());
361        assert!(err.to_string().contains("Gemfile.lock not found"));
362        assert!(err.to_string().contains("/tmp/missing"));
363    }
364
365    #[test]
366    fn scan_error_lockfile_parse_display() {
367        let err = ScanError::LockfileParse("bad content".to_string());
368        assert!(err.to_string().contains("failed to parse Gemfile.lock"));
369    }
370
371    #[test]
372    fn scan_error_io_display() {
373        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
374        let err = ScanError::Io(io_err);
375        assert!(err.to_string().contains("IO error"));
376    }
377
378    // ========== Version parse error tracking ==========
379
380    #[test]
381    fn scan_specs_tracks_version_parse_errors() {
382        let input = "\
383GEM
384  remote: https://rubygems.org/
385  specs:
386    badgem (!!!invalid!!!)
387
388PLATFORMS
389  ruby
390
391DEPENDENCIES
392  badgem
393";
394        let lockfile = lockfile::parse(input).unwrap();
395        let db = mock_database();
396        let scanner = Scanner::from_lockfile(lockfile, db);
397
398        let opts = ScanOptions::default();
399        let (_, version_parse_errors, _) = scanner.scan_specs(&opts);
400        assert!(
401            version_parse_errors > 0,
402            "expected version parse errors for invalid version"
403        );
404    }
405
406    #[test]
407    fn scan_specs_strict_mode_prints_warning() {
408        let input = "\
409GEM
410  remote: https://rubygems.org/
411  specs:
412    badgem (!!!invalid!!!)
413
414PLATFORMS
415  ruby
416
417DEPENDENCIES
418  badgem
419";
420        let lockfile = lockfile::parse(input).unwrap();
421        let db = mock_database();
422        let scanner = Scanner::from_lockfile(lockfile, db);
423
424        let opts = ScanOptions {
425            strict: true,
426            ..Default::default()
427        };
428        let (_, version_parse_errors, _) = scanner.scan_specs(&opts);
429        assert!(version_parse_errors > 0);
430    }
431
432    // ========== Path source scanning ==========
433
434    #[test]
435    fn scan_path_source_is_safe() {
436        let input = "\
437PATH
438  remote: .
439  specs:
440    my_gem (0.1.0)
441
442GEM
443  remote: https://rubygems.org/
444  specs:
445    rack (2.0.0)
446
447PLATFORMS
448  ruby
449
450DEPENDENCIES
451  my_gem!
452  rack
453";
454        let lockfile = lockfile::parse(input).unwrap();
455        let db = mock_database();
456        let scanner = Scanner::from_lockfile(lockfile, db);
457
458        let insecure = scanner.scan_sources();
459        assert!(insecure.is_empty(), "PATH sources should be safe");
460    }
461
462    // ========== Ruby Version Scanning ==========
463
464    #[test]
465    fn scan_ruby_detects_vulnerable_version() {
466        let input = include_str!("../../tests/fixtures/vulnerable_ruby/Gemfile.lock");
467        let lockfile = lockfile::parse(input).unwrap();
468        let db = mock_database();
469        let scanner = Scanner::from_lockfile(lockfile, db);
470
471        let opts = ScanOptions::default();
472        let (vulns, _) = scanner.scan_ruby(&opts);
473        assert_eq!(vulns.len(), 1);
474        assert_eq!(vulns[0].engine, "ruby");
475        assert_eq!(vulns[0].version, "2.6.0");
476        assert_eq!(vulns[0].advisory.id, "CVE-2021-31810");
477    }
478
479    #[test]
480    fn scan_ruby_no_ruby_version_section() {
481        let input = include_str!("../../tests/fixtures/secure/Gemfile.lock");
482        let lockfile = lockfile::parse(input).unwrap();
483        let db = mock_database();
484        let scanner = Scanner::from_lockfile(lockfile, db);
485
486        let opts = ScanOptions::default();
487        let (vulns, _) = scanner.scan_ruby(&opts);
488        assert!(vulns.is_empty());
489    }
490
491    #[test]
492    fn scan_ruby_respects_ignore_list() {
493        let input = include_str!("../../tests/fixtures/vulnerable_ruby/Gemfile.lock");
494        let lockfile = lockfile::parse(input).unwrap();
495        let db = mock_database();
496        let scanner = Scanner::from_lockfile(lockfile, db);
497
498        let mut ignore = HashSet::new();
499        ignore.insert("CVE-2021-31810".to_string());
500        let opts = ScanOptions {
501            ignore,
502            ..Default::default()
503        };
504        let (vulns, _) = scanner.scan_ruby(&opts);
505        assert!(vulns.is_empty());
506    }
507
508    #[test]
509    fn scan_ruby_respects_severity_filter() {
510        let input = include_str!("../../tests/fixtures/vulnerable_ruby/Gemfile.lock");
511        let lockfile = lockfile::parse(input).unwrap();
512        let db = mock_database();
513        let scanner = Scanner::from_lockfile(lockfile, db);
514
515        let opts = ScanOptions {
516            severity: Some(Criticality::High),
517            ..Default::default()
518        };
519        let (vulns, _) = scanner.scan_ruby(&opts);
520        assert!(vulns.is_empty());
521    }
522
523    #[test]
524    fn scan_full_includes_ruby_vulnerabilities() {
525        let input = include_str!("../../tests/fixtures/vulnerable_ruby/Gemfile.lock");
526        let lockfile = lockfile::parse(input).unwrap();
527        let db = mock_database();
528        let scanner = Scanner::from_lockfile(lockfile, db);
529
530        let opts = ScanOptions::default();
531        let report = scanner.scan(&opts);
532        assert!(report.vulnerable());
533        assert_eq!(report.vulnerable_rubies.len(), 1);
534    }
535
536    #[test]
537    fn scan_ruby_severity_threshold_met() {
538        let input = include_str!("../../tests/fixtures/vulnerable_ruby/Gemfile.lock");
539        let lockfile = lockfile::parse(input).unwrap();
540        let db = mock_database();
541        let scanner = Scanner::from_lockfile(lockfile, db);
542
543        let opts = ScanOptions {
544            severity: Some(Criticality::Medium),
545            ..Default::default()
546        };
547        let (vulns, _) = scanner.scan_ruby(&opts);
548        assert_eq!(vulns.len(), 1);
549    }
550
551    #[test]
552    fn scan_ruby_unparseable_version() {
553        let input = "\
554GEM
555  remote: https://rubygems.org/
556  specs:
557    rack (2.0.0)
558
559PLATFORMS
560  ruby
561
562DEPENDENCIES
563  rack
564
565RUBY VERSION
566   ruby !!!invalid!!!
567";
568        let lockfile = lockfile::parse(input).unwrap();
569        let db = mock_database();
570        let scanner = Scanner::from_lockfile(lockfile, db);
571
572        let opts = ScanOptions::default();
573        let (vulns, _) = scanner.scan_ruby(&opts);
574        assert!(vulns.is_empty());
575    }
576
577    #[test]
578    fn should_report_ignore_nonmatching() {
579        let mut ignore = HashSet::new();
580        ignore.insert("CVE-9999-0000".to_string());
581        let opts = ScanOptions {
582            ignore,
583            ..Default::default()
584        };
585        let yaml =
586            "---\ngem: test\ncve: 2020-1234\ncvss_v3: 9.0\npatched_versions:\n  - \">= 1.0\"\n";
587        let advisory =
588            crate::advisory::Advisory::from_yaml(yaml, Path::new("CVE-2020-1234.yml")).unwrap();
589        assert!(opts.should_report(&advisory));
590    }
591
592    #[test]
593    fn report_count_includes_ruby_vulns() {
594        let input = include_str!("../../tests/fixtures/vulnerable_ruby/Gemfile.lock");
595        let lockfile = lockfile::parse(input).unwrap();
596        let db = mock_database();
597        let scanner = Scanner::from_lockfile(lockfile, db);
598
599        let opts = ScanOptions::default();
600        let report = scanner.scan(&opts);
601        assert!(report.count() >= 1);
602    }
603}