import { useCallback, useEffect, useMemo, useState } from 'react'; import { Sheet, SheetContent, 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ShieldCheck, ExternalLink, ChevronLeft, ChevronRight, RefreshCw, Download, Loader2, Check, } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; import type { VulnerabilityScan, VulnerabilityDetail, VulnSeverity, } from '@/types/security'; interface VulnerabilityScanSheetProps { scanId: number | null; onClose: () => void; onRescan?: (imageRef: string) => void; canGenerateSbom?: boolean; } type SeverityFilter = 'ALL' | VulnSeverity; const PAGE_SIZE = 25; const SEVERITY_CLASSES: Record = { CRITICAL: 'text-destructive border-destructive/40 bg-destructive/10', HIGH: 'text-warning border-warning/40 bg-warning/10', MEDIUM: 'text-info border-info/40 bg-info/10', LOW: 'text-muted-foreground border-border bg-muted/30', UNKNOWN: 'text-muted-foreground border-border bg-muted/20', }; function SeverityChip({ severity }: { severity: VulnSeverity }) { return ( {severity} ); } export function VulnerabilityScanSheet({ scanId, onClose, onRescan, canGenerateSbom = false, }: VulnerabilityScanSheetProps) { const [scan, setScan] = useState(null); const [details, setDetails] = useState([]); const [loading, setLoading] = useState(false); const [severityFilter, setSeverityFilter] = useState('ALL'); const [page, setPage] = useState(0); const [downloadingSbom, setDownloadingSbom] = useState(false); const load = useCallback(async () => { if (scanId == null) return; setLoading(true); try { const [scanRes, detailsRes] = await Promise.all([ apiFetch(`/security/scans/${scanId}`), apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=500`), ]); if (!scanRes.ok) throw new Error('Failed to fetch scan'); if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities'); const scanData = await scanRes.json(); const detailsData = await detailsRes.json(); setScan(scanData); setDetails(Array.isArray(detailsData.items) ? detailsData.items : []); setPage(0); } catch (err) { toast.error((err as Error)?.message || 'Failed to load scan'); } finally { setLoading(false); } }, [scanId]); useEffect(() => { if (scanId != null) load(); else { setScan(null); setDetails([]); setSeverityFilter('ALL'); setPage(0); } }, [scanId, load]); const filtered = useMemo(() => { if (severityFilter === 'ALL') return details; return details.filter((d) => d.severity === severityFilter); }, [details, severityFilter]); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); const pageItems = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); const needsPagination = filtered.length > PAGE_SIZE; const downloadSbom = useCallback( async (format: 'spdx-json' | 'cyclonedx') => { if (!scan) return; setDownloadingSbom(true); try { const res = await apiFetch('/security/sbom', { method: 'POST', body: JSON.stringify({ imageRef: scan.image_ref, format }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body?.error || 'Failed to generate SBOM'); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-sbom-${format}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast.success('SBOM downloaded'); } catch (err) { toast.error((err as Error)?.message || 'SBOM generation failed'); } finally { setDownloadingSbom(false); } }, [scan], ); const exportCsv = useCallback(() => { if (!scan || details.length === 0) return; const header = 'CVE,Package,Severity,Installed,Fixed,URL\n'; const escape = (v: string) => `"${v.replace(/"/g, '""')}"`; const rows = details .map((d) => [ escape(d.vulnerability_id), escape(d.pkg_name), escape(d.severity), escape(d.installed_version), escape(d.fixed_version ?? ''), escape(d.primary_url ?? ''), ].join(','), ) .join('\n'); const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-vulnerabilities.csv`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }, [scan, details]); return ( !open && onClose()}> {scan?.image_ref ?? 'Loading...'} {loading && !scan && (
)} {scan && (
{/* Summary stats */}
{scan.critical_count > 0 && ( {scan.critical_count} CRITICAL )} {scan.high_count > 0 && ( {scan.high_count} HIGH )} {scan.medium_count > 0 && ( {scan.medium_count} MEDIUM )} {scan.low_count > 0 && ( {scan.low_count} LOW )} {scan.total_vulnerabilities === 0 && ( No vulnerabilities )}
Total
{scan.total_vulnerabilities}
Fixable
{scan.fixable_count}
Triggered
{scan.triggered_by}
Scanned
{new Date(scan.scanned_at).toLocaleString()}
{onRescan && ( )} {canGenerateSbom && ( downloadSbom('spdx-json')}> SPDX JSON downloadSbom('cyclonedx')}> CycloneDX )}
{/* Severity filter tabs */}
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => ( ))} {needsPagination && (
{safePage + 1} / {totalPages}
)}
{pageItems.length === 0 ? (
{details.length === 0 ? 'No vulnerabilities found.' : 'No vulnerabilities match the selected filter.'}
) : ( CVE Package Severity Installed Fixed {pageItems.map((d) => ( {d.primary_url ? ( {d.vulnerability_id} ) : ( d.vulnerability_id )} {d.pkg_name} {d.installed_version} {d.fixed_version ? ( {d.fixed_version} ) : ( - )} ))}
)}
)}
); } export { SeverityChip }; export type { VulnerabilityScanSheetProps };