diff --git a/.github/workflows/update-js-dependencies.yml b/.github/workflows/update-js-dependencies.yml index d92cf6b2c0..c6d13ee228 100644 --- a/.github/workflows/update-js-dependencies.yml +++ b/.github/workflows/update-js-dependencies.yml @@ -70,7 +70,7 @@ jobs: run: | python -c 'from test.test_output_engine import TestOutputEngine; \ from cve_bin_tool.output_engine.html import output_html; \ - output_html(TestOutputEngine.MOCK_OUTPUT, None, "", "", "", 3, 3, 0, None, None, open("test.html", "w"))' + output_html(TestOutputEngine.MOCK_OUTPUT, None, "", "", "", 3, 3, 0, None, None, open("test.html", "w"), no_scan=False)' - name: Upload mock report uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/cve_bin_tool/output_engine/__init__.py b/cve_bin_tool/output_engine/__init__.py index 98f19f246f..27023d02e5 100644 --- a/cve_bin_tool/output_engine/__init__.py +++ b/cve_bin_tool/output_engine/__init__.py @@ -799,6 +799,7 @@ def output_cves(self, outfile, output_type="console"): outfile, self.affected_versions, self.strip_scan_dir, + self.no_scan, ) else: # console, or anything else that is unrecognised output_console( diff --git a/cve_bin_tool/output_engine/html.py b/cve_bin_tool/output_engine/html.py index 85ea83e01a..c20864436b 100644 --- a/cve_bin_tool/output_engine/html.py +++ b/cve_bin_tool/output_engine/html.py @@ -97,6 +97,7 @@ def output_html( outfile, affected_versions: int = 0, strip_scan_dir: bool = False, + no_scan: bool = False, ): """Returns a HTML report for CVE's""" @@ -156,15 +157,27 @@ def output_html( # Start generating graph with the data # dash graph1: Products Vulnerability Graph - product_pie = go.Figure( - data=[ - go.Pie( - labels=["Vulnerable", "No Known Vulnerability"], - values=[products_with_cve, products_without_cve], - hole=0.4, - ) - ] - ) + if no_scan: + # In no-scan mode, show detected products vs no products + product_pie = go.Figure( + data=[ + go.Pie( + labels=["Detected Products", "No Products Detected"], + values=[products_with_cve + products_without_cve, 0], + hole=0.4, + ) + ] + ) + else: + product_pie = go.Figure( + data=[ + go.Pie( + labels=["Vulnerable", "No Known Vulnerability"], + values=[products_with_cve, products_without_cve], + hole=0.4, + ) + ] + ) # Chart configuration for product_pie product_pie.update_layout( @@ -183,46 +196,50 @@ def output_html( # dash graph2: Product CVE's Graph cve_bar = go.Figure() - data_by_cve_remarks: dict = { - "NEW": {"x": [], "y": []}, - "MITIGATED": {"x": [], "y": []}, - "CONFIRMED": {"x": [], "y": []}, - "UNEXPLORED": {"x": [], "y": []}, - "FALSE POSITIVE": {"x": [], "y": []}, - "NOT AFFECTED": {"x": [], "y": []}, - } - for product_info, cve_data in all_cve_data.items(): - # Check if product contains CVEs - if cve_data["cves"]: - cve_by_remark = group_cve_by_remark(cve_data["cves"]) - for key, val in [ - ["NEW", len(cve_by_remark[Remarks.NewFound])], - ["MITIGATED", len(cve_by_remark[Remarks.Mitigated])], - ["CONFIRMED", len(cve_by_remark[Remarks.Confirmed])], - ["UNEXPLORED", len(cve_by_remark[Remarks.Unexplored])], - ["FALSE POSITIVE", len(cve_by_remark[Remarks.FalsePositive])], - ["NOT AFFECTED", len(cve_by_remark[Remarks.NotAffected])], - ]: - x = ( - f"{product_info.vendor}-{product_info.product}({product_info.version})" - if product_info.vendor != "UNKNOWN" - else f"{product_info.product}({product_info.version})" + if not no_scan: + data_by_cve_remarks: dict = { + "NEW": {"x": [], "y": []}, + "MITIGATED": {"x": [], "y": []}, + "CONFIRMED": {"x": [], "y": []}, + "UNEXPLORED": {"x": [], "y": []}, + "FALSE POSITIVE": {"x": [], "y": []}, + "NOT AFFECTED": {"x": [], "y": []}, + } + for product_info, cve_data in all_cve_data.items(): + # Check if product contains CVEs + if cve_data["cves"]: + cve_by_remark = group_cve_by_remark(cve_data["cves"]) + for key, val in [ + ["NEW", len(cve_by_remark[Remarks.NewFound])], + ["MITIGATED", len(cve_by_remark[Remarks.Mitigated])], + ["CONFIRMED", len(cve_by_remark[Remarks.Confirmed])], + ["UNEXPLORED", len(cve_by_remark[Remarks.Unexplored])], + ["FALSE POSITIVE", len(cve_by_remark[Remarks.FalsePositive])], + ["NOT AFFECTED", len(cve_by_remark[Remarks.NotAffected])], + ]: + x = ( + f"{product_info.vendor}-{product_info.product}({product_info.version})" + if product_info.vendor != "UNKNOWN" + else f"{product_info.product}({product_info.version})" + ) + y = 0 if cve_data["cves"][0][1] == "UNKNOWN" else val + data_by_cve_remarks[key]["x"].append(x) + data_by_cve_remarks[key]["y"].append(y) + + for key, val in data_by_cve_remarks.items(): + cve_bar.add_trace( + go.Bar( + x=val["x"], + y=val["y"], + name=key, ) - y = 0 if cve_data["cves"][0][1] == "UNKNOWN" else val - data_by_cve_remarks[key]["x"].append(x) - data_by_cve_remarks[key]["y"].append(y) - - for key, val in data_by_cve_remarks.items(): - cve_bar.add_trace( - go.Bar( - x=val["x"], - y=val["y"], - name=key, ) - ) # Chart configuration for cve_bar - cve_bar.update_layout(yaxis_title="Number of CVE's", barmode="stack") + if no_scan: + cve_bar.update_layout(yaxis_title="No CVE Analysis Performed", barmode="stack") + else: + cve_bar.update_layout(yaxis_title="Number of CVE's", barmode="stack") all_paths = defaultdict(list) @@ -240,56 +257,71 @@ def output_html( cve_severity = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "UNKNOWN": 0} cve_by_metrics: defaultdict[Remarks, list[dict[str, str]]] = defaultdict(list) - for product_info, cve_data in all_cve_data.items(): - if cve_data["cves"]: - for cve in cve_data["cves"]: - probability = "-" - percentile = "-" - - for metric, field in cve.metric.items(): - if metric == "EPSS": - probability = round(field[0] * 100, 4) - percentile = field[1] - - cve_by_metrics[cve.remarks].append( - { - "cve_number": cve.cve_number, - "cvss_version": str(cve.cvss_version), - "cvss_score": str(cve.score), - "epss_probability": str(probability), - "epss_percentile": str(percentile), - "severity": cve.severity, - } - ) + if not no_scan: + for product_info, cve_data in all_cve_data.items(): + if cve_data["cves"]: + for cve in cve_data["cves"]: + probability = "-" + percentile = "-" + + for metric, field in cve.metric.items(): + if metric == "EPSS": + probability = round(field[0] * 100, 4) + percentile = field[1] + + cve_by_metrics[cve.remarks].append( + { + "cve_number": cve.cve_number, + "cvss_version": str(cve.cvss_version), + "cvss_score": str(cve.score), + "epss_probability": str(probability), + "epss_percentile": str(percentile), + "severity": cve.severity, + } + ) cve_metric_html_rows = [] - for remarks in sorted(cve_by_metrics): - for cve in cve_by_metrics[remarks]: - row_color = "table-success" - if cve["severity"] == "CRITICAL": - row_color = "table-danger" - elif cve["severity"] == "HIGH": - row_color = "table-primary" - elif cve["severity"] == "MEDIUM": - row_color = "table-warning" - - html_row = f""" - - {cve["cve_number"]} - {cve["cvss_version"]} - {cve["cvss_score"]} - {cve["epss_probability"]} - {cve["epss_percentile"]} - - """ - cve_metric_html_rows.append(html_row) + if not no_scan: + for remarks in sorted(cve_by_metrics): + for cve in cve_by_metrics[remarks]: + row_color = "table-success" + if cve["severity"] == "CRITICAL": + row_color = "table-danger" + elif cve["severity"] == "HIGH": + row_color = "table-primary" + elif cve["severity"] == "MEDIUM": + row_color = "table-warning" + + html_row = f""" + + {cve["cve_number"]} + {cve["cvss_version"]} + {cve["cvss_score"]} + {cve["epss_probability"]} + {cve["epss_percentile"]} + + """ + cve_metric_html_rows.append(html_row) # Join the HTML rows to create the full table content table_content = "\n".join(cve_metric_html_rows) # List of Products for product_info, cve_data in all_cve_data.items(): - # Check if product contains CVEs - if cve_data["cves"]: + # hid is unique for each product + if product_info.vendor != "UNKNOWN": + hid = f"{product_info.vendor}{product_info.product}{''.join(product_info.version.split('.'))}" + else: + hid = f"{product_info.product}{''.join(product_info.version.split('.'))}" + + if strip_scan_dir: + product_paths = [ + strip_path(path, scanned_dir) for path in cve_data["paths"] + ] + else: + product_paths = cve_data["paths"] + + if not no_scan and cve_data["cves"]: + # Process products with CVEs in normal scan mode # group product wise cves on the basis of remarks cve_by_remark = group_cve_by_remark(cve_data["cves"]) @@ -304,13 +336,6 @@ def output_html( norm_severity = normalize_severity(cve.severity) cve_severity[norm_severity] += 1 - # hid is unique for each product - if product_info.vendor != "UNKNOWN": - hid = f"{product_info.vendor}{product_info.product}{''.join(product_info.version.split('.'))}" - else: - hid = ( - f"{product_info.product}{''.join(product_info.version.split('.'))}" - ) new_cves = render_cves( hid, cve_row, @@ -404,13 +429,6 @@ def output_html( if not_affected_cves: remarks += "not_affected " - if strip_scan_dir: - product_paths = [ - strip_path(path, scanned_dir) for path in cve_data["paths"] - ] - else: - product_paths = cve_data["paths"] - products_found.append( product_row.render( vendor=product_info.vendor, @@ -432,13 +450,56 @@ def output_html( not_affected_cves=not_affected_cves, ) ) + else: + # Process products in no-scan mode or products without CVEs + if no_scan: + remarks = "no_scan" + cve_count = 0 + severity_analysis = "" + new_cves = "" + mitigated_cves = "" + confirmed_cves = "" + unexplored_cves = "" + false_positive_cves = "" + not_affected_cves = "" + else: + # Products without CVEs in normal scan mode + remarks = "no_cves" + cve_count = 0 + severity_analysis = "" + new_cves = "" + mitigated_cves = "" + confirmed_cves = "" + unexplored_cves = "" + false_positive_cves = "" + not_affected_cves = "" + + products_found.append( + product_row.render( + vendor=product_info.vendor, + name=product_info.product, + version=product_info.version, + cve_count=cve_count, + severity_analysis=severity_analysis, + remarks=remarks, + fix_id=hid, + paths=product_paths, + len_paths=len(product_paths), + new_cves=new_cves, + mitigated_cves=mitigated_cves, + confirmed_cves=confirmed_cves, + unexplored_cves=unexplored_cves, + false_positive_cves=false_positive_cves, + not_affected_cves=not_affected_cves, + ) + ) - if "*" in product_info.vendor: - star_warn = "* vendors guessed by the tool" + if "*" in product_info.vendor: + star_warn = "* vendors guessed by the tool" - # update all_paths - for path in product_paths: - all_paths[path].append(hid) + # update all_paths + for path in product_paths: + all_paths[path].append(hid) # Dashboard Rendering dashboard = dashboard.render( @@ -451,6 +512,7 @@ def output_html( cve_remarks=cve_remarks, cve_severity=cve_severity, table_content=table_content, + no_scan=no_scan, ) # try to load the bigger files just before the generation of report diff --git a/cve_bin_tool/output_engine/html_reports/templates/dashboard.html b/cve_bin_tool/output_engine/html_reports/templates/dashboard.html index 8e943efa38..dab78e3dad 100644 --- a/cve_bin_tool/output_engine/html_reports/templates/dashboard.html +++ b/cve_bin_tool/output_engine/html_reports/templates/dashboard.html @@ -1,5 +1,18 @@ +{% if no_scan %} +
+
+ +
+
+{% endif %} + +
+ {% if not no_scan %}
@@ -83,9 +96,10 @@
CVE Remarks
+ {% endif %}
- +
@@ -101,12 +115,21 @@
+ {% if no_scan %} +
+ Detected Products: + {{ products_with_cve + products_without_cve }} +
+ {% else %}
Vulnerable Files: {{ products_with_cve }}
+ {% endif %}
@@ -119,6 +142,7 @@
+{% if not no_scan %}
@@ -154,4 +178,6 @@
CVE metric
{{ table_content }} -
\ No newline at end of file +
+ +{% endif %} \ No newline at end of file diff --git a/cve_bin_tool/output_engine/html_reports/templates/row_product.html b/cve_bin_tool/output_engine/html_reports/templates/row_product.html index 5cf94a4959..e29a59fe10 100644 --- a/cve_bin_tool/output_engine/html_reports/templates/row_product.html +++ b/cve_bin_tool/output_engine/html_reports/templates/row_product.html @@ -18,7 +18,13 @@
+ {% if remarks == "no_scan" %} + No CVE Analysis + {% elif remarks == "no_cves" %} + No CVEs + {% else %} {{ cve_count }} + {% endif %}
@@ -39,12 +45,40 @@ + class="badge bg-info rounded-pill">{{ version }} + {% if remarks == "no_scan" %} + No CVE Analysis + {% elif remarks == "no_cves" %} + No CVEs Found + {% else %} + CVE Count: {{ cve_count }} + {% endif %} +
+ {% endif %} {% if paths %}
diff --git a/test/pages/html_report.py b/test/pages/html_report.py index 2a04b9492d..3b39e8753c 100644 --- a/test/pages/html_report.py +++ b/test/pages/html_report.py @@ -19,6 +19,7 @@ def __init__( page: Page, all_cve_data: dict[ProductInfo, CVEData], has_intermediate_report: bool = True, + no_scan: bool = False, ): self.html_output = NamedTemporaryFile( "w+", delete=False, suffix=".html", encoding="utf-8" @@ -52,6 +53,7 @@ def __init__( merge_report=intermediate_report, logger=logger, outfile=self.html_output, + no_scan=no_scan, ) self.page = page diff --git a/test/test_html.py b/test/test_html.py index d78a735a2c..cc9e5f1982 100644 --- a/test/test_html.py +++ b/test/test_html.py @@ -358,7 +358,13 @@ def test_without_intermediate_report(self) -> None: expect(product_rows).to_have_count(8) def test_empty_cve_list(self) -> None: - """Test that the HTML report renders correctly with an empty cve_data["cves"] list.""" + """Test that the HTML report renders correctly with an empty cve_data["cves"] list. + + Note: With the current implementation, products without CVEs are still displayed + in the HTML report. This is the correct behavior as it shows component identification + even when no vulnerabilities are found. The product will show a "No CVEs" badge + to indicate that it was scanned but no CVEs were detected. + """ empty_output = { ProductInfo("vendor0", "product0", "1.0", "usr/local/bin/product"): CVEData( @@ -371,7 +377,55 @@ def test_empty_cve_list(self) -> None: self.html_report_page.load() product_rows = self.html_report_page.product_rows - expect(product_rows).to_have_count(0) + # Products without CVEs are still displayed + # This is the correct behavior as it shows component identification + expect(product_rows).to_have_count(1) + + # Check that the product shows "No CVEs" badge + expect(product_rows.nth(0)).to_contain_text("No CVEs") + + def test_no_scan_mode(self) -> None: + """Test that the HTML report renders correctly in no-scan mode. + + This test verifies that when the HTML report is generated in no-scan mode, + it properly displays all detected products with their component identification + information, while hiding CVE-related content. Products should show "No CVE Analysis" + badges to indicate that no vulnerability scanning was performed. + """ + + # Create a test case with products but no CVEs (simulating no-scan mode) + no_scan_output = { + ProductInfo("vendor0", "product0", "1.0", "usr/local/bin/product"): CVEData( + cves=[], paths={"/path/to/file1", "/path/to/file2"} + ), + ProductInfo("vendor1", "product1", "2.0"): CVEData( + cves=[], paths={"/path/to/file3"} + ), + } + + if hasattr(self, "html_report_page") and self.html_report_page is not None: + self.html_report_page.cleanup() # Clean up the previous page + + # Create HTMLReport with no-scan mode + self.html_report_page = HTMLReport(self.page, no_scan_output, no_scan=True) + self.html_report_page.load() + product_rows = self.html_report_page.product_rows + + # In no-scan mode, all detected products should be displayed + expect(product_rows).to_have_count(2) + + # Check that products show "No CVE Analysis" badges + expect(product_rows.nth(0)).to_contain_text("No CVE Analysis") + expect(product_rows.nth(1)).to_contain_text("No CVE Analysis") + + # Check that product information is displayed + expect(product_rows.nth(0)).to_contain_text("vendor0") + expect(product_rows.nth(0)).to_contain_text("product0") + expect(product_rows.nth(0)).to_contain_text("1.0") + + expect(product_rows.nth(1)).to_contain_text("vendor1") + expect(product_rows.nth(1)).to_contain_text("product1") + expect(product_rows.nth(1)).to_contain_text("2.0") def test_get_intermediate_label_with_tag(): diff --git a/test/test_output_engine.py b/test/test_output_engine.py index dd07caadab..38dd4a99bc 100644 --- a/test/test_output_engine.py +++ b/test/test_output_engine.py @@ -1581,6 +1581,7 @@ def test_html_output_with_non_standard_severity(self): logger=logger, outfile=outfile, affected_versions=0, + no_scan=False, ) html_content = outfile.getvalue()