Skip to content

fix(output): normalize severity values to prevent HTML report failure #4786

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 7, 2025
31 changes: 29 additions & 2 deletions cve_bin_tool/output_engine/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,30 @@
}


def normalize_severity(severity: str) -> str:
"""Normalize severity values to standard format.

Args:
severity: Raw severity string

Returns:
Normalized severity string matching SEVERITY_TYPES_COLOR keys
"""
severity = severity.upper()

# Handle special cases with suffixes
if severity.startswith("CRITICAL"):
return "CRITICAL"
elif severity.startswith("HIGH"):
return "HIGH"
elif severity.startswith("MEDIUM"):
return "MEDIUM"
elif severity.startswith("LOW"):
return "LOW"

return "UNKNOWN"


def render_cves(
hid: str, cve_row: Template, tag: str, cves: list[dict[str, str]]
) -> str:
Expand Down Expand Up @@ -276,7 +300,8 @@ def output_html(
cve_remarks["NOT AFFECTED"] += len(cve_by_remark[Remarks.NotAffected])

for cve in cve_data["cves"]:
cve_severity[cve.severity] += 1
norm_severity = normalize_severity(cve.severity)
cve_severity[norm_severity] += 1

# hid is unique for each product
if product_info.vendor != "UNKNOWN":
Expand Down Expand Up @@ -322,7 +347,9 @@ def output_html(
cve_by_remark[Remarks.NotAffected],
)

analysis_data = Counter(cve.severity for cve in cve_data["cves"])
analysis_data = Counter(
normalize_severity(cve.severity) for cve in cve_data["cves"]
)

# initialize a figure object for Analysis Chart
analysis_pie = go.Figure(
Expand Down
74 changes: 74 additions & 0 deletions test/test_output_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@

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.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
from cve_bin_tool.sbom_manager.generate import SBOMGenerate

# Import additional types for building mock CVE data.
from cve_bin_tool.util import CVE, CVEData, ProductInfo, Remarks, VersionInfo


Expand Down Expand Up @@ -1509,6 +1512,77 @@ def test_csv_macros(self):
actual_output = [dict(x) for x in reader]
self.assertEqual(actual_output, expected_output)

def test_normalize_severity(self):
"""Unit test for normalize_severity with various inputs."""
test_cases = [
("CRITICAL", "CRITICAL"),
("critical", "CRITICAL"),
("CRITICAL-EXPLOIT", "CRITICAL"),
("HIGH-EXPLOIT", "HIGH"),
("High", "HIGH"),
("MEDIUM", "MEDIUM"),
("medium-risk", "MEDIUM"),
("LOW", "LOW"),
("low-vulnerability", "LOW"),
("UNKNOWN", "UNKNOWN"),
("something-else", "UNKNOWN"),
("", "UNKNOWN"),
]
for inp, expected in test_cases:
self.assertEqual(normalize_severity(inp), expected)

def test_html_output_with_non_standard_severity(self):
"""Integration test: Ensure HTML report generation handles non-standard severity."""
# Create a dummy CVE with non-standard severity "HIGH-EXPLOIT"
dummy_cve = CVE(
"CVE-TEST-0001",
"HIGH-EXPLOIT",
score=5.0,
cvss_version=3,
cvss_vector="CVSS3.0/DUMMY",
data_source="NVD",
description="Test vulnerability",
last_modified=datetime.now().strftime("%d-%m-%Y"),
metric={"EPSS": [0.1234, "0.5678"]},
)
prod = ProductInfo("vendor_test", "product_test", "1.0", "/path/to/product")
cve_data = CVEData(cves=[dummy_cve], paths={""})
all_cve_data = {prod: cve_data}

# Minimal dummy values for other required parameters.
all_cve_version_info = None
scanned_dir = "."
filename = "dummy.html"
theme_dir = ""
total_files = 1
products_with_cve = 1
products_without_cve = 0
merge_report = None
logger = MagicMock()

# Use StringIO as the outfile
outfile = io.StringIO()

# Call the HTML output generator. It should use the normalized severity.
output_html(
all_cve_data=all_cve_data,
all_cve_version_info=all_cve_version_info,
scanned_dir=scanned_dir,
filename=filename,
theme_dir=theme_dir,
total_files=total_files,
products_with_cve=products_with_cve,
products_without_cve=products_without_cve,
merge_report=merge_report,
logger=logger,
outfile=outfile,
affected_versions=0,
)

html_content = outfile.getvalue()
# Check that the output contains "HIGH"
self.assertIn("HIGH", html_content)

def test_output_with_unset_fields(self):
"""Regression test for unset optional fields (e.g. sbom_serial_number)"""
fields_to_test = ["sbom_serial_number"]
Expand Down
Loading