Files
sencho/frontend/src/components/security/SecurityCharts.tsx
T
SaelixCode 5a18be8667 fix: replace native title tooltips with Radix tooltips on Security page
Converted scanner info icon, exploitability n/a badge, and scan
image button from native HTML title attributes to the shared Radix
Tooltip component.
2026-07-03 19:35:13 -04:00

374 lines
18 KiB
TypeScript

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<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 },
};
function EmptyChart({ label, height }: { label: string; height: number }) {
return (
<div className="flex items-center justify-center text-center text-xs text-stat-subtitle px-4" style={{ height }}>
{label}
</div>
);
}
/** 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 <EmptyChart label="No scan history yet" height={220} />;
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 (
<ChartContainer config={SEVERITY_CONFIG} className="h-[220px] w-full">
<AreaChart data={trend} margin={{ left: 4, right: 8, top: 8 }}>
{gradient && (
<defs>
<linearGradient id="riskHigh" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-high)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-high)" stopOpacity={0.02} />
</linearGradient>
<linearGradient id="riskCritical" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-critical)" stopOpacity={0.4} />
<stop offset="95%" stopColor="var(--color-critical)" stopOpacity={0.02} />
</linearGradient>
</defs>
)}
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" tickFormatter={fmtDate} tickLine={false} axisLine={false} fontSize={10} minTickGap={24} />
<YAxis tickLine={false} axisLine={false} fontSize={10} width={28} allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
dataKey="high"
stackId="risk"
stroke="var(--color-high)"
fill={gradient ? 'url(#riskHigh)' : 'var(--color-high)'}
fillOpacity={gradient ? undefined : fillOpacity}
strokeWidth={stroke}
/>
<Area
dataKey="critical"
stackId="risk"
stroke="var(--color-critical)"
fill={gradient ? 'url(#riskCritical)' : 'var(--color-critical)'}
fillOpacity={gradient ? undefined : fillOpacity}
strokeWidth={stroke}
/>
</AreaChart>
</ChartContainer>
);
}
// 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 <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;
}
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<string, string> = {
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 (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel max-md:overflow-x-auto">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Top exploit-risk findings</h3>
{needsPagination && (
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage === 0} onClick={() => setPage(safePage - 1)} aria-label="Previous page">
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">{safePage + 1} / {totalPages}</span>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage >= totalPages - 1} onClick={() => setPage(safePage + 1)} aria-label="Next page">
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
{ranked.length === 0 ? (
<div className="flex min-h-[220px] items-center justify-center border-t border-border/60 px-4 text-center text-xs text-stat-subtitle">
No actionable Critical or High findings
</div>
) : (
<>
<div className={`grid ${EXPLOIT_GRID} items-center gap-2 border-t border-border/60 px-4 py-2 font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle`}>
<span />
<span>CVE</span>
<span>Image</span>
<span className="text-right">EPSS</span>
<span className="text-right">CVSS</span>
</div>
<ul className="divide-y divide-border/40">
{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.
<li
key={safePage * EXPLOIT_PAGE_SIZE + i}
role="button"
tabIndex={0}
onClick={() => 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`}
>
<span
className="h-[7px] w-[7px] shrink-0 justify-self-center rounded-full"
style={{ background: SEV_DOT_VAR[f.severity] ?? 'var(--stat-subtitle)' }}
aria-hidden
/>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-mono text-xs text-stat-value">{f.vulnerability_id}</span>
{f.kev && (
<span className="shrink-0 rounded border border-destructive/40 bg-destructive/10 px-1 py-px text-[9px] font-mono uppercase text-destructive">KEV</span>
)}
</span>
<span className="truncate font-mono text-[11px] text-stat-icon">{shortImage(f.image_ref)}</span>
<span className="text-right font-mono text-[11px] tabular-nums">
{f.epss_score !== null ? (
<span className="text-warning">{Math.round(f.epss_score * 100)}%</span>
) : (
<TooltipProvider>
<RadixTooltip>
<TooltipTrigger asChild>
<span className="text-stat-subtitle/70 cursor-help">n/a</span>
</TooltipTrigger>
<TooltipContent>Exploitability unrated; treated as potentially automatable</TooltipContent>
</RadixTooltip>
</TooltipProvider>
)}
</span>
<span className="text-right font-mono text-[11px] tabular-nums text-stat-subtitle">
{f.cvss_score !== null ? f.cvss_score : '-'}
</span>
</li>
))}
</ul>
{truncated && (
<p className="border-t border-border/40 px-4 py-2 text-[10px] leading-snug text-stat-subtitle">
Showing the highest-risk findings; more exist than can be listed here.
</p>
)}
{!anyIntel && (
<p className="border-t border-border/40 px-4 py-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>
);
}
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 (
<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 (
// 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.
<div>
<ChartContainer config={QUADRANT_CONFIG} className="h-[260px] w-full">
<ScatterChart margin={{ left: 12, right: 12, top: 8, bottom: 24 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
type="number" dataKey="epssPct" name="EPSS" unit="%" domain={[0, 100]}
tickLine={false} axisLine={false} fontSize={10}
>
<Label value="Exploitability (EPSS %)" position="insideBottom" offset={-14} fontSize={10} className="fill-stat-subtitle" />
</XAxis>
<YAxis
type="number" dataKey="cvss" name="CVSS" domain={[0, 10]}
tickLine={false} axisLine={false} fontSize={10} width={40}
>
<Label value="Severity (CVSS)" angle={-90} position="insideLeft" offset={8} fontSize={10} className="fill-stat-subtitle" style={{ textAnchor: 'middle' }} />
</YAxis>
<ZAxis range={[40, 40]} />
<ReferenceLine x={10} stroke="var(--border)" strokeDasharray="4 4" />
<ReferenceLine y={7} stroke="var(--border)" strokeDasharray="4 4" />
{/* cursor=false: the default scatter cursor is a full-plot rectangle
that reads as selecting the whole chart. Points still hover/tooltip. */}
<Tooltip cursor={false} 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>
);
}