Files
sencho/frontend/src/components/VulnerabilityScanSheet.tsx
T
Anso 1bca75a999 fix: load the full vulnerability list in the scan detail sheet (#1483)
The scan detail sheet fetched only the first 500 vulnerabilities for its
interactive table, so severity filtering, row inspection, and suppression
management could not reach findings beyond the first page on a scan with more
than 500. The CSV export already paged the complete list, but that is not a
substitute for working with the findings in the table.

The sheet now loads every vulnerability via the same paged helper the CSV uses,
so the table, filter, pagination, inspection, and suppression all operate over
the complete set. The "showing first N of M, export CSV for the complete list"
notice is removed because the table is no longer capped. The CSV export reuses
the already-complete in-memory set rather than refetching.

Secrets and misconfigurations keep their existing per-request cap; they are not
the suppression-managed findings this blocker concerns and rarely exceed it.
2026-06-26 21:12:18 -04:00

1282 lines
55 KiB
TypeScript

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<VulnSeverity, string> = {
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 (
<span
className={cn(
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel',
SEVERITY_CLASSES[severity],
)}
>
{severity}
</span>
);
}
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 (
<span className={cn('inline-flex items-center rounded border px-1.5 py-px text-[9px] font-mono uppercase tracking-[0.1em]', EVIDENCE_TAG_CLASSES[tone])}>
{children}
</span>
);
}
/**
* 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(<EvidenceTag key="kev" tone="danger">KEV</EvidenceTag>);
if (typeof d.epss_score === 'number') {
tags.push(
<EvidenceTag key="epss" tone={d.epss_score >= 0.1 ? 'warn' : 'muted'}>
EPSS {Math.round(d.epss_score * 100)}%
</EvidenceTag>,
);
}
if (d.status === 'will_not_fix' || d.status === 'end_of_life') {
tags.push(<EvidenceTag key="wontfix" tone="muted">{"Won't fix"}</EvidenceTag>);
}
if (typeof d.cvss_score === 'number') {
tags.push(<EvidenceTag key="cvss" tone="neutral">CVSS {d.cvss_score}</EvidenceTag>);
}
if (tags.length === 0) return null;
return <span className="mt-1 flex flex-wrap items-center gap-1">{tags}</span>;
}
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<VulnerabilityScan | null>(null);
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
const [totalDetails, setTotalDetails] = useState(0);
const [secrets, setSecrets] = useState<SecretFinding[]>([]);
const [misconfigs, setMisconfigs] = useState<MisconfigFinding[]>([]);
const [loading, setLoading] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
const [page, setPage] = useState(0);
const [secretsPage, setSecretsPage] = useState(0);
const [misconfigsPage, setMisconfigsPage] = useState(0);
const [tab, setTab] = useState<FindingTab>('vulns');
const [downloadingSbom, setDownloadingSbom] = useState(false);
const [compareOpen, setCompareOpen] = useState(false);
const [compareOptions, setCompareOptions] = useState<VulnerabilityScan[]>([]);
const [compareLoading, setCompareLoading] = useState(false);
const [compareBaselineId, setCompareBaselineId] = useState<number | null>(null);
const [suppressForm, setSuppressForm] = useState<SuppressDialogState | null>(null);
const [savingSuppression, setSavingSuppression] = useState(false);
const [ackForm, setAckForm] = useState<AckDialogState | null>(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 ? (
<span className="inline-flex flex-wrap items-center gap-2">
{scan.total_vulnerabilities} vulns · {scan.fixable_count} fixable · {scan.triggered_by}
{scan.publicly_exposed === true && (
<EvidenceTag tone="warn">Published service</EvidenceTag>
)}
</span>
) : (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 (
<>
<SystemSheet
open={scanId != null}
onOpenChange={(open) => !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 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
)}
{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).
<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"
className="relative rounded border border-destructive/40 bg-destructive/10 px-3 py-2 pl-4 mb-3 shadow-card-bevel"
>
<span
aria-hidden="true"
className="absolute left-0 top-0 bottom-0 w-[3px] bg-destructive/80 rounded-l"
/>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-destructive">
Policy violation
</div>
<div className="text-sm text-stat-value mt-0.5">
This scan violates{' '}
<span className="font-mono">{scan.policy_evaluation.policyName}</span>
{scan.policy_evaluation.reasons.length > 0 ? (
<>: matched {formatPolicyReasons(scan.policy_evaluation.reasons, scan.policy_evaluation.maxSeverity)}.</>
) : (
<>.</>
)}
</div>
</div>
)}
<div className="flex flex-wrap gap-2 mb-3">
{scan.critical_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.CRITICAL)}>
{scan.critical_count} CRITICAL
</span>
)}
{scan.high_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.HIGH)}>
{scan.high_count} HIGH
</span>
)}
{scan.medium_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.MEDIUM)}>
{scan.medium_count} MEDIUM
</span>
)}
{scan.low_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.LOW)}>
{scan.low_count} LOW
</span>
)}
{scan.total_vulnerabilities === 0 && (
<span className="rounded border border-success/40 bg-success/10 text-success px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel">
No vulnerabilities
</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Total</div>
<div className="font-mono tabular-nums text-stat-value">{scan.total_vulnerabilities}</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Fixable</div>
<div className="font-mono tabular-nums text-success">{scan.fixable_count}</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Triggered</div>
<div className="font-mono tabular-nums text-stat-value">{scan.triggered_by}</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Scanned</div>
<div className="font-mono tabular-nums text-stat-value">
{new Date(scan.scanned_at).toLocaleString()}
</div>
</div>
</div>
{canGenerateSbom && (
<div className="flex flex-wrap items-center gap-2 mt-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={downloadingSbom}>
{downloadingSbom ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
SBOM
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => downloadSbom('spdx-json')}>
SPDX JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={() => downloadSbom('cyclonedx')}>
CycloneDX
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{compareOpen && canCompare && (
<div className="pt-3 space-y-2">
{compareOptions.length === 0 && !compareLoading ? (
<div className="rounded border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
No other completed scans for this image yet. Run a second scan to enable comparison.
</div>
) : (
<>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Compare against
</div>
<Combobox
options={compareOptions.map((s) => ({
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..."
/>
</>
)}
</div>
)}
</SheetSection>
{tab === 'vulns' && (
<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
key={s}
variant={severityFilter === s ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => {
setSeverityFilter(s);
setPage(0);
}}
>
{s}
</Button>
))}
{needsPagination && (
<div className="flex items-center gap-1 ml-auto">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
>
<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">
{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}
>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
<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
? 'No vulnerabilities found.'
: 'No vulnerabilities match the selected filter.'}
</div>
) : (
<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>
<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">Installed</TableHead>
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fixed</TableHead>
{canManageSuppressions && <TableHead className="w-[40px]" />}
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((d) => {
const href = cveUrl(d.vulnerability_id, d.primary_url);
return (
<TableRow
key={d.id}
className={cn(SEVERITY_ROW_TINT[d.severity], d.suppressed && 'opacity-60')}
>
<TableCell className="font-mono text-xs tabular-nums align-top">
<span className="flex flex-col">
<span className="inline-flex items-center gap-1.5">
{d.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="inline-flex items-center gap-1 hover:underline"
>
{d.vulnerability_id}
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
) : (
d.vulnerability_id
)}
</span>
<EvidenceTags d={d} />
</span>
</TableCell>
<TableCell
className="font-mono text-xs truncate max-w-[180px]"
title={d.suppression_reason || d.pkg_name}
>
{d.pkg_name}
</TableCell>
<TableCell>
<SeverityChip severity={d.severity} />
</TableCell>
<TableCell className="font-mono text-xs">{d.installed_version}</TableCell>
<TableCell className="font-mono text-xs">
{d.fixed_version ? (
<span className="inline-flex items-center gap-1 text-success">
<Check className="w-3 h-3" strokeWidth={1.5} />
{d.fixed_version}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
{canManageSuppressions && (
<TableCell>
{!d.suppressed && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
title="Suppress this CVE"
onClick={() => openSuppressDialog(d)}
>
<ShieldOff className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
)}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ScrollArea>
</SheetSection>
)}
{tab === 'secrets' && (
<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">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setSecretsPage(Math.max(0, secretsSafePage - 1))}
disabled={secretsSafePage === 0}
>
<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">
{secretsSafePage + 1} / {secretsTotalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() =>
setSecretsPage(Math.min(secretsTotalPages - 1, secretsSafePage + 1))
}
disabled={secretsSafePage >= secretsTotalPages - 1}
>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
)}
<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 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>
<TableHead className="w-[160px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Rule</TableHead>
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
<TableHead className="w-[260px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{secretsPageItems.map((s) => (
<TableRow key={s.id} className={SEVERITY_ROW_TINT[s.severity]}>
<TableCell>
<SeverityChip severity={s.severity} />
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[160px]" title={s.rule_id}>
{s.rule_id}
</TableCell>
<TableCell className="text-xs">
<div className="truncate max-w-[320px]" title={s.title ?? undefined}>
{s.title || <span className="text-muted-foreground">-</span>}
</div>
{s.match_excerpt && (
<div
className="font-mono text-[11px] text-muted-foreground truncate max-w-[320px]"
title={s.match_excerpt}
>
{s.match_excerpt}
</div>
)}
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[260px]" title={s.target}>
{s.target}
{s.start_line != null && (
<span className="text-muted-foreground">
:{s.start_line}
{s.end_line != null && s.end_line !== s.start_line
? `-${s.end_line}`
: ''}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</ScrollArea>
</SheetSection>
)}
{tab === 'misconfigs' && (
<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">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setMisconfigsPage(Math.max(0, misconfigsSafePage - 1))}
disabled={misconfigsSafePage === 0}
>
<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">
{misconfigsSafePage + 1} / {misconfigsTotalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() =>
setMisconfigsPage(
Math.min(misconfigsTotalPages - 1, misconfigsSafePage + 1),
)
}
disabled={misconfigsSafePage >= misconfigsTotalPages - 1}
>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
)}
<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 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>
<TableHead className="w-[140px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Check</TableHead>
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
<TableHead className="w-[200px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
<TableHead className="w-[220px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fix</TableHead>
{canManageSuppressions && <TableHead className="w-[40px]" />}
</TableRow>
</TableHeader>
<TableBody>
{misconfigsPageItems.map((m) => (
<TableRow
key={m.id}
className={cn(
SEVERITY_ROW_TINT[m.severity],
m.acknowledged && 'opacity-60',
)}
title={
m.acknowledged
? `Acknowledged: ${m.acknowledgement_reason ?? '(no reason)'}`
: undefined
}
>
<TableCell>
<SeverityChip severity={m.severity} />
</TableCell>
<TableCell
className="font-mono text-xs truncate max-w-[140px]"
title={m.check_id ?? m.rule_id}
>
{m.check_id || m.rule_id}
</TableCell>
<TableCell className={cn('text-xs', m.acknowledged && 'line-through')}>
<div className="truncate max-w-[320px]" title={m.title ?? undefined}>
{m.primary_url ? (
<a
href={m.primary_url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 hover:underline"
>
{m.title || m.rule_id}
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
) : (
m.title || m.rule_id
)}
</div>
{m.message && (
<div
className="text-[11px] text-muted-foreground truncate max-w-[320px]"
title={m.message}
>
{m.message}
</div>
)}
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[200px]" title={m.target}>
{m.target}
</TableCell>
<TableCell className="text-xs truncate max-w-[220px]" title={m.resolution ?? undefined}>
{m.resolution || <span className="text-muted-foreground">-</span>}
</TableCell>
{canManageSuppressions && (
<TableCell>
{!m.acknowledged && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => openAckDialog(m)}
title="Acknowledge this misconfiguration"
>
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</ScrollArea>
</SheetSection>
)}
</div>
)}
</SystemSheet>
<ScanComparisonSheet
baselineScanId={compareBaselineId}
currentScanId={compareBaselineId != null ? scanId : null}
onClose={() => setCompareBaselineId(null)}
/>
<Dialog
open={suppressForm !== null}
onOpenChange={(open) => !open && setSuppressForm(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Suppress CVE</DialogTitle>
<DialogDescription className="sr-only">
Accept this CVE as known-benign so it stops triggering alerts across the fleet.
</DialogDescription>
</DialogHeader>
{suppressForm && (
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">CVE</Label>
<div className="font-mono text-sm">{suppressForm.cveId}</div>
</div>
<div className="space-y-1">
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">Package</Label>
<div className="font-mono text-sm truncate" title={suppressForm.pkgName}>
{suppressForm.pkgName || '-'}
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="suppress-pattern">Image pattern (optional)</Label>
<Input
id="suppress-pattern"
placeholder="e.g. registry.internal/* (leave blank for all images)"
value={suppressForm.imagePattern}
onChange={(e) =>
setSuppressForm((f) => (f ? { ...f, imagePattern: e.target.value } : f))
}
/>
<p className="text-xs text-muted-foreground">
Glob pattern matched against the image reference. Leave blank to suppress this CVE on any image.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="suppress-status">Triage decision</Label>
<select
id="suppress-status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={suppressForm.status}
onChange={(e) =>
setSuppressForm((f) => (f ? { ...f, status: e.target.value as TriageStatus } : f))
}
>
{TRIAGE_STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<p className="text-xs text-muted-foreground">
How this finding was triaged. Decided states (accepted, not affected, false positive, fixed, ignored) stop driving the posture; needs review stays counted but actionable.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="suppress-reason">Reason</Label>
<textarea
id="suppress-reason"
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Why is this CVE safe to accept?"
value={suppressForm.reason}
onChange={(e) =>
setSuppressForm((f) => (f ? { ...f, reason: e.target.value } : f))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="suppress-expiry">Expires in (days, optional)</Label>
<Input
id="suppress-expiry"
type="number"
min="1"
placeholder="Leave blank for no expiry"
value={suppressForm.expiresInDays}
onChange={(e) =>
setSuppressForm((f) => (f ? { ...f, expiresInDays: e.target.value } : f))
}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setSuppressForm(null)} disabled={savingSuppression}>
Cancel
</Button>
<Button onClick={submitSuppression} disabled={savingSuppression}>
{savingSuppression ? 'Saving...' : 'Suppress'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={ackForm !== null}
onOpenChange={(open) => !open && setAckForm(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Acknowledge misconfiguration</DialogTitle>
<DialogDescription className="sr-only">
Accept this misconfiguration as known-benign so it stops triggering alerts across the fleet.
</DialogDescription>
</DialogHeader>
{ackForm && (
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">Rule</Label>
<div className="font-mono text-sm">{ackForm.ruleId}</div>
</div>
<div className="space-y-2">
<Label htmlFor="ack-pattern">Stack pattern (optional)</Label>
<Input
id="ack-pattern"
placeholder="e.g. traefik or web-* (leave blank for all stacks)"
value={ackForm.stackPattern}
onChange={(e) =>
setAckForm((f) => (f ? { ...f, stackPattern: e.target.value } : f))
}
/>
<p className="text-xs text-muted-foreground">
Glob pattern matched against the stack name. Prefilled with the current stack so this ack stays narrowest by default.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ack-reason">Reason</Label>
<textarea
id="ack-reason"
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Why is this misconfiguration safe to accept?"
value={ackForm.reason}
onChange={(e) =>
setAckForm((f) => (f ? { ...f, reason: e.target.value } : f))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ack-expiry">Expires in (days, optional)</Label>
<Input
id="ack-expiry"
type="number"
min="1"
placeholder="Leave blank for no expiry"
value={ackForm.expiresInDays}
onChange={(e) =>
setAckForm((f) => (f ? { ...f, expiresInDays: e.target.value } : f))
}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setAckForm(null)} disabled={savingAck}>
Cancel
</Button>
<Button onClick={submitAcknowledgement} disabled={savingAck}>
{savingAck ? 'Saving...' : 'Acknowledge'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export { SeverityChip };
export type { VulnerabilityScanSheetProps };