fix: differentiate security action links and add suppression editing (#1500)

Security page UX fixes:

- Stop the CVSS x EPSS scatter chart from painting a full-plot "white
  rectangle" cursor on click (cursor disabled), and prevent click-drag
  selection on charts.
- Differentiate the overview action links: "fixable" links (masthead primary
  action, review-queue blocker, and the Fixable signal tile) now open the
  Images tab pre-filtered to fixable findings; the Stale and Failed signal
  tiles link to the History tab where those scans are listed; Secrets and
  Misconfigs tiles link to their tabs. The Images tab accepts an initialFilter
  and exposes a Fixable option in the severity dropdown.
- Fix the "Secrets / misconfigs" option wrapping and misaligning in the
  severity dropdown (single-line option labels, wider trigger).
- Add Edit for CVE suppressions and misconfig acknowledgements (reason, scope
  pattern, expiry), reusing the existing dialog and the existing PUT endpoints;
  the CVE/rule identity stays fixed.
This commit is contained in:
Anso
2026-06-28 06:17:47 -04:00
committed by GitHub
parent ba57c67048
commit 083442d5ea
15 changed files with 316 additions and 115 deletions
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react';
import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2 } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
@@ -40,6 +40,8 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<AckFormState>(EMPTY_FORM);
// The row being edited, or null when the dialog is creating a new ack.
const [editRow, setEditRow] = useState<MisconfigAcknowledgement | null>(null);
const [saving, setSaving] = useState(false);
const [deleteRow, setDeleteRow] = useState<MisconfigAcknowledgement | null>(null);
const [page, setPage] = useState(0);
@@ -68,13 +70,29 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
const needsPagination = rows.length > PAGE_SIZE;
const openCreate = () => {
setEditRow(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (row: MisconfigAcknowledgement) => {
setEditRow(row);
setForm({
ruleId: row.rule_id,
stackPattern: row.stack_pattern ?? '',
reason: row.reason,
// Recompute expiry as days from now; blank means no expiry. The rule id is
// identity and not editable.
expiresInDays: row.expires_at === null ? '' : String(Math.max(1, Math.ceil((row.expires_at - Date.now()) / 86_400_000))),
});
setDialogOpen(true);
};
const handleSave = async () => {
// The rule id identifies an acknowledgement, so it is only validated and sent
// on create; edit updates stack pattern, reason, and expiry.
const ruleId = form.ruleId.trim();
if (!RULE_RE.test(ruleId)) {
if (!editRow && !RULE_RE.test(ruleId)) {
toast.error('Rule id must be alpha-numeric (e.g. "DS002" or "AVD-DS-0002").');
return;
}
@@ -95,25 +113,35 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
}
setSaving(true);
try {
const res = await apiFetch('/security/misconfig-acks', {
method: 'POST',
localOnly: true,
body: JSON.stringify({
rule_id: ruleId,
stack_pattern: form.stackPattern.trim() || null,
reason,
expires_at: expiresAt,
}),
});
const res = editRow
? await apiFetch(`/security/misconfig-acks/${editRow.id}`, {
method: 'PUT',
localOnly: true,
body: JSON.stringify({
stack_pattern: form.stackPattern.trim() || null,
reason,
expires_at: expiresAt,
}),
})
: await apiFetch('/security/misconfig-acks', {
method: 'POST',
localOnly: true,
body: JSON.stringify({
rule_id: ruleId,
stack_pattern: form.stackPattern.trim() || null,
reason,
expires_at: expiresAt,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error || 'Failed to create acknowledgement');
throw new Error(body?.error || `Failed to ${editRow ? 'update' : 'create'} acknowledgement`);
}
toast.success('Acknowledgement created');
toast.success(editRow ? 'Acknowledgement updated' : 'Acknowledgement created');
setDialogOpen(false);
await load();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to create acknowledgement');
toast.error((err as Error)?.message || `Failed to ${editRow ? 'update' : 'create'} acknowledgement`);
} finally {
setSaving(false);
}
@@ -228,15 +256,26 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
</div>
</div>
{isAdmin && !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 acknowledgement"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-stat-subtitle hover:text-stat-value"
onClick={() => openEdit(row)}
title="Edit acknowledgement"
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteRow(row)}
title="Remove acknowledgement"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
</li>
))}
@@ -247,9 +286,11 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
<ModalHeader
kicker="ACKNOWLEDGEMENTS · NEW"
title="New misconfig acknowledgement"
description="Accept a known-benign misconfiguration so it stops triggering alerts across the fleet."
kicker={editRow ? 'ACKNOWLEDGEMENTS · EDIT' : 'ACKNOWLEDGEMENTS · NEW'}
title={editRow ? 'Edit acknowledgement' : 'New misconfig acknowledgement'}
description={editRow
? 'Update the reason, stack scope, or expiry. The rule id is fixed.'
: 'Accept a known-benign misconfiguration so it stops triggering alerts across the fleet.'}
/>
<ModalBody>
<div className="space-y-2">
@@ -258,6 +299,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
id="ack-rule"
placeholder="DS002 or AVD-DS-0002"
value={form.ruleId}
disabled={!!editRow}
onChange={(e) => setForm({ ...form, ruleId: e.target.value })}
/>
</div>
@@ -300,7 +342,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
}
primary={
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Create'}
{saving ? 'Saving...' : editRow ? 'Save changes' : 'Create'}
</Button>
}
/>
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { ChevronLeft, ChevronRight, Plus, Trash2, Download } from 'lucide-react';
import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2, Download } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
@@ -44,6 +44,8 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<SuppressionFormState>(EMPTY_FORM);
// The row being edited, or null when the dialog is creating a new suppression.
const [editRow, setEditRow] = useState<CveSuppression | null>(null);
const [saving, setSaving] = useState(false);
const [deleteRow, setDeleteRow] = useState<CveSuppression | null>(null);
const [page, setPage] = useState(0);
@@ -72,13 +74,30 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const needsPagination = rows.length > PAGE_SIZE;
const openCreate = () => {
setEditRow(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (row: CveSuppression) => {
setEditRow(row);
setForm({
cveId: row.cve_id,
pkgName: row.pkg_name ?? '',
imagePattern: row.image_pattern ?? '',
reason: row.reason,
// Recompute the expiry as days from now so the same control edits it; blank
// means "no expiry". The CVE id and package scope are identity, not editable.
expiresInDays: row.expires_at === null ? '' : String(Math.max(1, Math.ceil((row.expires_at - Date.now()) / 86_400_000))),
});
setDialogOpen(true);
};
const handleSave = async () => {
// CVE id and package scope identify a suppression, so they are only validated
// and sent on create; the edit endpoint updates reason, image pattern, expiry.
const cveId = form.cveId.trim();
if (!CVE_ID_RE.test(cveId)) {
if (!editRow && !CVE_ID_RE.test(cveId)) {
toast.error('CVE must look like CVE-YYYY-NNNN or GHSA-xxxx-xxxx-xxxx.');
return;
}
@@ -99,26 +118,36 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
}
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,
}),
});
const res = editRow
? await apiFetch(`/security/suppressions/${editRow.id}`, {
method: 'PUT',
localOnly: true,
body: JSON.stringify({
image_pattern: form.imagePattern.trim() || null,
reason,
expires_at: expiresAt,
}),
})
: 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');
throw new Error(body?.error || `Failed to ${editRow ? 'update' : 'create'} suppression`);
}
toast.success('Suppression created');
toast.success(editRow ? 'Suppression updated' : 'Suppression created');
setDialogOpen(false);
await load();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to create suppression');
toast.error((err as Error)?.message || `Failed to ${editRow ? 'update' : 'create'} suppression`);
} finally {
setSaving(false);
}
@@ -264,15 +293,26 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
</div>
</div>
{isAdmin && !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>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-stat-subtitle hover:text-stat-value"
onClick={() => openEdit(row)}
title="Edit suppression"
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteRow(row)}
title="Remove suppression"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
</li>
))}
@@ -283,9 +323,11 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
<ModalHeader
kicker="SUPPRESSIONS · NEW"
title="New suppression"
description="Accept a CVE as known-benign so it stops triggering alerts across the fleet."
kicker={editRow ? 'SUPPRESSIONS · EDIT' : 'SUPPRESSIONS · NEW'}
title={editRow ? 'Edit suppression' : 'New suppression'}
description={editRow
? 'Update the reason, image scope, or expiry. The CVE and package scope are fixed.'
: 'Accept a CVE as known-benign so it stops triggering alerts across the fleet.'}
/>
<ModalBody>
<div className="space-y-2">
@@ -294,6 +336,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
id="s-cve"
placeholder="CVE-2024-12345 or GHSA-xxxx-xxxx-xxxx"
value={form.cveId}
disabled={!!editRow}
onChange={(e) => setForm({ ...form, cveId: e.target.value })}
/>
</div>
@@ -303,6 +346,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
id="s-pkg"
placeholder="e.g. openssl (leave blank to match every package)"
value={form.pkgName}
disabled={!!editRow}
onChange={(e) => setForm({ ...form, pkgName: e.target.value })}
/>
</div>
@@ -345,7 +389,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
}
primary={
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Create'}
{saving ? 'Saving...' : editRow ? 'Save changes' : 'Create'}
</Button>
}
/>