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
@@ -133,12 +133,12 @@ describe('derivePostureReasons', () => {
knownExploited: 1,
secrets: 2,
}));
expect(primaryAction).toEqual({ label: 'Update affected images', targetTab: 'images' });
expect(primaryAction).toEqual({ label: 'Update affected images', targetTab: 'images', kind: 'fixable_cve' });
});
it('falls through to the next blocker when the first is absent', () => {
const { primaryAction } = derivePostureReasons(facts({ secrets: 1 }));
expect(primaryAction).toEqual({ label: 'Review detected secrets', targetTab: 'secrets' });
expect(primaryAction).toEqual({ label: 'Review detected secrets', targetTab: 'secrets', kind: 'secret' });
});
it('returns null primaryAction when no blockers exist', () => {
+8 -5
View File
@@ -60,6 +60,9 @@ export interface PostureReason {
export interface PostureAction {
label: string;
targetTab: SecurityPostureTargetTab;
/** The reason kind that produced this action, so the UI can target the
* affected items precisely (e.g. filter Images to fixable findings). */
kind: PostureReasonKind;
}
export interface SecurityPostureFacts {
@@ -123,7 +126,7 @@ export function derivePostureReasons(f: SecurityPostureFacts): {
targetTab: 'images',
};
reasons.push(r);
if (!primaryAction) primaryAction = { label: 'Update affected images', targetTab: 'images' };
if (!primaryAction) primaryAction = { label: 'Update affected images', targetTab: r.targetTab, kind: r.kind };
}
if (f.knownExploited > 0) {
@@ -136,7 +139,7 @@ export function derivePostureReasons(f: SecurityPostureFacts): {
targetTab: 'images',
};
reasons.push(r);
if (!primaryAction) primaryAction = { label: 'Review exploited findings', targetTab: 'images' };
if (!primaryAction) primaryAction = { label: 'Review exploited findings', targetTab: r.targetTab, kind: r.kind };
}
if (f.secrets > 0) {
@@ -149,7 +152,7 @@ export function derivePostureReasons(f: SecurityPostureFacts): {
targetTab: 'secrets',
};
reasons.push(r);
if (!primaryAction) primaryAction = { label: 'Review detected secrets', targetTab: 'secrets' };
if (!primaryAction) primaryAction = { label: 'Review detected secrets', targetTab: r.targetTab, kind: r.kind };
}
if (f.dangerousCompose > 0) {
@@ -162,7 +165,7 @@ export function derivePostureReasons(f: SecurityPostureFacts): {
targetTab: 'compose',
};
reasons.push(r);
if (!primaryAction) primaryAction = { label: 'Review Compose risks', targetTab: 'compose' };
if (!primaryAction) primaryAction = { label: 'Review Compose risks', targetTab: r.targetTab, kind: r.kind };
}
if (f.exposedBlocker > 0) {
@@ -175,7 +178,7 @@ export function derivePostureReasons(f: SecurityPostureFacts): {
targetTab: 'images',
};
reasons.push(r);
if (!primaryAction) primaryAction = { label: 'Review public exposure', targetTab: 'images' };
if (!primaryAction) primaryAction = { label: 'Review public exposure', targetTab: r.targetTab, kind: r.kind };
}
// Review items. These appear in-page but do not force a red masthead.
+18 -2
View File
@@ -17,11 +17,13 @@ import { useIsMobile } from '@/hooks/use-is-mobile';
import { Masthead, type Tone } from './mobile/mobile-ui';
import { SecurityMobileTabs, type SecurityMobileTab } from './security/SecurityMobile';
import type { SecurityTab } from '@/lib/events';
import type { ImageFilterValue } from '@/lib/severityStyles';
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole } from '@/types/security';
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
import { SuppressionsPanel } from './settings/SuppressionsPanel';
import { MisconfigAckPanel } from './settings/MisconfigAckPanel';
import { OverviewTab } from './security/OverviewTab';
import { reasonImageFilter } from './security/postureNavigation';
import { ImagesTab } from './security/ImagesTab';
import { FindingsTab } from './security/FindingsTab';
import { ScanPolicyManager } from './security/ScanPolicyManager';
@@ -85,6 +87,16 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
const [inspectInitialTab, setInspectInitialTab] = useState<ScanDetailTab | undefined>(undefined);
// Filter to preselect on the Images tab when arriving from an overview link
// (e.g. "fixable findings"). Null leaves the Images tab on its own default.
const [imagesFilter, setImagesFilter] = useState<ImageFilterValue | null>(null);
// Navigate between security tabs, optionally preselecting an Images filter so
// an overview action link lands on exactly the affected images.
const handleNavigate = useCallback((tab: SecurityTab, filter?: ImageFilterValue) => {
if (tab === 'images' && filter) setImagesFilter(filter);
onTabChange(tab);
}, [onTabChange]);
const onInspect = useCallback((scanId: number, initialTab?: ScanDetailTab) => {
setInspectInitialTab(initialTab);
@@ -254,7 +266,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
trend={trend}
exploitIntel={exploitIntel}
exploitTruncated={exploitTruncated}
onNavigate={onTabChange}
onNavigate={handleNavigate}
onInspect={onInspect}
canScan={canScan}
onScanComplete={() => setReloadToken((t) => t + 1)}
@@ -272,6 +284,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
canScan={canScan}
scanningRef={scanningRef}
onScan={scanImage}
initialFilter={imagesFilter ?? undefined}
/>
</CapabilityGate>
</TabsContent>
@@ -382,7 +395,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
{overview?.posture === 'Action needed' && overview.primaryAction ? (
<button
type="button"
onClick={() => onTabChange(overview.primaryAction!.targetTab)}
onClick={() => handleNavigate(
overview.primaryAction!.targetTab,
reasonImageFilter(overview.primaryAction!.kind),
)}
className="text-xs font-medium text-brand hover:underline whitespace-nowrap"
>
{overview.primaryAction.label}
+15 -5
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Boxes, AlertTriangle, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ShieldCheck, Loader2 } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
@@ -52,8 +52,9 @@ function SortHead({ label, k, sortKey, sortDir, onSort, className }: {
);
}
const FILTER_OPTIONS: Array<{ value: 'all' | SeverityKey; label: string }> = [
const FILTER_OPTIONS: Array<{ value: ImageFilterValue; label: string }> = [
{ value: 'all', label: 'All severities' },
{ value: 'FIXABLE', label: 'Fixable' },
{ value: 'CRITICAL', label: 'Critical' },
{ value: 'HIGH', label: 'High' },
{ value: 'MEDIUM', label: 'Medium' },
@@ -75,17 +76,26 @@ interface ImagesTabProps {
/** image_ref of the scan currently in flight, for the per-row spinner. */
scanningRef: string | null;
onScan: (imageRef: string, scanners: ScannerKind[]) => void;
/** Preselects the severity/fixable filter, e.g. when arriving from an
* overview "fixable findings" link. Applied whenever the value changes. */
initialFilter?: ImageFilterValue;
}
/** Latest-scan index for real images (stack/config scans live in Compose risks). */
export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan }: ImagesTabProps) {
export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan, initialFilter }: ImagesTabProps) {
const isMobile = useIsMobile();
const [search, setSearch] = useState('');
const [severity, setSeverity] = useState<ImageFilterValue>('all');
const [severity, setSeverity] = useState<ImageFilterValue>(initialFilter ?? 'all');
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(0);
// Apply an externally-driven filter (e.g. an overview "fixable" deep link).
// Keyed on the incoming value so re-navigating to the same filter re-applies.
useEffect(() => {
if (initialFilter) { setSeverity(initialFilter); setPage(0); }
}, [initialFilter]);
const filtered = useMemo(() => {
const term = search.trim().toLowerCase();
return Object.values(summaries)
@@ -198,7 +208,7 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
options={FILTER_OPTIONS}
value={severity}
onValueChange={(v) => { setSeverity((v || 'all') as ImageFilterValue); setPage(0); }}
className="w-[180px]"
className="w-[200px]"
/>
</div>
@@ -7,6 +7,8 @@ import { useIsMobile } from '@/hooks/use-is-mobile';
import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile';
import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding, PostureReason } from '@/types/security';
import type { SecurityTab } from '@/lib/events';
import type { ImageFilterValue } from '@/lib/severityStyles';
import { reasonImageFilter } from './postureNavigation';
import {
RiskTrendChart,
ActionPostureChart,
@@ -15,6 +17,9 @@ import {
} from './SecurityCharts';
import { ScanNodeLauncher } from './ScanNodeLauncher';
/** Navigate to a security tab, optionally preselecting an Images filter. */
type NavigateFn = (tab: SecurityTab, filter?: ImageFilterValue) => void;
interface OverviewTabProps {
overview: SecurityOverview | null;
/** 'unsupported' = node has no overview endpoint (benign); 'failed' = a real error. */
@@ -24,7 +29,7 @@ interface OverviewTabProps {
exploitIntel: ExploitIntelFinding[];
/** True when the exploit-intel set hit its row cap (highest-risk shown, not all). */
exploitTruncated: boolean;
onNavigate: (tab: SecurityTab) => void;
onNavigate: NavigateFn;
onInspect: (scanId: number) => void;
/** Admin on a node with a ready scanner; enables the node-scan launcher. */
canScan: boolean;
@@ -76,7 +81,7 @@ function ReviewQueueCard({
onNavigate,
}: {
reasons: PostureReason[];
onNavigate: (tab: SecurityTab) => void;
onNavigate: NavigateFn;
}) {
const blockers = reasons.filter((r) => r.severity === 'blocker');
const nonBlockers = reasons.filter((r) => r.severity !== 'blocker');
@@ -96,7 +101,7 @@ function ReviewQueueCard({
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{r.count}</span>
<button
type="button"
onClick={() => onNavigate(r.targetTab)}
onClick={() => onNavigate(r.targetTab, reasonImageFilter(r.kind))}
className="text-xs font-medium text-brand hover:underline whitespace-nowrap ml-auto"
>
Open {r.targetTab === 'compose' ? 'Compose risks' : r.targetTab === 'suppressions' ? 'Suppressions' : r.targetTab === 'secrets' ? 'Secrets' : r.targetTab === 'history' ? 'History' : r.targetTab === 'scanner' ? 'Scanner setup' : 'Images'}
@@ -164,11 +169,26 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
const tiles: SignalTile[] = [
{ kicker: 'Scanned images', value: String(overview.scannedImages) },
{ kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value' },
{ kicker: 'Secrets', value: String(overview.secrets), tone: overview.secrets > 0 ? 'error' : 'value' },
{ kicker: 'Misconfigs', value: String(overview.misconfigs), tone: overview.misconfigs > 0 ? 'warn' : 'value' },
{ kicker: 'Stale', value: String(overview.staleScans), tone: overview.staleScans > 0 ? 'warn' : 'value' },
{ kicker: 'Failed', value: String(overview.failedScans), tone: overview.failedScans > 0 ? 'error' : 'value' },
{
kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value',
onClick: overview.fixable > 0 ? () => onNavigate('images', 'FIXABLE') : undefined,
},
{
kicker: 'Secrets', value: String(overview.secrets), tone: overview.secrets > 0 ? 'error' : 'value',
onClick: overview.secrets > 0 ? () => onNavigate('secrets') : undefined,
},
{
kicker: 'Misconfigs', value: String(overview.misconfigs), tone: overview.misconfigs > 0 ? 'warn' : 'value',
onClick: overview.misconfigs > 0 ? () => onNavigate('compose') : undefined,
},
{
kicker: 'Stale', value: String(overview.staleScans), tone: overview.staleScans > 0 ? 'warn' : 'value',
onClick: overview.staleScans > 0 ? () => onNavigate('history') : undefined,
},
{
kicker: 'Failed', value: String(overview.failedScans), tone: overview.failedScans > 0 ? 'error' : 'value',
onClick: overview.failedScans > 0 ? () => onNavigate('history') : undefined,
},
];
const scannerValue = overview.scanner.available
@@ -348,7 +348,9 @@ export function CvssEpssQuadrantChart({ items }: { items: ExploitIntelFinding[]
<ZAxis range={[40, 40]} />
<ReferenceLine x={10} stroke="var(--border)" strokeDasharray="4 4" />
<ReferenceLine y={7} stroke="var(--border)" strokeDasharray="4 4" />
<Tooltip cursor={{ strokeDasharray: '3 3' }} content={<QuadrantTooltip />} />
{/* cursor=false: the default scatter cursor is a full-plot rectangle
that reads as selecting the whole chart. Points still hover/tooltip. */}
<Tooltip cursor={false} content={<QuadrantTooltip />} />
<Scatter data={otherPoints} fill="var(--sev-high)" fillOpacity={0.7} />
<Scatter data={kevPoints} fill="var(--sev-critical)" fillOpacity={0.9} />
</ScatterChart>
@@ -113,6 +113,21 @@ it('narrows the list with the severity filter', async () => {
expect(screen.queryByText('low:1')).not.toBeInTheDocument();
});
it('applies initialFilter to show only the matching images on arrival', () => {
render(
<ImagesTab
{...base}
initialFilter="FIXABLE"
summaries={asMap(
summary({ image_ref: 'fix:1', scan_id: 1, highest_severity: 'HIGH', total: 2, high: 2, fixable: 2 }),
summary({ image_ref: 'nofix:1', scan_id: 2, highest_severity: 'HIGH', total: 1, high: 1, fixable: 0 }),
)}
/>,
);
expect(screen.getByText('fix:1')).toBeInTheDocument();
expect(screen.queryByText('nofix:1')).not.toBeInTheDocument();
});
it('shows the scan action only when scanning is allowed', () => {
const data = asMap(summary({ image_ref: 'nginx:1', scan_id: 1 }));
const { rerender } = render(<ImagesTab {...base} canScan={false} summaries={data} />);
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { reasonImageFilter } from '../postureNavigation';
import type { PostureReasonKind } from '@/types/security';
describe('reasonImageFilter', () => {
it('maps fixable findings to the FIXABLE image filter', () => {
expect(reasonImageFilter('fixable_cve')).toBe('FIXABLE');
});
it('returns undefined for kinds with no per-image flag (opens Images unfiltered)', () => {
const others: PostureReasonKind[] = [
'known_exploited',
'secret',
'dangerous_compose',
'public_exposure',
'stale_scan',
'failed_scan',
'needs_review',
];
for (const kind of others) {
expect(reasonImageFilter(kind)).toBeUndefined();
}
});
});
@@ -0,0 +1,10 @@
import type { PostureReasonKind } from '@/types/security';
import type { ImageFilterValue } from '@/lib/severityStyles';
/** The Images filter that best isolates the affected images for a posture reason.
* Only fixable findings map to a data-backed filter; known-exploited and
* public-exposure have no per-image flag in the summaries, so they open Images
* unfiltered rather than mis-hiding the affected images. */
export function reasonImageFilter(kind: PostureReasonKind): ImageFilterValue | undefined {
return kind === 'fixable_cve' ? 'FIXABLE' : undefined;
}
@@ -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>
}
/>
+44 -32
View File
@@ -9,6 +9,8 @@ export interface SignalTile {
tone?: SignalTone;
/** Optional series for a 64x20 sparkline stroked in brand cyan. */
spark?: number[];
/** When set, the tile renders as a button that navigates on click. */
onClick?: () => void;
}
interface SignalRailProps {
@@ -33,39 +35,49 @@ export function SignalRail({ tiles, className }: SignalRailProps) {
)}
style={{ gridTemplateColumns: `repeat(${tiles.length}, minmax(0, 1fr))` }}
>
{tiles.map((tile, idx) => (
<div
key={tile.kicker}
className={cn(
'flex items-center justify-between gap-4 px-5 py-[var(--density-tile-y)]',
idx > 0 && 'border-l border-card-border',
)}
>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{tile.kicker}
</span>
<span
className={cn(
'font-mono tabular-nums tracking-tight text-2xl leading-none',
toneClass[tile.tone ?? 'value'],
)}
>
{tile.value}
</span>
</div>
{tile.spark && tile.spark.length > 1 ? (
<div className="h-5 w-16 shrink-0 opacity-90">
<Sparkline
points={tile.spark}
stroke="var(--brand)"
fill="var(--brand)"
strokeWidth={1.25}
/>
{tiles.map((tile, idx) => {
const inner = (
<>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{tile.kicker}
</span>
<span
className={cn(
'font-mono tabular-nums tracking-tight text-2xl leading-none',
toneClass[tile.tone ?? 'value'],
)}
>
{tile.value}
</span>
</div>
) : null}
</div>
))}
{tile.spark && tile.spark.length > 1 ? (
<div className="h-5 w-16 shrink-0 opacity-90">
<Sparkline
points={tile.spark}
stroke="var(--brand)"
fill="var(--brand)"
strokeWidth={1.25}
/>
</div>
) : null}
</>
);
const cellClass = cn(
'flex items-center justify-between gap-4 px-5 py-[var(--density-tile-y)] text-left',
idx > 0 && 'border-l border-card-border',
tile.onClick && 'cursor-pointer transition-colors hover:bg-accent/5',
);
return tile.onClick ? (
<button key={tile.kicker} type="button" onClick={tile.onClick} className={cellClass}>
{inner}
</button>
) : (
<div key={tile.kicker} className={cellClass}>
{inner}
</div>
);
})}
</div>
);
}
+1 -1
View File
@@ -57,7 +57,7 @@ const ChartContainer = React.forwardRef<
data-chart={chartId}
ref={ref}
className={cn(
"flex justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
"flex select-none justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
+2 -2
View File
@@ -176,7 +176,7 @@ export function Combobox({
type="button"
onClick={() => handleSelect(option)}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground",
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground",
value === option.value && "bg-accent/50"
)}
>
@@ -187,7 +187,7 @@ export function Combobox({
)}
strokeWidth={1.5}
/>
{option.label}
<span className="min-w-0 truncate">{option.label}</span>
</button>
))
)}
+3
View File
@@ -263,6 +263,9 @@ export interface PostureReason {
export interface PostureAction {
label: string;
targetTab: SecurityTab;
/** The reason kind behind this action, so the UI can target the affected
* items precisely (e.g. filter Images to fixable findings). */
kind: PostureReasonKind;
}
/** Node-scoped security posture rollup for the Security page Overview. */