Files
sencho/frontend/src/components/ScanComparisonSheet.tsx
T
Anso 12c2b37510 feat(security): polish scan sheets, fix CVE links, surface policy violations (#721)
* feat(security): polish scan sheets, fix CVE links, surface policy violations

Adds cveUrl helper that rewrites Trivy's 404-ing avd.aquasec.com links to
cve.org for CVE-prefixed IDs (GHSA and misconfig URLs pass through unchanged).
Redesigns both scan sheets with shadow-card-bevel chips, tracked-mono kickers,
severity row tinting with a left accent rail, and tabular-nums timestamps.
Surfaces a destructive policy-violation banner on scans whose policy_evaluation
row flags a block, and fixes the compare sheet's delta ribbon so CRITICAL
net-positive deltas render in destructive (not warning) tone. Backend parses
the JSON policy_evaluation column at the API boundary so the UI receives a
structured object.

* chore(security): suppress CVE-2026-32281 and CVE-2026-32283 in Trivy scan

Both CVEs affect Go stdlib crypto/x509 and TLS in Docker CLI 29.4.0
(Go 1.26.1) and Compose v5.1.2 (Go 1.25.8). No upstream static binary
has been released with the patched Go 1.26.2 or 1.25.9 runtimes yet.

Exposure analysis: the Docker CLI and compose plugin connect to the local
Docker socket (Unix socket, no TLS) and to public registries with well-known
CAs. Neither CVE is exploitable in this configuration. Added alongside
sibling entries already in .trivyignore for the same binary versions.

Revisit on next Docker CLI and Compose upstream release.
2026-04-21 08:51:35 -04:00

395 lines
17 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
ArrowRight,
ChevronLeft,
ChevronRight,
GitCompare,
Loader2,
MinusCircle,
PlusCircle,
ShieldCheck,
ShieldOff,
Equal,
AlertTriangle,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { cveUrl } from '@/lib/cveUrl';
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
import { SeverityChip } from './VulnerabilityScanSheet';
import type {
ScanCompareResult,
ScanCompareVulnerability,
VulnSeverity,
} from '@/types/security';
interface ScanComparisonSheetProps {
baselineScanId: number | null;
currentScanId: number | null;
onClose: () => void;
}
type DiffFilter = 'added' | 'removed' | 'unchanged';
const PAGE_SIZE = 25;
const SEVERITY_ORDER: Record<VulnSeverity, number> = {
CRITICAL: 0,
HIGH: 1,
MEDIUM: 2,
LOW: 3,
UNKNOWN: 4,
};
function sortBySeverity<T extends { severity: VulnSeverity }>(rows: T[]): T[] {
return [...rows].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
}
function countBySeverity(rows: Array<{ severity: VulnSeverity }>): Record<VulnSeverity, number> {
const counts: Record<VulnSeverity, number> = {
CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, UNKNOWN: 0,
};
for (const r of rows) counts[r.severity] += 1;
return counts;
}
type DeltaTone = 'success' | 'warning' | 'destructive' | 'muted';
const DELTA_TONE_CLASS: Record<DeltaTone, string> = {
destructive: 'text-destructive border-destructive/40 bg-destructive/10',
warning: 'text-warning border-warning/40 bg-warning/10',
success: 'text-success border-success/40 bg-success/10',
muted: 'text-muted-foreground border-border bg-muted/30',
};
function formatDelta(
severity: VulnSeverity,
added: number,
removed: number,
): { text: string; tone: DeltaTone } {
const net = added - removed;
if (net > 0) {
const tone: DeltaTone = severity === 'CRITICAL' ? 'destructive' : 'warning';
return { text: `+${net}`, tone };
}
if (net < 0) return { text: `${net}`, tone: 'success' };
return { text: '0', tone: 'muted' };
}
export function ScanComparisonSheet({
baselineScanId,
currentScanId,
onClose,
}: ScanComparisonSheetProps) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<ScanCompareResult | null>(null);
const [filter, setFilter] = useState<DiffFilter>('added');
const [page, setPage] = useState(0);
const load = useCallback(async () => {
if (baselineScanId == null || currentScanId == null) return;
setLoading(true);
setData(null);
setPage(0);
setFilter('added');
try {
const res = await apiFetch(
`/security/compare?scanId1=${baselineScanId}&scanId2=${currentScanId}`,
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error || 'Failed to load comparison');
}
const body = (await res.json()) as ScanCompareResult;
setData(body);
} catch (err) {
toast.error((err as Error)?.message || 'Failed to load comparison');
onClose();
} finally {
setLoading(false);
}
}, [baselineScanId, currentScanId, onClose]);
useEffect(() => {
if (baselineScanId != null && currentScanId != null) load();
}, [baselineScanId, currentScanId, load]);
const open = baselineScanId != null && currentScanId != null;
const addedCounts = useMemo(() => (data ? countBySeverity(data.added) : null), [data]);
const removedCounts = useMemo(() => (data ? countBySeverity(data.removed) : null), [data]);
const crossImage = data != null && data.scanA.image_ref !== data.scanB.image_ref;
const rows = useMemo<ScanCompareVulnerability[]>(() => {
if (!data) return [];
if (filter === 'added') return sortBySeverity(data.added);
if (filter === 'removed') return sortBySeverity(data.removed);
return sortBySeverity(data.unchanged as ScanCompareVulnerability[]);
}, [data, filter]);
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const needsPagination = rows.length > PAGE_SIZE;
return (
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
<SheetContent className="sm:max-w-4xl flex flex-col p-0">
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Scan comparison
</div>
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
<GitCompare className="w-5 h-5 text-muted-foreground not-italic" strokeWidth={1.5} />
Diff
</SheetTitle>
<SheetDescription className="sr-only">
Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings.
</SheetDescription>
</SheetHeader>
{loading && (
<div className="flex items-center justify-center flex-1">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
)}
{data && !loading && (
<div className="flex flex-col flex-1 min-h-0">
{/* Scan identification */}
<div className="px-6 py-4 border-b space-y-3">
<div className="flex items-center gap-3 text-xs font-mono tabular-nums">
<div className="flex-1 min-w-0">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Baseline</div>
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<div className="flex-1 min-w-0">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Current</div>
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
</div>
</div>
{crossImage && (
<div className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
<span>
You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift.
</span>
</div>
)}
{data.truncated && (
<div
role="alert"
className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
>
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
<span>
Showing the first {data.row_limit ?? 1000} findings per scan. One or both scans exceed this limit, so the comparison may be incomplete.
</span>
</div>
)}
{/* Delta ribbon */}
{addedCounts && removedCounts && (
<div className="flex flex-wrap gap-2">
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => {
const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]);
return (
<span
key={sev}
aria-label={`${sev} delta ${delta.text}`}
data-tone={delta.tone}
className={cn(
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums shadow-card-bevel',
DELTA_TONE_CLASS[delta.tone],
)}
>
<span className="uppercase tracking-[0.18em] text-[10px]">{sev}</span>
<span>{delta.text}</span>
</span>
);
})}
</div>
)}
</div>
{/* Filter pills */}
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
<Button
variant={filter === 'added' ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => { setFilter('added'); setPage(0); }}
>
<PlusCircle className="w-3 h-3 mr-1" strokeWidth={1.5} />
Added ({data.added.length})
</Button>
<Button
variant={filter === 'removed' ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => { setFilter('removed'); setPage(0); }}
>
<MinusCircle className="w-3 h-3 mr-1" strokeWidth={1.5} />
Removed ({data.removed.length})
</Button>
<Button
variant={filter === 'unchanged' ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => { setFilter('unchanged'); setPage(0); }}
title={
crossImage
? 'Same CVE and package name appear in both images. Because the images differ, this does not necessarily mean the finding is literally unchanged.'
: 'Findings present in both scans'
}
>
<Equal className="w-3 h-3 mr-1" strokeWidth={1.5} />
{crossImage ? 'Shared' : 'Unchanged'} ({data.unchanged.length})
</Button>
{needsPagination && (
<div className="flex items-center gap-1 ml-auto" aria-live="polite">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
aria-label="Previous page"
>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<span
className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center"
aria-label={`Page ${safePage + 1} of ${totalPages}`}
>
{safePage + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
aria-label="Next page"
>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-3">
{pageItems.length === 0 ? (
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
<ShieldCheck className="w-8 h-8 text-success" strokeWidth={1.5} />
<div className="text-sm text-muted-foreground">
{filter === 'added' && 'No new findings. Nothing regressed between these scans.'}
{filter === 'removed' && 'No findings were resolved between these scans.'}
{filter === 'unchanged' && 'No findings are shared between the two scans.'}
</div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((v, idx) => {
const href = cveUrl(v.vulnerability_id, v.primary_url);
const rowClass = cn(
SEVERITY_ROW_TINT[v.severity],
filter === 'unchanged' && 'opacity-75',
v.suppressed && 'opacity-60',
);
return (
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
<TableCell className="font-mono text-xs tabular-nums">
<span className="inline-flex items-center gap-1.5">
{v.suppressed && (
<ShieldOff
className="w-3 h-3 text-muted-foreground"
strokeWidth={1.5}
aria-label="Suppressed"
/>
)}
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="hover:underline"
>
{v.vulnerability_id}
</a>
) : (
v.vulnerability_id
)}
</span>
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={v.pkg_name}>
{v.pkg_name}
</TableCell>
<TableCell>
<SeverityChip severity={v.severity} />
</TableCell>
<TableCell className="font-mono text-xs">
{filter === 'added' && (
<span className="inline-flex items-center gap-1 text-destructive">
<PlusCircle className="w-3 h-3" strokeWidth={1.5} />
Added
</span>
)}
{filter === 'removed' && (
<span className="inline-flex items-center gap-1 text-success">
<MinusCircle className="w-3 h-3" strokeWidth={1.5} />
Removed
</span>
)}
{filter === 'unchanged' && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Equal className="w-3 h-3" strokeWidth={1.5} />
{crossImage ? 'Shared' : 'Unchanged'}
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ScrollArea>
</div>
)}
</SheetContent>
</Sheet>
);
}
export type { ScanComparisonSheetProps };