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#[derive(Debug, Default)]
17pub struct ScanOptions {
18 pub ignore: HashSet<String>,
20 pub severity: Option<Criticality>,
22 pub strict: bool,
24}
25
26impl ScanOptions {
27 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
57pub struct Scanner {
59 lockfile: Lockfile,
60 database: Database,
61}
62
63impl Scanner {
64 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 pub fn from_lockfile(lockfile: Lockfile, database: Database) -> Self {
77 Scanner { lockfile, database }
78 }
79
80 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 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 }
118 }
119 }
120
121 results
122 }
123
124 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 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 results.sort_by(|a, b| b.advisory.criticality().cmp(&a.advisory.criticality()));
173
174 (results, version_parse_errors, advisory_load_errors)
175 }
176
177 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}