mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
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:
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user