feat(security): prioritization-led Overview charts (posture + exploit intel) (#1427)

* feat(security): rework Overview charts around posture and exploit intel

Replace the three severity-variation charts (severity donut, top exposed images,
findings by type) with prioritization views, keeping the risk trend for context:

- Action posture: bars of fixable / known-exploited / needs-review / accepted /
  not-affected with a known-exploited headline, derived from the existing
  overview facts (no new fetch).
- Top exploit-risk findings: ranked actionable Critical/High by KEV, then EPSS,
  then CVSS, each row opening its scan.
- Severity x exploitability: a CVSS-by-EPSS quadrant that separates
  scary-but-not-exploitable from act-first.

The two intel panels are fed by a new bounded GET /security/overview/exploit-intel
(latest-scan Critical/High, suppression-filtered, KEV/EPSS joined at read time)
and degrade to clear empty states until exploit intel is fetched and images are
rescanned.

* fix(security): drop the redundant SECURITY kicker from the desktop masthead

The desktop nav strip already names the page, so the masthead's "SECURITY" label
above the posture word was redundant. Make PageMasthead's kicker optional (pages
that pass one render unchanged) and omit it on the Security masthead. The mobile
masthead keeps its kicker, since on the phone layout it is the page identity and
there is no nav strip.

* docs(security): describe the prioritization-led Overview charts

Update the Security overview docs for the reworked chart set (risk trend, action
posture, top exploit-risk, and the severity-by-exploitability quadrant) and note
the exploit-risk charts populate once exploit intelligence is enabled.

* fix(security): move the scanner-detections note into a masthead info icon

Replace the standing "scanner detections show vulnerable components..." caption
below the masthead (desktop and mobile) with an info affordance next to the
scanned-images count in the masthead subtitle. Declutters the overview while
keeping the disclaimer one hover away.

* fix(security): apply "assume it's automatable" to exploit-risk ranking

