diff --git a/cve_bin_tool/cli.py b/cve_bin_tool/cli.py index f7f520dc9c..0361359d9e 100644 --- a/cve_bin_tool/cli.py +++ b/cve_bin_tool/cli.py @@ -32,6 +32,7 @@ import textwrap import time from collections import ChainMap +from datetime import datetime from pathlib import Path from cve_bin_tool.available_fix import ( @@ -1091,6 +1092,7 @@ def main(argv=None): check_exploits=args["exploits"], exploits_list=cvedb_orig.get_exploits_list(), disabled_sources=disabled_sources, + no_scan=args["no_scan"], ) as cve_scanner: triage_data: TriageData total_files: int = 0 @@ -1267,7 +1269,9 @@ def main(argv=None): scanned_dir=args["directory"], filename=args["output_file"], themes_dir=args["html_theme"], - time_of_last_update=cvedb_orig.time_of_last_update, + time_of_last_update=( + cvedb_orig.time_of_last_update if cvedb_orig else datetime.now() + ), tag=args["tag"], products_with_cve=cve_scanner.products_with_cve, products_without_cve=cve_scanner.products_without_cve, @@ -1290,6 +1294,7 @@ def main(argv=None): sbom_root=sbom_root, strip_scan_dir=args["strip_scan_dir"], offline=args["offline"], + no_scan=args["no_scan"], ) if not args["quiet"]: diff --git a/cve_bin_tool/cve_scanner.py b/cve_bin_tool/cve_scanner.py index ae1fa9104d..f353dc2b09 100644 --- a/cve_bin_tool/cve_scanner.py +++ b/cve_bin_tool/cve_scanner.py @@ -46,20 +46,22 @@ def __init__( check_exploits: bool = False, exploits_list: List[str] = [], disabled_sources: List[str] = [], + no_scan: bool = False, ): - self.logger = logger or LOGGER.getChild(self.__class__.__name__) - self.error_mode = error_mode self.score = score self.check_metrics = check_metrics self.epss_percentile = epss_percentile self.epss_probability = epss_probability - self.products_with_cve = 0 - self.products_without_cve = 0 - self.all_cve_data = defaultdict(CVEData) - self.all_cve_version_info = dict() + self.logger = logger or LOGGER.getChild(self.__class__.__name__) + self.error_mode = error_mode self.check_exploits = check_exploits self.exploits_list = exploits_list self.disabled_sources = disabled_sources + self.no_scan = no_scan + self.products_with_cve = 0 + self.products_without_cve = 0 + self.all_cve_data = defaultdict(lambda: {"cves": [], "paths": set()}) + self.all_cve_version_info = dict() self.all_product_data = dict() def get_cves(self, product_info: ProductInfo, triage_data: TriageData): @@ -74,6 +76,21 @@ def get_cves(self, product_info: ProductInfo, triage_data: TriageData): if self.score > 10 or self.epss_probability > 1.0 or self.epss_percentile > 1.0: return + # Handle no-scan mode + if self.no_scan: + # In no-scan mode, just populate the product data without CVE scanning + if product_info not in self.all_product_data: + self.logger.debug(f"Add product {product_info} (no-scan mode)") + self.all_product_data[product_info] = 0 + + # Also populate all_cve_data with empty CVE list and paths + if product_info not in self.all_cve_data: + self.all_cve_data[product_info] = {"cves": [], "paths": set()} + + # Update paths + self.all_cve_data[product_info]["paths"] |= set(triage_data["paths"]) + return + if product_info.vendor == "UNKNOWN": # Add product if product_info not in self.all_product_data: @@ -298,7 +315,7 @@ def get_cves(self, product_info: ProductInfo, triage_data: TriageData): self.epss_percentile, self.epss_probability, ) - # row_dict doesnt have metric as key. As it based on result from query on + # row_dict doesn't have metric as key. As it based on result from query on # cve_severity table declaring row_dict[metric] row_dict["metric"] = {} # looping for result of query for metrics. @@ -481,9 +498,10 @@ def __enter__(self): Returns: CVEScanner: The instance of the CVEScanner with an active database connection. """ - self.connection = sqlite3.connect(self.dbname) - self.connection.row_factory = sqlite3.Row - self.cursor = self.connection.cursor() + if not self.no_scan: + self.connection = sqlite3.connect(self.dbname) + self.connection.row_factory = sqlite3.Row + self.cursor = self.connection.cursor() return self def __exit__(self, exc_type, exc_val, exc_tb): @@ -498,5 +516,6 @@ def __exit__(self, exc_type, exc_val, exc_tb): Returns: None """ - self.cursor.close() - self.connection.close() + if not self.no_scan and hasattr(self, "cursor") and hasattr(self, "connection"): + self.cursor.close() + self.connection.close() diff --git a/cve_bin_tool/output_engine/__init__.py b/cve_bin_tool/output_engine/__init__.py index eeccc3ba75..98f19f246f 100644 --- a/cve_bin_tool/output_engine/__init__.py +++ b/cve_bin_tool/output_engine/__init__.py @@ -621,7 +621,7 @@ def output_pdf( class OutputEngine: """ - Class represention of OutputEngine + Class representation of OutputEngine Attributes: all_cve_data (dict[ProductInfo, CVEData]) scanned_dir (str) @@ -693,6 +693,7 @@ def __init__( vex_product_info: dict[str, str] = {}, offline: bool = False, organized_arguements: dict = None, + no_scan: bool = False, ): """Constructor for OutputEngine class.""" self.logger = logger or LOGGER.getChild(self.__class__.__name__) @@ -726,6 +727,7 @@ def __init__( self.vex_type = vex_type self.vex_product_info = vex_product_info self.vex_filename = vex_filename + self.no_scan = no_scan def output_cves(self, outfile, output_type="console"): """Output a list of CVEs @@ -812,6 +814,7 @@ def output_cves(self, outfile, output_type="console"): self.offline, None, outfile, + self.no_scan, ) if isinstance(self.append, str): diff --git a/cve_bin_tool/output_engine/console.py b/cve_bin_tool/output_engine/console.py index fc28153d96..88fdbff08a 100644 --- a/cve_bin_tool/output_engine/console.py +++ b/cve_bin_tool/output_engine/console.py @@ -32,16 +32,16 @@ def output_console(*args: Any): """wrapper function for _output_console to enable output to a file""" ls_args = list(args) - output_file = ls_args[-1] - ls_args.pop() + no_scan = ls_args[-1] + output_file = ls_args[-2] + ls_args = ls_args[:-2] if output_file: with open(output_file, "w", encoding="utf-8") as f: console = Console(theme=cve_theme, file=f) - ls_args.append(console) - _output_console_nowrap(*ls_args) + _output_console_nowrap(*ls_args, console, no_scan) else: - _output_console_nowrap(*ls_args) + _output_console_nowrap(*ls_args, None, no_scan) def _output_console_nowrap( @@ -57,9 +57,11 @@ def _output_console_nowrap( offline: bool = False, width: int = None, console: Console = Console(theme=cve_theme), + no_scan: bool = False, ): """Output list of CVEs in a tabular format with color support""" - + if console is None: + console = Console(theme=cve_theme) console._width = width now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") time_of_last_update = time_of_last_update.strftime("%Y-%m-%d %H:%M:%S") @@ -76,6 +78,19 @@ def _output_console_nowrap( ) ) + # Show no-scan mode message if applicable + if no_scan: + console.print( + Panel( + "[yellow]⚠️ NO-SCAN MODE[/yellow]\n" + "CVE scanning was disabled. This report shows only the products and versions " + "that were detected, without any vulnerability analysis.", + title="[yellow]No-Scan Mode Active[/yellow]", + border_style="yellow", + ) + ) + console.print() + remarks_colors = { Remarks.Mitigated: "green", Remarks.Confirmed: "red", @@ -85,30 +100,35 @@ def _output_console_nowrap( Remarks.NotAffected: "white", } - # Create table instance for CVE Summary - table = Table() - # Add Head Columns to the Table - table.add_column("Severity") - table.add_column("Count") - summary = get_cve_summary(all_cve_data, exploits) - summary_color = { - "CRITICAL": "red", - "HIGH": "blue", - "MEDIUM": "yellow", - "LOW": "green", - "UNKNOWN": "white", - } + if not no_scan: + # Create table instance for CVE Summary + table = Table() + # Add Head Columns to the Table + table.add_column("Severity") + table.add_column("Count") + summary = get_cve_summary(all_cve_data, exploits) + summary_color = { + "CRITICAL": "red", + "HIGH": "blue", + "MEDIUM": "yellow", + "LOW": "green", + "UNKNOWN": "white", + } + + for severity, count in summary.items(): + color = summary_color[severity.split("-")[0]] + cells = [ + Text.styled(severity, color), + Text.styled(str(count), color), + ] + table.add_row(*cells) + # Print the table to the console + console.print(table) - for severity, count in summary.items(): - color = summary_color[severity.split("-")[0]] - cells = [ - Text.styled(severity, color), - Text.styled(str(count), color), - ] - table.add_row(*cells) - # Print the table to the console - console.print(Panel("CVE SUMMARY", expand=False)) - console.print(table) + if no_scan: + console.print(Panel("(No CVE Scanning Performed)", expand=False)) + else: + console.print(Panel("CVE SUMMARY", expand=False)) # Create table instance for CPE Summary table = Table() @@ -117,25 +137,27 @@ def _output_console_nowrap( table.add_column("Product") table.add_column("Version") table.add_column("Latest Upstream Stable Version") - table.add_column("CRITICAL CVEs Count") - table.add_column("HIGH CVEs Count") - table.add_column("MEDIUM CVEs Count") - table.add_column("LOW CVEs Count") - table.add_column("UNKNOWN CVEs Count") - table.add_column("TOTAL CVEs Count") + if not no_scan: + table.add_column("CRITICAL CVEs Count") + table.add_column("HIGH CVEs Count") + table.add_column("MEDIUM CVEs Count") + table.add_column("LOW CVEs Count") + table.add_column("UNKNOWN CVEs Count") + table.add_column("TOTAL CVEs Count") if all_product_data is not None: for product_data in sorted(all_product_data, key=lambda item: item.product): color = None - summary = get_cve_summary( - {product_data: all_cve_data[product_data]}, exploits - ) + if not no_scan: + summary = get_cve_summary( + {product_data: all_cve_data[product_data]}, exploits + ) - # Display package with the color of the highest CVE - for severity, count in summary.items(): - if color is None and count > 0: - color = summary_color[severity.split("-")[0]] + # Display package with the color of the highest CVE + for severity, count in summary.items(): + if color is None and count > 0: + color = summary_color[severity.split("-")[0]] - if all_product_data[product_data] != 0: + if all_product_data[product_data] != 0 or no_scan: if offline: latest_stable_version = "UNKNOWN (offline mode)" else: @@ -143,25 +165,29 @@ def _output_console_nowrap( product_data ) cells = [ - Text.styled(product_data.vendor, color), - Text.styled(product_data.product, color), - Text.styled(product_data.version, color), - Text.styled(latest_stable_version, color), + Text.styled(product_data.vendor, color or "white"), + Text.styled(product_data.product, color or "white"), + Text.styled(product_data.version, color or "white"), + Text.styled(latest_stable_version, color or "white"), ] - for severity, count in summary.items(): - if count > 0: - color = summary_color[severity.split("-")[0]] - else: - color = "white" + if not no_scan: + for severity, count in summary.items(): + if count > 0: + color = summary_color[severity.split("-")[0]] + else: + color = "white" + cells += [ + Text.styled(str(count), color), + ] cells += [ - Text.styled(str(count), color), + Text.styled(str(all_product_data[product_data]), color), ] - cells += [ - Text.styled(str(all_product_data[product_data]), color), - ] table.add_row(*cells) # Print the table to the console - console.print(Panel("CPE SUMMARY", expand=False)) + if no_scan: + console.print(Panel("DETECTED PRODUCTS (No CVE Analysis)", expand=False)) + else: + console.print(Panel("CPE SUMMARY", expand=False)) console.print(table) cve_by_remarks: defaultdict[Remarks, list[dict[str, str]]] = defaultdict(list) @@ -209,54 +235,60 @@ def _output_console_nowrap( ) for remarks in sorted(cve_by_remarks): - color = remarks_colors[remarks] - console.print(Panel(f"[{color}] {remarks.name} CVEs [/{color}]", expand=False)) - # table instance - table = Table() - - # Add Head Columns to the Table - table.add_column("Vendor") - table.add_column("Product") - table.add_column("Version") - table.add_column("CVE Number") - table.add_column("Source") - table.add_column("Severity") - table.add_column("Score (CVSS Version)") - if metrics: - table.add_column("EPSS probability") - table.add_column("EPSS percentile") - if affected_versions != 0: - table.add_column("Affected Versions") - - for cve_data in cve_by_remarks[remarks]: - color = cve_data["severity"].split("-")[0].lower() - if cve_data["score"] == "unknown": - cvss_text = "unknown" - else: - cvss_text = ( - str(cve_data["score"]) + " (v" + str(cve_data["cvss_version"]) + ")" - ) - cells = [ - Text.styled(cve_data["vendor"], color), - Text.styled(cve_data["product"], color), - Text.styled(cve_data["version"], color), - linkify_cve(Text.styled(cve_data["cve_number"], color)), - Text.styled(cve_data["source"], color), - Text.styled(cve_data["severity"], color), - Text.styled(cvss_text, color), - ] + if not no_scan: + color = remarks_colors[remarks] + console.print( + Panel(f"[{color}] {remarks.name} CVEs [/{color}]", expand=False) + ) + # table instance + table = Table() + + # Add Head Columns to the Table + table.add_column("Vendor") + table.add_column("Product") + table.add_column("Version") + table.add_column("CVE Number") + table.add_column("Source") + table.add_column("Severity") + table.add_column("Score (CVSS Version)") if metrics: - cells.append(Text.styled(cve_data["epss_probability"], color)) - cells.append(Text.styled(cve_data["epss_percentile"], color)) + table.add_column("EPSS probability") + table.add_column("EPSS percentile") if affected_versions != 0: - cells.append(Text.styled(cve_data["affected_versions"], color)) - table.add_row(*cells) - # Print the table to the console - console.print(table) - for cve_data in cve_by_remarks[remarks]: - if "*" in cve_data["vendor"]: - console.print("* vendors guessed by the tool") - break + table.add_column("Affected Versions") + + for cve_data in cve_by_remarks[remarks]: + color = cve_data["severity"].split("-")[0].lower() + if cve_data["score"] == "unknown": + cvss_text = "unknown" + else: + cvss_text = ( + str(cve_data["score"]) + + " (v" + + str(cve_data["cvss_version"]) + + ")" + ) + cells = [ + Text.styled(cve_data["vendor"], color), + Text.styled(cve_data["product"], color), + Text.styled(cve_data["version"], color), + linkify_cve(Text.styled(cve_data["cve_number"], color)), + Text.styled(cve_data["source"], color), + Text.styled(cve_data["severity"], color), + Text.styled(cvss_text, color), + ] + if metrics: + cells.append(Text.styled(cve_data["epss_probability"], color)) + cells.append(Text.styled(cve_data["epss_percentile"], color)) + if affected_versions != 0: + cells.append(Text.styled(cve_data["affected_versions"], color)) + table.add_row(*cells) + # Print the table to the console + console.print(table) + for cve_data in cve_by_remarks[remarks]: + if "*" in cve_data["vendor"]: + console.print("* vendors guessed by the tool") + break # Show table of vulnerable products mapped to filename paths # As names can be long, these maybe replaced with a note which @@ -314,7 +346,7 @@ def validate_cell_length(cell_name, cell_type): i = i + 1 # List of scanned products with no identified vulnerabilities - if all_product_data is not None: + if all_product_data is not None and not no_scan: color = "green" console.print( Panel( @@ -348,7 +380,7 @@ def validate_cell_length(cell_name, cell_type): # Print the table to the console console.print(table) - if metrics: + if metrics and not no_scan: table = Table() # Add Head Columns to the Table table.add_column("CVE") @@ -392,4 +424,5 @@ def validate_cell_length(cell_name, cell_type): ] table.add_row(*cells) # Print the table to the console + console.print(table) diff --git a/test/test_output_engine.py b/test/test_output_engine.py index 74b7861a31..dd07caadab 100644 --- a/test/test_output_engine.py +++ b/test/test_output_engine.py @@ -20,7 +20,7 @@ from rich.console import Console from cve_bin_tool.output_engine import OutputEngine, output_csv, output_pdf -from cve_bin_tool.output_engine.console import output_console +from cve_bin_tool.output_engine.console import _output_console_nowrap, output_console from cve_bin_tool.output_engine.html import normalize_severity, output_html from cve_bin_tool.output_engine.json_output import output_json, output_json2 from cve_bin_tool.output_engine.util import format_output @@ -1151,10 +1151,9 @@ def test_output_console(self): exploits = False metrics = True console = Console(file=self.mock_file) - outfile = None all_product_data = None - output_console( + _output_console_nowrap( self.MOCK_OUTPUT, self.MOCK_ALL_CVE_VERSION_INFO, ".", @@ -1167,7 +1166,7 @@ def test_output_console(self): True, 120, console, - outfile, + False, ) expected_output = ( @@ -1202,10 +1201,9 @@ def test_output_console_affected_versions(self): exploits = False metrics = True console = Console(file=self.mock_file) - outfile = None all_product_data = None - output_console( + _output_console_nowrap( self.MOCK_ALL_CVE_DATA, self.MOCK_ALL_CVE_VERSION_INFO, ".", @@ -1218,7 +1216,7 @@ def test_output_console_affected_versions(self): True, 120, console, - outfile, + False, ) expected_output = ( @@ -1270,6 +1268,7 @@ def test_output_console_outfile(self): True, 120, outfile, + False, ) expected_output = ( @@ -1305,10 +1304,9 @@ def test_output_console_metrics_false(self): exploits = False metrics = False console = Console(file=self.mock_file) - outfile = None all_product_data = None - output_console( + _output_console_nowrap( self.MOCK_OUTPUT_2, self.MOCK_ALL_CVE_VERSION_INFO, ".", @@ -1321,7 +1319,7 @@ def test_output_console_metrics_false(self): True, 120, console, - outfile, + False, ) expected_output = ( @@ -1635,6 +1633,7 @@ def test_output_with_unset_fields(self): vex_product_info=vex_info, offline=False, organized_arguements={}, + no_scan=False, ) # Use an in-memory output file. dummy_out = io.StringIO()