feat(security): fleet-replicated CVE suppression list (#650)

Operators can accept known-benign findings once and have Sencho filter
them out of scan drawers, comparison views, and other read surfaces.
Suppressions replicate from the control instance to every remote node.

* New cve_suppressions table with a COALESCE-based unique index so NULL
  scope slots collide the way users expect
* Admin + paid-tier CRUD routes; writes are rejected on replicas
* Read-time filter enriches vulnerability details and compare payloads
  without mutating stored counts
* Settings > Security panel for managing rules, per-CVE suppress action
  in the scan drawer, dimmed rows with a shield-off indicator
* Vitest unit tests for the filter (glob, expiry, specificity) and
  route tests (auth, tier, replica, UNIQUE conflict)
This commit is contained in:
Anso
2026-04-17 05:16:34 -04:00
committed by GitHub
parent 708d15b2b3
commit 732fc95415
16 changed files with 1568 additions and 41 deletions
@@ -1491,6 +1491,7 @@ export default function ResourcesView() {
onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, true); }}
canGenerateSbom={isPaid}
canCompare={isPaid}
canManageSuppressions={isPaid && isAdmin}
/>
</div>
);
+24 -13
View File
@@ -19,6 +19,7 @@ import {
MinusCircle,
PlusCircle,
ShieldCheck,
ShieldOff,
Equal,
AlertTriangle,
} from 'lucide-react';
@@ -282,27 +283,37 @@ export function ScanComparisonSheet({
</TableHeader>
<TableBody>
{pageItems.map((v, idx) => {
const rowClass =
const baseRowClass =
filter === 'added'
? 'bg-destructive/5'
: filter === 'removed'
? 'bg-success/5'
: 'opacity-70';
const rowClass = cn(baseRowClass, v.suppressed && 'opacity-60');
return (
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
<TableCell className="font-mono text-xs">
{v.primary_url ? (
<a
href={v.primary_url}
target="_blank"
rel="noreferrer noopener"
className="hover:underline"
>
{v.vulnerability_id}
</a>
) : (
v.vulnerability_id
)}
<span className="inline-flex items-center gap-1.5">
{v.suppressed && (
<ShieldOff
className="w-3 h-3 text-muted-foreground"
strokeWidth={1.5}
aria-label="Suppressed"
/>
)}
{v.primary_url ? (
<a
href={v.primary_url}
target="_blank"
rel="noreferrer noopener"
className="hover:underline"
>
{v.vulnerability_id}
</a>
) : (
v.vulnerability_id
)}
</span>
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={v.pkg_name}>
{v.pkg_name}
@@ -28,6 +28,7 @@ import { ScanComparisonSheet } from './ScanComparisonSheet';
import { SeverityChip } from './VulnerabilityScanSheet';
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import type { VulnerabilityScan } from '@/types/security';
@@ -56,6 +57,7 @@ function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] {
export function SecurityHistoryView() {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
const [loading, setLoading] = useState(false);
@@ -304,6 +306,7 @@ export function SecurityHistoryView() {
onClose={() => setInspectScanId(null)}
canGenerateSbom={isPaid}
canCompare={false}
canManageSuppressions={isPaid && isAdmin}
/>
</div>
);
@@ -18,6 +18,7 @@ import {
} from '@/components/ui/dropdown-menu';
import {
ShieldCheck,
ShieldOff,
ExternalLink,
ChevronLeft,
ChevronRight,
@@ -28,6 +29,16 @@ import {
GitCompare,
} from 'lucide-react';
import { Combobox } from '@/components/ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScanComparisonSheet } from './ScanComparisonSheet';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
@@ -44,6 +55,15 @@ interface VulnerabilityScanSheetProps {
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;
@@ -77,6 +97,7 @@ export function VulnerabilityScanSheet({
onRescan,
canGenerateSbom = false,
canCompare = false,
canManageSuppressions = false,
}: VulnerabilityScanSheetProps) {
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
@@ -89,6 +110,8 @@ export function VulnerabilityScanSheet({
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;
@@ -194,6 +217,60 @@ export function VulnerabilityScanSheet({
}
}, [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';
@@ -446,27 +523,40 @@ export function VulnerabilityScanSheet({
<TableHead className="w-[100px]">Severity</TableHead>
<TableHead className="w-[110px]">Installed</TableHead>
<TableHead className="w-[110px]">Fixed</TableHead>
{canManageSuppressions && <TableHead className="w-[40px]" />}
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((d) => (
<TableRow key={d.id}>
<TableRow key={d.id} className={d.suppressed ? 'opacity-60' : undefined}>
<TableCell className="font-mono text-xs">
{d.primary_url ? (
<a
href={d.primary_url}
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 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"
/>
)}
{d.primary_url ? (
<a
href={d.primary_url}
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.pkg_name}>
<TableCell
className="font-mono text-xs truncate max-w-[180px]"
title={d.suppression_reason || d.pkg_name}
>
{d.pkg_name}
</TableCell>
<TableCell>
@@ -483,6 +573,21 @@ export function VulnerabilityScanSheet({
<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>
@@ -498,6 +603,83 @@ export function VulnerabilityScanSheet({
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>
);
}
@@ -32,6 +32,7 @@ import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info }
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
import { useLicense } from '@/context/LicenseContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { SuppressionsPanel } from './SuppressionsPanel';
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
{ value: 'CRITICAL', label: 'Critical' },
@@ -452,6 +453,8 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</div>
))}
<SuppressionsPanel isReplica={isReplica} />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -0,0 +1,366 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ChevronLeft, ChevronRight, Plus, ShieldOff, Trash2 } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import type { CveSuppression } from '@/types/security';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
const PAGE_SIZE = 8;
interface SuppressionFormState {
cveId: string;
pkgName: string;
imagePattern: string;
reason: string;
expiresInDays: string;
}
const EMPTY_FORM: SuppressionFormState = {
cveId: '',
pkgName: '',
imagePattern: '',
reason: '',
expiresInDays: '',
};
interface SuppressionsPanelProps {
isReplica: boolean;
}
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const [rows, setRows] = useState<CveSuppression[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<SuppressionFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [deleteRow, setDeleteRow] = useState<CveSuppression | null>(null);
const [page, setPage] = useState(0);
const load = useCallback(async () => {
try {
const res = await apiFetch('/security/suppressions', { localOnly: true });
if (res.ok) {
const data = await res.json();
setRows(Array.isArray(data) ? data : []);
}
} catch (err) {
console.error('Failed to load suppressions:', err);
toast.error('Failed to load suppressions');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const needsPagination = rows.length > PAGE_SIZE;
const openCreate = () => {
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const handleSave = async () => {
const cveId = form.cveId.trim();
if (!CVE_ID_RE.test(cveId)) {
toast.error('CVE must look like CVE-YYYY-NNNN or GHSA-xxxx-xxxx-xxxx.');
return;
}
const reason = form.reason.trim();
if (!reason) {
toast.error('A reason is required.');
return;
}
let expiresAt: number | null = null;
const days = form.expiresInDays.trim();
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;
}
setSaving(true);
try {
const res = await apiFetch('/security/suppressions', {
method: 'POST',
localOnly: true,
body: JSON.stringify({
cve_id: cveId,
pkg_name: form.pkgName.trim() || null,
image_pattern: form.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');
setDialogOpen(false);
await load();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to create suppression');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteRow) return;
try {
const res = await apiFetch(`/security/suppressions/${deleteRow.id}`, {
method: 'DELETE',
localOnly: true,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error || 'Failed to delete suppression');
}
toast.success('Suppression removed');
await load();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to delete suppression');
} finally {
setDeleteRow(null);
}
};
const formatExpiry = (row: CveSuppression): string => {
if (row.expires_at === null) return 'Never';
const d = new Date(row.expires_at);
return d.toLocaleDateString();
};
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<ShieldOff className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm">CVE Suppressions</span>
<Badge variant="outline" className="text-[10px] shrink-0 font-mono tabular-nums">
{rows.length}
</Badge>
</div>
<div className="flex items-center gap-1 shrink-0">
{needsPagination && (
<>
<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>
</>
)}
{!isReplica && (
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add Suppression
</Button>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across every
instance in the fleet and never modify stored scan data.
</p>
{loading && (
<div className="space-y-2">
<Skeleton className="h-10 w-full rounded" />
<Skeleton className="h-10 w-full rounded" />
</div>
)}
{!loading && rows.length === 0 && (
<div className="text-center py-6 text-xs text-muted-foreground">
No suppressions yet. Accept a CVE from any scan result to silence it fleet-wide.
</div>
)}
{!loading && rows.length > 0 && (
<ScrollArea className="max-h-[420px] pr-2">
<ul className="divide-y divide-glass-border">
{pageItems.map((row) => (
<li key={row.id} className="py-2.5 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{row.cve_id}</span>
{row.pkg_name && (
<Badge variant="outline" className="text-[10px] font-mono">
{row.pkg_name}
</Badge>
)}
{row.image_pattern && (
<Badge variant="outline" className="text-[10px] font-mono truncate max-w-[220px]">
{row.image_pattern}
</Badge>
)}
{!row.active && (
<Badge variant="secondary" className="text-[10px]">expired</Badge>
)}
{row.replicated_from_control === 1 && (
<Badge variant="secondary" className="text-[10px]">replicated</Badge>
)}
</div>
<div className="text-xs text-muted-foreground line-clamp-2">{row.reason}</div>
<div className="text-[11px] font-mono text-stat-subtitle">
by {row.created_by} - expires {formatExpiry(row)}
</div>
</div>
{!isReplica && row.replicated_from_control === 0 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground shrink-0"
onClick={() => setDeleteRow(row)}
title="Remove suppression"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
)}
</li>
))}
</ul>
</ScrollArea>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New Suppression</DialogTitle>
<DialogDescription className="sr-only">
Accept a CVE as known-benign so it stops triggering alerts across the fleet.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="s-cve">CVE or advisory ID</Label>
<Input
id="s-cve"
placeholder="CVE-2024-12345 or GHSA-xxxx-xxxx-xxxx"
value={form.cveId}
onChange={(e) => setForm({ ...form, cveId: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="s-pkg">Package (optional)</Label>
<Input
id="s-pkg"
placeholder="e.g. openssl (leave blank to match every package)"
value={form.pkgName}
onChange={(e) => setForm({ ...form, pkgName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="s-image">Image pattern (optional)</Label>
<Input
id="s-image"
placeholder="e.g. registry.internal/* (leave blank for all images)"
value={form.imagePattern}
onChange={(e) => setForm({ ...form, imagePattern: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="s-reason">Reason</Label>
<textarea
id="s-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={form.reason}
onChange={(e) => setForm({ ...form, reason: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="s-expiry">Expires in (days, optional)</Label>
<Input
id="s-expiry"
type="number"
min="1"
placeholder="Leave blank for no expiry"
value={form.expiresInDays}
onChange={(e) => setForm({ ...form, expiresInDays: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteRow !== null} onOpenChange={(open) => !open && setDeleteRow(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove suppression?</AlertDialogTitle>
<AlertDialogDescription>
Future scan results will surface {deleteRow?.cve_id} again wherever it applies.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}