mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +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:
@@ -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