import { useMemo, useState } from 'react'; import { AreaChart, Area, BarChart, Bar, Cell, ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Label, LabelList, ReferenceLine, Tooltip, } from 'recharts'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'; import { Button } from '@/components/ui/button'; import { useChartStyle, type ChartStyle } from '@/hooks/use-theme'; import { cn } from '@/lib/utils'; import { TooltipProvider, Tooltip as RadixTooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; 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 // Heat are calmer ramps). ChartContainer injects them as --color-* for recharts. const SEVERITY_CONFIG = { critical: { label: 'Critical', color: 'var(--sev-critical)' }, high: { label: 'High', color: 'var(--sev-high)' }, } satisfies ChartConfig; // Area fill opacity, gradient on/off, and stroke per chart-style. const TREND_SHAPE: Record = { 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 }, }; function EmptyChart({ label, height }: { label: string; height: number }) { return (
{label}
); } /** 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(); if (trend.length === 0) return ; const fmtDate = (d: string) => d.slice(5); // MM-DD const shape = TREND_SHAPE[chartStyle]; const gradient = shape.gradient && !reduced; const fillOpacity = reduced ? shape.fill * 0.62 : shape.fill; const stroke = reduced ? 1.9 : shape.stroke; return ( {gradient && ( )} } /> ); } // 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 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 ; return (

0 ? 'text-destructive' : 'text-stat-value')}>{known} {' of '} {denom} {' Critical+High '}{known === 1 ? 'is' : 'are'} known-exploited.

{data.map((d) => ())}
); } // 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; } const EXPLOIT_PAGE_SIZE = 8; // Header and body rows share this template so columns stay aligned. `max-md:min-w` // keeps the table from crushing its columns below md, where the card scrolls // horizontally instead; desktop is untouched by the `max-md:` prefix. const EXPLOIT_GRID = 'grid-cols-[10px_minmax(0,1.4fr)_minmax(0,1fr)_56px_52px] max-md:min-w-[480px]'; // Per-severity dot color. A KEV finding can now be any severity, so the dot must // reflect the row's real severity rather than collapsing everything non-Critical // to the High color. UNKNOWN gets the neutral subtitle tone (matching SeverityChip), // not the low color, so an UNKNOWN-severity finding is not understated as low risk. const SEV_DOT_VAR: Record = { CRITICAL: 'var(--sev-critical)', HIGH: 'var(--sev-high)', MEDIUM: 'var(--sev-medium)', LOW: 'var(--sev-low)', UNKNOWN: 'var(--stat-subtitle)', }; /** Ranked, paginated table of the highest exploit-risk actionable findings; a row opens the scan. * Renders its own card chrome (header + pagination + column headers) so the Overview reads as a * table, mirroring the dashboard Stack-health table. */ export function TopExploitRiskList({ items, truncated = false, onInspect, }: { items: ExploitIntelFinding[]; /** The backend capped the result; the highest-risk findings are shown, not all. */ truncated?: boolean; onInspect: (scanId: number) => void; }) { const [page, setPage] = useState(0); const ranked = useMemo(() => [...items].sort(exploitRank), [items]); const anyIntel = items.some((i) => i.epss_score !== null || i.kev); const totalPages = Math.max(1, Math.ceil(ranked.length / EXPLOIT_PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); const pageItems = ranked.slice(safePage * EXPLOIT_PAGE_SIZE, (safePage + 1) * EXPLOIT_PAGE_SIZE); const needsPagination = ranked.length > EXPLOIT_PAGE_SIZE; return (

Top exploit-risk findings

{needsPagination && (
{safePage + 1} / {totalPages}
)}
{ranked.length === 0 ? (
No actionable Critical or High findings
) : ( <>
CVE Image EPSS CVSS
    {pageItems.map((f, i) => ( // Key by absolute rank position: the same CVE can recur across // packages/images with an identical scan_id + vulnerability_id, so // those fields are not unique. Position in the sorted list is.
  • onInspect(f.scan_id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onInspect(f.scan_id); } }} className={`grid ${EXPLOIT_GRID} cursor-pointer items-center gap-2 px-4 py-2 transition-colors hover:bg-glass-highlight`} > {f.vulnerability_id} {f.kev && ( KEV )} {shortImage(f.image_ref)} {f.epss_score !== null ? ( {Math.round(f.epss_score * 100)}% ) : ( n/a Exploitability unrated; treated as potentially automatable )} {f.cvss_score !== null ? f.cvss_score : '-'}
  • ))}
{truncated && (

Showing the highest-risk findings; more exist than can be listed here.

)} {!anyIntel && (

Ranked by severity. Enable exploit intelligence and re-scan to rank by known-exploited and EPSS.

)} )}
); } 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 (
{p.cve}{p.kev && KEV}
CVSS {p.cvss} · EPSS {Math.round(p.epssPct)}%
{p.image}
); } 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 ; } const missing = items.length - plotted.length; const kevPoints = plotted.filter((p) => p.kev); const otherPoints = plotted.filter((p) => !p.kev); return ( // Fixed height, not flex-fill: a flex/grid-stretched ResponsiveContainer // re-measures a content-driven height and grows on every render. The parent // grid (OverviewTab) uses items-start so this card does not stretch to a // taller neighbour, which is what previously left dead space under the chart.
{/* cursor=false: the default scatter cursor is a full-plot rectangle that reads as selecting the whole chart. Points still hover/tooltip. */} } /> {missing > 0 && (

{missing} finding{missing === 1 ? '' : 's'} unrated (missing CVSS or EPSS), not lower risk.

)}
); }