diff --git a/cve_bin_tool/output_engine/html.py b/cve_bin_tool/output_engine/html.py
index 85ea83e01a..bf4ad7681a 100644
--- a/cve_bin_tool/output_engine/html.py
+++ b/cve_bin_tool/output_engine/html.py
@@ -3,6 +3,7 @@
from __future__ import annotations
+import json
from collections import Counter, defaultdict
from datetime import datetime
from logging import Logger
@@ -14,7 +15,7 @@
from cve_bin_tool.merge import MergeReports
-from ..util import CVEData, ProductInfo, Remarks, VersionInfo, strip_path
+from ..util import CVE, CVEData, ProductInfo, Remarks, VersionInfo, strip_path
from ..version import VERSION
from .print_mode import html_print_mode
from .util import group_cve_by_remark
@@ -52,6 +53,94 @@ def normalize_severity(severity: str) -> str:
return "UNKNOWN"
+def serialize_vulnerability_data(
+ all_cve_data: dict[ProductInfo, CVEData],
+ all_cve_version_info: dict[str, VersionInfo] | None,
+ scanned_dir: str,
+ total_files: int,
+ products_with_cve: int,
+ products_without_cve: int,
+ affected_versions: int = 0,
+) -> str:
+ """
+ Serialize all vulnerability data to JSON format for embedding in HTML.
+
+ Returns:
+ JSON string containing all vulnerability data in a structured format
+ """
+ data = {
+ "metadata": {
+ "scanned_dir": scanned_dir,
+ "total_files": total_files,
+ "products_with_cve": products_with_cve,
+ "products_without_cve": products_without_cve,
+ "affected_versions": affected_versions,
+ "generated_at": datetime.now().isoformat(),
+ "tool_version": VERSION,
+ },
+ "products": [],
+ "vulnerabilities": {},
+ }
+
+ # Process each product and its CVEs
+ for product_info, cve_data in all_cve_data.items():
+ product_dict = {
+ "vendor": product_info.vendor,
+ "product": product_info.product,
+ "version": product_info.version,
+ "location": product_info.purl or "",
+ "purl": product_info.purl,
+ "paths": list(cve_data.get("paths", set())),
+ "cve_count": len(cve_data.get("cves", [])),
+ "cves": [],
+ }
+
+ # Process CVEs for this product
+ for cve in cve_data.get("cves", []):
+ if isinstance(cve, CVE):
+ cve_id = cve.cve_number
+
+ # Add CVE to product's CVE list
+ product_dict["cves"].append(cve_id)
+
+ # Add detailed CVE information to vulnerabilities dictionary
+ if cve_id not in data["vulnerabilities"]:
+ data["vulnerabilities"][cve_id] = {
+ "cve_number": cve.cve_number,
+ "severity": cve.severity,
+ "score": cve.score,
+ "cvss_version": cve.cvss_version,
+ "cvss_vector": cve.cvss_vector,
+ "description": cve.description,
+ "data_source": cve.data_source,
+ "last_modified": cve.last_modified,
+ "metric": cve.metric,
+ "remarks": (
+ cve.remarks.value
+ if hasattr(cve.remarks, "value")
+ else str(cve.remarks)
+ ),
+ "comments": cve.comments,
+ "justification": cve.justification,
+ "response": cve.response,
+ }
+
+ data["products"].append(product_dict)
+
+ # Add version information if available
+ if all_cve_version_info:
+ data["version_info"] = {}
+ for cve_id, version_info in all_cve_version_info.items():
+ data["version_info"][cve_id] = {
+ "start_including": version_info.start_including,
+ "start_excluding": version_info.start_excluding,
+ "end_including": version_info.end_including,
+ "end_excluding": version_info.end_excluding,
+ }
+
+ return json.dumps(data, indent=2)
+
+
def render_cves(
hid: str, cve_row: Template, tag: str, cves: list[dict[str, str]]
) -> str:
@@ -466,10 +555,23 @@ def output_html(
js_main = "js/main.js"
js_bootstrap = "js/bootstrap.js"
js_plotly = "js/plotly.js"
+ js_triage = "js/triage.js"
script_main = templates_env.get_template(js_main)
script_bootstrap = templates_env.get_template(js_bootstrap)
script_plotly = templates_env.get_template(js_plotly)
+ script_triage = templates_env.get_template(js_triage)
+
+ # Generate vulnerability data JSON for interactive features
+ vulnerability_data_json = serialize_vulnerability_data(
+ all_cve_data,
+ all_cve_version_info,
+ scanned_dir,
+ total_files,
+ products_with_cve,
+ products_without_cve,
+ affected_versions,
+ )
# Render the base html to generate report
outfile.write(
@@ -500,6 +602,8 @@ def output_html(
script_main=script_main.render(),
script_bootstrap=script_bootstrap.render(),
script_plotly=script_plotly.render(),
+ script_triage=script_triage.render(),
+ vulnerability_data_json=vulnerability_data_json,
)
)
diff --git a/cve_bin_tool/output_engine/html_reports/css/main.css b/cve_bin_tool/output_engine/html_reports/css/main.css
index 5806a5c72d..f4b98764de 100644
--- a/cve_bin_tool/output_engine/html_reports/css/main.css
+++ b/cve_bin_tool/output_engine/html_reports/css/main.css
@@ -205,4 +205,84 @@ a{
.btn-filter{
margin-top: 0px;
}
+}
+
+/* VEX Triage Specific Styles */
+.vex-triage-controls {
+ border: 2px solid #dee2e6;
+ border-radius: 8px;
+ background-color: #f8f9fa;
+ transition: all 0.3s ease;
+}
+
+.vex-triage-controls:hover {
+ border-color: #007bff;
+ box-shadow: 0 4px 8px rgba(0, 123, 255, 0.1);
+}
+
+.triaged-item {
+ position: relative;
+ transition: all 0.3s ease;
+}
+
+.triaged-item::before {
+ content: '✓';
+ position: absolute;
+ left: -15px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #28a745;
+ font-weight: bold;
+ font-size: 1.2em;
+}
+
+.vex-justification-container {
+ transition: all 0.3s ease;
+}
+
+.vex-status-select:focus,
+.vex-justification-select:focus,
+.vex-detail-textarea:focus {
+ border-color: #007bff;
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.triage-status-badge {
+ animation: fadeIn 0.3s ease-in;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: scale(0.8); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+/* VEX Status Badge Colors */
+.bg-success { background-color: #28a745 !important; }
+.bg-danger { background-color: #dc3545 !important; }
+.bg-warning { background-color: #ffc107 !important; color: #212529 !important; }
+.bg-info { background-color: #17a2b8 !important; }
+.bg-secondary { background-color: #6c757d !important; }
+
+/* Progress indicator for triage completion */
+.triage-progress {
+ height: 4px;
+ background: linear-gradient(90deg, #28a745 0%, #ffc107 50%, #dc3545 100%);
+ border-radius: 2px;
+ margin-bottom: 1rem;
+}
+
+/* Responsive adjustments for VEX controls */
+@media only screen and (max-width: 768px) {
+ .vex-triage-controls .row > .col-md-6 {
+ margin-bottom: 1rem;
+ }
+
+ .vex-triage-controls h6 {
+ font-size: 1.1rem;
+ }
+
+ .btn-group .btn-sm {
+ font-size: 0.8rem;
+ padding: 0.25rem 0.5rem;
+ }
}
\ No newline at end of file
diff --git a/cve_bin_tool/output_engine/html_reports/js/triage.js b/cve_bin_tool/output_engine/html_reports/js/triage.js
new file mode 100644
index 0000000000..bc55761a75
--- /dev/null
+++ b/cve_bin_tool/output_engine/html_reports/js/triage.js
@@ -0,0 +1,1213 @@
+/**
+ * Interactive Triage Functionality for CVE-bin-tool HTML Reports
+ * VEX-Compliant Vulnerability Exchange Implementation
+ *
+ * This file contains all the interactive logic for triaging vulnerabilities
+ * directly within the HTML report, allowing users to:
+ * - Update CVE status (VEX-compliant)
+ * - Add justifications for "Not Affected" status
+ * - Add detailed comments and impact statements
+ * - Save triage results offline
+ * - Export triage data in VEX format
+ */
+
+// Global variables
+let vulnerabilityData = {};
+let triageChanges = {};
+
+// VEX Status options
+const VEX_STATUS_OPTIONS = {
+ 'not_affected': 'Not Affected',
+ 'affected': 'Affected',
+ 'under_investigation': 'Under Investigation',
+ 'fixed': 'Fixed'
+};
+
+// VEX Justification options (only for "Not Affected" status)
+const VEX_JUSTIFICATION_OPTIONS = {
+ 'component_not_present': 'Component Not Present',
+ 'vulnerable_code_not_present': 'Vulnerable Code Not Present',
+ 'vulnerable_code_not_in_execute_path': 'Vulnerable Code Not in Execution Path',
+ 'vulnerable_code_unreachable': 'Vulnerable Code Unreachable'
+};
+
+/**
+ * Initialize the triage functionality when the page loads
+ */
+function initializeTriage() {
+ // Load vulnerability data from the embedded JSON
+ const dataElement = document.getElementById('vulnerability-data');
+ if (dataElement) {
+ try {
+ vulnerabilityData = JSON.parse(dataElement.textContent);
+ console.log('Vulnerability data loaded:', vulnerabilityData);
+ } catch (error) {
+ console.error('Failed to parse vulnerability data:', error);
+ return;
+ }
+ } else {
+ console.error('Vulnerability data not found in HTML');
+ return;
+ }
+
+ // Load any existing triage changes from localStorage
+ loadTriageChanges();
+
+ // Initialize triage controls
+ setupTriageControls();
+
+ // Apply any existing triage changes to the UI
+ applyTriageChangesToUI();
+}
+
+/**
+ * Load existing triage changes from localStorage
+ */
+function loadTriageChanges() {
+ const stored = localStorage.getItem('cve-bin-tool-triage');
+ if (stored) {
+ try {
+ triageChanges = JSON.parse(stored);
+ } catch (error) {
+ console.error('Failed to load triage changes from localStorage:', error);
+ triageChanges = {};
+ }
+ }
+}
+
+/**
+ * Save triage changes to localStorage
+ */
+function saveTriageChanges() {
+ try {
+ localStorage.setItem('cve-bin-tool-triage', JSON.stringify(triageChanges));
+ console.log('Triage changes saved to localStorage');
+ } catch (error) {
+ console.error('Failed to save triage changes to localStorage:', error);
+ }
+}
+
+/**
+ * Setup triage controls for all CVEs in the report
+ */
+function setupTriageControls() {
+ // Add triage controls to each CVE item
+ document.querySelectorAll('[id^="info"]').forEach(cveElement => {
+ const cveId = extractCVEFromElement(cveElement);
+ if (cveId && vulnerabilityData.vulnerabilities[cveId]) {
+ addTriageControlsToElement(cveElement, cveId);
+ }
+ });
+
+ // Add save and export buttons to the header
+ addTriageButtons();
+}
+
+/**
+ * Extract CVE ID from a DOM element
+ */
+function extractCVEFromElement(element) {
+ // Look for CVE pattern in the element's content or parent elements
+ let current = element;
+ while (current) {
+ const content = current.textContent || current.innerHTML;
+ const cveMatch = content.match(/CVE-\d{4}-\d+/);
+ if (cveMatch) return cveMatch[0];
+ current = current.parentElement;
+ }
+ return null;
+}
+
+/**
+ * Add VEX-compliant triage controls to a specific CVE element
+ */
+function addTriageControlsToElement(element, cveId) {
+ const vulnerability = vulnerabilityData.vulnerabilities[cveId];
+ const currentTriage = triageChanges[cveId] || {};
+
+ // Get current status (default to 'under_investigation' if not set)
+ const currentStatus = currentTriage.vex_status || 'under_investigation';
+ const currentJustification = currentTriage.vex_justification || '';
+
+ // Build status options HTML
+ const statusOptionsHtml = Object.entries(VEX_STATUS_OPTIONS)
+ .map(([value, label]) =>
+ ``
+ ).join('');
+
+ // Build justification options HTML
+ const justificationOptionsHtml = Object.entries(VEX_JUSTIFICATION_OPTIONS)
+ .map(([value, label]) =>
+ ``
+ ).join('');
+
+ // Escape HTML to prevent XSS
+ const escapeHtml = (unsafe) => {
+ return unsafe
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ };
+
+ const escapedCveId = escapeHtml(cveId);
+ const escapedDetail = escapeHtml(currentTriage.vex_detail || vulnerability.comments || '');
+
+ // Create VEX-compliant triage control HTML
+ const triageHtml = `
+
+
+
+ VEX Triage Assessment
+
+
+
+
+
+
+ Select the vulnerability exchange status
+
+
+
+
+
+ Required when status is "Not Affected"
+
+
+
+
+
+
+ Provide additional context, impact analysis, or mitigation details
+
+
+
+
+ Status: ${VEX_STATUS_OPTIONS[currentStatus]}
+ ${currentTriage.timestamp ? `Last updated: ${new Date(currentTriage.timestamp).toLocaleString()}` : 'Not triaged yet'}
+
+
+
+
+ `;
+
+ // Append the triage controls to the CVE element
+ element.insertAdjacentHTML('beforeend', triageHtml);
+
+ // Add event listeners for the controls
+ addVexTriageEventListeners(cveId);
+
+ // Apply visual state indication if already triaged
+ updateVisualStateIndication(cveId);
+}
+
+/**
+ * Add event listeners for VEX triage controls
+ */
+function addVexTriageEventListeners(cveId) {
+ const controls = document.querySelector(`[data-cve="${cveId}"].vex-triage-controls`);
+ if (!controls) return;
+
+ // Status change handler
+ const statusSelect = controls.querySelector('.vex-status-select');
+ statusSelect.addEventListener('change', (e) => {
+ const newStatus = e.target.value;
+ updateTriageData(cveId, 'vex_status', newStatus);
+
+ // Show/hide justification dropdown based on status
+ const justificationContainer = controls.querySelector('.vex-justification-container');
+ if (newStatus === 'not_affected') {
+ justificationContainer.style.display = 'block';
+ // Make justification required
+ const justificationSelect = controls.querySelector('.vex-justification-select');
+ justificationSelect.setAttribute('required', 'required');
+ } else {
+ justificationContainer.style.display = 'none';
+ // Clear justification if not "Not Affected"
+ const justificationSelect = controls.querySelector('.vex-justification-select');
+ justificationSelect.removeAttribute('required');
+ justificationSelect.value = '';
+ updateTriageData(cveId, 'vex_justification', '');
+ }
+
+ updateStatusIndicator(cveId, newStatus);
+ });
+
+ // Justification change handler
+ const justificationSelect = controls.querySelector('.vex-justification-select');
+ justificationSelect.addEventListener('change', (e) => {
+ updateTriageData(cveId, 'vex_justification', e.target.value);
+ });
+
+ // Detail/comment change handler
+ const detailTextarea = controls.querySelector('.vex-detail-textarea');
+ detailTextarea.addEventListener('blur', (e) => {
+ updateTriageData(cveId, 'vex_detail', e.target.value);
+ });
+
+ // Clear triage button handler
+ const clearBtn = controls.querySelector('.clear-triage-btn');
+ clearBtn.addEventListener('click', () => {
+ clearTriageData(cveId);
+ });
+}
+
+/**
+ * Update triage data for a specific CVE
+ */
+function updateTriageData(cveId, field, value) {
+ if (!triageChanges[cveId]) {
+ triageChanges[cveId] = {};
+ }
+
+ triageChanges[cveId][field] = value;
+ triageChanges[cveId].timestamp = new Date().toISOString();
+
+ // Auto-save changes
+ saveTriageChanges();
+
+ // Update visual indicators
+ updateVisualStateIndication(cveId);
+
+ console.log(`Updated triage data for ${cveId}:`, triageChanges[cveId]);
+}
+
+/**
+ * Update the status indicator badge
+ */
+function updateStatusIndicator(cveId, status) {
+ const controls = document.querySelector(`[data-cve="${cveId}"].vex-triage-controls`);
+ if (!controls) return;
+
+ const statusIndicator = controls.querySelector('.triage-status-indicator .badge');
+ if (statusIndicator) {
+ statusIndicator.textContent = `Status: ${VEX_STATUS_OPTIONS[status]}`;
+ statusIndicator.className = `badge ${getStatusBadgeClass(status)}`;
+ }
+
+ const timestampSpan = controls.querySelector('.triage-status-indicator small');
+ if (timestampSpan) {
+ timestampSpan.textContent = `Last updated: ${new Date().toLocaleString()}`;
+ }
+}
+
+/**
+ * Get Bootstrap badge class for VEX status
+ */
+function getStatusBadgeClass(status) {
+ const statusClasses = {
+ 'not_affected': 'bg-success',
+ 'affected': 'bg-danger',
+ 'under_investigation': 'bg-warning',
+ 'fixed': 'bg-info'
+ };
+ return statusClasses[status] || 'bg-secondary';
+}
+
+/**
+ * Clear triage data for a specific CVE
+ */
+function clearTriageData(cveId) {
+ if (confirm(`Are you sure you want to clear triage data for ${cveId}?`)) {
+ delete triageChanges[cveId];
+ saveTriageChanges();
+
+ // Reset the form to defaults
+ const controls = document.querySelector(`[data-cve="${cveId}"].vex-triage-controls`);
+ if (controls) {
+ controls.querySelector('.vex-status-select').value = 'under_investigation';
+ controls.querySelector('.vex-justification-select').value = '';
+ controls.querySelector('.vex-detail-textarea').value = '';
+ controls.querySelector('.vex-justification-container').style.display = 'none';
+
+ updateStatusIndicator(cveId, 'under_investigation');
+ updateVisualStateIndication(cveId);
+ }
+ }
+}
+
+/**
+ * Update visual state indication for triaged CVEs
+ */
+function updateVisualStateIndication(cveId) {
+ const triage = triageChanges[cveId];
+ const cveElement = document.querySelector(`[id*="${cveId}"]`);
+
+ if (!cveElement) return;
+
+ // Find the parent list item that contains this CVE
+ let listItem = cveElement;
+ while (listItem && !listItem.classList.contains('list-group-item')) {
+ listItem = listItem.parentElement;
+ }
+
+ if (listItem) {
+ if (triage && triage.vex_status) {
+ // Apply visual indication for triaged items
+ listItem.classList.add('triaged-item');
+ listItem.style.backgroundColor = '#f8f9fa';
+ listItem.style.borderLeft = '4px solid #28a745';
+
+ // Add triage badge
+ let triageBadge = listItem.querySelector('.triage-status-badge');
+ if (!triageBadge) {
+ triageBadge = document.createElement('span');
+ triageBadge.className = 'badge rounded-pill triage-status-badge ms-2';
+
+ // Find the CVE number element to append the badge
+ const cveNumberElement = listItem.querySelector('[id*="' + cveId + '"]');
+ if (cveNumberElement) {
+ cveNumberElement.appendChild(triageBadge);
+ }
+ }
+
+ if (triageBadge) {
+ triageBadge.textContent = VEX_STATUS_OPTIONS[triage.vex_status];
+ triageBadge.className = `badge rounded-pill triage-status-badge ms-2 ${getStatusBadgeClass(triage.vex_status)}`;
+ }
+ } else {
+ // Remove visual indication for non-triaged items
+ listItem.classList.remove('triaged-item');
+ listItem.style.backgroundColor = '';
+ listItem.style.borderLeft = '';
+
+ // Remove triage badge
+ const triageBadge = listItem.querySelector('.triage-status-badge');
+ if (triageBadge) {
+ triageBadge.remove();
+ }
+ }
+ }
+}
+
+/**
+ * Apply existing triage changes to the UI
+ */
+function applyTriageChangesToUI() {
+ Object.keys(triageChanges).forEach(cveId => {
+ updateVisualStateIndication(cveId);
+ });
+}
+
+/**
+ * Add save and export buttons to the header
+ */
+function addTriageButtons() {
+ const navbar = document.querySelector('.navbar');
+ if (!navbar) return;
+
+ const buttonGroup = document.createElement('div');
+ buttonGroup.className = 'btn-group ms-2';
+ buttonGroup.innerHTML = `
+
+
+
+
+
+ `;
+
+ // Find the navbar content div and append the button group
+ const navbarContent = navbar.querySelector('.container-fluid') || navbar.querySelector('.my-2');
+ if (navbarContent) {
+ navbarContent.appendChild(buttonGroup);
+ }
+}
+
+/**
+ * Save triage data as a CycloneDX VEX JSON file (Step 3 requirement)
+ */
+function saveCycloneDxVexFile() {
+ const timestamp = new Date().toISOString();
+ const serialNumber = `urn:uuid:${generateUUID()}`;
+
+ // Create CycloneDX VEX document structure
+ const cycloneDxVex = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.4",
+ serialNumber: serialNumber,
+ version: 1,
+ metadata: {
+ timestamp: timestamp,
+ tools: [{
+ vendor: "Intel",
+ name: "cve-bin-tool",
+ version: vulnerabilityData.metadata.tool_version || "unknown"
+ }],
+ properties: [
+ {
+ name: "urn:cve-bin-tool:property:scanned-directory",
+ value: vulnerabilityData.metadata.scanned_dir || "unknown"
+ },
+ {
+ name: "urn:cve-bin-tool:property:triage-session",
+ value: timestamp
+ }
+ ]
+ },
+ vulnerabilities: []
+ };
+
+ // Convert triage data to CycloneDX VEX vulnerabilities
+ Object.entries(triageChanges).forEach(([cveId, triage]) => {
+ const vulnerability = vulnerabilityData.vulnerabilities[cveId];
+ if (vulnerability) {
+ const vexVuln = {
+ id: cveId,
+ source: {
+ name: "NVD",
+ url: `https://nvd.nist.gov/vuln/detail/${cveId}`
+ },
+ ratings: [{
+ source: {
+ name: "NVD",
+ url: `https://nvd.nist.gov/vuln-metrics/cvss/v${vulnerability.cvss_version}-calculator?name=${cveId}`
+ },
+ score: vulnerability.score,
+ severity: vulnerability.severity.toLowerCase(),
+ method: `CVSSv${vulnerability.cvss_version}`,
+ vector: vulnerability.cvss_vector
+ }],
+ description: vulnerability.description || "No description available",
+ recommendation: "",
+ advisories: [],
+ created: timestamp,
+ published: "NOT_KNOWN",
+ updated: triage.timestamp || timestamp,
+ analysis: {
+ state: mapVexStatusToCycloneDx(triage.vex_status),
+ response: [],
+ detail: triage.vex_detail || ""
+ }
+ };
+
+ // Add justification if status is "not_affected"
+ if (triage.vex_status === 'not_affected' && triage.vex_justification) {
+ vexVuln.analysis.justification = mapJustificationToCycloneDx(triage.vex_justification);
+ }
+
+ // Add affects array (simplified - would need product info in real implementation)
+ vexVuln.affects = [{
+ ref: `urn:cbt:${vulnerabilityData.metadata.scanned_dir || 'unknown'}#${cveId}`
+ }];
+
+ cycloneDxVex.vulnerabilities.push(vexVuln);
+ }
+ });
+
+ // Add untriaged vulnerabilities with default state
+ Object.keys(vulnerabilityData.vulnerabilities || {}).forEach(cveId => {
+ if (!triageChanges[cveId]) {
+ const vulnerability = vulnerabilityData.vulnerabilities[cveId];
+ const vexVuln = {
+ id: cveId,
+ source: {
+ name: "NVD",
+ url: `https://nvd.nist.gov/vuln/detail/${cveId}`
+ },
+ ratings: [{
+ source: {
+ name: "NVD",
+ url: `https://nvd.nist.gov/vuln-metrics/cvss/v${vulnerability.cvss_version}-calculator?name=${cveId}`
+ },
+ score: vulnerability.score,
+ severity: vulnerability.severity.toLowerCase(),
+ method: `CVSSv${vulnerability.cvss_version}`,
+ vector: vulnerability.cvss_vector
+ }],
+ description: vulnerability.description || "No description available",
+ recommendation: "",
+ advisories: [],
+ created: timestamp,
+ published: "NOT_KNOWN",
+ updated: timestamp,
+ analysis: {
+ state: "in_triage",
+ response: [],
+ detail: "Vulnerability requires triage assessment"
+ },
+ affects: [{
+ ref: `urn:cbt:${vulnerabilityData.metadata.scanned_dir || 'unknown'}#${cveId}`
+ }]
+ };
+
+ cycloneDxVex.vulnerabilities.push(vexVuln);
+ }
+ });
+
+ // Download the CycloneDX VEX file
+ const blob = new Blob([JSON.stringify(cycloneDxVex, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `triage.vex.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ console.log('CycloneDX VEX file saved');
+}
+
+/**
+ * Save the current HTML report with all triage decisions visible (Step 3 requirement)
+ */
+function saveUpdatedReport() {
+ // Create a copy of the current document
+ const docClone = document.cloneNode(true);
+
+ // Remove interactive controls from the clone to create a static report
+ const vexControls = docClone.querySelectorAll('.vex-triage-controls');
+ vexControls.forEach(control => {
+ // Replace controls with static summary
+ const cveId = control.getAttribute('data-cve');
+ const triage = triageChanges[cveId];
+
+ if (triage) {
+ const staticSummary = docClone.createElement('div');
+ staticSummary.className = 'triage-summary mt-3 p-3 border rounded bg-success text-white';
+ staticSummary.innerHTML = `
+
+
+ Triage Completed
+
+ Status: ${VEX_STATUS_OPTIONS[triage.vex_status] || 'Unknown'}
+ ${triage.vex_justification ? `Justification: ${VEX_JUSTIFICATION_OPTIONS[triage.vex_justification]}
` : ''}
+ ${triage.vex_detail ? `Impact Statement: ${triage.vex_detail}
` : ''}
+ Triaged on: ${new Date(triage.timestamp).toLocaleString()}
+ `;
+ control.parentNode.replaceChild(staticSummary, control);
+ } else {
+ // Remove controls for untriaged items
+ control.remove();
+ }
+ });
+
+ // Remove interactive buttons from navbar
+ const buttonGroups = docClone.querySelectorAll('.btn-group');
+ buttonGroups.forEach(group => {
+ if (group.innerHTML.includes('Save VEX File') || group.innerHTML.includes('Save Updated Report')) {
+ group.remove();
+ }
+ });
+
+ // Add a header indicating this is a static report
+ const navbar = docClone.querySelector('.navbar-brand');
+ if (navbar) {
+ navbar.textContent = 'CVE Binary Tool - Triaged Report (Static)';
+ }
+
+ // Add triage summary to the top of the document
+ const dashboard = docClone.querySelector('#dashboard .container-fluid');
+ if (dashboard) {
+ const summary = getVexTriageSummary();
+ const summaryCard = docClone.createElement('div');
+ summaryCard.className = 'card mb-3';
+ summaryCard.innerHTML = `
+
+
+
+
+
+
${summary.total_triaged}
+
Triaged
+
+
+
+
+
${Object.keys(vulnerabilityData.vulnerabilities || {}).length - summary.total_triaged}
+
Remaining
+
+
+
+
Status Breakdown:
+ ${Object.entries(summary.by_status).map(([status, count]) =>
+ `${VEX_STATUS_OPTIONS[status]}: ${count}`
+ ).join('')}
+
+
+
+ Report generated on: ${new Date().toLocaleString()}
+
+
+ `;
+ dashboard.insertBefore(summaryCard, dashboard.firstChild);
+ }
+
+ // Create the updated HTML content
+ const htmlContent = `\n${docClone.documentElement.outerHTML}`;
+
+ // Download the updated HTML file
+ const blob = new Blob([htmlContent], { type: 'text/html' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `cve-report-triaged-${new Date().toISOString().split('T')[0]}.html`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ console.log('Updated HTML report saved');
+}
+
+/**
+ * Map VEX status to CycloneDX format
+ */
+function mapVexStatusToCycloneDx(vexStatus) {
+ const mapping = {
+ 'not_affected': 'not_affected',
+ 'affected': 'affected',
+ 'under_investigation': 'in_triage',
+ 'fixed': 'resolved'
+ };
+ return mapping[vexStatus] || 'in_triage';
+}
+
+/**
+ * Map justification to CycloneDX format
+ */
+function mapJustificationToCycloneDx(justification) {
+ const mapping = {
+ 'component_not_present': 'code_not_present',
+ 'vulnerable_code_not_present': 'code_not_present',
+ 'vulnerable_code_not_in_execute_path': 'code_not_reachable',
+ 'vulnerable_code_unreachable': 'code_not_reachable'
+ };
+ return mapping[justification] || 'code_not_present';
+}
+
+/**
+ * Generate a UUID for CycloneDX serial number
+ */
+function generateUUID() {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ const r = Math.random() * 16 | 0;
+ const v = c == 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+}
+
+/**
+ * Validate VEX file structure and content
+ */
+function validateVexFile(data) {
+ const errors = [];
+
+ // Basic JSON structure validation
+ if (!data || typeof data !== 'object') {
+ errors.push('File must contain a valid JSON object');
+ return errors;
+ }
+
+ // Check for CycloneDX VEX format
+ if (data.bomFormat === 'CycloneDX') {
+ return validateCycloneDxVex(data);
+ }
+
+ // Check for legacy cve-bin-tool VEX format
+ if (data.metadata && data.vulnerabilities) {
+ return validateLegacyVex(data);
+ }
+
+ // Try to detect format based on structure
+ if (Array.isArray(data.vulnerabilities)) {
+ // Assume CycloneDX if vulnerabilities is array
+ return validateCycloneDxVex(data);
+ } else if (data.vulnerabilities && typeof data.vulnerabilities === 'object') {
+ // Assume legacy if vulnerabilities is object
+ return validateLegacyVex(data);
+ }
+
+ errors.push('Unrecognized VEX file format. Expected CycloneDX or cve-bin-tool VEX format.');
+ return errors;
+}
+
+/**
+ * Validate CycloneDX VEX format
+ */
+function validateCycloneDxVex(data) {
+ const errors = [];
+
+ // Check required CycloneDX fields
+ if (!data.bomFormat || data.bomFormat !== 'CycloneDX') {
+ errors.push('CycloneDX VEX must have bomFormat: "CycloneDX"');
+ }
+
+ if (!data.specVersion) {
+ errors.push('CycloneDX VEX must have specVersion');
+ }
+
+ if (!Array.isArray(data.vulnerabilities)) {
+ errors.push('CycloneDX VEX must have vulnerabilities array');
+ return errors;
+ }
+
+ // Validate vulnerabilities structure
+ data.vulnerabilities.forEach((vuln, index) => {
+ if (!vuln.id) {
+ errors.push(`Vulnerability ${index + 1}: missing id field`);
+ }
+
+ if (vuln.analysis && vuln.analysis.state) {
+ const validStates = ['not_affected', 'affected', 'in_triage', 'resolved'];
+ if (!validStates.includes(vuln.analysis.state)) {
+ errors.push(`Vulnerability ${vuln.id || index + 1}: invalid analysis state "${vuln.analysis.state}"`);
+ }
+ }
+ });
+
+ return errors;
+}
+
+/**
+ * Validate legacy cve-bin-tool VEX format
+ */
+function validateLegacyVex(data) {
+ const errors = [];
+
+ // Check metadata
+ if (!data.metadata || typeof data.metadata !== 'object') {
+ errors.push('Legacy VEX must have metadata object');
+ }
+
+ if (!data.vulnerabilities || typeof data.vulnerabilities !== 'object') {
+ errors.push('Legacy VEX must have vulnerabilities object');
+ return errors;
+ }
+
+ // Validate vulnerabilities structure
+ Object.entries(data.vulnerabilities).forEach(([cveId, vuln]) => {
+ if (!cveId.match(/^CVE-\d{4}-\d+$/)) {
+ errors.push(`Invalid CVE ID format: ${cveId}`);
+ }
+
+ if (vuln.status) {
+ const validStatuses = ['not_affected', 'affected', 'under_investigation', 'fixed'];
+ if (!validStatuses.includes(vuln.status)) {
+ errors.push(`${cveId}: invalid status "${vuln.status}"`);
+ }
+ }
+ });
+
+ return errors;
+}
+
+/**
+ * Import VEX triage data from a JSON file with comprehensive validation
+ */
+function importVexTriageData() {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json';
+ input.onchange = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ // Validate file type
+ if (!file.name.toLowerCase().endsWith('.json')) {
+ showValidationError('Please select a JSON file (.json extension required).');
+ return;
+ }
+
+ // Validate file size (max 10MB)
+ if (file.size > 10 * 1024 * 1024) {
+ showValidationError('File too large. Maximum size is 10MB.');
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ // Parse JSON with error handling
+ let data;
+ try {
+ data = JSON.parse(e.target.result);
+ } catch (parseError) {
+ showValidationError(`Invalid JSON format: ${parseError.message}`);
+ return;
+ }
+
+ // Validate VEX file structure
+ const validationErrors = validateVexFile(data);
+ if (validationErrors.length > 0) {
+ showValidationError('VEX file validation failed:\n\n' + validationErrors.join('\n'));
+ return;
+ }
+
+ // Import the data based on format
+ let importedCount = 0;
+ let skippedCount = 0;
+
+ if (data.bomFormat === 'CycloneDX') {
+ ({ importedCount, skippedCount } = importCycloneDxVex(data));
+ } else {
+ ({ importedCount, skippedCount } = importLegacyVex(data));
+ }
+
+ // Show success message
+ showImportSuccess(importedCount, skippedCount);
+
+ // Save and refresh
+ saveTriageChanges();
+ applyTriageChangesToUI();
+
+ } catch (error) {
+ console.error('Error importing VEX file:', error);
+ showValidationError(`Unexpected error: ${error.message}`);
+ }
+ };
+
+ reader.onerror = () => {
+ showValidationError('Failed to read file. Please try again.');
+ };
+
+ reader.readAsText(file);
+ };
+ input.click();
+}
+
+/**
+ * Import CycloneDX VEX format
+ */
+function importCycloneDxVex(data) {
+ let importedCount = 0;
+ let skippedCount = 0;
+
+ data.vulnerabilities.forEach(vuln => {
+ if (vuln.id && vulnerabilityData.vulnerabilities[vuln.id]) {
+ // Map CycloneDX analysis state back to internal format
+ let vexStatus = 'under_investigation';
+ if (vuln.analysis && vuln.analysis.state) {
+ const stateMapping = {
+ 'not_affected': 'not_affected',
+ 'affected': 'affected',
+ 'in_triage': 'under_investigation',
+ 'resolved': 'fixed'
+ };
+ vexStatus = stateMapping[vuln.analysis.state] || 'under_investigation';
+ }
+
+ triageChanges[vuln.id] = {
+ vex_status: vexStatus,
+ vex_detail: vuln.analysis?.detail || '',
+ vex_justification: vuln.analysis?.justification ? mapCycloneDxJustificationToInternal(vuln.analysis.justification) : '',
+ timestamp: vuln.updated || new Date().toISOString()
+ };
+ importedCount++;
+ } else {
+ skippedCount++;
+ }
+ });
+
+ return { importedCount, skippedCount };
+}
+
+/**
+ * Import legacy cve-bin-tool VEX format
+ */
+function importLegacyVex(data) {
+ let importedCount = 0;
+ let skippedCount = 0;
+
+ Object.entries(data.vulnerabilities).forEach(([cveId, vuln]) => {
+ if (vulnerabilityData.vulnerabilities[cveId]) {
+ triageChanges[cveId] = {
+ vex_status: vuln.status || 'under_investigation',
+ vex_detail: vuln.detail || '',
+ vex_justification: vuln.justification || '',
+ timestamp: vuln.timestamp || new Date().toISOString()
+ };
+ importedCount++;
+ } else {
+ skippedCount++;
+ }
+ });
+
+ return { importedCount, skippedCount };
+}
+
+/**
+ * Map CycloneDX justification back to internal format
+ */
+function mapCycloneDxJustificationToInternal(justification) {
+ const mapping = {
+ 'code_not_present': 'component_not_present',
+ 'code_not_reachable': 'vulnerable_code_not_in_execute_path'
+ };
+ return mapping[justification] || 'component_not_present';
+}
+
+/**
+ * Show validation error with user-friendly formatting
+ */
+function showValidationError(message) {
+ const modal = document.createElement('div');
+ modal.className = 'modal fade';
+ modal.innerHTML = `
+
+ `;
+
+ document.body.appendChild(modal);
+ const bsModal = new bootstrap.Modal(modal);
+ bsModal.show();
+
+ modal.addEventListener('hidden.bs.modal', () => {
+ document.body.removeChild(modal);
+ });
+}
+
+/**
+ * Show import success message
+ */
+function showImportSuccess(importedCount, skippedCount) {
+ const modal = document.createElement('div');
+ modal.className = 'modal fade';
+ modal.innerHTML = `
+
+
+
+
+
Successfully imported ${importedCount} triage decisions.
+ ${skippedCount > 0 ? `
Skipped ${skippedCount} vulnerabilities not found in current report.
` : ''}
+
+
+
+
+ `;
+
+ document.body.appendChild(modal);
+ const bsModal = new bootstrap.Modal(modal);
+ bsModal.show();
+
+ modal.addEventListener('hidden.bs.modal', () => {
+ document.body.removeChild(modal);
+ });
+}
+
+/**
+ * Show triage summary in a modal
+ */
+function showTriageSummary() {
+ const summary = getVexTriageSummary();
+
+ const modalHtml = `
+
+
+
+
+
+
+
+
Overall Progress
+
+ -
+ Total Vulnerabilities:
+ ${Object.keys(vulnerabilityData.vulnerabilities || {}).length}
+
+ -
+ Triaged:
+ ${summary.total_triaged}
+
+ -
+ Remaining:
+ ${Object.keys(vulnerabilityData.vulnerabilities || {}).length - summary.total_triaged}
+
+
+
+
+
Status Breakdown
+
+ ${Object.entries(summary.by_status).map(([status, count]) =>
+ `-
+ ${VEX_STATUS_OPTIONS[status] || status}:
+ ${count}
+
`
+ ).join('')}
+
+
+
+
+
+
Completion Rate
+
+
+ ${Math.round((summary.total_triaged / Object.keys(vulnerabilityData.vulnerabilities || {}).length) * 100)}%
+
+
+
+
+
+
+
+
+ `;
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('triageSummaryModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to page
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+
+ // Show modal
+ const modal = new bootstrap.Modal(document.getElementById('triageSummaryModal'));
+ modal.show();
+}
+
+/**
+ * Clear all VEX triage data
+ */
+function clearAllVexTriage() {
+ if (confirm('Are you sure you want to clear all VEX triage data? This action cannot be undone.')) {
+ triageChanges = {};
+ localStorage.removeItem('cve-bin-tool-triage');
+ location.reload();
+ }
+}
+
+/**
+ * Get summary of VEX triage changes
+ */
+function getVexTriageSummary() {
+ const summary = {
+ total_triaged: Object.keys(triageChanges).length,
+ by_status: {}
+ };
+
+ Object.values(triageChanges).forEach(triage => {
+ const status = triage.vex_status || 'under_investigation';
+ summary.by_status[status] = (summary.by_status[status] || 0) + 1;
+ });
+
+ return summary;
+}
+
+/**
+ * Validate VEX triage data completeness
+ */
+function validateVexTriage() {
+ const errors = [];
+
+ Object.entries(triageChanges).forEach(([cveId, triage]) => {
+ // Check if "not_affected" status has justification
+ if (triage.vex_status === 'not_affected' && !triage.vex_justification) {
+ errors.push(`${cveId}: "Not Affected" status requires justification`);
+ }
+
+ // Check if detail is provided for meaningful statuses
+ if (['affected', 'fixed'].includes(triage.vex_status) && !triage.vex_detail) {
+ errors.push(`${cveId}: "${VEX_STATUS_OPTIONS[triage.vex_status]}" status should include impact details`);
+ }
+ });
+
+ return errors;
+}
+
+/**
+ * Get appropriate Bootstrap badge class for VEX status
+ */
+function getStatusBadgeClass(status) {
+ const classes = {
+ 'not_affected': 'bg-success',
+ 'affected': 'bg-danger',
+ 'under_investigation': 'bg-warning text-dark',
+ 'fixed': 'bg-info'
+ };
+ return classes[status] || 'bg-secondary';
+}
+
+// Initialize triage functionality when the DOM is loaded
+document.addEventListener('DOMContentLoaded', initializeTriage);
+
+// Make functions available globally for button onclick handlers
+window.saveCycloneDxVexFile = saveCycloneDxVexFile;
+window.saveUpdatedReport = saveUpdatedReport;
+window.importVexTriageData = importVexTriageData;
+window.showTriageSummary = showTriageSummary;
+window.clearAllVexTriage = clearAllVexTriage;
+window.validateVexTriage = validateVexTriage;
diff --git a/cve_bin_tool/output_engine/html_reports/templates/base.html b/cve_bin_tool/output_engine/html_reports/templates/base.html
index 2a1b989cf3..194d40b955 100644
--- a/cve_bin_tool/output_engine/html_reports/templates/base.html
+++ b/cve_bin_tool/output_engine/html_reports/templates/base.html
@@ -9,6 +9,10 @@
+
+
+
+
@@ -267,6 +271,9 @@ Want to Contribute?
+
+
+