import { type ReactNode, 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 { fetchAllScanVulnerabilities } from './VulnerabilityScanSheet.export'; 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 { formatPolicyReasons } from '@/lib/policyReasons'; import type { VulnerabilityScan, VulnerabilityDetail, VulnSeverity, SecretFinding, MisconfigFinding, ScanDetailTab, TriageStatus, } from '@/types/security'; // Triage decision options for the suppress dialog (value -> label). 'accepted' // is the default: a plain suppress is an accepted risk. const TRIAGE_STATUS_OPTIONS: ReadonlyArray<{ value: TriageStatus; label: string }> = [ { value: 'accepted', label: 'Accepted risk' }, { value: 'not_affected', label: 'Not affected' }, { value: 'false_positive', label: 'False positive' }, { value: 'needs_review', label: 'Needs review' }, { value: 'fixed', label: 'Fixed' }, { value: 'ignored', label: 'Ignored until expiry' }, ]; interface VulnerabilityScanSheetProps { scanId: number | null; onClose: () => void; onRescan?: (imageRef: string) => void; canGenerateSbom?: boolean; canExportSarif?: boolean; canCompare?: boolean; canManageSuppressions?: boolean; /** * Tab to open on first load. Defaults to 'vulns' (with the existing * auto-switch to a populated tab when the scan has no vulnerabilities). * Callers that open the sheet from a secret/misconfig context pass the * matching tab so it lands there even when the scan also has CVEs. */ initialTab?: FindingTab; } interface SuppressDialogState { cveId: string; pkgName: string; imagePattern: string; reason: string; expiresInDays: string; status: TriageStatus; } interface AckDialogState { ruleId: string; stackPattern: string; reason: string; expiresInDays: string; } type SeverityFilter = 'ALL' | VulnSeverity; // Single source of truth lives in types/security as ScanDetailTab; alias here so // the initialTab prop is provably the same type its callers (SecurityView) hold. type FindingTab = ScanDetailTab; 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} ); } const EVIDENCE_TAG_CLASSES = { danger: 'text-destructive border-destructive/40 bg-destructive/10', warn: 'text-warning border-warning/40 bg-warning/10', muted: 'text-stat-subtitle border-border bg-muted/30', neutral: 'text-stat-value border-border bg-muted/20', } as const; function EvidenceTag({ tone, children }: { tone: keyof typeof EVIDENCE_TAG_CLASSES; children: ReactNode }) { return ( {children} ); } /** * Small, independently-verifiable evidence atoms per finding. Severity is one * signal among several, not the only one: these surface exploit intel (KEV, * EPSS), vendor status, and the CVSS score so an operator can tell scary from * exploitable without an invented composite priority number. */ function EvidenceTags({ d }: { d: VulnerabilityDetail }) { const tags: ReactNode[] = []; if (d.kev) tags.push(KEV); if (typeof d.epss_score === 'number') { tags.push( = 0.1 ? 'warn' : 'muted'}> EPSS {Math.round(d.epss_score * 100)}% , ); } if (d.status === 'will_not_fix' || d.status === 'end_of_life') { tags.push({"Won't fix"}); } if (typeof d.cvss_score === 'number') { tags.push(CVSS {d.cvss_score}); } if (tags.length === 0) return null; return {tags}; } export function VulnerabilityScanSheet({ scanId, onClose, onRescan, canGenerateSbom = false, canExportSarif = false, canCompare = false, canManageSuppressions: canManageSuppressionsProp = false, initialTab, }: VulnerabilityScanSheetProps) { const [isReplica, setIsReplica] = useState(false); useEffect(() => { // Reset on every probe so a stale `true` from a previous replica view // does not survive switching to a control instance with the sheet kept // mounted by its parent. Defense in depth: if the probe never resolves // the UI stays permissive and the backend blockIfReplica guard runs. setIsReplica(false); if (!canManageSuppressionsProp || scanId == null) return; let cancelled = false; (async () => { try { const res = await apiFetch('/fleet/role', { localOnly: true }); if (cancelled || !res.ok) return; const data = await res.json(); if (!cancelled) setIsReplica(data?.role === 'replica'); } catch (err) { console.warn('Failed to probe fleet role for replica gate:', err); } })(); return () => { cancelled = true; }; }, [canManageSuppressionsProp, scanId]); const canManageSuppressions = canManageSuppressionsProp && !isReplica; 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 [exportingCsv, setExportingCsv] = useState(false); const DETAIL_FETCH_LIMIT = 500; const load = useCallback(async () => { if (scanId == null) return; setLoading(true); try { // The vulnerability list is fetched in full (paging past the per-request // cap), not a single capped page, so severity filtering, inspection, and // suppression reach every finding rather than only the first page. const [scanRes, allVulns, secretsRes, misconfigsRes] = await Promise.all([ apiFetch(`/security/scans/${scanId}`), fetchAllScanVulnerabilities(scanId), 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'); const scanData = (await scanRes.json()) as VulnerabilityScan; const secretsData = secretsRes.ok ? await secretsRes.json() : { items: [] }; const misconfigsData = misconfigsRes.ok ? await misconfigsRes.json() : { items: [] }; setScan(scanData); setDetails(allVulns); setTotalDetails(allVulns.length); setSecrets(Array.isArray(secretsData.items) ? secretsData.items : []); setMisconfigs(Array.isArray(misconfigsData.items) ? misconfigsData.items : []); setPage(0); setSecretsPage(0); setMisconfigsPage(0); if (initialTab) { // Caller asked to land on a specific tab (e.g. opened from the // Secrets or Compose-risks list), which wins over the default. setTab(initialTab); } else 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, initialTab]); 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: '', status: 'accepted', }); }, []); 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, status: suppressForm.status, }), }); 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(async () => { if (!scan || details.length === 0) return; setExportingCsv(true); try { // The table renders a capped page; the CSV is the complete-list recovery // path the in-sheet notice promises, so fetch every row when the loaded // set is short of the total. Otherwise reuse what is already in memory. const rows = details.length < totalDetails ? await fetchAllScanVulnerabilities(scan.id) : details; const escape = (v: string) => `"${v.replace(/"/g, '""')}"`; const csv = 'CVE,Package,Severity,Installed,Fixed,URL\n' + rows .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 url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); 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); toast.success(`Exported ${rows.length} ${rows.length === 1 ? 'vulnerability' : 'vulnerabilities'}`); } catch (err) { toast.error((err as Error)?.message || 'CSV export failed'); } finally { setExportingCsv(false); } }, [scan, details, totalDetails]); 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} {scan.publicly_exposed === true && ( Published service )} ) : (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: exportingCsv ? Loader2 : Download, onClick: () => { void exportCsv(); }, disabled: exportingCsv, }] : []), ...(canExportSarif && 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" noScroll > {loading && !scan && (
)} {scan && ( // 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).
{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}
)}
{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.

How this finding was triaged. Decided states (accepted, not affected, false positive, fixed, ignored) stop driving the posture; needs review stays counted but actionable.