mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
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:
@@ -1491,6 +1491,7 @@ export default function ResourcesView() {
|
||||
onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, true); }}
|
||||
canGenerateSbom={isPaid}
|
||||
canCompare={isPaid}
|
||||
canManageSuppressions={isPaid && isAdmin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user