import { useCallback, useEffect, useMemo, useState } from 'react'; import { SystemSheet, SheetSection } from '@/components/ui/system-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 { ShieldOff, ShieldCheck, ExternalLink, ChevronLeft, ChevronRight, RefreshCw, Download, Loader2, Check, GitCompare, } from 'lucide-react'; import { Combobox } from '@/components/ui/combobox'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScanComparisonSheet } from './ScanComparisonSheet'; 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 { formatTimeAgo } from '@/lib/relativeTime'; import type { VulnerabilityScan, VulnerabilityDetail, VulnSeverity, SecretFinding, MisconfigFinding, } from '@/types/security'; interface VulnerabilityScanSheetProps { scanId: number | null; onClose: () => void; onRescan?: (imageRef: string) => void; canGenerateSbom?: boolean; canCompare?: boolean; canManageSuppressions?: boolean; } interface SuppressDialogState { cveId: string; pkgName: string; imagePattern: string; reason: string; expiresInDays: string; } interface AckDialogState { ruleId: string; stackPattern: string; reason: string; expiresInDays: string; } type SeverityFilter = 'ALL' | VulnSeverity; type FindingTab = 'vulns' | 'secrets' | 'misconfigs'; 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-warning border-warning/40 bg-warning/10', LOW: 'text-stat-subtitle border-border bg-muted/30', UNKNOWN: 'text-stat-subtitle border-border bg-muted/20', }; function SeverityChip({ severity }: { severity: VulnSeverity }) { return ( {severity} ); } export function VulnerabilityScanSheet({ scanId, onClose, onRescan, canGenerateSbom = false, canCompare = false, canManageSuppressions = false, }: VulnerabilityScanSheetProps) { const [scan, setScan] = useState(null); const [details, setDetails] = useState([]); const [totalDetails, setTotalDetails] = useState(0); const [secrets, setSecrets] = useState([]); const [misconfigs, setMisconfigs] = useState([]); const [loading, setLoading] = useState(false); const [severityFilter, setSeverityFilter] = useState('ALL'); const [page, setPage] = useState(0); const [secretsPage, setSecretsPage] = useState(0); const [misconfigsPage, setMisconfigsPage] = useState(0); const [tab, setTab] = useState('vulns'); const [downloadingSbom, setDownloadingSbom] = useState(false); const [compareOpen, setCompareOpen] = useState(false); const [compareOptions, setCompareOptions] = useState([]); const [compareLoading, setCompareLoading] = useState(false); const [compareBaselineId, setCompareBaselineId] = useState(null); const [suppressForm, setSuppressForm] = useState(null); const [savingSuppression, setSavingSuppression] = useState(false); const [ackForm, setAckForm] = useState(null); const [savingAck, setSavingAck] = useState(false); const DETAIL_FETCH_LIMIT = 500; const load = useCallback(async () => { if (scanId == null) return; setLoading(true); try { const [scanRes, detailsRes, secretsRes, misconfigsRes] = await Promise.all([ apiFetch(`/security/scans/${scanId}`), apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=${DETAIL_FETCH_LIMIT}`), apiFetch(`/security/scans/${scanId}/secrets?limit=${DETAIL_FETCH_LIMIT}`), apiFetch(`/security/scans/${scanId}/misconfigs?limit=${DETAIL_FETCH_LIMIT}`), ]); 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()) as VulnerabilityScan; const detailsData = await detailsRes.json(); const secretsData = secretsRes.ok ? await secretsRes.json() : { items: [] }; const misconfigsData = misconfigsRes.ok ? await misconfigsRes.json() : { items: [] }; setScan(scanData); setDetails(Array.isArray(detailsData.items) ? detailsData.items : []); setTotalDetails(typeof detailsData.total === 'number' ? detailsData.total : 0); setSecrets(Array.isArray(secretsData.items) ? secretsData.items : []); setMisconfigs(Array.isArray(misconfigsData.items) ? misconfigsData.items : []); setPage(0); setSecretsPage(0); setMisconfigsPage(0); if ((scanData.total_vulnerabilities ?? 0) === 0) { if ((scanData.misconfig_count ?? 0) > 0) setTab('misconfigs'); else if ((scanData.secret_count ?? 0) > 0) setTab('secrets'); else setTab('vulns'); } else { setTab('vulns'); } } catch (err) { toast.error((err as Error)?.message || 'Failed to load scan'); } finally { setLoading(false); } }, [scanId]); useEffect(() => { setCompareOpen(false); setCompareOptions([]); setCompareBaselineId(null); if (scanId != null) { load(); } else { setScan(null); setDetails([]); setTotalDetails(0); setSecrets([]); setMisconfigs([]); setSeverityFilter('ALL'); setPage(0); setSecretsPage(0); setMisconfigsPage(0); setTab('vulns'); } }, [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 secretsTotalPages = Math.max(1, Math.ceil(secrets.length / PAGE_SIZE)); const secretsSafePage = Math.min(secretsPage, secretsTotalPages - 1); const secretsPageItems = secrets.slice( secretsSafePage * PAGE_SIZE, (secretsSafePage + 1) * PAGE_SIZE, ); const secretsNeedsPagination = secrets.length > PAGE_SIZE; const misconfigsTotalPages = Math.max(1, Math.ceil(misconfigs.length / PAGE_SIZE)); const misconfigsSafePage = Math.min(misconfigsPage, misconfigsTotalPages - 1); const misconfigsPageItems = misconfigs.slice( misconfigsSafePage * PAGE_SIZE, (misconfigsSafePage + 1) * PAGE_SIZE, ); const misconfigsNeedsPagination = misconfigs.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 openCompareMenu = useCallback(async () => { if (!scan) return; setCompareOpen((o) => !o); if (compareOptions.length > 0 || compareLoading) return; setCompareLoading(true); try { const res = await apiFetch( `/security/scans?imageRef=${encodeURIComponent(scan.image_ref)}&limit=25`, ); if (!res.ok) throw new Error('Failed to load scan history'); const body = await res.json(); const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : []; setCompareOptions( items.filter((s) => s.id !== scan.id && s.status === 'completed'), ); } catch (err) { toast.error((err as Error)?.message || 'Could not load scan history'); } finally { setCompareLoading(false); } }, [scan, compareOptions.length, compareLoading]); const openSuppressDialog = useCallback((d: VulnerabilityDetail) => { setSuppressForm({ cveId: d.vulnerability_id, pkgName: d.pkg_name, imagePattern: '', reason: '', expiresInDays: '', }); }, []); const submitSuppression = useCallback(async () => { if (!suppressForm) return; const reason = suppressForm.reason.trim(); if (!reason) { toast.error('A reason is required.'); return; } const days = suppressForm.expiresInDays.trim(); let expiresAt: number | null = null; if (days) { const n = Number(days); if (!Number.isFinite(n) || n <= 0) { toast.error('Expiry must be a positive number of days or blank.'); return; } expiresAt = Date.now() + n * 24 * 60 * 60 * 1000; } setSavingSuppression(true); try { const res = await apiFetch('/security/suppressions', { method: 'POST', localOnly: true, body: JSON.stringify({ cve_id: suppressForm.cveId, pkg_name: suppressForm.pkgName || null, image_pattern: suppressForm.imagePattern.trim() || null, reason, expires_at: expiresAt, }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body?.error || 'Failed to create suppression'); } toast.success('Suppression created'); setSuppressForm(null); await load(); } catch (err) { toast.error((err as Error)?.message || 'Failed to create suppression'); } finally { setSavingSuppression(false); } }, [suppressForm, load]); const openAckDialog = useCallback((m: MisconfigFinding) => { // Prefill stack_pattern with the exact stack name when this scan is a // stack-scoped config scan. Operators can broaden in the dialog. const stackPattern = scan?.stack_context ?? ''; setAckForm({ ruleId: m.rule_id, stackPattern, reason: '', expiresInDays: '', }); }, [scan]); const submitAcknowledgement = useCallback(async () => { if (!ackForm) return; const reason = ackForm.reason.trim(); if (!reason) { toast.error('A reason is required.'); return; } const days = ackForm.expiresInDays.trim(); let expiresAt: number | null = null; if (days) { const n = Number(days); if (!Number.isFinite(n) || n <= 0) { toast.error('Expiry must be a positive number of days or blank.'); return; } expiresAt = Date.now() + n * 24 * 60 * 60 * 1000; } setSavingAck(true); try { const res = await apiFetch('/security/misconfig-acks', { method: 'POST', localOnly: true, body: JSON.stringify({ rule_id: ackForm.ruleId, stack_pattern: ackForm.stackPattern.trim() || null, reason, expires_at: expiresAt, }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body?.error || 'Failed to create acknowledgement'); } toast.success('Acknowledgement created'); setAckForm(null); await load(); } catch (err) { toast.error((err as Error)?.message || 'Failed to create acknowledgement'); } finally { setSavingAck(false); } }, [ackForm, load]); 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]); const exportSarif = useCallback(async () => { if (!scan) return; try { const res = await apiFetch(`/security/scans/${scan.id}/sarif`); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body?.error || 'Failed to generate SARIF'); } 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, '_')}.sarif.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast.success('SARIF downloaded'); } catch (err) { const error = err as { message?: string; error?: string; data?: { error?: string } }; toast.error(error?.message || error?.error || error?.data?.error || 'SARIF export failed'); } }, [scan]); const meta = scan ? `${scan.total_vulnerabilities} vulns · ${scan.fixable_count} fixable · ${scan.triggered_by}` : (loading ? 'Loading…' : 'No scan'); const footerContext = scan ? `Scanned ${formatTimeAgo(new Date(scan.scanned_at).getTime())}` : undefined; const secondaryActions = scan ? [ ...(canCompare ? [{ label: 'Compare', icon: compareLoading ? Loader2 : GitCompare, onClick: openCompareMenu, disabled: compareLoading, }] : []), ...(details.length > 0 ? [{ label: 'CSV', icon: Download, onClick: exportCsv, }] : []), ...(canGenerateSbom && scan.status === 'completed' ? [{ label: 'SARIF', icon: Download, onClick: () => { void exportSarif(); }, }] : []), ] : undefined; return ( <> !open && onClose()} crumb={['Security', 'Scans', scan?.image_ref ?? '…']} name={scan?.image_ref ?? 'Loading…'} meta={meta} primaryAction={onRescan && scan ? { label: 'Re-scan', icon: RefreshCw, onClick: () => onRescan(scan.image_ref), disabled: scan.status === 'in_progress', } : undefined} secondaryActions={secondaryActions} tabs={scan ? [ { id: 'vulns', label: 'Vulnerabilities', count: totalDetails }, { id: 'secrets', label: 'Secrets', count: scan.secret_count ?? secrets.length }, { id: 'misconfigs', label: 'Misconfigs', count: scan.misconfig_count ?? misconfigs.length }, ] : undefined} activeTab={tab} onTabChange={(id) => setTab(id as FindingTab)} footerContext={footerContext} size="lg" > {loading && !scan && (
)} {scan && ( <> {scan.policy_evaluation?.violated && (
)}
{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()}
{canGenerateSbom && (
downloadSbom('spdx-json')}> SPDX JSON downloadSbom('cyclonedx')}> CycloneDX
)} {compareOpen && canCompare && (
{compareOptions.length === 0 && !compareLoading ? (
No other completed scans for this image yet. Run a second scan to enable comparison.
) : ( <>
Compare against
({ value: String(s.id), label: `${new Date(s.scanned_at).toLocaleString()} - ${s.total_vulnerabilities} findings (${s.triggered_by})`, }))} value={compareBaselineId != null ? String(compareBaselineId) : ''} onValueChange={(v) => setCompareBaselineId(v ? Number(v) : null)} placeholder="Choose a baseline scan..." searchPlaceholder="Search by date..." /> )}
)}
{tab === 'vulns' && (
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => ( ))} {needsPagination && (
{safePage + 1} / {totalPages}
)}
{totalDetails > details.length && (
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
)} {pageItems.length === 0 ? (
{details.length === 0 ? 'No vulnerabilities found.' : 'No vulnerabilities match the selected filter.'}
) : ( CVE Package Severity Installed Fixed {canManageSuppressions && } {pageItems.map((d) => { const href = cveUrl(d.vulnerability_id, d.primary_url); return ( {d.suppressed && ( )} {href ? ( {d.vulnerability_id} ) : ( d.vulnerability_id )} {d.pkg_name} {d.installed_version} {d.fixed_version ? ( {d.fixed_version} ) : ( - )} {canManageSuppressions && ( {!d.suppressed && ( )} )} ); })}
)}
)} {tab === 'secrets' && ( {secretsNeedsPagination && (
{secretsSafePage + 1} / {secretsTotalPages}
)} {secrets.length === 0 ? (
No secrets detected.
) : ( Severity Rule Title Target {secretsPageItems.map((s) => ( {s.rule_id}
{s.title || -}
{s.match_excerpt && (
{s.match_excerpt}
)}
{s.target} {s.start_line != null && ( :{s.start_line} {s.end_line != null && s.end_line !== s.start_line ? `-${s.end_line}` : ''} )}
))}
)}
)} {tab === 'misconfigs' && ( {misconfigsNeedsPagination && (
{misconfigsSafePage + 1} / {misconfigsTotalPages}
)} {misconfigs.length === 0 ? (
No misconfigurations detected.
) : ( Severity Check Title Target Fix {canManageSuppressions && } {misconfigsPageItems.map((m) => ( {m.check_id || m.rule_id}
{m.primary_url ? ( {m.title || m.rule_id} ) : ( m.title || m.rule_id )}
{m.message && (
{m.message}
)}
{m.target} {m.resolution || -} {canManageSuppressions && ( {!m.acknowledged && ( )} )}
))}
)}
)} )}
setCompareBaselineId(null)} /> !open && setSuppressForm(null)} > Suppress CVE Accept this CVE as known-benign so it stops triggering alerts across the fleet. {suppressForm && (
{suppressForm.cveId}
{suppressForm.pkgName || '-'}
setSuppressForm((f) => (f ? { ...f, imagePattern: e.target.value } : f)) } />

Glob pattern matched against the image reference. Leave blank to suppress this CVE on any image.