Repository: intel/cve-bin-tool

Issue:
bug: HTML report generator fails due to unknown severity
### Description

HTML report generator fails due to unknown severity "HIGH-EXPLOIT"

### To reproduce

Steps to reproduce the behaviour:
1. Scan using this command:
cve-bin-tool -S critical -S high --detailed -l info --affected-versions --exploits mac_build/theapp.app -f json,html -o mac_build/vulnerability

Expected behaviour:
HTML report is generated correctly

Actual behaviour:
HTML report generation fails due to unexpected severity

### Version/platform info

Version of CVE-bin-tool( e.g. output of `cve-bin-tool --version`): 3.3
Installed from pypi or github? pypi
Operating system: macOS
Python version (e.g. `python3 --version`): Python 3.10
Running in any particular CI environment we should know about? Gitlab

### Anything else?

#### Relevant vulnerability.json snippet
    {
        "vendor": "webmproject",
        "product": "libvpx",
        "version": "1.11.0",
        "cve_number": "CVE-2023-5217",
        "severity": "HIGH-EXPLOIT",
        "score": "8.8",
        "source": "NVD",
        "cvss_version": "3",
        "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
        "paths": "[redacted]/MacOS/ffmpeg",
        "remarks": "NewFound",
        "comments": "",
        "description": "Heap buffer overflow in vp8 encoding in libvpx in Google Chrome prior to 117.0.5938.132 and libvpx 1.13.1 allowed a remote attacker to potentially exploit heap corruption via a crafted HTML page. (Chromium security severity: High)",
        "affected_versions": "< 1.13.1"
    },

#### cve-bin-tool callstack

```
    INFO     cve_bin_tool.OutputEngine - HTML report     __init__.py:1029
                    stored at                                                   
                    /Users/gitlabrunner/builds/Ln3Astbb/0/git/l                 
                    uxion/mac_build/vulnerability.html                          
╭───────────────────── Traceback (most recent call last) ──────────────────────╮
│ /Users/gitlabrunner/.ci-python-venv/bin/cve-bin-tool:8 in <module>           │
│                                                                              │
│   5 from cve_bin_tool.cli import main                                        │
│   6 if __name__ == '__main__':                                               │
│   7 │   sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])     │
│ ❱ 8 │   sys.exit(main())                                                     │
│   9                                                                          │
│                                                                              │
│ /Users/gitlabrunner/.ci-python-venv/lib/python3.10/site-packages/cve_bin_too │
│ l/cli.py:1103 in main                                                        │
│                                                                              │
│   1100 │   │   )                                                             │
│   1101 │   │                                                                 │
│   1102 │   │   if not args["quiet"]:                                         │
│ ❱ 1103 │   │   │   output.output_file_wrapper(output_formats)                │
│   1104 │   │   │   if args["backport_fix"] or args["available_fix"]:         │
│   1105 │   │   │   │   distro_info = args["backport_fix"] or args["available │
│   1106 │   │   │   │   is_backport = True if args["backport_fix"] else False │
│                                                                              │
│ /Users/gitlabrunner/.ci-python-venv/lib/python3.10/site-packages/cve_bin_too │
│ l/output_engine/__init__.py:977 in output_file_wrapper                       │
│                                                                              │
│    974 │   def output_file_wrapper(self, output_types=["console"]):          │
│    975 │   │   """Call output_file method for all output types."""           │
│    976 │   │   for output_type in output_types:                              │
│ ❱  977 │   │   │   self.output_file(output_type)                             │
│    978 │                                                                     │
│    979 │   def output_file(self, output_type="console"):                     │
│    980 │   │   """Generate a file for list of CVE"""                         │
│                                                                              │
│ /Users/gitlabrunner/.ci-python-venv/lib/python3.10/site-packages/cve_bin_too │
│ l/output_engine/__init__.py:1037 in output_file                              │
│                                                                              │
│   1034 │   │   │   │   self.output_cves(f, output_type)                      │
│   1035 │   │   else:                                                         │
│   1036 │   │   │   with open(self.filename, "w", encoding="utf8") as f:      │
│ ❱ 1037 │   │   │   │   self.output_cves(f, output_type)                      │
│   1038 │                                                                     │
│   1039 │   def check_file_path(self, filepath: str, output_type: str, prefix │
│   1040 │   │   """Generate a new filename if file already exists."""         │
│                                                                              │
│ /Users/gitlabrunner/.ci-python-venv/lib/python3.10/site-packages/cve_bin_too │
│ l/output_engine/__init__.py:752 in output_cves                               │
│                                                                              │
│    749 │   │   │   │   self.metrics,                                         │
│    750 │   │   │   )                                                         │
│    751 │   │   elif output_type == "html":                                   │
│ ❱  752 │   │   │   output_html(                                              │
│    753 │   │   │   │   self.all_cve_data,                                    │
│    754 │   │   │   │   self.all_cve_version_info,                            │
│    755 │   │   │   │   self.scanned_dir,                                     │
│                                                                              │
│ /Users/gitlabrunner/.ci-python-venv/lib/python3.10/site-packages/cve_bin_too │
│ l/output_engine/html.py:279 in output_html                                   │
│                                                                              │
│   276 │   │   │   cve_remarks["NOT AFFECTED"] += len(cve_by_remark[Remarks.N │
│   277 │   │   │                                                              │
│   278 │   │   │   for cve in cve_data["cves"]:                               │
│ ❱ 279 │   │   │   │   cve_severity[cve.severity] += 1                        │
│   280 │   │   │                                                              │
│   281 │   │   │   # hid is unique for each product                           │
│   282 │   │   │   if product_info.vendor != "UNKNOWN":                       │
╰──────────────────────────────────────────────────────────────────────────────╯
KeyError: 'HIGH-EXPLOIT'
```

Hints from issue comments:
@terriko: I'm not sure if this is related to our other severity bug (fix is in code review) or if it's due to the fact that there's an expectation of the value being "high, medium, low" (as in, "high-exploit" isn't a valid severity), but we should definitely handle this more gracefully.  Thanks for letting us know!@stjen: Hi again, thanks for getting back to me.

Just some more info FYI, the issue disappeared when I removed the --exploits flag, so it seems to be related to that.

Solution patch:
diff --git a/cve_bin_tool/output_engine/html.py b/cve_bin_tool/output_engine/html.py
index 10f345a4..22ff2507 100644
--- a/cve_bin_tool/output_engine/html.py
+++ b/cve_bin_tool/output_engine/html.py
@@ -28,6 +28,30 @@ SEVERITY_TYPES_COLOR = {
 }
 
 
+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:
@@ -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":
@@ -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(


Test patch:
diff --git a/test/test_output_engine.py b/test/test_output_engine.py
index 45bcc51c..18b81656 100644
--- a/test/test_output_engine.py
+++ b/test/test_output_engine.py
@@ -21,9 +21,12 @@ 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.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
 
 
@@ -1509,6 +1512,67 @@ class TestOutputEngine(unittest.TestCase):
         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}
+
+        # Use StringIO as the outfile and create logger mock
+        outfile = io.StringIO()
+        logger = MagicMock()
+
+        # Call the HTML output generator directly with minimal parameters
+        output_html(
+            all_cve_data=all_cve_data,
+            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=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"]