Files
sencho/frontend/src/components/VulnerabilityScanSheet.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

1040 lines
46 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ShieldCheck,
ShieldOff,
ExternalLink,
ChevronLeft,
ChevronRight,
RefreshCw,
Download,
Loader2,
Check,
GitCompare,
KeyRound,
FileWarning,
} from 'lucide-react';
import { Combobox } from '@/components/ui/combobox';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
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 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;
}
type SeverityFilter = 'ALL' | VulnSeverity;
type FindingTab = 'vulns' | 'secrets' | 'misconfigs';
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-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 (
<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>
);
}
export function VulnerabilityScanSheet({
scanId,
onClose,
onRescan,
canGenerateSbom = false,
canCompare = false,
canManageSuppressions = false,
}: VulnerabilityScanSheetProps) {
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 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 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]);
return (
<Sheet open={scanId != null} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="sm:max-w-2xl 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">
Vulnerability scan · {scan?.triggered_by ?? '-'}
</div>
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
<ShieldCheck className="w-5 h-5 text-muted-foreground" strokeWidth={1.5} />
<span className="font-mono text-base truncate not-italic">
{scan?.image_ref ?? 'Loading...'}
</span>
</SheetTitle>
<SheetDescription className="sr-only">
{scan
? `Vulnerability scan results for ${scan.image_ref}: ${scan.total_vulnerabilities} total findings.`
: 'Vulnerability scan details.'}
</SheetDescription>
</SheetHeader>
{loading && !scan && (
<div className="flex items-center justify-center flex-1">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
)}
{scan && (
<div className="flex flex-col flex-1 min-h-0">
{/* Summary stats */}
<div className="px-6 py-4 border-b space-y-3">
{scan.policy_evaluation?.violated && (
<div
role="alert"
className="relative rounded border border-destructive/40 bg-destructive/10 px-3 py-2 pl-4 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">
<span className="font-mono">{scan.policy_evaluation.policyName}</span> blocks
severities at or above{' '}
<span className="font-mono tabular-nums">
{scan.policy_evaluation.maxSeverity}
</span>
. This scan's highest severity is{' '}
<span className="font-mono tabular-nums">
{scan.highest_severity ?? 'UNKNOWN'}
</span>
.
</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{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>
<div className="flex flex-wrap items-center gap-2 pt-2">
{onRescan && (
<Button
variant="outline"
size="sm"
onClick={() => onRescan(scan.image_ref)}
disabled={scan.status === 'in_progress'}
>
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
Re-scan
</Button>
)}
{canGenerateSbom && (
<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>
)}
<Button variant="outline" size="sm" onClick={exportCsv} disabled={details.length === 0}>
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
CSV
</Button>
{canGenerateSbom && (
<Button
variant="outline"
size="sm"
onClick={exportSarif}
disabled={scan.status !== 'completed'}
title="Export findings as SARIF 2.1.0 for GitHub code scanning"
>
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
SARIF
</Button>
)}
{canCompare && (
<Button
variant="outline"
size="sm"
onClick={openCompareMenu}
disabled={compareLoading}
title="Compare this scan to a previous one for the same image"
>
{compareLoading ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<GitCompare className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Compare
</Button>
)}
</div>
{compareOpen && canCompare && (
<div className="pt-2 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>
)}
</div>
<Tabs
value={tab}
onValueChange={(v) => setTab(v as FindingTab)}
className="flex flex-col flex-1 min-h-0"
>
<div className="px-6 pt-3">
<TabsList>
<TabsTrigger value="vulns" className="gap-1.5">
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
Vulnerabilities
<span className="font-mono tabular-nums text-stat-subtitle">
({totalDetails})
</span>
</TabsTrigger>
<TabsTrigger value="secrets" className="gap-1.5">
<KeyRound className="w-3.5 h-3.5" strokeWidth={1.5} />
Secrets
<span className="font-mono tabular-nums text-stat-subtitle">
({scan.secret_count ?? secrets.length})
</span>
</TabsTrigger>
<TabsTrigger value="misconfigs" className="gap-1.5">
<FileWarning className="w-3.5 h-3.5" strokeWidth={1.5} />
Misconfigs
<span className="font-mono tabular-nums text-stat-subtitle">
({scan.misconfig_count ?? misconfigs.length})
</span>
</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="vulns"
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
>
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
{(['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>
{totalDetails > details.length && (
<div className="px-6 pt-2 text-xs text-stat-subtitle font-mono">
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
</div>
)}
<ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-3">
{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>
<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">
<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>
</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>
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="secrets"
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
>
{secretsNeedsPagination && (
<div className="px-6 pt-3 flex items-center gap-1">
<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 className="flex-1 min-h-0">
<div className="px-6 py-3">
{secrets.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
No secrets detected.
</div>
) : (
<Table>
<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>
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="misconfigs"
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
>
{misconfigsNeedsPagination && (
<div className="px-6 pt-3 flex items-center gap-1">
<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 className="flex-1 min-h-0">
<div className="px-6 py-3">
{misconfigs.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
No misconfigurations detected.
</div>
) : (
<Table>
<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>
</TableRow>
</TableHeader>
<TableBody>
{misconfigsPageItems.map((m) => (
<TableRow key={m.id} className={SEVERITY_ROW_TINT[m.severity]}>
<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="text-xs">
<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>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
)}
</SheetContent>
<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-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>
</Sheet>
);
}
export { SeverityChip };
export type { VulnerabilityScanSheetProps };