Files
Bjorn/web/js/pages/vulnerabilities.js
Fabien POLLY eb20b168a6 Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences.
- Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling.
- Included helper methods for querying the database and sending JSON responses.
- Integrated model metadata extraction for visualization purposes.
2026-02-18 22:36:10 +01:00

917 lines
43 KiB
JavaScript

/**
* Vulnerabilities page module — Bjorn Project
*
* Changes vs previous version:
* - Card click → opens detail modal directly (no manual expand needed)
* - Direct chips on every card: 🐱 GitHub PoC · 🛡 Rapid7 · NVD ↗ · MITRE ↗
* - Global "💣 Search All Exploits" button: batch enrichment, stored in DB
* - Exploit chips rendered from DB data, updated after enrichment
* - Progress indicator during global exploit search
* - Poller suspended while modal is open
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
const PAGE = 'vulnerabilities';
const ITEMS_PER_PAGE = 20;
const SEVERITY_ORDER = { critical: 4, high: 3, medium: 2, low: 1 };
/* ── state ── */
let tracker = null;
let poller = null;
let disposeSidebarLayout = null;
let vulnerabilities = [];
let filteredVulns = [];
let currentView = 'cve';
let showActiveOnly = false;
let severityFilters = new Set();
let searchTerm = '';
let currentPage = 1;
let totalPages = 1;
let expandedHosts = new Set();
let historyMode = false;
let sortField = 'cvss_score';
let sortDir = 'desc';
let dateFrom = '';
let dateTo = '';
let lastFetchTime = null;
let modalInFlight = null;
let searchDebounce = null;
let historyPage = 1;
let historySearch = '';
let allHistory = [];
let exploitSearchRunning = false;
/* ── prefs ── */
const getPref = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } };
/* ════════════════════════════════════════
LIFECYCLE
═══════════════════════════════════════ */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
const shell = buildShell();
container.appendChild(shell);
disposeSidebarLayout = initSharedSidebarLayout(shell, {
sidebarSelector: '.vuln-sidebar',
mainSelector: '.vuln-main',
storageKey: 'sidebar:vulnerabilities',
toggleLabel: t('common.menu'),
});
await fetchVulnerabilities();
loadFeedStatus();
const interval = parseInt(getPref('vuln:refresh', '30000'), 10) || 30000;
if (interval > 0) {
poller = new Poller(fetchVulnerabilities, interval);
poller.start();
}
}
export function unmount() {
clearTimeout(searchDebounce);
searchDebounce = null;
if (poller) { poller.stop(); poller = null; }
if (disposeSidebarLayout) { try { disposeSidebarLayout(); } catch {} disposeSidebarLayout = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
vulnerabilities = []; filteredVulns = [];
currentView = 'cve'; showActiveOnly = false;
severityFilters.clear(); searchTerm = '';
currentPage = 1; expandedHosts.clear();
historyMode = false; modalInFlight = null; allHistory = [];
}
/* ════════════════════════════════════════
SHELL
═══════════════════════════════════════ */
function buildShell() {
const sidebar = el('aside', { class: 'vuln-sidebar page-sidebar panel' }, [
el('div', { class: 'sidehead' }, [
el('div', { class: 'sidetitle' }, [t('nav.vulnerabilities')]),
el('div', { class: 'spacer' }),
el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide')]),
]),
el('div', { class: 'sidecontent' }, [
/* stats */
el('div', { class: 'stats-header' }, [
statItem('\u{1F6E1}', 'total-cves', 'Total CVEs'),
statItem('\u{1F534}', 'active-vulns', 'Active'),
statItem('\u2705', 'remediated-vulns', 'Remediated'),
statItem('\u{1F525}', 'critical-count', 'Critical'),
statItem('\u{1F5A5}', 'affected-hosts', 'Hosts'),
statItem('\u{1F4A3}', 'exploit-count', 'w/ Exploit'),
statItem('\u26A0', 'kev-count', 'KEV'),
]),
/* freshness */
el('div', { id: 'vuln-freshness', style: 'font-size:.75rem;opacity:.5;padding:8px 0 0 4px' }),
/* ── feed sync ── */
el('div', { style: 'margin-top:14px;padding:0 4px' }, [
el('button', {
id: 'btn-feed-sync',
class: 'vuln-btn exploit-btn',
style: 'width:100%;font-weight:600',
onclick: runFeedSync,
}, ['\u{1F504} Update Exploit Feeds']),
el('div', { id: 'feed-sync-status', style: 'font-size:.72rem;opacity:.55;margin-top:4px;min-height:16px' }),
]),
/* sort */
el('div', { style: 'margin-top:14px;padding:0 4px' }, [
el('div', { style: 'font-size:.75rem;opacity:.55;margin-bottom:4px' }, ['Sort by']),
el('select', { id: 'vuln-sort-field', class: 'vuln-select', onchange: onSortChange }, [
el('option', { value: 'cvss_score' }, ['CVSS Score']),
el('option', { value: 'severity' }, ['Severity']),
el('option', { value: 'last_seen' }, ['Last Seen']),
el('option', { value: 'first_seen' }, ['First Seen']),
]),
el('select', { id: 'vuln-sort-dir', class: 'vuln-select', onchange: onSortChange, style: 'margin-top:4px' }, [
el('option', { value: 'desc' }, ['Descending']),
el('option', { value: 'asc' }, ['Ascending']),
]),
]),
/* date filter */
el('div', { style: 'margin-top:14px;padding:0 4px' }, [
el('div', { style: 'font-size:.75rem;opacity:.55;margin-bottom:4px' }, ['Date filter (last seen)']),
el('input', { type: 'date', id: 'vuln-date-from', class: 'vuln-date-input', onchange: onDateChange }),
el('input', { type: 'date', id: 'vuln-date-to', class: 'vuln-date-input', onchange: onDateChange, style: 'margin-top:4px' }),
el('button', { class: 'vuln-btn', style: 'margin-top:6px;width:100%', onclick: clearDateFilter }, ['Clear dates']),
]),
]),
]);
const main = el('div', { class: 'vuln-main page-main' }, [
el('div', { class: 'vuln-controls' }, [
el('div', { class: 'global-search-container' }, [
el('input', { type: 'text', class: 'global-search-input', id: 'vuln-search', placeholder: t('common.search'), oninput: onSearch }),
el('button', { class: 'clear-global-button', onclick: clearSearch }, ['\u2716']),
]),
el('div', { class: 'vuln-buttons' }, [
el('button', { class: 'vuln-btn active', id: 'vuln-view-cve', onclick: () => switchView('cve') }, ['CVE View']),
el('button', { class: 'vuln-btn', id: 'vuln-view-host', onclick: () => switchView('host') }, ['Host View']),
el('button', { class: 'vuln-btn', id: 'vuln-view-exploits', onclick: () => switchView('exploits') }, ['\u{1F4A3} Exploits']),
el('button', { class: 'vuln-btn', id: 'vuln-active-toggle', onclick: toggleActiveFilter }, [t('status.online')]),
el('button', { class: 'vuln-btn', id: 'vuln-history-btn', onclick: toggleHistory }, [t('sched.history')]),
el('button', { class: 'vuln-btn', onclick: exportCSV }, [t('common.export') + ' CSV']),
el('button', { class: 'vuln-btn', onclick: exportJSON }, [t('common.export') + ' JSON']),
]),
]),
el('div', { class: 'vuln-severity-bar' }, [
severityBtn('critical'), severityBtn('high'), severityBtn('medium'), severityBtn('low'),
]),
el('div', { class: 'services-grid', id: 'vuln-grid' }),
el('div', { class: 'vuln-pagination', id: 'vuln-pagination' }),
/* ── MODAL ── */
el('div', { class: 'vuln-modal', id: 'vuln-modal', onclick: onModalBackdrop }, [
el('div', { class: 'vuln-modal-content' }, [
el('div', { class: 'vuln-modal-header' }, [
el('span', { class: 'vuln-modal-title', id: 'vuln-modal-title' }),
/* ref chips in modal header */
el('div', { class: 'vuln-modal-header-chips', id: 'vuln-modal-header-chips' }),
el('button', { class: 'vuln-modal-close', onclick: closeModal }, ['\u2716']),
]),
el('div', { class: 'vuln-modal-body', id: 'vuln-modal-body' }),
]),
]),
]);
return el('div', { class: 'vuln-container page-with-sidebar' }, [sidebar, main]);
}
function statItem(icon, id, label) {
return el('div', { class: 'stat-card stat-item' }, [
el('span', { class: 'stat-icon' }, [icon]),
el('span', { class: 'stat-number stat-value', id }, ['0']),
el('span', { class: 'stat-label' }, [label]),
]);
}
function severityBtn(sev) {
return el('button', {
class: `vuln-severity-btn severity-${sev}`,
'data-severity': sev,
onclick: (e) => toggleSeverity(sev, e.currentTarget),
}, [sev.charAt(0).toUpperCase() + sev.slice(1)]);
}
/* ════════════════════════════════════════
DATA FETCH
═══════════════════════════════════════ */
async function fetchVulnerabilities() {
if (historyMode) return;
try {
const data = await api.get('/list_vulnerabilities', { timeout: 10000 });
vulnerabilities = Array.isArray(data) ? data : (data?.vulnerabilities || []);
lastFetchTime = new Date();
const f = $('#vuln-freshness');
if (f) f.textContent = `Last refresh: ${lastFetchTime.toLocaleTimeString()}`;
updateStats();
filterAndRender();
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
}
}
/* ════════════════════════════════════════
FEED SYNC
POST /api/feeds/sync — downloads CISA KEV + Exploit-DB + EPSS into local DB
GET /api/feeds/status — last sync timestamps
═══════════════════════════════════════ */
async function runFeedSync() {
const btn = $('#btn-feed-sync');
const status = $('#feed-sync-status');
if (btn && btn.disabled) return;
if (btn) { btn.disabled = true; btn.textContent = '\u23F3 Downloading\u2026'; }
if (status) status.textContent = 'Syncing CISA KEV, Exploit-DB, EPSS\u2026';
try {
const res = await api.post('/api/feeds/sync', {}, { timeout: 120000 });
const feeds = res?.feeds || {};
const parts = [];
for (const [name, info] of Object.entries(feeds)) {
if (info.status === 'ok') parts.push(`${name}: ${info.count} records`);
else parts.push(`${name}: \u274C ${info.message || 'error'}`);
}
if (status) status.textContent = '\u2705 ' + (parts.join(' \u00B7 ') || 'Done');
await fetchVulnerabilities();
} catch (err) {
if (status) status.textContent = `\u274C ${err.message}`;
} finally {
if (btn) { btn.disabled = false; btn.textContent = '\u{1F504} Update Exploit Feeds'; }
}
}
async function loadFeedStatus() {
try {
const res = await api.get('/api/feeds/status');
const status = $('#feed-sync-status');
if (!status || !res?.feeds) return;
const entries = Object.entries(res.feeds);
if (!entries.length) { status.textContent = 'No sync yet — click to update.'; return; }
// show the most recent sync time
const latest = entries.reduce((a, [, v]) => Math.max(a, v.last_synced || 0), 0);
if (latest) {
const d = new Date(latest * 1000);
status.textContent = `Last sync: ${d.toLocaleDateString()} ${d.toLocaleTimeString()} \u00B7 ${res.total_exploits || 0} exploits`;
}
} catch { /* ignore */ }
}
/* ════════════════════════════════════════
STATS
═══════════════════════════════════════ */
function updateStats() {
const sv = (id, v) => { const e = $(`#${id}`); if (e) e.textContent = v; };
sv('total-cves', vulnerabilities.length);
sv('active-vulns', vulnerabilities.filter(v => v.is_active === 1).length);
sv('remediated-vulns', vulnerabilities.filter(v => v.is_active === 0).length);
sv('critical-count', vulnerabilities.filter(v => v.is_active === 1 && v.severity === 'critical').length);
sv('exploit-count', vulnerabilities.filter(v => v.has_exploit).length);
sv('kev-count', vulnerabilities.filter(v => v.is_kev).length);
const macs = new Set(vulnerabilities.map(v => v.mac_address).filter(Boolean));
sv('affected-hosts', macs.size);
}
/* ════════════════════════════════════════
FILTER + SORT
═══════════════════════════════════════ */
function filterAndRender() {
const needle = searchTerm.toLowerCase();
const from = dateFrom ? new Date(dateFrom).getTime() : null;
const to = dateTo ? new Date(dateTo + 'T23:59:59').getTime() : null;
filteredVulns = vulnerabilities.filter(v => {
if (showActiveOnly && v.is_active === 0) return false;
if (severityFilters.size > 0 && !severityFilters.has(v.severity)) return false;
if (needle) {
if (!`${v.vuln_id} ${v.ip} ${v.hostname} ${v.port} ${v.description}`.toLowerCase().includes(needle)) return false;
}
if (from || to) {
const ls = v.last_seen ? new Date(v.last_seen).getTime() : null;
if (from && (!ls || ls < from)) return false;
if (to && (!ls || ls > to)) return false;
}
return true;
});
filteredVulns.sort((a, b) => {
let va, vb;
switch (sortField) {
case 'severity': va = SEVERITY_ORDER[a.severity] || 0; vb = SEVERITY_ORDER[b.severity] || 0; break;
case 'last_seen': va = a.last_seen ? new Date(a.last_seen).getTime() : 0; vb = b.last_seen ? new Date(b.last_seen).getTime() : 0; break;
case 'first_seen': va = a.first_seen ? new Date(a.first_seen).getTime() : 0; vb = b.first_seen ? new Date(b.first_seen).getTime() : 0; break;
default: va = parseFloat(a.cvss_score) || 0; vb = parseFloat(b.cvss_score) || 0;
}
return sortDir === 'asc' ? va - vb : vb - va;
});
totalPages = Math.max(1, Math.ceil(filteredVulns.length / ITEMS_PER_PAGE));
if (currentPage > totalPages) currentPage = 1;
if (currentView === 'host') renderHostView();
else if (currentView === 'exploits') renderExploitsView();
else renderCVEView();
renderPagination();
}
/* ════════════════════════════════════════
CHIP BUILDERS (shared across all views)
═══════════════════════════════════════ */
/** Four external reference chips — always visible on every card & in modal */
function buildRefChips(cveId) {
const enc = encodeURIComponent(cveId);
return el('div', { class: 'vuln-ref-chips', onclick: e => e.stopPropagation() }, [
refChip('\u{1F431} GitHub', `https://github.com/search?q=${enc}&type=repositories`, 'chip-github'),
refChip('\u{1F6E1} Rapid7', `https://www.rapid7.com/db/?q=${enc}`, 'chip-rapid7'),
refChip('NVD \u2197', `https://nvd.nist.gov/vuln/detail/${enc}`, 'chip-nvd'),
refChip('MITRE \u2197', `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${enc}`, 'chip-mitre'),
]);
}
/** Exploit chips built from DB data — shown only when exploit data exists */
function buildExploitChips(v) {
const exploits = Array.isArray(v.exploits) ? v.exploits : [];
if (!v.has_exploit && exploits.length === 0) return null;
const chips = exploits.slice(0, 5).map(entry => {
const isStr = typeof entry === 'string';
const label = isStr
? (entry.startsWith('http') ? 'ExploitDB' : entry.substring(0, 28))
: (entry.title || 'Exploit').substring(0, 28);
const href = isStr
? (entry.startsWith('http') ? entry : `https://www.exploit-db.com/exploits/${entry}`)
: (entry.url || `https://www.exploit-db.com/search?cve=${encodeURIComponent(v.vuln_id)}`);
return refChip('\u26A1 ' + label, href, 'chip-exploit');
});
/* fallback generic chip if flag set but no detail yet */
if (chips.length === 0)
chips.push(refChip('\u{1F4A3} ExploitDB', `https://www.exploit-db.com/search?cve=${encodeURIComponent(v.vuln_id)}`, 'chip-exploit'));
return el('div', { class: 'vuln-exploit-chips', onclick: e => e.stopPropagation() }, chips);
}
function refChip(label, href, cls) {
return el('a', { href, target: '_blank', rel: 'noopener noreferrer', class: `vuln-chip ${cls}` }, [label]);
}
/* ════════════════════════════════════════
CVE VIEW — full-card click → modal
═══════════════════════════════════════ */
function renderCVEView() {
const grid = $('#vuln-grid');
if (!grid) return;
empty(grid);
const page = filteredVulns.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
if (!page.length) { grid.appendChild(emptyState('No vulnerabilities found')); return; }
page.forEach((v, i) => {
const exploitChips = buildExploitChips(v);
const card = el('div', {
class: `vuln-card ${v.is_active === 0 ? 'inactive' : ''}`,
style: `animation-delay:${i * 0.03}s;cursor:pointer`,
onclick: (e) => {
if (e.target.closest('a, .vuln-ref-chips, .vuln-exploit-chips')) return;
showCVEDetails(v.vuln_id);
},
}, [
/* header */
el('div', { class: 'vuln-card-header' }, [
el('div', { class: 'vuln-card-title' }, [
el('span', { class: 'vuln-id' }, [v.vuln_id || 'N/A']),
el('span', { class: `severity-badge severity-${v.severity}` }, [v.severity || '?']),
el('span', { class: 'cvss-pill' }, [`CVSS ${parseFloat(v.cvss_score || 0).toFixed(1)}`]),
...(v.is_active === 0 ? [el('span', { class: 'vuln-tag remediated' }, ['REMEDIATED'])] : []),
...(v.is_kev ? [el('span', { class: 'vuln-tag kev', title: 'CISA Known Exploited' }, ['KEV'])] : []),
...(v.epss > 0.1 ? [el('span', { class: 'vuln-tag epss' }, [`EPSS ${(v.epss * 100).toFixed(1)}%`])] : []),
]),
el('span', { style: 'font-size:.72rem;opacity:.35;white-space:nowrap' }, ['\u{1F4CB} click for details']),
]),
/* meta */
el('div', { class: 'vuln-meta' }, [
metaItem('IP', v.ip),
metaItem('Host', v.hostname),
metaItem('Port', v.port),
]),
/* description */
el('div', { style: 'font-size:.83rem;opacity:.7;margin:6px 0 8px;line-height:1.4' }, [
(v.description || '').substring(0, 160) + ((v.description || '').length > 160 ? '\u2026' : ''),
]),
/* ★ reference chips — always visible */
buildRefChips(v.vuln_id),
/* ★ exploit chips — from DB, only if available */
...(exploitChips ? [exploitChips] : []),
]);
grid.appendChild(card);
});
}
/* ════════════════════════════════════════
HOST VIEW
═══════════════════════════════════════ */
function renderHostView() {
const grid = $('#vuln-grid');
if (!grid) return;
empty(grid);
const groups = new Map();
filteredVulns.forEach(v => {
const key = `${v.mac_address}_${v.hostname || 'unknown'}`;
if (!groups.has(key)) groups.set(key, { mac: v.mac_address, hostname: v.hostname, ip: v.ip, vulns: [] });
groups.get(key).vulns.push(v);
});
const hostArr = [...groups.values()];
totalPages = Math.max(1, Math.ceil(hostArr.length / ITEMS_PER_PAGE));
if (currentPage > totalPages) currentPage = 1;
const page = hostArr.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
if (!page.length) { grid.appendChild(emptyState('No hosts found')); return; }
page.forEach((host, i) => {
const hostId = `host-${i + (currentPage - 1) * ITEMS_PER_PAGE}`;
const isExpanded = expandedHosts.has(hostId);
const sevCounts = countSeverities(host.vulns);
const remediated = host.vulns.filter(v => v.is_active === 0).length;
const card = el('div', {
class: `vuln-card host-card ${isExpanded ? 'expanded' : ''}`,
'data-id': hostId,
style: `animation-delay:${i * 0.03}s`,
}, [
el('div', { class: 'vuln-card-header', onclick: () => toggleHostCard(hostId) }, [
el('div', { class: 'vuln-card-title' }, [
el('span', { class: 'vuln-id' }, [host.hostname || host.ip || host.mac || 'Unknown']),
el('span', { class: 'stat-label' }, [`${host.vulns.length} vulns`]),
...(remediated > 0 ? [el('span', { class: 'vuln-tag remediated' }, [`${remediated} FIXED`])] : []),
...(host.vulns.some(v => v.has_exploit) ? [el('span', { class: 'vuln-tag exploit' }, ['\u{1F4A3}'])] : []),
]),
el('div', { class: 'host-severity-pills' }, [
...(sevCounts.critical > 0 ? [sevPill('critical', sevCounts.critical)] : []),
...(sevCounts.high > 0 ? [sevPill('high', sevCounts.high)] : []),
...(sevCounts.medium > 0 ? [sevPill('medium', sevCounts.medium)] : []),
...(sevCounts.low > 0 ? [sevPill('low', sevCounts.low)] : []),
]),
el('span', { class: 'collapse-indicator' }, ['\u25BC']),
]),
el('div', { class: 'vuln-content' }, [
el('div', { class: 'vuln-meta' }, [
metaItem('IP', host.ip),
metaItem('MAC', host.mac),
metaItem('Active', host.vulns.filter(v => v.is_active === 1).length),
metaItem('Max CVSS', Math.max(...host.vulns.map(v => parseFloat(v.cvss_score) || 0)).toFixed(1)),
]),
...sortVulnsByPriority(host.vulns).map(v => {
const exploitChips = buildExploitChips(v);
return el('div', {
class: `host-vuln-item ${v.is_active === 0 ? 'inactive' : ''}`,
style: 'cursor:pointer',
onclick: (e) => {
if (e.target.closest('a, .vuln-ref-chips, .vuln-exploit-chips')) return;
showCVEDetails(v.vuln_id);
},
}, [
el('div', { class: 'host-vuln-info' }, [
el('span', { class: 'vuln-id' }, [v.vuln_id]),
el('span', { class: `severity-badge severity-${v.severity}` }, [v.severity]),
el('span', { class: 'cvss-pill' }, [`CVSS ${parseFloat(v.cvss_score || 0).toFixed(1)}`]),
...(v.is_active === 0 ? [el('span', { class: 'vuln-tag remediated' }, ['REMEDIATED'])] : []),
]),
el('div', { class: 'vuln-meta', style: 'margin:4px 0' }, [
metaItem('Port', v.port),
metaItem('Last', formatDate(v.last_seen)),
]),
el('div', { style: 'font-size:.82rem;opacity:.65;margin-bottom:6px' }, [
(v.description || '').substring(0, 110) + ((v.description || '').length > 110 ? '\u2026' : ''),
]),
buildRefChips(v.vuln_id),
...(exploitChips ? [exploitChips] : []),
]);
}),
]),
]);
grid.appendChild(card);
});
}
/* ════════════════════════════════════════
EXPLOITS VIEW
═══════════════════════════════════════ */
function renderExploitsView() {
const grid = $('#vuln-grid');
if (!grid) return;
empty(grid);
const withExploit = filteredVulns.filter(v => v.has_exploit || (v.exploits && v.exploits.length > 0));
totalPages = Math.max(1, Math.ceil(withExploit.length / ITEMS_PER_PAGE));
if (currentPage > totalPages) currentPage = 1;
const page = withExploit.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
if (!page.length) {
const wrapper = el('div', { style: 'text-align:center;padding:40px' }, [
emptyState('\u{1F4A3} No exploit data yet'),
el('div', { style: 'margin-top:16px' }, [
el('button', { class: 'vuln-btn exploit-btn', onclick: runGlobalExploitSearch },
['\u{1F4A3} Search All Exploits now']),
]),
]);
grid.appendChild(wrapper);
return;
}
page.forEach((v, i) => {
const exploitChips = buildExploitChips(v);
const card = el('div', {
class: `vuln-card exploit-card ${v.is_active === 0 ? 'inactive' : ''}`,
style: `animation-delay:${i * 0.03}s;cursor:pointer`,
onclick: (e) => {
if (e.target.closest('a, .vuln-ref-chips, .vuln-exploit-chips')) return;
showCVEDetails(v.vuln_id);
},
}, [
el('div', { class: 'vuln-card-header' }, [
el('div', { class: 'vuln-card-title' }, [
el('span', { class: 'vuln-tag exploit' }, ['\u{1F4A3}']),
el('span', { class: 'vuln-id' }, [v.vuln_id || 'N/A']),
el('span', { class: `severity-badge severity-${v.severity}` }, [v.severity || '?']),
el('span', { class: 'cvss-pill' }, [`CVSS ${parseFloat(v.cvss_score || 0).toFixed(1)}`]),
...(v.is_kev ? [el('span', { class: 'vuln-tag kev' }, ['KEV'])] : []),
...(v.epss > 0.1 ? [el('span', { class: 'vuln-tag epss' }, [`EPSS ${(v.epss * 100).toFixed(1)}%`])] : []),
]),
el('span', { style: 'font-size:.72rem;opacity:.35' }, ['\u{1F4CB} click for details']),
]),
el('div', { class: 'vuln-meta' }, [metaItem('IP', v.ip), metaItem('Host', v.hostname), metaItem('Port', v.port)]),
el('div', { style: 'font-size:.83rem;opacity:.7;margin:6px 0 8px' }, [
(v.description || '').substring(0, 180) + ((v.description || '').length > 180 ? '\u2026' : ''),
]),
buildRefChips(v.vuln_id),
...(exploitChips ? [exploitChips] : []),
]);
grid.appendChild(card);
});
}
/* ════════════════════════════════════════
HISTORY VIEW
═══════════════════════════════════════ */
async function toggleHistory() {
const btn = $('#vuln-history-btn');
if (historyMode) {
historyMode = false;
if (btn) btn.classList.remove('active');
await fetchVulnerabilities();
return;
}
historyMode = true;
if (btn) btn.classList.add('active');
try {
const data = await api.get('/vulnerabilities/history?limit=500', { timeout: 10000 });
allHistory = data?.history || [];
historyPage = 1; historySearch = '';
renderHistory();
} catch (err) {
console.warn(`[${PAGE}]`, err.message);
}
}
function renderHistory() {
const grid = $('#vuln-grid'); const pagDiv = $('#vuln-pagination');
if (!grid) return;
empty(grid); if (pagDiv) empty(pagDiv);
const needle = historySearch.toLowerCase();
const filtered = allHistory.filter(e => !needle || `${e.vuln_id} ${e.ip} ${e.hostname}`.toLowerCase().includes(needle));
const hTotal = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
if (historyPage > hTotal) historyPage = 1;
grid.appendChild(el('div', { style: 'margin-bottom:12px' }, [
el('input', {
type: 'text', class: 'global-search-input', value: historySearch,
placeholder: 'Filter history\u2026',
oninput: (e) => { historySearch = e.target.value; historyPage = 1; renderHistory(); },
style: 'width:100%;max-width:360px',
}),
]));
if (!filtered.length) { grid.appendChild(emptyState('No history entries')); return; }
filtered.slice((historyPage - 1) * ITEMS_PER_PAGE, historyPage * ITEMS_PER_PAGE).forEach((entry, i) => {
grid.appendChild(el('div', { class: 'vuln-card', style: `animation-delay:${i * 0.02}s` }, [
el('div', { class: 'vuln-card-header' }, [
el('span', { class: 'vuln-id' }, [entry.vuln_id || 'N/A']),
el('span', { class: 'vuln-tag' }, [entry.event || '']),
]),
el('div', { class: 'vuln-meta' }, [
metaItem('Date', entry.seen_at ? new Date(entry.seen_at).toLocaleString() : 'N/A'),
metaItem('IP', entry.ip), metaItem('Host', entry.hostname),
metaItem('Port', entry.port), metaItem('MAC', entry.mac_address),
]),
]));
});
if (pagDiv && hTotal > 1) {
pagDiv.appendChild(pageBtn('Prev', historyPage > 1, () => { historyPage--; renderHistory(); }));
for (let i = Math.max(1, historyPage - 2); i <= Math.min(hTotal, historyPage + 2); i++) {
pagDiv.appendChild(pageBtn(String(i), true, () => { historyPage = i; renderHistory(); }, i === historyPage));
}
pagDiv.appendChild(pageBtn('Next', historyPage < hTotal, () => { historyPage++; renderHistory(); }));
pagDiv.appendChild(el('span', { class: 'vuln-page-info' }, [`Page ${historyPage}/${hTotal}${filtered.length} entries`]));
}
}
/* ════════════════════════════════════════
CVE DETAIL MODAL
═══════════════════════════════════════ */
async function showCVEDetails(cveId) {
if (!cveId || modalInFlight === cveId) return;
modalInFlight = cveId;
if (poller) poller.stop();
const titleEl = $('#vuln-modal-title');
const body = $('#vuln-modal-body');
const modal = $('#vuln-modal');
const chipsEl = $('#vuln-modal-header-chips');
if (!modal) { modalInFlight = null; return; }
if (titleEl) titleEl.textContent = cveId;
/* reference chips in modal header */
if (chipsEl) {
empty(chipsEl);
const enc = encodeURIComponent(cveId);
[
['\u{1F431} GitHub', `https://github.com/search?q=${enc}&type=repositories`, 'chip-github'],
['\u{1F6E1} Rapid7', `https://www.rapid7.com/db/?q=${enc}`, 'chip-rapid7'],
['NVD \u2197', `https://nvd.nist.gov/vuln/detail/${enc}`, 'chip-nvd'],
['MITRE \u2197', `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${enc}`, 'chip-mitre'],
].forEach(([label, href, cls]) => chipsEl.appendChild(refChip(label, href, cls)));
}
if (body) { empty(body); body.appendChild(el('div', { class: 'page-loading' }, ['Loading\u2026'])); }
modal.classList.add('show');
try {
const data = await api.get(`/api/cve/${encodeURIComponent(cveId)}`, { timeout: 10000 });
if (!body) return;
empty(body);
if (data.description) body.appendChild(modalSection('Description', data.description));
if (data.cvss) {
const s = data.cvss;
body.appendChild(modalSection('CVSS',
`Score: ${s.baseScore || 'N/A'} | Severity: ${s.baseSeverity || 'N/A'}` +
(s.vectorString ? ` | Vector: ${s.vectorString}` : '')
));
}
if (data.is_kev) body.appendChild(modalSection('\u26A0 CISA KEV', 'This vulnerability is in the CISA Known Exploited Vulnerabilities catalog.'));
if (data.epss) body.appendChild(modalSection('EPSS',
`Probability: ${(data.epss.probability * 100).toFixed(2)}% | Percentile: ${(data.epss.percentile * 100).toFixed(2)}%`
));
/* Affected */
if (data.affected && data.affected.length > 0) {
const rows = normalizeAffected(data.affected);
body.appendChild(el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, ['Affected Products']),
el('div', { class: 'vuln-affected-table' }, [
el('div', { class: 'vuln-affected-row header' }, [el('span', {}, ['Vendor']), el('span', {}, ['Product']), el('span', {}, ['Versions'])]),
...rows.map(r => el('div', { class: 'vuln-affected-row' }, [el('span', {}, [r.vendor]), el('span', {}, [r.product]), el('span', {}, [r.versions])])),
]),
]));
}
/* Exploits section */
const exploits = data.exploits || [];
const exploitSection = el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, ['\u{1F4A3} Exploits & References']),
/* dynamic entries from DB */
...exploits.map(entry => {
const isStr = typeof entry === 'string';
const label = isStr ? entry : (entry.title || entry.url || 'Exploit');
const href = isStr
? (entry.startsWith('http') ? entry : `https://www.exploit-db.com/exploits/${entry}`)
: (entry.url || '#');
return el('div', { class: 'modal-exploit-item' }, [
refChip('\u26A1 ' + String(label).substring(0, 120), href, 'chip-exploit chip-exploit-detail'),
]);
}),
/* always-present search chips row */
el('div', { class: 'exploit-links-block', style: 'margin-top:10px;display:flex;flex-wrap:wrap;gap:6px' }, [
refChip('\u{1F50D} ExploitDB', `https://www.exploit-db.com/search?cve=${encodeURIComponent(cveId)}`, 'chip-exploit chip-exploitdb'),
refChip('\u{1F431} GitHub PoC', `https://github.com/search?q=${encodeURIComponent(cveId)}&type=repositories`, 'chip-github'),
refChip('\u{1F6E1} Rapid7', `https://www.rapid7.com/db/?q=${encodeURIComponent(cveId)}`, 'chip-rapid7'),
refChip('NVD \u2197', `https://nvd.nist.gov/vuln/detail/${encodeURIComponent(cveId)}`, 'chip-nvd'),
refChip('MITRE \u2197', `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${encodeURIComponent(cveId)}`, 'chip-mitre'),
]),
exploits.length === 0
? el('div', { style: 'opacity:.45;font-size:.8rem;margin-top:6px' }, ['No exploit records in DB yet — use \u201cSearch All Exploits\u201d to enrich.'])
: null,
].filter(Boolean));
body.appendChild(exploitSection);
/* References */
if (data.references && data.references.length > 0) {
body.appendChild(el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, ['References']),
...data.references.map(url => el('div', {}, [
el('a', { href: url, target: '_blank', rel: 'noopener', class: 'vuln-ref-link' }, [url]),
])),
]));
}
if (data.lastModified) body.appendChild(modalSection('Last Modified', formatDate(data.lastModified)));
if (!data.description && !data.cvss && !data.affected) {
body.appendChild(el('div', { style: 'opacity:.6;padding:20px;text-align:center' }, ['No enrichment data available.']));
}
} catch (err) {
if (body) { empty(body); body.appendChild(el('div', { style: 'color:var(--danger);padding:20px' }, [`Failed: ${err.message}`])); }
} finally {
modalInFlight = null;
}
}
function normalizeAffected(affected) {
return affected.map(item => {
const vendor = item.vendor || item.vendor_name || item.vendorName || 'N/A';
let product = item.product || item.product_name || item.productName || 'N/A';
if (Array.isArray(product)) product = product.join(', ');
else if (typeof product === 'object' && product !== null)
product = product.product || product.product_name || product.productName || 'N/A';
let versions = 'unspecified';
if (Array.isArray(item.versions)) {
versions = item.versions.map(ver => {
if (typeof ver === 'string') return ver;
const parts = [ver.version || ver.versionName || ver.version_value || ''];
if (ver.lessThan) parts.push(`< ${ver.lessThan}`);
if (ver.lessThanOrEqual) parts.push(`<= ${ver.lessThanOrEqual}`);
if (ver.status) parts.push(`(${ver.status})`);
return parts.join(' ');
}).join('; ');
} else if (typeof item.versions === 'string') {
versions = item.versions;
}
return { vendor, product: String(product), versions };
});
}
/* ════════════════════════════════════════
SEARCH / FILTER / SORT HANDLERS
═══════════════════════════════════════ */
function onSearch(e) {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
searchTerm = e.target.value; currentPage = 1; filterAndRender();
const b = e.target.nextElementSibling; if (b) b.classList.toggle('show', searchTerm.length > 0);
}, 300);
}
function clearSearch() {
const inp = $('#vuln-search'); if (inp) inp.value = '';
searchTerm = ''; currentPage = 1; filterAndRender();
const b = $('#vuln-search')?.nextElementSibling; if (b) b.classList.remove('show');
}
function switchView(view) {
currentView = view; currentPage = 1;
['cve','host','exploits'].forEach(v => { const b = $(`#vuln-view-${v}`); if (b) b.classList.toggle('active', v === view); });
filterAndRender();
}
function toggleActiveFilter() {
showActiveOnly = !showActiveOnly;
const b = $('#vuln-active-toggle'); if (b) b.classList.toggle('active', showActiveOnly);
currentPage = 1; filterAndRender();
}
function toggleSeverity(sev, btn) {
if (severityFilters.has(sev)) { severityFilters.delete(sev); btn.classList.remove('active'); }
else { severityFilters.add(sev); btn.classList.add('active'); }
currentPage = 1; filterAndRender();
}
function onSortChange() {
const f = $('#vuln-sort-field'); const d = $('#vuln-sort-dir');
if (f) sortField = f.value; if (d) sortDir = d.value;
currentPage = 1; filterAndRender();
}
function onDateChange() {
dateFrom = ($('#vuln-date-from') || {}).value || '';
dateTo = ($('#vuln-date-to') || {}).value || '';
currentPage = 1; filterAndRender();
}
function clearDateFilter() {
dateFrom = ''; dateTo = '';
const f = $('#vuln-date-from'); const t_ = $('#vuln-date-to');
if (f) f.value = ''; if (t_) t_.value = '';
currentPage = 1; filterAndRender();
}
function toggleHostCard(id) {
if (expandedHosts.has(id)) expandedHosts.delete(id); else expandedHosts.add(id);
const card = document.querySelector(`.vuln-card[data-id="${id}"]`);
if (card) card.classList.toggle('expanded');
}
/* ════════════════════════════════════════
PAGINATION
═══════════════════════════════════════ */
function renderPagination() {
const pag = $('#vuln-pagination'); if (!pag) return;
empty(pag);
if (historyMode || totalPages <= 1) return;
pag.appendChild(pageBtn('Prev', currentPage > 1, () => changePage(currentPage - 1)));
for (let i = Math.max(1, currentPage - 2); i <= Math.min(totalPages, currentPage + 2); i++)
pag.appendChild(pageBtn(String(i), true, () => changePage(i), i === currentPage));
pag.appendChild(pageBtn('Next', currentPage < totalPages, () => changePage(currentPage + 1)));
pag.appendChild(el('span', { class: 'vuln-page-info' }, [`Page ${currentPage}/${totalPages}${filteredVulns.length} results`]));
}
function pageBtn(label, enabled, onclick, active = false) {
return el('button', {
class: `vuln-page-btn ${active ? 'active' : ''} ${!enabled ? 'disabled' : ''}`,
onclick: enabled ? onclick : null, disabled: !enabled,
}, [label]);
}
function changePage(p) {
currentPage = Math.max(1, Math.min(totalPages, p)); filterAndRender();
const g = $('#vuln-grid'); if (g) g.scrollTop = 0;
}
/* ════════════════════════════════════════
EXPORT
═══════════════════════════════════════ */
function csvCell(val) {
const s = String(val ?? '');
const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
return safe.includes(',') || safe.includes('"') || safe.includes('\n') ? `"${safe.replace(/"/g, '""')}"` : safe;
}
function exportCSV() {
const data = filteredVulns.length ? filteredVulns : vulnerabilities;
if (!data.length) return;
const rows = [['CVE ID','IP','Hostname','Port','Severity','CVSS','Status','First Seen','Last Seen','KEV','Has Exploit','EPSS'].join(',')];
data.forEach(v => rows.push([
v.vuln_id, v.ip, v.hostname, v.port, v.severity,
v.cvss_score != null ? parseFloat(v.cvss_score).toFixed(1) : '',
v.is_active === 1 ? 'Active' : 'Remediated',
v.first_seen, v.last_seen,
v.is_kev ? 'Yes' : 'No',
v.has_exploit ? 'Yes' : 'No',
v.epss != null ? (v.epss * 100).toFixed(2) + '%' : '',
].map(csvCell).join(',')));
downloadBlob(rows.join('\n'), `vulnerabilities_${isoDate()}.csv`, 'text/csv');
}
function exportJSON() {
const data = filteredVulns.length ? filteredVulns : vulnerabilities;
if (!data.length) return;
downloadBlob(JSON.stringify(data, null, 2), `vulnerabilities_${isoDate()}.json`, 'application/json');
}
function downloadBlob(content, filename, type) {
const url = URL.createObjectURL(new Blob([content], { type }));
const a = document.createElement('a'); a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
}
/* ════════════════════════════════════════
MODAL CLOSE
═══════════════════════════════════════ */
function closeModal() {
const modal = $('#vuln-modal'); if (modal) modal.classList.remove('show');
modalInFlight = null;
if (poller) poller.start(); // resume polling
}
function onModalBackdrop(e) { if (e.target.classList.contains('vuln-modal')) closeModal(); }
/* ════════════════════════════════════════
HELPERS
═══════════════════════════════════════ */
function metaItem(label, value) {
return el('div', { class: 'meta-item' }, [
el('span', { class: 'meta-label' }, [label + ':']),
el('span', { class: 'meta-value' }, [String(value ?? 'N/A')]),
]);
}
function modalSection(title, text) {
return el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, [title]),
el('div', { class: 'modal-section-text' }, [String(text)]),
]);
}
function emptyState(msg) {
return el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:40px' }, [
el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['\u{1F50D}']),
msg,
]);
}
function sevPill(sev, count) {
return el('span', { class: `severity-badge severity-${sev}` }, [`${count} ${sev}`]);
}
function formatDate(d) {
if (!d) return 'Unknown';
try { return new Date(d).toLocaleString('en-US', { year:'numeric', month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }); }
catch { return String(d); }
}
function isoDate() { return new Date().toISOString().split('T')[0]; }
function countSeverities(vulns) {
const c = { critical: 0, high: 0, medium: 0, low: 0 };
vulns.forEach(v => { if (v.is_active === 1 && c[v.severity] !== undefined) c[v.severity]++; });
return c;
}
function sortVulnsByPriority(vulns) {
return [...vulns].sort((a, b) => {
if (a.is_active !== b.is_active) return b.is_active - a.is_active;
return (SEVERITY_ORDER[b.severity] || 0) - (SEVERITY_ORDER[a.severity] || 0);
});
}