mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
5a18be8667
Converted scanner info icon, exploitability n/a badge, and scan image button from native HTML title attributes to the shared Radix Tooltip component.
374 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|