mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +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:
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} →
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user