feat(reporting): add CVEs column from vulnerabilities.vulnInfoList

- Backend extracts cves[] array from f.vulnerabilities.vulnInfoList[].cve
- Frontend shows up to 2 CVE badges (purple) with "+N more" overflow tooltip
- Filter is multi-value aware: selecting a CVE matches any finding containing it
- FilterDropdown expands multi-value arrays into individual checkbox options
- Sort by CVE count (number of associated CVEs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 13:17:01 -06:00
parent 1f36d302ea
commit 3fd6158eb3
2 changed files with 56 additions and 15 deletions

View File

@@ -127,6 +127,9 @@ function extractFinding(f) {
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"] // BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || ''; const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
// CVE list: vulnerabilities.vulnInfoList[].cve
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
return { return {
id: String(f.id), id: String(f.id),
title: f.title || '', title: f.title || '',
@@ -139,7 +142,8 @@ function extractFinding(f) {
slaStatus: f.slaStatus || '', slaStatus: f.slaStatus || '',
dueDate, dueDate,
lastFoundOn: f.lastFoundOn || '', lastFoundOn: f.lastFoundOn || '',
buOwnership buOwnership,
cves
}; };
} }

View File

@@ -9,21 +9,23 @@ const STORAGE_KEY = 'steam_findings_columns_v1';
// Column definitions — source of truth for labels, sort behaviour, rendering // Column definitions — source of truth for labels, sort behaviour, rendering
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const COLUMN_DEFS = { const COLUMN_DEFS = {
severity: { label: 'Severity', sortable: true, filterable: true }, severity: { label: 'Severity', sortable: true, filterable: true },
title: { label: 'Title', sortable: true, filterable: true }, title: { label: 'Title', sortable: true, filterable: true },
hostName: { label: 'Host', sortable: true, filterable: true }, cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true },
ipAddress: { label: 'IP Address', sortable: true, filterable: true }, hostName: { label: 'Host', sortable: true, filterable: true },
dns: { label: 'DNS', sortable: true, filterable: true }, ipAddress: { label: 'IP Address', sortable: true, filterable: true },
dueDate: { label: 'Due Date', sortable: true, filterable: true }, dns: { label: 'DNS', sortable: true, filterable: true },
slaStatus: { label: 'SLA', sortable: true, filterable: true }, dueDate: { label: 'Due Date', sortable: true, filterable: true },
buOwnership: { label: 'BU', sortable: true, filterable: true }, slaStatus: { label: 'SLA', sortable: true, filterable: true },
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true }, buOwnership: { label: 'BU', sortable: true, filterable: true },
note: { label: 'Notes', sortable: false, filterable: false }, lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
note: { label: 'Notes', sortable: false, filterable: false },
}; };
const DEFAULT_COLUMN_ORDER = [ const DEFAULT_COLUMN_ORDER = [
{ key: 'severity', visible: true }, { key: 'severity', visible: true },
{ key: 'title', visible: true }, { key: 'title', visible: true },
{ key: 'cves', visible: true },
{ key: 'hostName', visible: true }, { key: 'hostName', visible: true },
{ key: 'ipAddress', visible: true }, { key: 'ipAddress', visible: true },
{ key: 'dns', visible: true }, { key: 'dns', visible: true },
@@ -68,6 +70,7 @@ function getVal(finding, key) {
case 'dns': return finding.dns ?? ''; case 'dns': return finding.dns ?? '';
case 'dueDate': return finding.dueDate ?? ''; case 'dueDate': return finding.dueDate ?? '';
case 'slaStatus': return finding.slaStatus ?? ''; case 'slaStatus': return finding.slaStatus ?? '';
case 'cves': return (finding.cves || []).length; // sort by CVE count
case 'buOwnership': return finding.buOwnership ?? ''; case 'buOwnership': return finding.buOwnership ?? '';
case 'lastFoundOn': return finding.lastFoundOn ?? ''; case 'lastFoundOn': return finding.lastFoundOn ?? '';
case 'note': return finding.note ?? ''; case 'note': return finding.note ?? '';
@@ -76,10 +79,11 @@ function getVal(finding, key) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Filter accessor — severity filters by vrrGroup label, not numeric value // Filter accessor — severity vrrGroup label; cves handled as multi-value
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getFilterVal(finding, key) { function getFilterVal(finding, key) {
if (key === 'severity') return finding.vrrGroup || ''; if (key === 'severity') return finding.vrrGroup || '';
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
return String(getVal(finding, key) ?? ''); return String(getVal(finding, key) ?? '');
} }
@@ -327,12 +331,18 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
}, [onClose]); }, [onClose]);
// Unique values from the full (unfiltered) findings list // Unique values from the full (unfiltered) findings list.
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
const allValues = useMemo(() => { const allValues = useMemo(() => {
const def = COLUMN_DEFS[colKey];
const vals = new Set(); const vals = new Set();
findings.forEach((f) => { findings.forEach((f) => {
const v = getFilterVal(f, colKey).trim(); if (def?.multiValue) {
if (v) vals.add(v); (f[colKey] || []).forEach((v) => { if (String(v).trim()) vals.add(String(v).trim()); });
} else {
const v = getFilterVal(f, colKey).trim();
if (v) vals.add(v);
}
}); });
return [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); return [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}, [findings, colKey]); }, [findings, colKey]);
@@ -458,6 +468,28 @@ function TableCell({ colKey, finding }) {
</span> </span>
</td> </td>
); );
case 'cves': {
const cves = finding.cves || [];
if (cves.length === 0) return <td style={{ padding: '0.45rem 0.75rem', color: '#475569' }}></td>;
const shown = cves.slice(0, 2);
const rest = cves.length - shown.length;
return (
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
{shown.map((cve) => (
<span key={cve} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}>
{cve}
</span>
))}
{rest > 0 && (
<span title={cves.slice(2).join('\n')} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(100,116,139,0.12)', border: '1px solid rgba(100,116,139,0.25)', color: '#64748B', fontFamily: 'monospace', fontSize: '0.65rem', cursor: 'help', whiteSpace: 'nowrap' }}>
+{rest} more
</span>
)}
</div>
</td>
);
}
case 'hostName': case 'hostName':
case 'ipAddress': case 'ipAddress':
return ( return (
@@ -606,6 +638,11 @@ export default function ReportingPage() {
return findings.filter((f) => return findings.filter((f) =>
active.every(([key, vals]) => { active.every(([key, vals]) => {
if (!vals || vals.size === 0) return false; if (!vals || vals.size === 0) return false;
const def = COLUMN_DEFS[key];
if (def?.multiValue) {
// Row matches if ANY of its values is in the selected set
return (f[key] || []).some((v) => vals.has(String(v).trim()));
}
return vals.has(getFilterVal(f, key).trim()); return vals.has(getFilterVal(f, key).trim());
}) })
); );