Absence of exploitability evidence must not be treated as low risk. Rank the top
exploit-risk list by tier (known-exploited > known-high EPSS > unknown EPSS >
known-low EPSS) so an unrated finding outranks one with evidence of low
likelihood; label unrated findings "EPSS n/a"; and reword the quadrant footnote
so excluded findings read as unrated rather than lower risk.
This commit is contained in:
Anso
2026-06-23 21:55:11 -04:00
committed by GitHub
parent f794702171
commit b1630788ba
10 changed files with 527 additions and 212 deletions
@@ -395,3 +395,49 @@ describe('GET /api/security/vex/export (Admiral)', () => {
expect(stmt).toMatchObject({ status: 'not_affected', justification: 'component_not_present', products: ['nginx*'] });
});
});
describe('GET /api/security/overview/exploit-intel', () => {
beforeEach(() => resetSecurity());
it('returns actionable Crit/High findings with KEV/EPSS joined and dismissed excluded', async () => {
const now = Date.now();
const scanId = db().createVulnerabilityScan({
node_id: 1, image_ref: 'app:1', image_digest: 'sha256:app', scanned_at: now,
total_vulnerabilities: 3, critical_count: 2, high_count: 1, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 2, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
});
const d = (id: string, severity: 'CRITICAL' | 'HIGH', cvss: number | null, fixed: string | null) => ({
vulnerability_id: id, pkg_name: `p-${id}`, installed_version: '1', fixed_version: fixed,
severity, title: null, description: null, primary_url: null, cvss_score: cvss,
});
db().insertVulnerabilityDetails(scanId, [
d('CVE-2024-AAAA', 'CRITICAL', 9.8, '2'), // actionable, has KEV + EPSS
d('CVE-2024-BBBB', 'HIGH', 7.2, null), // actionable, no intel yet
d('CVE-2024-CCCC', 'CRITICAL', 8.1, '3'), // dismissed -> excluded
]);
db().replaceKev([{ cve_id: 'CVE-2024-AAAA', date_added: '2024-01-01' }], now);
db().upsertEpss([{ cve_id: 'CVE-2024-AAAA', epss_score: 0.6, epss_percentile: 0.97 }], now);
db().createCveSuppression({
cve_id: 'CVE-2024-CCCC', pkg_name: null, image_pattern: null, reason: 'accepted',
created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'accepted',
});
const res = await request(app).get('/api/security/overview/exploit-intel').set('Cookie', adminCookie);
expect(res.status).toBe(200);
const items = res.body.items as Array<{ vulnerability_id: string; cvss_score: number | null; epss_score: number | null; kev: boolean; severity: string; scan_id: number }>;
const ids = items.map((i) => i.vulnerability_id);
expect(ids).toContain('CVE-2024-AAAA');
expect(ids).toContain('CVE-2024-BBBB');
expect(ids).not.toContain('CVE-2024-CCCC'); // dismissed triage decision
expect(items.find((i) => i.vulnerability_id === 'CVE-2024-AAAA')).toMatchObject({ cvss_score: 9.8, epss_score: 0.6, kev: true, severity: 'CRITICAL', scan_id: scanId });
expect(items.find((i) => i.vulnerability_id === 'CVE-2024-BBBB')).toMatchObject({ cvss_score: 7.2, epss_score: null, kev: false });
expect(res.body.truncated).toBe(false);
});
it('requires authentication', async () => {
const res = await request(app).get('/api/security/overview/exploit-intel');
expect(res.status).toBe(401);
});
});
+55
View File
@@ -828,6 +828,61 @@ securityRouter.get('/overview/trend', authMiddleware, (req: Request, res: Respon
}
});
// One actionable Critical/High finding for the overview exploit-intel charts.
interface ExploitIntelFinding {
vulnerability_id: string;
image_ref: string;
scan_id: number;
severity: VulnSeverity;
cvss_score: number | null;
epss_score: number | null;
epss_percentile: number | null;
kev: boolean;
fixed_version: string | null;
}
// Node-scoped, auth-only (Community). Returns the latest-scan Critical/High
// findings that are still actionable (dismissed triage decisions filtered out),
// enriched at read time with KEV/EPSS intel. Powers the Top exploit-risk list
// and the CVSS-by-EPSS quadrant on the Security overview. Bounded; `truncated`
// flags a capped node.
securityRouter.get('/overview/exploit-intel', authMiddleware, (req: Request, res: Response): void => {
try {
const db = DatabaseService.getInstance();
const found = db.getLatestCritHighFindingsWithCvssForNode(req.nodeId);
const suppressions = db.getCveSuppressions();
const intel = db.getCveIntel(found.items.map((f) => f.vulnerability_id));
const byImage = new Map<string, typeof found.items>();
for (const f of found.items) {
const group = byImage.get(f.image_ref);
if (group) group.push(f);
else byImage.set(f.image_ref, [f]);
}
const items: ExploitIntelFinding[] = [];
for (const [imageRef, group] of byImage) {
for (const e of applySuppressions(group, imageRef, suppressions)) {
if (e.suppressed) continue; // decided findings are not part of the act-first view
const i = intel.get(e.vulnerability_id);
items.push({
vulnerability_id: e.vulnerability_id,
image_ref: imageRef,
scan_id: e.scan_id,
severity: e.severity,
cvss_score: e.cvss_score,
epss_score: i?.epssScore ?? null,
epss_percentile: i?.epssPercentile ?? null,
kev: i?.kev ?? false,
fixed_version: e.fixed_version,
});
}
}
res.json({ items, truncated: found.truncated });
} catch (error) {
console.error('[Security] Failed to build exploit-intel overview:', error);
res.status(500).json({ error: 'Failed to build exploit-intel overview' });
}
});
// Static, read-only policy-pack catalog. Auth-only (Community), no DB, no
// enforcement. The frontend fetches this with localOnly so the global catalog
// is available regardless of which node is active.
+51
View File
@@ -4775,6 +4775,57 @@ export class DatabaseService {
return { items: truncated ? rows.slice(0, limit) : rows, truncated };
}
/**
* Critical/High findings from the latest completed scan per image, with the
* severity + CVSS the overview's exploit-intel charts need. Same bounded
* shape as getLatestCritHighVulnFindingsForNode (single latest-per-image
* JOIN, capped, `truncated` flagged). Intel (KEV/EPSS) and suppression
* filtering are applied by the caller at read time.
*/
public getLatestCritHighFindingsWithCvssForNode(
nodeId: number,
limit = 2000,
): {
items: Array<{
image_ref: string;
scan_id: number;
vulnerability_id: string;
pkg_name: string;
severity: VulnSeverity;
cvss_score: number | null;
fixed_version: string | null;
}>;
truncated: boolean;
} {
const rows = this.db
.prepare(
`SELECT vs.image_ref, vs.id AS scan_id, vd.vulnerability_id, vd.pkg_name,
vd.severity, vd.cvss_score, vd.fixed_version
FROM vulnerability_details vd
INNER JOIN vulnerability_scans vs ON vs.id = vd.scan_id
INNER JOIN (
SELECT image_ref, MAX(scanned_at) AS max_scanned
FROM vulnerability_scans
WHERE node_id = ? AND status = 'completed'
GROUP BY image_ref
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
WHERE vs.node_id = ? AND vs.status = 'completed'
AND vd.severity IN ('CRITICAL', 'HIGH')
LIMIT ?`,
)
.all(nodeId, nodeId, limit + 1) as Array<{
image_ref: string;
scan_id: number;
vulnerability_id: string;
pkg_name: string;
severity: VulnSeverity;
cvss_score: number | null;
fixed_version: string | null;
}>;
const truncated = rows.length > limit;
return { items: truncated ? rows.slice(0, limit) : rows, truncated };
}
/**
* Distinct CVE ids present in stored findings, for the intel service to fetch
* EPSS only for what exists (EPSS covers CVEs, not GHSA, so filter to CVE-*).
+8 -3
View File
@@ -27,9 +27,14 @@ posture itself: a vulnerable component being present is not the same as a reacha
The masthead carries a standing note to that effect, and posture weighs fix availability, exploit
intelligence, and triage decisions rather than raw severity alone.
Below it, a signal rail summarizes the supporting numbers: scanned images, fixable findings, secrets,
Compose misconfigurations, stale scans, and failed scans. A status strip shows scanner health
(installed source and version, auto-update) and the active node's deploy enforcement posture.
Below it, the charts lead with prioritization rather than raw severity: a **risk trend** for context,
an **action posture** breakdown (fixable, known-exploited, needs-review, accepted, not-affected), a
**top exploit-risk** list ranking actionable findings by known-exploited status then EPSS, and a
**severity-by-exploitability** quadrant that separates high-severity-but-unlikely findings from the
ones to act on first. The exploit-risk charts populate once exploit intelligence is enabled and images
are scanned. A signal rail summarizes the supporting numbers (scanned images, fixable findings,
secrets, Compose misconfigurations, stale scans, failed scans), and a status strip shows scanner health
and the active node's deploy enforcement posture.
If a node does not report an overview (for example an older remote node), the page falls back to a
clear "overview unavailable" state and the other tabs keep working.
+31 -12
View File
@@ -17,7 +17,7 @@ import { useIsMobile } from '@/hooks/use-is-mobile';
import { Masthead, type Tone } from './mobile/mobile-ui';
import { SecurityMobileTabs, type SecurityMobileTab } from './security/SecurityMobile';
import type { SecurityTab } from '@/lib/events';
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, FleetRole } from '@/types/security';
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole } from '@/types/security';
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
import { SuppressionsPanel } from './settings/SuppressionsPanel';
import { MisconfigAckPanel } from './settings/MisconfigAckPanel';
@@ -75,6 +75,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
const [summariesLoading, setSummariesLoading] = useState(true);
const [summariesError, setSummariesError] = useState(false);
const [trend, setTrend] = useState<SecurityRiskTrendPoint[]>([]);
const [exploitIntel, setExploitIntel] = useState<ExploitIntelFinding[]>([]);
const [isReplica, setIsReplica] = useState(false);
// Bumped after a node-wide scan completes to refetch the active node's posture.
const [reloadToken, setReloadToken] = useState(0);
@@ -114,6 +115,12 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
.then((r) => (r.ok ? r.json() : []))
.then((t) => (Array.isArray(t) ? t : []))
.catch(() => []);
// Exploit-intel powers two overview charts; isolate it like the trend so a
// failure (or an older node without the endpoint) degrades to empty panels.
const exploitIntelPromise: Promise<ExploitIntelFinding[]> = apiFetch('/security/overview/exploit-intel')
.then((r) => (r.ok ? r.json() : { items: [] }))
.then((b) => (b && Array.isArray(b.items) ? b.items : []))
.catch(() => []);
try {
const [overviewRes, summariesRes] = await Promise.all([
apiFetch('/security/overview'),
@@ -154,8 +161,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
} finally {
if (!cancelled) setSummariesLoading(false);
}
const trend = await trendPromise;
if (!cancelled) setTrend(trend);
const [trendData, intelData] = await Promise.all([trendPromise, exploitIntelPromise]);
if (!cancelled) {
setTrend(trendData);
setExploitIntel(intelData);
}
})();
return () => { cancelled = true; };
}, [activeNode?.id, reloadToken]);
@@ -202,9 +212,22 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
{ value: 'scanner', label: 'Scanner setup' },
];
const subtitle = overview
? `${overview.scannedImages} ${overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner ${overview.scanner.available ? 'ready' : 'not installed'}`
: undefined;
// The scanner-detections disclaimer rides as an info affordance next to the
// scanned-images count rather than a standing caption below the masthead.
const subtitle = overview ? (
<span className="inline-flex items-center gap-1.5">
<span>
{overview.scannedImages} {overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner {overview.scanner.available ? 'ready' : 'not installed'}
</span>
<span
className="inline-flex shrink-0 cursor-help text-stat-subtitle/70 hover:text-stat-subtitle"
title={SCANNER_DETECTIONS_NOTE}
aria-label={SCANNER_DETECTIONS_NOTE}
>
<Info className="h-3 w-3" strokeWidth={1.5} aria-hidden="true" />
</span>
</span>
) : undefined;
// The tab panels are identical on desktop and mobile; only the masthead and
// the tab strip differ, so the panels are shared between both layouts.
@@ -214,8 +237,8 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
<OverviewTab
overview={overview}
loadError={overviewLoadError}
summaries={summaries}
trend={trend}
exploitIntel={exploitIntel}
onNavigate={onTabChange}
onInspect={onInspect}
canScan={canScan}
@@ -329,12 +352,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
return (
<div className="h-full overflow-auto p-6">
<PageMasthead
kicker="SECURITY"
state={state}
tone={tone}
pulsing={pulsing}
size="hero"
className="rounded-lg mb-2"
className="rounded-lg mb-4"
subtitle={subtitle}
metadata={overview ? [
{ label: 'CRITICAL', value: String(overview.critical), tone: overview.critical > 0 ? 'error' : 'value' },
@@ -342,9 +364,6 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
{ label: 'LAST SCAN', value: overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never', tone: 'subtitle' },
] : undefined}
/>
<p className="mb-4 max-w-3xl font-mono text-[11px] leading-snug text-stat-subtitle">
{SCANNER_DETECTIONS_NOTE}
</p>
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SecurityTab)}>
<TabsList className="mb-4">
@@ -5,14 +5,13 @@ import { cn } from '@/lib/utils';
import { formatTimeAgo } from '@/lib/relativeTime';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile';
import { SCANNER_DETECTIONS_NOTE } from './securityMasthead';
import type { SecurityOverview, ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding } from '@/types/security';
import type { SecurityTab } from '@/lib/events';
import {
SeverityDonutChart,
RiskTrendChart,
TopExposedImagesChart,
FindingsByTypeChart,
ActionPostureChart,
TopExploitRiskList,
CvssEpssQuadrantChart,
} from './SecurityCharts';
import { ScanNodeLauncher } from './ScanNodeLauncher';
@@ -20,8 +19,9 @@ interface OverviewTabProps {
overview: SecurityOverview | null;
/** 'unsupported' = node has no overview endpoint (benign); 'failed' = a real error. */
loadError: 'unsupported' | 'failed' | null;
summaries: Record<string, ScanSummary>;
trend: SecurityRiskTrendPoint[];
/** Actionable Critical/High findings with KEV/EPSS for the exploit-intel charts. */
exploitIntel: ExploitIntelFinding[];
onNavigate: (tab: SecurityTab) => void;
onInspect: (scanId: number) => void;
/** Admin on a node with a ready scanner; enables the node-scan launcher. */
@@ -57,7 +57,7 @@ function ChartCard({ title, className, children }: { title: string; className?:
);
}
export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) {
export function OverviewTab({ overview, loadError, trend, exploitIntel, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) {
const isMobile = useIsMobile();
if (loadError === 'unsupported') {
@@ -93,8 +93,6 @@ export function OverviewTab({ overview, loadError, summaries, trend, onNavigate,
);
}
const summaryList = Object.values(summaries);
const tiles: SignalTile[] = [
{ kicker: 'Scanned images', value: String(overview.scannedImages) },
{ kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value' },
@@ -125,31 +123,27 @@ export function OverviewTab({ overview, loadError, summaries, trend, onNavigate,
)
)}
{/* The masthead hides its stat cluster on a phone; restate it here, framed
as scanner detections rather than posture. */}
{isMobile && (
<div className="space-y-2">
<SecuritySevStrip overview={overview} />
<p className="font-mono text-[10px] leading-snug text-stat-subtitle">{SCANNER_DETECTIONS_NOTE}</p>
</div>
)}
{/* The masthead hides its stat cluster on a phone; restate it here. The
scanner-detections note lives in the masthead's info affordance. */}
{isMobile && <SecuritySevStrip overview={overview} />}
{/* Charts lead the dashboard. */}
{/* Charts lead the dashboard: the trend gives severity context, the rest
answer "what should I act on first?" from posture + exploit intel. */}
<div className="grid gap-4 lg:grid-cols-3">
<ChartCard title="Risk trend · 30 days · critical + high" className="lg:col-span-2">
<RiskTrendChart trend={trend} />
</ChartCard>
<ChartCard title="Severity distribution">
<SeverityDonutChart summaries={summaryList} />
<ChartCard title="Action posture">
<ActionPostureChart overview={overview} />
</ChartCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard title="Top exposed images">
<TopExposedImagesChart summaries={summaryList} onInspect={onInspect} />
<ChartCard title="Top exploit-risk findings">
<TopExploitRiskList items={exploitIntel} onInspect={onInspect} />
</ChartCard>
<ChartCard title="Findings by type">
<FindingsByTypeChart summaries={summaryList} />
<ChartCard title="Severity × exploitability">
<CvssEpssQuadrantChart items={exploitIntel} />
</ChartCard>
</div>
@@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, act, renderHook } from '@testing-library/react';
import { render, act, renderHook, fireEvent } from '@testing-library/react';
import { useTheme } from '@/hooks/use-theme';
// recharts paints nothing at 0x0 in jsdom, so stub every export with a prop-
// capturing element. The real ChartContainer still runs (it injects the
// --color-* vars from SEVERITY_CONFIG), so the token mapping is observable.
// --color-* vars), so the token mapping is observable.
vi.mock('recharts', async () => {
const React = await import('react');
const stub = (tag: string) => (props: Record<string, unknown>) =>
@@ -21,76 +21,136 @@ vi.mock('recharts', async () => {
},
props.children as React.ReactNode,
);
// Explicit named exports (vitest validates named imports against real keys,
// so a Proxy namespace will not do). Covers what SecurityCharts and the shared
// ChartContainer (ResponsiveContainer / Tooltip / Legend) reference.
return {
ResponsiveContainer: stub('ResponsiveContainer'),
Tooltip: stub('Tooltip'),
Legend: stub('Legend'),
PieChart: stub('PieChart'),
Pie: stub('Pie'),
AreaChart: stub('AreaChart'),
Area: stub('Area'),
BarChart: stub('BarChart'),
Bar: stub('Bar'),
Cell: stub('Cell'),
ScatterChart: stub('ScatterChart'),
Scatter: stub('Scatter'),
XAxis: stub('XAxis'),
YAxis: stub('YAxis'),
ZAxis: stub('ZAxis'),
CartesianGrid: stub('CartesianGrid'),
LabelList: stub('LabelList'),
ReferenceLine: stub('ReferenceLine'),
};
});
import { RiskTrendChart, FindingsByTypeChart, SeverityDonutChart } from './SecurityCharts';
import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
import { RiskTrendChart, ActionPostureChart, TopExploitRiskList, CvssEpssQuadrantChart } from './SecurityCharts';
import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding } from '@/types/security';
const TREND: SecurityRiskTrendPoint[] = [
{ date: '2026-06-01', critical: 2, high: 5 },
{ date: '2026-06-02', critical: 1, high: 3 },
];
const SUMMARY: ScanSummary = {
image_ref: 'nginx:1.27',
highest_severity: 'CRITICAL',
scanned_at: 0,
scan_id: 1,
total: 11,
critical: 2, high: 5, medium: 3, low: 1, unknown: 0,
fixable: 3,
secret_count: 1, misconfig_count: 4,
};
function overview(o: Partial<SecurityOverview>): SecurityOverview {
return {
scannedImages: 0, critical: 0, high: 0, fixable: 0, secrets: 0, misconfigs: 0,
staleScans: 0, failedScans: 0, lastSuccessfulScanAt: null,
scanner: { available: true, version: '1', source: 'managed', autoUpdate: false },
deployEnforcement: { honorSuppressionsOnDeploy: false, eligibleBlockPolicies: 0 },
rawCritical: 0, rawHigh: 0, fixableCriticalHigh: 0, knownExploited: 0, publiclyExposed: 0,
dangerousCompose: 0, needsReview: 0, accepted: 0, notAffected: 0, actionable: 0,
posture: 'Secure', posturePartial: false,
...o,
};
}
function configureChart(opts: { chartStyle?: 'muted' | 'heat' | 'signature'; reducedEffects?: boolean; readability?: boolean } = {}) {
function finding(o: Partial<ExploitIntelFinding>): ExploitIntelFinding {
return {
vulnerability_id: 'CVE-0000-0000', image_ref: 'img:1', scan_id: 1, severity: 'HIGH',
cvss_score: null, epss_score: null, epss_percentile: null, kev: false, fixed_version: null,
...o,
};
}
function configureChart(opts: { chartStyle?: 'muted' | 'heat' | 'signature'; reducedEffects?: boolean } = {}) {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setReadability(false);
result.current.setVisualStyle('signature');
if (opts.chartStyle) result.current.setChartStyle(opts.chartStyle);
if (opts.reducedEffects) result.current.setReducedEffects(true);
if (opts.readability) result.current.setReadability(true);
});
}
describe('SecurityCharts palette routing', () => {
describe('ActionPostureChart', () => {
beforeEach(() => configureChart());
it('routes FindingsByType through --sev-* / neutral (no destructive/warning/brand)', () => {
const { container } = render(<FindingsByTypeChart summaries={[SUMMARY]} />);
it('renders the five posture bars from the overview facts', () => {
const { container } = render(
<ActionPostureChart overview={overview({ fixableCriticalHigh: 3, knownExploited: 1, needsReview: 2, accepted: 1, notAffected: 0, rawCritical: 5, rawHigh: 4 })} />,
);
const chart = container.querySelector('[data-rc="BarChart"]');
const data = JSON.parse(chart!.getAttribute('data-chartdata')!) as { fill: string }[];
expect(data.map((d) => d.fill)).toEqual(['var(--sev-vuln)', 'var(--sev-critical)', 'var(--stat-icon)']);
for (const d of data) {
expect(d.fill).not.toMatch(/--(destructive|warning|brand)\)/);
}
const data = JSON.parse(chart!.getAttribute('data-chartdata')!) as { label: string; value: number }[];
expect(data.map((d) => [d.label, d.value])).toEqual([
['Fixable', 3], ['Known exploited', 1], ['Needs review', 2], ['Accepted', 1], ['Not affected', 0],
]);
expect(container.textContent).toContain('known-exploited');
});
it('maps the four donut severities to the --sev-* tokens', () => {
const { container } = render(<SeverityDonutChart summaries={[SUMMARY]} />);
const css = container.querySelector('style')?.textContent ?? '';
expect(css).toContain('--color-critical: var(--sev-critical)');
expect(css).toContain('--color-high: var(--sev-high)');
expect(css).toContain('--color-medium: var(--sev-medium)');
expect(css).toContain('--color-low: var(--sev-low)');
it('shows an empty state with no Critical or High findings', () => {
const { container } = render(<ActionPostureChart overview={overview({})} />);
expect(container.textContent).toContain('No Critical or High findings');
});
});
describe('TopExploitRiskList', () => {
it('ranks KEV > high EPSS > unknown EPSS > low EPSS (assume automatable), and opens the scan', () => {
const items = [
finding({ vulnerability_id: 'CVE-LOW', cvss_score: 5, epss_score: 0.01, scan_id: 10 }),
finding({ vulnerability_id: 'CVE-KEV', cvss_score: 6, kev: true, scan_id: 11 }),
finding({ vulnerability_id: 'CVE-EPSS', cvss_score: 5, epss_score: 0.8, scan_id: 12 }),
finding({ vulnerability_id: 'CVE-UNK', cvss_score: 5, epss_score: null, scan_id: 13 }),
];
const onInspect = vi.fn();
const { container } = render(<TopExploitRiskList items={items} onInspect={onInspect} />);
const buttons = [...container.querySelectorAll('button')];
const order = buttons.map((b) => b.querySelector('.font-mono')?.textContent);
// Unknown-exploitability (CVE-UNK) outranks the evidenced-low one (CVE-LOW).
expect(order).toEqual(['CVE-KEV', 'CVE-EPSS', 'CVE-UNK', 'CVE-LOW']);
fireEvent.click(buttons[0]);
expect(onInspect).toHaveBeenCalledWith(11);
});
it('shows the severity-ranked hint when no intel is present', () => {
const { container } = render(
<TopExploitRiskList items={[finding({ vulnerability_id: 'CVE-A', cvss_score: 8 })]} onInspect={vi.fn()} />,
);
expect(container.textContent).toContain('Enable exploit intelligence');
});
it('shows an empty state with no actionable findings', () => {
const { container } = render(<TopExploitRiskList items={[]} onInspect={vi.fn()} />);
expect(container.textContent).toContain('No actionable');
});
});
describe('CvssEpssQuadrantChart', () => {
beforeEach(() => configureChart());
it('plots only findings with both CVSS and EPSS and notes the excluded ones', () => {
const items = [
finding({ vulnerability_id: 'CVE-1', cvss_score: 9, epss_score: 0.5, kev: true }),
finding({ vulnerability_id: 'CVE-2', cvss_score: 7, epss_score: 0.2 }),
finding({ vulnerability_id: 'CVE-3', cvss_score: 8, epss_score: null }), // excluded
];
const { container } = render(<CvssEpssQuadrantChart items={items} />);
const scatters = [...container.querySelectorAll('[data-rc="Scatter"]')];
const plotted = scatters.flatMap((s) => JSON.parse(s.getAttribute('data-chartdata') ?? '[]') as { cve: string }[]);
expect(plotted.map((p) => p.cve).sort()).toEqual(['CVE-1', 'CVE-2']);
expect(container.textContent).toContain('unrated');
});
it('shows an empty state when no finding has both scores', () => {
const { container } = render(<CvssEpssQuadrantChart items={[finding({ cvss_score: 9, epss_score: null })]} />);
expect(container.textContent).toContain('Enable exploit intelligence');
});
});
@@ -120,18 +180,6 @@ describe('RiskTrendChart gradient vs flat', () => {
}
});
it('uses the heat fill (0.15) with no gradient under Heat', () => {
configureChart({ chartStyle: 'heat' });
const { container } = render(<RiskTrendChart trend={TREND} />);
const areas = [...container.querySelectorAll('[data-rc="Area"]')];
expect(areas).toHaveLength(2);
for (const a of areas) {
expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/);
expect(a.getAttribute('data-fillopacity')).toBe('0.15');
expect(a.getAttribute('data-strokewidth')).toBe('1.9');
}
});
it('dims the fill further and flattens under reduced effects, even in Signature', () => {
configureChart({ chartStyle: 'signature', reducedEffects: true });
const { container } = render(<RiskTrendChart trend={TREND} />);
@@ -139,7 +187,6 @@ describe('RiskTrendChart gradient vs flat', () => {
for (const a of areas) {
expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/);
expect(a.getAttribute('data-strokewidth')).toBe('1.9');
// 0.30 * 0.62
expect(Number(a.getAttribute('data-fillopacity'))).toBeCloseTo(0.186, 5);
}
});
@@ -1,8 +1,11 @@
import { useMemo } from 'react';
import { PieChart, Pie, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, LabelList } from 'recharts';
import {
AreaChart, Area, BarChart, Bar, Cell, ScatterChart, Scatter,
XAxis, YAxis, ZAxis, CartesianGrid, LabelList, ReferenceLine, Tooltip,
} from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { useChartStyle, type ChartStyle } from '@/hooks/use-theme';
import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
import { cn } from '@/lib/utils';
import type { SecurityRiskTrendPoint, SecurityOverview, ExploitIntelFinding } from '@/types/security';
// Severity colours resolve through the --sev-* tokens, which the appearance
// chart-style switches (Signature keeps today's saturated semantics; Muted and
@@ -10,61 +13,23 @@ import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
const SEVERITY_CONFIG = {
critical: { label: 'Critical', color: 'var(--sev-critical)' },
high: { label: 'High', color: 'var(--sev-high)' },
medium: { label: 'Medium', color: 'var(--sev-medium)' },
low: { label: 'Low', color: 'var(--sev-low)' },
} satisfies ChartConfig;
// Area fill opacity, gradient on/off, and stroke per chart-style. Colours stay in
// the --sev-* tokens; only these shape values vary. Reduced effects flattens
// (no gradient) and dims the fill, matching the calm material direction.
// Area fill opacity, gradient on/off, and stroke per chart-style.
const TREND_SHAPE: Record<ChartStyle, { fill: number; gradient: boolean; stroke: number }> = {
signature: { fill: 0.30, gradient: true, stroke: 1.5 },
muted: { fill: 0.16, gradient: false, stroke: 1.9 },
heat: { fill: 0.15, gradient: false, stroke: 1.9 },
};
// The trend and top-exposed charts both plot only the Critical + High slots.
const CRITICAL_HIGH_CONFIG = {
critical: SEVERITY_CONFIG.critical,
high: SEVERITY_CONFIG.high,
} satisfies ChartConfig;
function EmptyChart({ label, height }: { label: string; height: number }) {
return (
<div className="flex items-center justify-center text-xs text-stat-subtitle" style={{ height }}>
<div className="flex items-center justify-center text-center text-xs text-stat-subtitle px-4" style={{ height }}>
{label}
</div>
);
}
/** Donut of total findings by severity across the node's scanned images. */
export function SeverityDonutChart({ summaries }: { summaries: ScanSummary[] }) {
const data = useMemo(() => {
const totals = { critical: 0, high: 0, medium: 0, low: 0 };
for (const s of summaries) {
totals.critical += s.critical;
totals.high += s.high;
totals.medium += s.medium;
totals.low += s.low;
}
return (['critical', 'high', 'medium', 'low'] as const)
.map((k) => ({ key: k, label: SEVERITY_CONFIG[k].label, value: totals[k], fill: `var(--color-${k})` }))
.filter((d) => d.value > 0);
}, [summaries]);
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) return <EmptyChart label="No findings to chart" height={220} />;
return (
<ChartContainer config={SEVERITY_CONFIG} className="h-[220px] w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
<Pie data={data} dataKey="value" nameKey="label" innerRadius={55} outerRadius={85} strokeWidth={2} paddingAngle={2} />
</PieChart>
</ChartContainer>
);
}
/** Stacked area of Critical + High findings by scan-day (days with no scans are omitted). */
export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) {
const { chartStyle, reduced } = useChartStyle();
@@ -73,14 +38,12 @@ export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) {
const fmtDate = (d: string) => d.slice(5); // MM-DD
const shape = TREND_SHAPE[chartStyle];
// Signature (gradient, stroke 1.5) is the no-op baseline. Flat styles and
// reduced effects drop the gradient for a solid low-opacity fill + thicker line.
const gradient = shape.gradient && !reduced;
const fillOpacity = reduced ? shape.fill * 0.62 : shape.fill;
const stroke = reduced ? 1.9 : shape.stroke;
return (
<ChartContainer config={CRITICAL_HIGH_CONFIG} className="h-[220px] w-full">
<ChartContainer config={SEVERITY_CONFIG} className="h-[220px] w-full">
<AreaChart data={trend} margin={{ left: 4, right: 8, top: 8 }}>
{gradient && (
<defs>
@@ -119,91 +82,203 @@ export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) {
);
}
interface TopImageDatum { name: string; critical: number; high: number; scanId: number }
// Action-posture bars. These are independent counts (a finding can be both
// fixable and known-exploited), so they are bars, not a part-of-whole donut.
const POSTURE_CONFIG = { value: { label: 'Findings' } } satisfies ChartConfig;
/** Horizontal stacked bars of the top images by Critical+High; click opens the scan. */
export function TopExposedImagesChart({
summaries,
/** Horizontal bars of the actionability facts, from the overview posture. */
export function ActionPostureChart({ overview }: { overview: SecurityOverview }) {
const data = [
{ label: 'Fixable', value: overview.fixableCriticalHigh ?? 0, fill: 'var(--sev-high)' },
{ label: 'Known exploited', value: overview.knownExploited ?? 0, fill: 'var(--sev-critical)' },
{ label: 'Needs review', value: overview.needsReview ?? 0, fill: 'var(--sev-medium)' },
{ label: 'Accepted', value: overview.accepted ?? 0, fill: 'var(--stat-icon)' },
{ label: 'Not affected', value: overview.notAffected ?? 0, fill: 'var(--sev-low)' },
];
const known = overview.knownExploited ?? 0;
const denom = (overview.rawCritical ?? 0) + (overview.rawHigh ?? 0);
const total = data.reduce((sum, d) => sum + d.value, 0);
if (denom === 0 && total === 0) return <EmptyChart label="No Critical or High findings" height={220} />;
return (
<div>
<p className="mb-2 text-xs text-stat-subtitle">
<span className={cn('font-mono tabular-nums', known > 0 ? 'text-destructive' : 'text-stat-value')}>{known}</span>
{' of '}
<span className="font-mono tabular-nums text-stat-value">{denom}</span>
{' Critical+High '}{known === 1 ? 'is' : 'are'} known-exploited.
</p>
<ChartContainer config={POSTURE_CONFIG} className="h-[188px] w-full">
<BarChart data={data} layout="vertical" margin={{ left: 8, right: 28, top: 4 }}>
<XAxis type="number" hide allowDecimals={false} />
<YAxis type="category" dataKey="label" width={104} tickLine={false} axisLine={false} fontSize={10} />
<Bar dataKey="value" radius={[0, 2, 2, 0]} maxBarSize={20}>
{data.map((d) => (<Cell key={d.label} fill={d.fill} />))}
<LabelList dataKey="value" position="right" className="fill-stat-subtitle" fontSize={10} />
</Bar>
</BarChart>
</ChartContainer>
</div>
);
}
// EPSS at or above this is treated as an elevated exploitation likelihood.
const HIGH_EPSS = 0.1;
// Rank by exploitation risk under the "assume it's automatable" principle
// (CISA BOD 26-04): absence of EPSS evidence is NOT treated as low risk. Tiers:
// known-exploited (KEV) > known-elevated EPSS > unknown EPSS > known-low EPSS.
// A finding we have no exploitability evidence for outranks one we have
// evidence is unlikely. CVSS is only a within-tier tiebreaker.
function exploitTier(f: ExploitIntelFinding): number {
if (f.kev) return 0;
if (f.epss_score === null) return 2; // unknown: assume potentially automatable
if (f.epss_score >= HIGH_EPSS) return 1;
return 3; // evidence of low likelihood
}
function exploitRank(a: ExploitIntelFinding, b: ExploitIntelFinding): number {
const ta = exploitTier(a);
const tb = exploitTier(b);
if (ta !== tb) return ta - tb;
const ae = a.epss_score ?? -1;
const be = b.epss_score ?? -1;
if (ae !== be) return be - ae;
return (b.cvss_score ?? -1) - (a.cvss_score ?? -1);
}
function shortImage(ref: string): string {
return ref.length > 30 ? `${ref.slice(-29)}` : ref;
}
/** Ranked list of the highest exploit-risk actionable findings; row opens the scan. */
export function TopExploitRiskList({
items,
onInspect,
}: {
summaries: ScanSummary[];
items: ExploitIntelFinding[];
onInspect: (scanId: number) => void;
}) {
const data: TopImageDatum[] = useMemo(
() =>
summaries
.filter((s) => !s.image_ref.startsWith('stack:') && s.critical + s.high > 0)
.sort((a, b) => b.critical + b.high - (a.critical + a.high))
.slice(0, 6)
.map((s) => ({
name: s.image_ref.length > 28 ? `${s.image_ref.slice(-27)}` : s.image_ref,
critical: s.critical,
high: s.high,
scanId: s.scan_id,
})),
[summaries],
);
if (data.length === 0) return <EmptyChart label="No exposed images" height={220} />;
const handleBarClick = (d: unknown) => {
const dd = d as TopImageDatum;
if (dd?.scanId != null) onInspect(dd.scanId);
};
if (items.length === 0) return <EmptyChart label="No actionable Critical or High findings" height={220} />;
const ranked = [...items].sort(exploitRank).slice(0, 8);
const anyIntel = items.some((i) => i.epss_score !== null || i.kev);
return (
<ChartContainer config={CRITICAL_HIGH_CONFIG} className="h-[220px] w-full">
<BarChart data={data} layout="vertical" margin={{ left: 8, right: 16 }}>
<XAxis type="number" hide allowDecimals={false} />
<YAxis type="category" dataKey="name" width={150} tickLine={false} axisLine={false} fontSize={10} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="critical" stackId="r" fill="var(--color-critical)" radius={[2, 0, 0, 2]} className="cursor-pointer" onClick={handleBarClick} />
<Bar dataKey="high" stackId="r" fill="var(--color-high)" radius={[0, 2, 2, 0]} className="cursor-pointer" onClick={handleBarClick} />
</BarChart>
</ChartContainer>
<div className="flex flex-col">
<div className="min-h-[220px]">
{ranked.map((f) => (
<button
key={`${f.scan_id}:${f.vulnerability_id}`}
type="button"
onClick={() => onInspect(f.scan_id)}
className="flex w-full items-center gap-2 border-b border-hairline py-2 text-left last:border-b-0 hover:bg-glass-highlight"
>
<span
className="h-[7px] w-[7px] shrink-0 rounded-full"
style={{ background: f.severity === 'CRITICAL' ? 'var(--sev-critical)' : 'var(--sev-high)' }}
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-xs text-stat-value">{f.vulnerability_id}</span>
<span className="block truncate font-mono text-[10px] text-stat-icon">{shortImage(f.image_ref)}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
{f.kev && (
<span className="rounded border border-destructive/40 bg-destructive/10 px-1 py-px text-[9px] font-mono uppercase text-destructive">KEV</span>
)}
{f.epss_score !== null && (
<span className="font-mono text-[10px] tabular-nums text-warning">{Math.round(f.epss_score * 100)}%</span>
)}
{!f.kev && f.epss_score === null && (
<span
className="font-mono text-[10px] tabular-nums text-stat-subtitle/70"
title="Exploitability unrated; treated as potentially automatable"
>
EPSS n/a
</span>
)}
{f.cvss_score !== null && (
<span className="font-mono text-[10px] tabular-nums text-stat-subtitle">CVSS {f.cvss_score}</span>
)}
</span>
</button>
))}
</div>
{!anyIntel && (
<p className="pt-2 text-[10px] leading-snug text-stat-subtitle">
Ranked by severity. Enable exploit intelligence and re-scan to rank by known-exploited and EPSS.
</p>
)}
</div>
);
}
/** Vertical bars comparing the three finding types. */
export function FindingsByTypeChart({ summaries }: { summaries: ScanSummary[] }) {
const data = useMemo(() => {
let vulnerabilities = 0;
let secrets = 0;
let misconfigs = 0;
for (const s of summaries) {
vulnerabilities += s.total;
secrets += s.secret_count;
misconfigs += s.misconfig_count;
}
// Route every series through the severity ramp (or a neutral for misconfigs)
// so no two complementary hues sit adjacent (the old cyan-next-to-rose clash).
// --stat-icon is palette-invariant by design: misconfigs stay neutral across
// Muted/Heat rather than picking up a severity hue.
return [
{ type: 'Vulnerabilities', value: vulnerabilities, fill: 'var(--sev-vuln)' },
{ type: 'Secrets', value: secrets, fill: 'var(--sev-critical)' },
{ type: 'Misconfigs', value: misconfigs, fill: 'var(--stat-icon)' },
];
}, [summaries]);
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) return <EmptyChart label="No findings to chart" height={220} />;
const config = {
value: { label: 'Findings' },
} satisfies ChartConfig;
interface QuadrantPoint { epssPct: number; cvss: number; cve: string; kev: boolean; image: string }
function QuadrantTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: QuadrantPoint }> }) {
if (!active || !payload || payload.length === 0) return null;
const p = payload[0].payload;
return (
<ChartContainer config={config} className="h-[220px] w-full">
<BarChart data={data} margin={{ left: 4, right: 8, top: 16 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="type" tickLine={false} axisLine={false} fontSize={10} />
<YAxis tickLine={false} axisLine={false} fontSize={10} width={28} allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} maxBarSize={64}>
<LabelList dataKey="value" position="top" className="fill-stat-subtitle" fontSize={10} />
</Bar>
</BarChart>
</ChartContainer>
<div className="rounded-md border border-card-border bg-card px-2 py-1.5 text-xs shadow-card-bevel">
<div className="font-mono">{p.cve}{p.kev && <span className="ml-1.5 text-destructive">KEV</span>}</div>
<div className="text-stat-subtitle">CVSS {p.cvss} · EPSS {Math.round(p.epssPct)}%</div>
<div className="max-w-[220px] truncate text-stat-subtitle">{p.image}</div>
</div>
);
}
const QUADRANT_CONFIG = { cvss: { label: 'CVSS' } } satisfies ChartConfig;
/** Scatter of CVSS (severity) by EPSS (exploitability) for actionable findings.
* Separates "scary but not exploitable" (high CVSS, low EPSS) from "act first"
* (high both). Only findings with both scores can be plotted. */
export function CvssEpssQuadrantChart({ items }: { items: ExploitIntelFinding[] }) {
const plotted: QuadrantPoint[] = items
.filter((i) => i.cvss_score !== null && i.epss_score !== null)
.slice(0, 300)
.map((i) => ({
epssPct: (i.epss_score as number) * 100,
cvss: i.cvss_score as number,
cve: i.vulnerability_id,
kev: i.kev,
image: i.image_ref,
}));
if (plotted.length === 0) {
return <EmptyChart label="Enable exploit intelligence and re-scan to populate" height={220} />;
}
const missing = items.length - plotted.length;
const kevPoints = plotted.filter((p) => p.kev);
const otherPoints = plotted.filter((p) => !p.kev);
return (
<div>
<ChartContainer config={QUADRANT_CONFIG} className="h-[220px] w-full">
<ScatterChart margin={{ left: 0, right: 12, top: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
type="number" dataKey="epssPct" name="EPSS" unit="%" domain={[0, 100]}
tickLine={false} axisLine={false} fontSize={10}
/>
<YAxis
type="number" dataKey="cvss" name="CVSS" domain={[0, 10]}
tickLine={false} axisLine={false} fontSize={10} width={28}
/>
<ZAxis range={[40, 40]} />
<ReferenceLine x={10} stroke="var(--border)" strokeDasharray="4 4" />
<ReferenceLine y={7} stroke="var(--border)" strokeDasharray="4 4" />
<Tooltip cursor={{ strokeDasharray: '3 3' }} content={<QuadrantTooltip />} />
<Scatter data={otherPoints} fill="var(--sev-high)" fillOpacity={0.7} />
<Scatter data={kevPoints} fill="var(--sev-critical)" fillOpacity={0.9} />
</ScatterChart>
</ChartContainer>
{missing > 0 && (
<p className="pt-2 text-[10px] leading-snug text-stat-subtitle">
{missing} finding{missing === 1 ? '' : 's'} unrated (missing CVSS or EPSS), not lower risk.
</p>
)}
</div>
);
}
+7 -4
View File
@@ -10,7 +10,8 @@ export interface MastheadMetadataItem {
}
export interface PageMastheadProps {
kicker: string;
/** Small uppercase label above the state word. Omit to show only the state. */
kicker?: string;
state: string;
tone: MastheadTone;
pulsing?: boolean;
@@ -95,9 +96,11 @@ export function PageMasthead({
)}
/>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
{kicker}
</span>
{kicker ? (
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
{kicker}
</span>
) : null}
<span
className={cn(
'font-heading tracking-[-0.01em]',
+20
View File
@@ -274,3 +274,23 @@ export interface SecurityRiskTrendPoint {
critical: number;
high: number;
}
/** One actionable Critical/High finding for the overview exploit-intel charts
* (Top exploit-risk list + CVSS-by-EPSS quadrant). Intel fields are null until
* CveIntelService has fetched; cvss_score is null on pre-enrichment scans. */
export interface ExploitIntelFinding {
vulnerability_id: string;
image_ref: string;
scan_id: number;
severity: VulnSeverity;
cvss_score: number | null;
epss_score: number | null;
epss_percentile: number | null;
kev: boolean;
fixed_version: string | null;
}
export interface ExploitIntelOverview {
items: ExploitIntelFinding[];
truncated: boolean;
}