fix(security): fix Security page table layouts and exploit-risk pagination (#1434)

Image scan sheet: make the finding table the single bounded scroll region
(SystemSheet noScroll + flex-fill) so it no longer clips at the bottom on
shorter viewports, and give the tables a phone min-width so they scroll
horizontally instead of cutting off the right-hand columns.

Scan history table: add the same horizontal scroll on phones.

Overview "Top exploit-risk findings": render as a paginated table with column
headers and top-right pagination, modeled on the dashboard stack-health table.
Key the rows by rank position so recurring CVE/scan pairs no longer collide and
duplicate rows when paging through.

Overview severity-by-exploitability chart: label the axes (EPSS exploitability
and CVSS severity) and stop the card from stretching to a taller neighbour,
removing the dead space beneath the chart.
This commit is contained in:
Anso
2026-06-24 20:06:04 -04:00
committed by GitHub
parent 6527bc971b
commit 330f9f1acd
5 changed files with 199 additions and 73 deletions
@@ -579,6 +579,7 @@ export function VulnerabilityScanSheet({
onTabChange={(id) => setTab(id as FindingTab)}
footerContext={footerContext}
size="lg"
noScroll
>
{loading && !scan && (
<div className="flex items-center justify-center py-12">
@@ -587,8 +588,11 @@ export function VulnerabilityScanSheet({
)}
{scan && (
<>
<SheetSection title="Summary">
// noScroll skips SystemSheet's default px-6 py-5 wrapper, so supply it
// here; SheetSection's -mx-6 bleed depends on this px-6. The column lets
// the active finding section flex to fill the sheet (single scroll box).
<div className="flex min-h-0 flex-1 flex-col px-6 py-5">
<SheetSection title="Summary" className="shrink-0">
{scan.policy_evaluation?.violated && (
<div
role="alert"
@@ -717,7 +721,7 @@ export function VulnerabilityScanSheet({
</SheetSection>
{tab === 'vulns' && (
<SheetSection title={`Vulnerabilities · ${totalDetails}`}>
<SheetSection title={`Vulnerabilities · ${totalDetails}`} className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-1 flex-wrap mb-3">
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
<Button
@@ -766,7 +770,7 @@ export function VulnerabilityScanSheet({
</div>
)}
<ScrollArea block className="max-h-[60vh]">
<ScrollArea block className="flex-1 min-h-0">
{pageItems.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
{details.length === 0
@@ -774,7 +778,7 @@ export function VulnerabilityScanSheet({
: 'No vulnerabilities match the selected filter.'}
</div>
) : (
<Table>
<Table className="max-md:min-w-[720px]">
<TableHeader>
<TableRow>
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
@@ -866,7 +870,7 @@ export function VulnerabilityScanSheet({
)}
{tab === 'secrets' && (
<SheetSection title={`Secrets · ${scan.secret_count ?? secrets.length}`}>
<SheetSection title={`Secrets · ${scan.secret_count ?? secrets.length}`} className="flex min-h-0 flex-1 flex-col">
{secretsNeedsPagination && (
<div className="flex items-center gap-1 mb-3">
<div className="flex items-center gap-1 ml-auto">
@@ -896,13 +900,13 @@ export function VulnerabilityScanSheet({
</div>
</div>
)}
<ScrollArea block className="max-h-[60vh]">
<ScrollArea block className="flex-1 min-h-0">
{secrets.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
No secrets detected.
</div>
) : (
<Table>
<Table className="max-md:min-w-[720px]">
<TableHeader>
<TableRow>
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
@@ -954,7 +958,7 @@ export function VulnerabilityScanSheet({
)}
{tab === 'misconfigs' && (
<SheetSection title={`Misconfigs · ${scan.misconfig_count ?? misconfigs.length}`}>
<SheetSection title={`Misconfigs · ${scan.misconfig_count ?? misconfigs.length}`} className="flex min-h-0 flex-1 flex-col">
{misconfigsNeedsPagination && (
<div className="flex items-center gap-1 mb-3">
<div className="flex items-center gap-1 ml-auto">
@@ -986,13 +990,13 @@ export function VulnerabilityScanSheet({
</div>
</div>
)}
<ScrollArea block className="max-h-[60vh]">
<ScrollArea block className="flex-1 min-h-0">
{misconfigs.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
No misconfigurations detected.
</div>
) : (
<Table>
<Table className="max-md:min-w-[720px]">
<TableHeader>
<TableRow>
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
@@ -1080,7 +1084,7 @@ export function VulnerabilityScanSheet({
</ScrollArea>
</SheetSection>
)}
</>
</div>
)}
</SystemSheet>
@@ -174,8 +174,8 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="max-h-[60vh] bg-background">
<Table>
<ScrollArea block className="max-h-[60vh] bg-background">
<Table className="max-md:min-w-[720px]">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[40px]" />
@@ -138,10 +138,11 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, onNaviga
</ChartCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard title="Top exploit-risk findings">
<TopExploitRiskList items={exploitIntel} onInspect={onInspect} />
</ChartCard>
{/* items-start: each card keeps its natural height so the fixed-height chart
card never stretches to a taller exploit table (which left dead space
under the chart). The exploit-risk table owns its own card chrome. */}
<div className="grid items-start gap-4 lg:grid-cols-2">
<TopExploitRiskList items={exploitIntel} onInspect={onInspect} />
<ChartCard title="Severity × exploitability">
<CvssEpssQuadrantChart items={exploitIntel} />
</ChartCard>
@@ -36,6 +36,7 @@ vi.mock('recharts', async () => {
YAxis: stub('YAxis'),
ZAxis: stub('ZAxis'),
CartesianGrid: stub('CartesianGrid'),
Label: stub('Label'),
LabelList: stub('LabelList'),
ReferenceLine: stub('ReferenceLine'),
};
@@ -102,6 +103,18 @@ describe('ActionPostureChart', () => {
});
describe('TopExploitRiskList', () => {
const rowsOf = (container: HTMLElement) => [...container.querySelectorAll('li[role="button"]')];
it('renders column headers for the table', () => {
const { container } = render(
<TopExploitRiskList items={[finding({ vulnerability_id: 'CVE-A', cvss_score: 8, epss_score: 0.2 })]} onInspect={vi.fn()} />,
);
expect(container.textContent).toContain('CVE');
expect(container.textContent).toContain('Image');
expect(container.textContent).toContain('EPSS');
expect(container.textContent).toContain('CVSS');
});
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 }),
@@ -111,14 +124,63 @@ describe('TopExploitRiskList', () => {
];
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);
const rows = rowsOf(container);
const order = rows.map((r) => r.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]);
fireEvent.click(rows[0]);
expect(onInspect).toHaveBeenCalledWith(11);
});
it('paginates beyond the page size and advances and rewinds pages', () => {
const items = Array.from({ length: 9 }, (_, i) =>
finding({ vulnerability_id: `CVE-${i}`, cvss_score: 9 - i * 0.1, epss_score: 0.5, scan_id: i }),
);
const { container } = render(<TopExploitRiskList items={items} onInspect={vi.fn()} />);
expect(rowsOf(container)).toHaveLength(8);
expect(container.textContent).toContain('1 / 2');
const prev = container.querySelector('button[aria-label="Previous page"]') as HTMLButtonElement;
const next = container.querySelector('button[aria-label="Next page"]') as HTMLButtonElement;
expect(prev.disabled).toBe(true); // disabled on the first page
fireEvent.click(next);
expect(rowsOf(container)).toHaveLength(1);
expect(container.textContent).toContain('2 / 2');
fireEvent.click(prev);
expect(rowsOf(container)).toHaveLength(8);
expect(container.textContent).toContain('1 / 2');
});
it('does not collide keys or accumulate rows when CVE/scan pairs recur across a page boundary', () => {
// The same scan_id + vulnerability_id recurs across packages/images, so the
// old composite key was non-unique and React duplicated rows when paging.
// Position-based keys keep every row unique: no duplicate-key warning, and
// each page renders exactly its slice.
const items = Array.from({ length: 20 }, (_, i) =>
finding({ vulnerability_id: 'CVE-DUP', scan_id: 1, image_ref: `img-${i}:1`, cvss_score: 7, epss_score: 0.5 }),
);
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const { container } = render(<TopExploitRiskList items={items} onInspect={vi.fn()} />);
expect(rowsOf(container)).toHaveLength(8);
const next = container.querySelector('button[aria-label="Next page"]') as HTMLButtonElement;
fireEvent.click(next);
fireEvent.click(next);
expect(rowsOf(container)).toHaveLength(4); // page 3 of 20 = 4 rows, not an accumulation
expect(container.textContent).toContain('3 / 3');
const keyWarnings = errSpy.mock.calls.filter((c) => /same key|unique "key"/i.test(String(c[0])));
expect(keyWarnings).toEqual([]);
} finally {
errSpy.mockRestore();
}
});
it('shows no pager at or below the page size', () => {
const items = Array.from({ length: 8 }, (_, i) => finding({ vulnerability_id: `CVE-${i}`, cvss_score: 9, epss_score: 0.5, scan_id: i }));
const { container } = render(<TopExploitRiskList items={items} onInspect={vi.fn()} />);
expect(rowsOf(container)).toHaveLength(8);
expect(container.querySelector('button[aria-label="Next page"]')).toBeNull();
});
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()} />,
@@ -1,8 +1,11 @@
import { useMemo, useState } from 'react';
import {
AreaChart, Area, BarChart, Bar, Cell, ScatterChart, Scatter,
XAxis, YAxis, ZAxis, CartesianGrid, LabelList, ReferenceLine, Tooltip,
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 type { SecurityRiskTrendPoint, SecurityOverview, ExploitIntelFinding } from '@/types/security';
@@ -152,7 +155,16 @@ 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. */
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]';
/** 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,
onInspect,
@@ -160,55 +172,94 @@ export function TopExploitRiskList({
items: ExploitIntelFinding[];
onInspect: (scanId: number) => void;
}) {
if (items.length === 0) return <EmptyChart label="No actionable Critical or High findings" height={220} />;
const ranked = [...items].sort(exploitRank).slice(0, 8);
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="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 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>
{!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>
{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: f.severity === 'CRITICAL' ? 'var(--sev-critical)' : 'var(--sev-high)' }}
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>
) : (
<span className="text-stat-subtitle/70" title="Exploitability unrated; treated as potentially automatable">n/a</span>
)}
</span>
<span className="text-right font-mono text-[11px] tabular-nums text-stat-subtitle">
{f.cvss_score !== null ? f.cvss_score : '-'}
</span>
</li>
))}
</ul>
{!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>
);
@@ -254,18 +305,26 @@ export function CvssEpssQuadrantChart({ items }: { items: ExploitIntelFinding[]
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-[220px] w-full">
<ScatterChart margin={{ left: 0, right: 12, top: 8, bottom: 8 }}>
<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={28}
/>
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" />