cve-sentinel 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,513 @@
1
+ """Main scanner module for CVE Sentinel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
+ from cve_sentinel.analyzers.base import AnalysisResult, AnalyzerRegistry, Package
14
+ from cve_sentinel.config import Config, ConfigError, load_config
15
+ from cve_sentinel.fetchers.nvd import NVDClient
16
+ from cve_sentinel.fetchers.osv import OSVClient
17
+ from cve_sentinel.matcher import VulnerabilityMatch, VulnerabilityMatcher
18
+ from cve_sentinel.reporter import Reporter, create_reporter
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ __version__ = "0.1.0"
23
+
24
+
25
+ @dataclass
26
+ class ScanResult:
27
+ """Result of a CVE scan.
28
+
29
+ Attributes:
30
+ success: Whether the scan completed successfully.
31
+ packages_scanned: Number of packages scanned.
32
+ vulnerabilities: List of detected vulnerabilities.
33
+ errors: List of error messages encountered during scan.
34
+ scan_duration: Duration of the scan in seconds.
35
+ """
36
+
37
+ success: bool
38
+ packages_scanned: int
39
+ vulnerabilities: List[VulnerabilityMatch] = field(default_factory=list)
40
+ errors: List[str] = field(default_factory=list)
41
+ scan_duration: float = 0.0
42
+
43
+ @property
44
+ def has_vulnerabilities(self) -> bool:
45
+ """Check if any vulnerabilities were found."""
46
+ return len(self.vulnerabilities) > 0
47
+
48
+ @property
49
+ def critical_count(self) -> int:
50
+ """Count of critical severity vulnerabilities."""
51
+ return sum(1 for v in self.vulnerabilities if (v.severity or "").upper() == "CRITICAL")
52
+
53
+ @property
54
+ def high_count(self) -> int:
55
+ """Count of high severity vulnerabilities."""
56
+ return sum(1 for v in self.vulnerabilities if (v.severity or "").upper() == "HIGH")
57
+
58
+
59
+ class CVESentinelScanner:
60
+ """Main scanner class that orchestrates the CVE detection process.
61
+
62
+ This class coordinates the dependency analysis, vulnerability matching,
63
+ and result reporting for CVE Sentinel.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ config: Config,
69
+ nvd_client: Optional[NVDClient] = None,
70
+ osv_client: Optional[OSVClient] = None,
71
+ ) -> None:
72
+ """Initialize the scanner with configuration.
73
+
74
+ Args:
75
+ config: Configuration object with scan settings.
76
+ nvd_client: Optional pre-configured NVD client.
77
+ osv_client: Optional pre-configured OSV client.
78
+ """
79
+ self.config = config
80
+ self._nvd_client = nvd_client
81
+ self._osv_client = osv_client
82
+ self._reporter: Optional[Reporter] = None
83
+ self._matcher: Optional[VulnerabilityMatcher] = None
84
+ self._initialized = False
85
+
86
+ def _initialize_components(self, target_path: Path) -> None:
87
+ """Initialize scanner components.
88
+
89
+ Args:
90
+ target_path: Path to the project being scanned.
91
+ """
92
+ if self._initialized:
93
+ return
94
+
95
+ # Set up cache directory
96
+ cache_dir = target_path / ".cve-sentinel" / "cache"
97
+ cache_dir.mkdir(parents=True, exist_ok=True)
98
+
99
+ # Initialize NVD client if not provided
100
+ if self._nvd_client is None and self.config.nvd_api_key:
101
+ self._nvd_client = NVDClient(
102
+ api_key=self.config.nvd_api_key,
103
+ cache_dir=cache_dir,
104
+ cache_ttl_hours=self.config.cache_ttl_hours,
105
+ )
106
+
107
+ # Initialize OSV client if not provided
108
+ if self._osv_client is None:
109
+ self._osv_client = OSVClient(
110
+ cache_dir=cache_dir,
111
+ cache_ttl_hours=self.config.cache_ttl_hours,
112
+ )
113
+
114
+ # Initialize matcher
115
+ self._matcher = VulnerabilityMatcher(
116
+ nvd_client=self._nvd_client,
117
+ osv_client=self._osv_client,
118
+ fetch_nvd_details=self._nvd_client is not None,
119
+ )
120
+
121
+ # Initialize reporter
122
+ self._reporter = create_reporter(target_path)
123
+
124
+ # Register analyzers
125
+ self._register_analyzers()
126
+
127
+ self._initialized = True
128
+
129
+ def _register_analyzers(self) -> None:
130
+ """Register all available dependency analyzers."""
131
+ # Import and register analyzers
132
+ from cve_sentinel.analyzers.go import GoAnalyzer
133
+ from cve_sentinel.analyzers.maven import GradleAnalyzer, MavenAnalyzer
134
+ from cve_sentinel.analyzers.npm import NpmAnalyzer
135
+ from cve_sentinel.analyzers.php import PhpAnalyzer
136
+ from cve_sentinel.analyzers.python import PythonAnalyzer
137
+ from cve_sentinel.analyzers.ruby import RubyAnalyzer
138
+ from cve_sentinel.analyzers.rust import RustAnalyzer
139
+
140
+ registry = AnalyzerRegistry.get_instance()
141
+ registry.clear()
142
+
143
+ analysis_level = self.config.analysis_level
144
+ custom_patterns = self.config.custom_patterns or {}
145
+
146
+ # Map ecosystem names to their aliases for custom_patterns lookup
147
+ # Users can use either the common name or the ecosystem name
148
+ ecosystem_aliases = {
149
+ "npm": ["javascript", "npm"],
150
+ "pypi": ["python", "pypi"],
151
+ "go": ["go"],
152
+ "maven": ["java", "maven", "gradle"],
153
+ "rubygems": ["ruby", "rubygems"],
154
+ "crates.io": ["rust", "crates.io"],
155
+ "packagist": ["php", "packagist"],
156
+ }
157
+
158
+ def get_custom_patterns_for(ecosystem: str) -> Optional[dict]:
159
+ """Get custom patterns for an ecosystem, checking aliases."""
160
+ for alias in ecosystem_aliases.get(ecosystem, []):
161
+ if alias in custom_patterns:
162
+ return custom_patterns[alias]
163
+ return None
164
+
165
+ # Register analyzers with appropriate analysis level and custom patterns
166
+ # Note: PythonAnalyzer uses exclude_patterns instead of analysis_level
167
+ registry.register(
168
+ NpmAnalyzer(
169
+ analysis_level=analysis_level,
170
+ custom_patterns=get_custom_patterns_for("npm"),
171
+ )
172
+ )
173
+ registry.register(PythonAnalyzer(custom_patterns=get_custom_patterns_for("pypi")))
174
+ registry.register(
175
+ GoAnalyzer(
176
+ analysis_level=analysis_level,
177
+ custom_patterns=get_custom_patterns_for("go"),
178
+ )
179
+ )
180
+ registry.register(
181
+ MavenAnalyzer(
182
+ analysis_level=analysis_level,
183
+ custom_patterns=get_custom_patterns_for("maven"),
184
+ )
185
+ )
186
+ registry.register(
187
+ GradleAnalyzer(
188
+ analysis_level=analysis_level,
189
+ custom_patterns=get_custom_patterns_for("maven"),
190
+ )
191
+ )
192
+ registry.register(
193
+ RubyAnalyzer(
194
+ analysis_level=analysis_level,
195
+ custom_patterns=get_custom_patterns_for("rubygems"),
196
+ )
197
+ )
198
+ registry.register(
199
+ RustAnalyzer(
200
+ analysis_level=analysis_level,
201
+ custom_patterns=get_custom_patterns_for("crates.io"),
202
+ )
203
+ )
204
+ registry.register(
205
+ PhpAnalyzer(
206
+ analysis_level=analysis_level,
207
+ custom_patterns=get_custom_patterns_for("packagist"),
208
+ )
209
+ )
210
+
211
+ def scan(self, target_path: Path) -> ScanResult:
212
+ """Perform a CVE scan on the target path.
213
+
214
+ Executes the 10-step scan flow:
215
+ 1. Update status.json to "scanning"
216
+ 2. Load configuration
217
+ 3. Validate target path
218
+ 4. Detect dependency files
219
+ 5. Parse packages (Level 1-2)
220
+ 6. Import statement scanning (Level 3, if enabled)
221
+ 7. CVE matching
222
+ 8. Aggregate results
223
+ 9. Write results.json
224
+ 10. Update status.json to "completed"
225
+
226
+ Args:
227
+ target_path: Path to the project directory to scan.
228
+
229
+ Returns:
230
+ ScanResult with scan outcome and vulnerabilities.
231
+ """
232
+ start_time = time.time()
233
+ errors: List[str] = []
234
+ packages: List[Package] = []
235
+ vulnerabilities: List[VulnerabilityMatch] = []
236
+
237
+ try:
238
+ # Step 3: Validate target path BEFORE initialization
239
+ if not target_path.exists():
240
+ raise ConfigError(f"Target path does not exist: {target_path}")
241
+ if not target_path.is_dir():
242
+ raise ConfigError(f"Target path is not a directory: {target_path}")
243
+
244
+ # Step 1: Initialize components and update status
245
+ self._initialize_components(target_path)
246
+ if self._reporter:
247
+ self._reporter.update_status("scanning")
248
+
249
+ logger.info(f"Starting CVE scan for: {target_path}")
250
+
251
+ # Step 4-5: Detect and parse dependency files
252
+ registry = AnalyzerRegistry.get_instance()
253
+ analyzers = registry.get_all()
254
+
255
+ if not analyzers:
256
+ logger.warning("No analyzers registered")
257
+ errors.append("No analyzers registered")
258
+
259
+ combined_result = AnalysisResult()
260
+
261
+ for analyzer in analyzers:
262
+ logger.debug(f"Running {analyzer.ecosystem} analyzer")
263
+ try:
264
+ result = analyzer.analyze(
265
+ target_path,
266
+ exclude_patterns=self.config.exclude,
267
+ )
268
+ combined_result = combined_result.merge(result)
269
+ logger.debug(
270
+ f"{analyzer.ecosystem}: found {result.package_count} packages "
271
+ f"in {len(result.scanned_files)} files"
272
+ )
273
+ except Exception as e:
274
+ error_msg = f"Error in {analyzer.ecosystem} analyzer: {e}"
275
+ logger.warning(error_msg)
276
+ errors.append(error_msg)
277
+
278
+ # Deduplicate packages
279
+ packages = self._deduplicate_packages(combined_result.packages)
280
+ errors.extend(combined_result.errors)
281
+
282
+ logger.info(f"Found {len(packages)} unique packages")
283
+
284
+ # Step 6: Import statement scanning (Level 3)
285
+ # Note: Level 3 scanning is handled within individual analyzers
286
+ # when analysis_level >= 3
287
+
288
+ # Step 7: CVE matching
289
+ if packages and self._matcher:
290
+ logger.info("Matching packages against vulnerability databases...")
291
+ try:
292
+ vulnerabilities = self._matcher.match(packages)
293
+ logger.info(f"Found {len(vulnerabilities)} vulnerabilities")
294
+ except Exception as e:
295
+ error_msg = f"Error during vulnerability matching: {e}"
296
+ logger.error(error_msg)
297
+ errors.append(error_msg)
298
+
299
+ # Step 8-9: Aggregate and write results
300
+ if self._reporter:
301
+ self._reporter.write_results(
302
+ project_path=target_path,
303
+ packages_scanned=len(packages),
304
+ vulnerabilities=vulnerabilities,
305
+ )
306
+
307
+ # Print summary to terminal
308
+ self._reporter.print_summary(len(packages), vulnerabilities)
309
+
310
+ # Step 10: Update status to completed
311
+ if self._reporter:
312
+ self._reporter.update_status("completed")
313
+
314
+ scan_duration = time.time() - start_time
315
+ logger.info(f"Scan completed in {scan_duration:.2f}s")
316
+
317
+ return ScanResult(
318
+ success=True,
319
+ packages_scanned=len(packages),
320
+ vulnerabilities=vulnerabilities,
321
+ errors=errors,
322
+ scan_duration=scan_duration,
323
+ )
324
+
325
+ except Exception as e:
326
+ # Error handling
327
+ scan_duration = time.time() - start_time
328
+ error_msg = str(e)
329
+ logger.error(f"Scan failed: {error_msg}")
330
+ errors.append(error_msg)
331
+
332
+ # Update status to error
333
+ if self._reporter:
334
+ self._reporter.update_status("error", error_message=error_msg)
335
+
336
+ return ScanResult(
337
+ success=False,
338
+ packages_scanned=len(packages),
339
+ vulnerabilities=vulnerabilities,
340
+ errors=errors,
341
+ scan_duration=scan_duration,
342
+ )
343
+
344
+ def _deduplicate_packages(self, packages: List[Package]) -> List[Package]:
345
+ """Remove duplicate packages, preferring direct dependencies.
346
+
347
+ Args:
348
+ packages: List of packages possibly with duplicates.
349
+
350
+ Returns:
351
+ Deduplicated list of packages.
352
+ """
353
+ seen: dict[tuple[str, str, str], Package] = {}
354
+
355
+ for pkg in packages:
356
+ key = (pkg.name, pkg.version, pkg.ecosystem)
357
+ if key not in seen:
358
+ seen[key] = pkg
359
+ elif pkg.is_direct and not seen[key].is_direct:
360
+ # Prefer direct dependency
361
+ seen[key] = pkg
362
+
363
+ return list(seen.values())
364
+
365
+
366
+ def setup_logging(verbose: bool = False) -> None:
367
+ """Set up logging configuration.
368
+
369
+ Args:
370
+ verbose: Whether to enable debug logging.
371
+ """
372
+ level = logging.DEBUG if verbose else logging.INFO
373
+
374
+ # Configure root logger
375
+ logging.basicConfig(
376
+ level=level,
377
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
378
+ datefmt="%Y-%m-%d %H:%M:%S",
379
+ )
380
+
381
+ # Suppress noisy loggers
382
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
383
+ logging.getLogger("requests").setLevel(logging.WARNING)
384
+
385
+
386
+ def create_argument_parser() -> argparse.ArgumentParser:
387
+ """Create the CLI argument parser.
388
+
389
+ Returns:
390
+ Configured ArgumentParser.
391
+ """
392
+ parser = argparse.ArgumentParser(
393
+ prog="cve-sentinel",
394
+ description="Scan project dependencies for known CVE vulnerabilities",
395
+ )
396
+
397
+ parser.add_argument(
398
+ "--path",
399
+ "-p",
400
+ type=Path,
401
+ default=Path("."),
402
+ help="Path to the project directory to scan (default: current directory)",
403
+ )
404
+
405
+ parser.add_argument(
406
+ "--config",
407
+ "-c",
408
+ type=Path,
409
+ default=None,
410
+ help="Path to configuration file (default: .cve-sentinel.yaml in project)",
411
+ )
412
+
413
+ parser.add_argument(
414
+ "--verbose",
415
+ "-v",
416
+ action="store_true",
417
+ help="Enable verbose (debug) output",
418
+ )
419
+
420
+ parser.add_argument(
421
+ "--version",
422
+ "-V",
423
+ action="version",
424
+ version=f"%(prog)s {__version__}",
425
+ )
426
+
427
+ parser.add_argument(
428
+ "--fail-on",
429
+ choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"],
430
+ default="HIGH",
431
+ help="Exit with error code if vulnerabilities at or above this severity are found (default: HIGH)",
432
+ )
433
+
434
+ parser.add_argument(
435
+ "--no-color",
436
+ action="store_true",
437
+ help="Disable colored output",
438
+ )
439
+
440
+ parser.add_argument(
441
+ "--json",
442
+ action="store_true",
443
+ help="Output results in JSON format to stdout",
444
+ )
445
+
446
+ return parser
447
+
448
+
449
+ def main(args: Optional[List[str]] = None) -> int:
450
+ """Main entry point for CVE Sentinel CLI.
451
+
452
+ Args:
453
+ args: Command line arguments (defaults to sys.argv[1:]).
454
+
455
+ Returns:
456
+ Exit code (0: success, 1: vulnerabilities found, 2: error).
457
+ """
458
+ parser = create_argument_parser()
459
+ parsed_args = parser.parse_args(args)
460
+
461
+ # Set up logging
462
+ setup_logging(verbose=parsed_args.verbose)
463
+
464
+ # Resolve target path
465
+ target_path = parsed_args.path.resolve()
466
+
467
+ try:
468
+ # Load configuration
469
+ config = load_config(
470
+ base_path=target_path,
471
+ validate=True,
472
+ require_api_key=False, # Allow scanning without NVD API key
473
+ )
474
+
475
+ # Create scanner
476
+ scanner = CVESentinelScanner(config)
477
+
478
+ # Run scan
479
+ result = scanner.scan(target_path)
480
+
481
+ if not result.success:
482
+ logger.error("Scan failed with errors")
483
+ for error in result.errors:
484
+ logger.error(f" - {error}")
485
+ return 2
486
+
487
+ # Determine exit code based on vulnerabilities
488
+ if result.has_vulnerabilities:
489
+ # Check severity threshold
490
+ severity_order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
491
+ threshold_index = severity_order.index(parsed_args.fail_on)
492
+
493
+ for vuln in result.vulnerabilities:
494
+ severity = (vuln.severity or "UNKNOWN").upper()
495
+ if severity in severity_order:
496
+ if severity_order.index(severity) <= threshold_index:
497
+ return 1
498
+
499
+ return 0
500
+
501
+ except ConfigError as e:
502
+ logger.error(f"Configuration error: {e}")
503
+ return 2
504
+ except KeyboardInterrupt:
505
+ logger.info("Scan cancelled by user")
506
+ return 2
507
+ except Exception as e:
508
+ logger.exception(f"Unexpected error: {e}")
509
+ return 2
510
+
511
+
512
+ if __name__ == "__main__":
513
+ sys.exit(main())
@@ -0,0 +1,13 @@
1
+ """Source code scanners for CVE Sentinel."""
2
+
3
+ from cve_sentinel.scanners.import_scanner import (
4
+ ImportReference,
5
+ ImportScanner,
6
+ get_scanner_for_ecosystem,
7
+ )
8
+
9
+ __all__ = [
10
+ "ImportReference",
11
+ "ImportScanner",
12
+ "get_scanner_for_ecosystem",
13
+ ]