diff --git a/backend/src/__tests__/securityPosture.test.ts b/backend/src/__tests__/securityPosture.test.ts index ef41e633..69664ca8 100644 --- a/backend/src/__tests__/securityPosture.test.ts +++ b/backend/src/__tests__/securityPosture.test.ts @@ -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', () => { diff --git a/backend/src/services/securityPosture.ts b/backend/src/services/securityPosture.ts index 1eff6708..fa37de30 100644 --- a/backend/src/services/securityPosture.ts +++ b/backend/src/services/securityPosture.ts @@ -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. diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index baa381c0..c06b8351 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -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(null); const [inspectInitialTab, setInspectInitialTab] = useState(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(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} /> @@ -382,7 +395,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security {overview?.posture === 'Action needed' && overview.primaryAction ? ( +
+ + +
)} ))} @@ -247,9 +286,11 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
@@ -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 })} />
@@ -300,7 +342,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) { } primary={ } /> diff --git a/frontend/src/components/settings/SuppressionsPanel.tsx b/frontend/src/components/settings/SuppressionsPanel.tsx index c5e287f6..37fed2ae 100644 --- a/frontend/src/components/settings/SuppressionsPanel.tsx +++ b/frontend/src/components/settings/SuppressionsPanel.tsx @@ -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(EMPTY_FORM); + // The row being edited, or null when the dialog is creating a new suppression. + const [editRow, setEditRow] = useState(null); const [saving, setSaving] = useState(false); const [deleteRow, setDeleteRow] = useState(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) { {isAdmin && !isReplica && row.replicated_from_control === 0 && ( - +
+ + +
)} ))} @@ -283,9 +323,11 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
@@ -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 })} />
@@ -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 })} /> @@ -345,7 +389,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) { } primary={ } /> diff --git a/frontend/src/components/ui/SignalRail.tsx b/frontend/src/components/ui/SignalRail.tsx index eefbdf4c..7126661f 100644 --- a/frontend/src/components/ui/SignalRail.tsx +++ b/frontend/src/components/ui/SignalRail.tsx @@ -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) => ( -
0 && 'border-l border-card-border', - )} - > -
- - {tile.kicker} - - - {tile.value} - -
- {tile.spark && tile.spark.length > 1 ? ( -
- + {tiles.map((tile, idx) => { + const inner = ( + <> +
+ + {tile.kicker} + + + {tile.value} +
- ) : null} -
- ))} + {tile.spark && tile.spark.length > 1 ? ( +
+ +
+ ) : 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 ? ( + + ) : ( +
+ {inner} +
+ ); + })}
); } diff --git a/frontend/src/components/ui/chart.tsx b/frontend/src/components/ui/chart.tsx index 14e94adc..8463c196 100644 --- a/frontend/src/components/ui/chart.tsx +++ b/frontend/src/components/ui/chart.tsx @@ -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} diff --git a/frontend/src/components/ui/combobox.tsx b/frontend/src/components/ui/combobox.tsx index 27a1af2e..bb85e253 100644 --- a/frontend/src/components/ui/combobox.tsx +++ b/frontend/src/components/ui/combobox.tsx @@ -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} + {option.label} )) )} diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 2350e888..72036de5 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -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. */