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.
- cve_sentinel/__init__.py +4 -0
- cve_sentinel/__main__.py +18 -0
- cve_sentinel/analyzers/__init__.py +19 -0
- cve_sentinel/analyzers/base.py +274 -0
- cve_sentinel/analyzers/go.py +186 -0
- cve_sentinel/analyzers/maven.py +291 -0
- cve_sentinel/analyzers/npm.py +586 -0
- cve_sentinel/analyzers/php.py +238 -0
- cve_sentinel/analyzers/python.py +435 -0
- cve_sentinel/analyzers/ruby.py +182 -0
- cve_sentinel/analyzers/rust.py +199 -0
- cve_sentinel/cli.py +517 -0
- cve_sentinel/config.py +347 -0
- cve_sentinel/fetchers/__init__.py +22 -0
- cve_sentinel/fetchers/nvd.py +544 -0
- cve_sentinel/fetchers/osv.py +719 -0
- cve_sentinel/matcher.py +496 -0
- cve_sentinel/reporter.py +549 -0
- cve_sentinel/scanner.py +513 -0
- cve_sentinel/scanners/__init__.py +13 -0
- cve_sentinel/scanners/import_scanner.py +1121 -0
- cve_sentinel/utils/__init__.py +5 -0
- cve_sentinel/utils/cache.py +61 -0
- cve_sentinel-0.1.2.dist-info/METADATA +454 -0
- cve_sentinel-0.1.2.dist-info/RECORD +28 -0
- cve_sentinel-0.1.2.dist-info/WHEEL +4 -0
- cve_sentinel-0.1.2.dist-info/entry_points.txt +2 -0
- cve_sentinel-0.1.2.dist-info/licenses/LICENSE +21 -0
cve_sentinel/reporter.py
ADDED
|
@@ -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)
|