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 = ` +
+
Triage Summary
+
+
+
+
+
+

${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 = ` + + `; + + 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 = ` + + `; + + // 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?

+ + + diff --git a/test/pages/html_report.py b/test/pages/html_report.py index 2a04b9492d..6f4e06b5fd 100644 --- a/test/pages/html_report.py +++ b/test/pages/html_report.py @@ -88,6 +88,17 @@ def __init__( self.cve_summary_table = page.locator("#cveSummary tbody tr") self.cve_remarks_table = page.locator("#cveRemarks tbody tr") + # VEX Triage selectors + self.vex_triage_controls = page.locator(".vex-triage-controls") + self.vex_status_select = page.locator(".vex-status-select") + self.vex_justification_select = page.locator(".vex-justification-select") + self.vex_detail_textarea = page.locator(".vex-detail-textarea") + self.save_vex_button = page.locator("button:has-text('Save VEX File')") + self.save_report_button = page.locator("button:has-text('Save Updated Report')") + self.import_vex_button = page.locator("button:has-text('Import VEX')") + self.summary_button = page.locator("button:has-text('Summary')") + self.clear_all_button = page.locator("button:has-text('Clear All')") + def load(self) -> None: self.page.goto(f"file://{self.html_output.name}") diff --git a/test/test_html.py b/test/test_html.py index d78a735a2c..581000851f 100644 --- a/test/test_html.py +++ b/test/test_html.py @@ -373,6 +373,61 @@ def test_empty_cve_list(self) -> None: expect(product_rows).to_have_count(0) + # Test for cve_data["cves"] list with an element containing "UNKNOWN" CVE number + def test_unknown_cve_number(self) -> None: + """Test that the HTML report renders correctly with a cve_data["cves"] list containing an 'UNKNOWN' CVE number.""" + unknown_cve_output = { + ProductInfo("vendor0", "product0", "1.0", "usr/local/bin/product"): CVEData( + cves=[ + CVE( + "UNKNOWN", + "MEDIUM", + score=4.2, + cvss_version=2, + cvss_vector="C:H", + remarks=Remarks.NewFound, + comments="showup", + ) + ], + paths={""}, + ) + } + self.html_report_page.cleanup() # Clean up the previous page + self.html_report_page = HTMLReport( + self.html_report_page.page, unknown_cve_output + ) + self.html_report_page.load() + product_rows = self.html_report_page.product_rows + expect(product_rows).to_have_count(1) + + def test_vex_triage_buttons_in_navbar(self) -> None: + """Test that VEX triage buttons are present in HTML reports.""" + # Check for essential triage buttons + save_vex_button = self.page.locator("button:has-text('Save VEX File')") + save_report_button = self.page.locator("button:has-text('Save Updated Report')") + + # These buttons should be present if triage.js is loaded + if save_vex_button.count() > 0: + expect(save_vex_button).to_be_visible() + if save_report_button.count() > 0: + expect(save_report_button).to_be_visible() + + def test_vex_triage_data_persistence_basic(self) -> None: + """Test basic localStorage functionality for VEX triage.""" + # Test localStorage operations directly + self.page.evaluate( + """ + localStorage.setItem('cve-bin-tool-triage', '{"test": "data"}'); + """ + ) + + # Check that data is stored + stored_data = self.page.evaluate("localStorage.getItem('cve-bin-tool-triage')") + assert stored_data == '{"test": "data"}' + + # Clear storage for cleanup + self.page.evaluate("localStorage.removeItem('cve-bin-tool-triage')") + def test_get_intermediate_label_with_tag(): """Test get_intermediate_label returns correct format with tag""" diff --git a/test/test_triage_js.py b/test/test_triage_js.py new file mode 100644 index 0000000000..091469e8f5 --- /dev/null +++ b/test/test_triage_js.py @@ -0,0 +1,59 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +Essential tests for VEX triage JavaScript functionality integration. +These tests run only the most critical checks without complex Playwright setup. +""" + +from pathlib import Path + + +class TestTriageJSIntegration: + """Test basic triage.js integration points.""" + + def test_triage_js_properly_structured(self) -> None: + """Test that triage.js has proper JavaScript structure.""" + triage_js_path = ( + Path(__file__).parent.parent + / "cve_bin_tool/output_engine/html_reports/js/triage.js" + ) + content = triage_js_path.read_text() + + # Test for basic JavaScript structure + assert "function " in content, "Should contain JavaScript functions" + assert "addEventListener" in content, "Should use event listeners" + assert "document." in content, "Should interact with DOM" + + def test_vex_constants_defined(self) -> None: + """Test that VEX constants are properly defined.""" + triage_js_path = ( + Path(__file__).parent.parent + / "cve_bin_tool/output_engine/html_reports/js/triage.js" + ) + content = triage_js_path.read_text() + + # Test VEX status options + assert "'not_affected'" in content, "Should define not_affected status" + assert "'affected'" in content, "Should define affected status" + assert ( + "'under_investigation'" in content + ), "Should define under_investigation status" + assert "'fixed'" in content, "Should define fixed status" + + def test_export_functions_exist(self) -> None: + """Test that export functions are defined.""" + triage_js_path = ( + Path(__file__).parent.parent + / "cve_bin_tool/output_engine/html_reports/js/triage.js" + ) + content = triage_js_path.read_text() + + # Test critical export functions + assert ( + "window.saveCycloneDxVexFile" in content + ), "Should expose saveCycloneDxVexFile globally" + assert ( + "window.saveUpdatedReport" in content + ), "Should expose saveUpdatedReport globally" + assert "Blob(" in content, "Should support file downloads via Blob" diff --git a/test/test_triage_js_content.py b/test/test_triage_js_content.py new file mode 100644 index 0000000000..6a3add3641 --- /dev/null +++ b/test/test_triage_js_content.py @@ -0,0 +1,54 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +Essential tests for triage.js file presence and content validation. +These tests ensure the VEX triage functionality is properly implemented. +""" + +from pathlib import Path + + +class TestTriageJSContent: + """Test the content of triage.js file for required functions and constants.""" + + def test_triage_js_file_exists(self) -> None: + """Test that triage.js file exists.""" + triage_js_path = ( + Path(__file__).parent.parent + / "cve_bin_tool/output_engine/html_reports/js/triage.js" + ) + assert triage_js_path.exists(), "triage.js file should exist" + + def test_essential_vex_functions_present(self) -> None: + """Test that essential VEX functions are present in triage.js.""" + triage_js_path = ( + Path(__file__).parent.parent + / "cve_bin_tool/output_engine/html_reports/js/triage.js" + ) + content = triage_js_path.read_text() + + # Only test the most critical functions + essential_functions = [ + "saveCycloneDxVexFile", + "initializeTriage", + "VEX_STATUS_OPTIONS", + ] + + for func in essential_functions: + assert ( + func in content + ), f"Essential function/constant {func} should be present in triage.js" + + def test_cyclonedx_vex_support(self) -> None: + """Test that CycloneDX VEX format is supported.""" + triage_js_path = ( + Path(__file__).parent.parent + / "cve_bin_tool/output_engine/html_reports/js/triage.js" + ) + content = triage_js_path.read_text() + + # Test for CycloneDX VEX format support + assert "bomFormat" in content, "CycloneDX bomFormat should be supported" + assert "CycloneDX" in content, "CycloneDX format should be referenced" + assert "specVersion" in content, "CycloneDX specVersion should be included"