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,549 @@
1
+ """Result reporter for CVE Sentinel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from io import StringIO
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, TextIO
13
+
14
+ from cve_sentinel.matcher import VulnerabilityMatch
15
+
16
+
17
+ @dataclass
18
+ class ScanSummary:
19
+ """Summary of a vulnerability scan.
20
+
21
+ Attributes:
22
+ total_vulnerabilities: Total number of vulnerabilities found.
23
+ critical_count: Number of critical severity vulnerabilities.
24
+ high_count: Number of high severity vulnerabilities.
25
+ medium_count: Number of medium severity vulnerabilities.
26
+ low_count: Number of low severity vulnerabilities.
27
+ unknown_count: Number of unknown severity vulnerabilities.
28
+ packages_scanned: Total number of packages scanned.
29
+ """
30
+
31
+ total_vulnerabilities: int
32
+ critical_count: int
33
+ high_count: int
34
+ medium_count: int
35
+ low_count: int
36
+ unknown_count: int
37
+ packages_scanned: int
38
+
39
+ @classmethod
40
+ def from_vulnerabilities(
41
+ cls,
42
+ vulnerabilities: List[VulnerabilityMatch],
43
+ packages_scanned: int,
44
+ ) -> ScanSummary:
45
+ """Create summary from vulnerability list.
46
+
47
+ Args:
48
+ vulnerabilities: List of vulnerability matches.
49
+ packages_scanned: Number of packages scanned.
50
+
51
+ Returns:
52
+ ScanSummary instance.
53
+ """
54
+ counts = {
55
+ "CRITICAL": 0,
56
+ "HIGH": 0,
57
+ "MEDIUM": 0,
58
+ "LOW": 0,
59
+ "UNKNOWN": 0,
60
+ }
61
+
62
+ for vuln in vulnerabilities:
63
+ severity = (vuln.severity or "UNKNOWN").upper()
64
+ if severity in counts:
65
+ counts[severity] += 1
66
+ else:
67
+ counts["UNKNOWN"] += 1
68
+
69
+ return cls(
70
+ total_vulnerabilities=len(vulnerabilities),
71
+ critical_count=counts["CRITICAL"],
72
+ high_count=counts["HIGH"],
73
+ medium_count=counts["MEDIUM"],
74
+ low_count=counts["LOW"],
75
+ unknown_count=counts["UNKNOWN"],
76
+ packages_scanned=packages_scanned,
77
+ )
78
+
79
+ def to_dict(self) -> Dict[str, int]:
80
+ """Convert to dictionary."""
81
+ return {
82
+ "total": self.total_vulnerabilities,
83
+ "critical": self.critical_count,
84
+ "high": self.high_count,
85
+ "medium": self.medium_count,
86
+ "low": self.low_count,
87
+ "unknown": self.unknown_count,
88
+ "packages_scanned": self.packages_scanned,
89
+ }
90
+
91
+
92
+ class TerminalColors:
93
+ """ANSI color codes for terminal output."""
94
+
95
+ # Basic colors
96
+ RED = "\033[91m"
97
+ ORANGE = "\033[38;5;208m" # 256-color orange
98
+ YELLOW = "\033[93m"
99
+ GREEN = "\033[92m"
100
+ BLUE = "\033[94m"
101
+ GRAY = "\033[90m"
102
+ WHITE = "\033[97m"
103
+
104
+ # Styles
105
+ BOLD = "\033[1m"
106
+ DIM = "\033[2m"
107
+ RESET = "\033[0m"
108
+
109
+ # Severity colors
110
+ CRITICAL = RED + BOLD
111
+ HIGH = ORANGE
112
+ MEDIUM = YELLOW
113
+ LOW = GREEN
114
+ UNKNOWN = GRAY
115
+
116
+ # Symbols
117
+ WARNING_SYMBOL = "⚠"
118
+ CHECK_SYMBOL = "✓"
119
+ BULLET_SYMBOL = "•"
120
+
121
+ @classmethod
122
+ def supports_color(cls) -> bool:
123
+ """Check if terminal supports color output.
124
+
125
+ Returns:
126
+ True if colors are supported.
127
+ """
128
+ # Check for NO_COLOR environment variable
129
+ if os.environ.get("NO_COLOR"):
130
+ return False
131
+
132
+ # Check for FORCE_COLOR environment variable
133
+ if os.environ.get("FORCE_COLOR"):
134
+ return True
135
+
136
+ # Check if stdout is a TTY
137
+ if not hasattr(sys.stdout, "isatty"):
138
+ return False
139
+
140
+ if not sys.stdout.isatty():
141
+ return False
142
+
143
+ # Check for dumb terminal
144
+ if os.environ.get("TERM") == "dumb":
145
+ return False
146
+
147
+ return True
148
+
149
+ @classmethod
150
+ def get_severity_color(cls, severity: str, use_color: bool = True) -> str:
151
+ """Get color code for severity level.
152
+
153
+ Args:
154
+ severity: Severity level string.
155
+ use_color: Whether to use color codes.
156
+
157
+ Returns:
158
+ Color code string or empty string.
159
+ """
160
+ if not use_color:
161
+ return ""
162
+
163
+ severity_upper = (severity or "UNKNOWN").upper()
164
+ color_map = {
165
+ "CRITICAL": cls.CRITICAL,
166
+ "HIGH": cls.HIGH,
167
+ "MEDIUM": cls.MEDIUM,
168
+ "LOW": cls.LOW,
169
+ "UNKNOWN": cls.UNKNOWN,
170
+ }
171
+ return color_map.get(severity_upper, cls.GRAY)
172
+
173
+
174
+ class Reporter:
175
+ """Generates scan reports and status files."""
176
+
177
+ def __init__(
178
+ self,
179
+ output_dir: Path,
180
+ use_color: Optional[bool] = None,
181
+ ) -> None:
182
+ """Initialize reporter with output directory.
183
+
184
+ Args:
185
+ output_dir: Directory to write output files.
186
+ use_color: Whether to use colored output. Auto-detects if None.
187
+ """
188
+ self.output_dir = output_dir
189
+ self.output_dir.mkdir(parents=True, exist_ok=True)
190
+
191
+ if use_color is None:
192
+ self.use_color = TerminalColors.supports_color()
193
+ else:
194
+ self.use_color = use_color
195
+
196
+ def _colorize(self, text: str, color: str) -> str:
197
+ """Apply color to text if colors are enabled.
198
+
199
+ Args:
200
+ text: Text to colorize.
201
+ color: ANSI color code.
202
+
203
+ Returns:
204
+ Colorized text or plain text.
205
+ """
206
+ if self.use_color and color:
207
+ return f"{color}{text}{TerminalColors.RESET}"
208
+ return text
209
+
210
+ def update_status(
211
+ self,
212
+ status: str,
213
+ error_message: Optional[str] = None,
214
+ ) -> None:
215
+ """Update status.json file.
216
+
217
+ Args:
218
+ status: Current status ('scanning', 'completed', 'error').
219
+ error_message: Error message if status is 'error'.
220
+ """
221
+ status_file = self.output_dir / "status.json"
222
+ now = datetime.now(timezone.utc).isoformat()
223
+
224
+ data: Dict[str, Any] = {"status": status}
225
+
226
+ if status == "scanning":
227
+ data["started_at"] = now
228
+ elif status in ("completed", "error"):
229
+ # Try to preserve started_at from existing file
230
+ if status_file.exists():
231
+ try:
232
+ existing = json.loads(status_file.read_text())
233
+ data["started_at"] = existing.get("started_at", now)
234
+ except (json.JSONDecodeError, KeyError):
235
+ data["started_at"] = now
236
+ else:
237
+ data["started_at"] = now
238
+ data["completed_at"] = now
239
+
240
+ if error_message:
241
+ data["error_message"] = error_message
242
+
243
+ status_file.write_text(json.dumps(data, indent=2))
244
+
245
+ def write_results(
246
+ self,
247
+ project_path: Path,
248
+ packages_scanned: int,
249
+ vulnerabilities: List[VulnerabilityMatch],
250
+ ) -> Path:
251
+ """Write results.json file.
252
+
253
+ Args:
254
+ project_path: Path to the scanned project.
255
+ packages_scanned: Number of packages scanned.
256
+ vulnerabilities: List of vulnerability matches.
257
+
258
+ Returns:
259
+ Path to the results file.
260
+ """
261
+ results_file = self.output_dir / "results.json"
262
+ summary = ScanSummary.from_vulnerabilities(vulnerabilities, packages_scanned)
263
+
264
+ data = {
265
+ "scan_date": datetime.now(timezone.utc).isoformat(),
266
+ "project_path": str(project_path.absolute()),
267
+ "packages_scanned": packages_scanned,
268
+ "summary": summary.to_dict(),
269
+ "vulnerabilities": [self._format_vulnerability_for_json(v) for v in vulnerabilities],
270
+ }
271
+
272
+ results_file.write_text(json.dumps(data, indent=2, ensure_ascii=False))
273
+ return results_file
274
+
275
+ def _format_vulnerability_for_json(self, vuln: VulnerabilityMatch) -> Dict[str, Any]:
276
+ """Format a vulnerability for JSON output.
277
+
278
+ Args:
279
+ vuln: Vulnerability match to format.
280
+
281
+ Returns:
282
+ Dictionary representation.
283
+ """
284
+ return {
285
+ "cve_id": vuln.cve_id,
286
+ "osv_id": vuln.osv_id,
287
+ "package_name": vuln.package.name,
288
+ "installed_version": vuln.package.version,
289
+ "ecosystem": vuln.package.ecosystem,
290
+ "severity": vuln.severity,
291
+ "cvss_score": vuln.cvss_score,
292
+ "description": vuln.description,
293
+ "affected_files": vuln.affected_files,
294
+ "fix_version": vuln.fix_version,
295
+ "fix_command": vuln.fix_command,
296
+ "references": vuln.references[:5] if vuln.references else [],
297
+ }
298
+
299
+ def print_summary(
300
+ self,
301
+ packages_scanned: int,
302
+ vulnerabilities: List[VulnerabilityMatch],
303
+ output: Optional[TextIO] = None,
304
+ ) -> None:
305
+ """Print scan summary to terminal.
306
+
307
+ Args:
308
+ packages_scanned: Number of packages scanned.
309
+ vulnerabilities: List of vulnerability matches.
310
+ output: Output stream (defaults to stdout).
311
+ """
312
+ if output is None:
313
+ output = sys.stdout
314
+
315
+ summary = ScanSummary.from_vulnerabilities(vulnerabilities, packages_scanned)
316
+
317
+ if vulnerabilities:
318
+ self._print_vulnerabilities_found(summary, vulnerabilities, output)
319
+ else:
320
+ self._print_no_vulnerabilities(summary, output)
321
+
322
+ def _print_vulnerabilities_found(
323
+ self,
324
+ summary: ScanSummary,
325
+ vulnerabilities: List[VulnerabilityMatch],
326
+ output: TextIO,
327
+ ) -> None:
328
+ """Print output when vulnerabilities are found.
329
+
330
+ Args:
331
+ summary: Scan summary.
332
+ vulnerabilities: List of vulnerability matches.
333
+ output: Output stream.
334
+ """
335
+ # Header with warning
336
+ warning_symbol = TerminalColors.WARNING_SYMBOL
337
+ header = f"{warning_symbol} CVE Scan Complete: {summary.total_vulnerabilities} vulnerabilities found"
338
+ output.write(self._colorize(header, TerminalColors.RED + TerminalColors.BOLD) + "\n\n")
339
+
340
+ # Severity summary
341
+ self._print_severity_summary(summary, output)
342
+ output.write("\n")
343
+
344
+ # Sort vulnerabilities by severity
345
+ severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "UNKNOWN": 4}
346
+ sorted_vulns = sorted(
347
+ vulnerabilities,
348
+ key=lambda v: severity_order.get((v.severity or "UNKNOWN").upper(), 5),
349
+ )
350
+
351
+ # Print each vulnerability
352
+ for vuln in sorted_vulns:
353
+ self._print_vulnerability(vuln, output)
354
+
355
+ def _print_no_vulnerabilities(
356
+ self,
357
+ summary: ScanSummary,
358
+ output: TextIO,
359
+ ) -> None:
360
+ """Print output when no vulnerabilities are found.
361
+
362
+ Args:
363
+ summary: Scan summary.
364
+ output: Output stream.
365
+ """
366
+ check_symbol = TerminalColors.CHECK_SYMBOL
367
+ header = f"{check_symbol} CVE Scan Complete: No vulnerabilities detected"
368
+ output.write(self._colorize(header, TerminalColors.GREEN + TerminalColors.BOLD) + "\n")
369
+ output.write(f" Scanned: {summary.packages_scanned} packages\n")
370
+
371
+ def _print_severity_summary(self, summary: ScanSummary, output: TextIO) -> None:
372
+ """Print severity breakdown summary.
373
+
374
+ Args:
375
+ summary: Scan summary.
376
+ output: Output stream.
377
+ """
378
+ parts = []
379
+
380
+ if summary.critical_count > 0:
381
+ critical_text = f"Critical: {summary.critical_count}"
382
+ parts.append(self._colorize(critical_text, TerminalColors.CRITICAL))
383
+
384
+ if summary.high_count > 0:
385
+ high_text = f"High: {summary.high_count}"
386
+ parts.append(self._colorize(high_text, TerminalColors.HIGH))
387
+
388
+ if summary.medium_count > 0:
389
+ medium_text = f"Medium: {summary.medium_count}"
390
+ parts.append(self._colorize(medium_text, TerminalColors.MEDIUM))
391
+
392
+ if summary.low_count > 0:
393
+ low_text = f"Low: {summary.low_count}"
394
+ parts.append(self._colorize(low_text, TerminalColors.LOW))
395
+
396
+ if summary.unknown_count > 0:
397
+ unknown_text = f"Unknown: {summary.unknown_count}"
398
+ parts.append(self._colorize(unknown_text, TerminalColors.UNKNOWN))
399
+
400
+ if parts:
401
+ output.write(" By Severity: " + " | ".join(parts) + "\n")
402
+
403
+ output.write(f" Scanned: {summary.packages_scanned} packages\n")
404
+
405
+ def _print_vulnerability(self, vuln: VulnerabilityMatch, output: TextIO) -> None:
406
+ """Print a single vulnerability entry.
407
+
408
+ Args:
409
+ vuln: Vulnerability match to print.
410
+ output: Output stream.
411
+ """
412
+ severity_color = TerminalColors.get_severity_color(vuln.severity, self.use_color)
413
+
414
+ # CVE ID and package info
415
+ cve_display = f"[{vuln.cve_id}]"
416
+ pkg_display = f"{vuln.package.name}@{vuln.package.version}"
417
+ output.write(f"{self._colorize(cve_display, TerminalColors.BOLD)} {pkg_display}\n")
418
+
419
+ # Severity and CVSS score
420
+ severity_text = vuln.severity or "UNKNOWN"
421
+ cvss_text = f"CVSS {vuln.cvss_score}" if vuln.cvss_score else "CVSS N/A"
422
+ output.write(f" Severity: {self._colorize(severity_text, severity_color)} ({cvss_text})\n")
423
+
424
+ # Description (truncated)
425
+ if vuln.description:
426
+ desc = (
427
+ vuln.description[:100] + "..." if len(vuln.description) > 100 else vuln.description
428
+ )
429
+ output.write(f" Description: {desc}\n")
430
+
431
+ # Affected files
432
+ if vuln.affected_files:
433
+ output.write(" Affected Files:\n")
434
+ for af in vuln.affected_files[:5]: # Limit to 5 files
435
+ file_path = af.get("file", "unknown")
436
+ line_num = af.get("line")
437
+ if line_num:
438
+ output.write(f" {file_path}:{line_num}\n")
439
+ else:
440
+ output.write(f" {file_path}\n")
441
+
442
+ # Fix command
443
+ if vuln.fix_command:
444
+ fix_text = f" Fix: {vuln.fix_command}"
445
+ output.write(self._colorize(fix_text, TerminalColors.GREEN) + "\n")
446
+
447
+ output.write("\n")
448
+
449
+ def format_cli_report(
450
+ self,
451
+ packages_scanned: int,
452
+ vulnerabilities: List[VulnerabilityMatch],
453
+ ) -> str:
454
+ """Format scan results as CLI report string.
455
+
456
+ Args:
457
+ packages_scanned: Number of packages scanned.
458
+ vulnerabilities: List of vulnerability matches.
459
+
460
+ Returns:
461
+ Formatted report string.
462
+ """
463
+ buffer = StringIO()
464
+ self.print_summary(packages_scanned, vulnerabilities, buffer)
465
+ return buffer.getvalue()
466
+
467
+ def get_summary(
468
+ self,
469
+ packages_scanned: int,
470
+ vulnerabilities: List[VulnerabilityMatch],
471
+ ) -> ScanSummary:
472
+ """Get scan summary object.
473
+
474
+ Args:
475
+ packages_scanned: Number of packages scanned.
476
+ vulnerabilities: List of vulnerability matches.
477
+
478
+ Returns:
479
+ ScanSummary instance.
480
+ """
481
+ return ScanSummary.from_vulnerabilities(vulnerabilities, packages_scanned)
482
+
483
+ def has_critical_vulnerabilities(
484
+ self,
485
+ vulnerabilities: List[VulnerabilityMatch],
486
+ ) -> bool:
487
+ """Check if any critical vulnerabilities exist.
488
+
489
+ Args:
490
+ vulnerabilities: List of vulnerability matches.
491
+
492
+ Returns:
493
+ True if critical vulnerabilities exist.
494
+ """
495
+ for vuln in vulnerabilities:
496
+ if (vuln.severity or "").upper() == "CRITICAL":
497
+ return True
498
+ return False
499
+
500
+ def get_exit_code(
501
+ self,
502
+ vulnerabilities: List[VulnerabilityMatch],
503
+ fail_on_severity: str = "HIGH",
504
+ ) -> int:
505
+ """Get suggested exit code based on vulnerabilities.
506
+
507
+ Args:
508
+ vulnerabilities: List of vulnerability matches.
509
+ fail_on_severity: Minimum severity to cause failure.
510
+
511
+ Returns:
512
+ 0 if no issues, 1 if vulnerabilities exceed threshold.
513
+ """
514
+ if not vulnerabilities:
515
+ return 0
516
+
517
+ severity_order = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]
518
+ try:
519
+ threshold_index = severity_order.index(fail_on_severity.upper())
520
+ except ValueError:
521
+ threshold_index = 1 # Default to HIGH
522
+
523
+ for vuln in vulnerabilities:
524
+ severity = (vuln.severity or "UNKNOWN").upper()
525
+ try:
526
+ severity_index = severity_order.index(severity)
527
+ if severity_index <= threshold_index:
528
+ return 1
529
+ except ValueError:
530
+ continue
531
+
532
+ return 0
533
+
534
+
535
+ def create_reporter(
536
+ project_path: Path,
537
+ use_color: Optional[bool] = None,
538
+ ) -> Reporter:
539
+ """Create a reporter instance with default output directory.
540
+
541
+ Args:
542
+ project_path: Path to the project being scanned.
543
+ use_color: Whether to use colored output.
544
+
545
+ Returns:
546
+ Configured Reporter instance.
547
+ """
548
+ output_dir = project_path / ".cve-sentinel"
549
+ return Reporter(output_dir, use_color=use_color)