diff --git a/backend/src/__tests__/database-security-overview-helpers.test.ts b/backend/src/__tests__/database-security-overview-helpers.test.ts index d207f8af..45eb9c9e 100644 --- a/backend/src/__tests__/database-security-overview-helpers.test.ts +++ b/backend/src/__tests__/database-security-overview-helpers.test.ts @@ -58,6 +58,40 @@ function seedFailed(imageRef: string): void { }); } +/** Midnight (UTC) `daysAgo` days back, so seeded times stay within one calendar day. */ +function dayStartMs(daysAgo: number): number { + const d = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + d.setUTCHours(0, 0, 0, 0); + return d.getTime(); +} + +function seedCompleted(o: { imageRef: string; scannedAt: number; critical: number; high: number; nodeId?: number; status?: 'completed' | 'failed' }): void { + db().createVulnerabilityScan({ + node_id: o.nodeId ?? 1, + image_ref: o.imageRef, + image_digest: `sha256:${o.imageRef}-${Math.random().toString(16).slice(2)}`, + scanned_at: o.scannedAt, + total_vulnerabilities: o.critical + o.high, + critical_count: o.critical, + high_count: o.high, + medium_count: 0, + low_count: 0, + unknown_count: 0, + fixable_count: 0, + secret_count: 0, + misconfig_count: 0, + scanners_used: 'vuln', + highest_severity: o.critical > 0 ? 'CRITICAL' : o.high > 0 ? 'HIGH' : null, + os_info: null, + trivy_version: null, + scan_duration_ms: null, + triggered_by: 'manual', + status: o.status ?? 'completed', + error: o.status === 'failed' ? 'boom' : null, + stack_context: null, + }); +} + function seedPolicy(overrides: Partial>): void { db().createScanPolicy({ name: overrides.name ?? 'p', @@ -116,3 +150,33 @@ describe('countEligibleBlockPolicies (replica)', () => { expect(db().countEligibleBlockPolicies(1, 'replica', 'self-id')).toBe(1); }); }); + +describe('getDailyRiskTrend', () => { + it('sums latest-per-image critical/high per day and orders days ascending', () => { + const day1 = dayStartMs(3); + const day2 = dayStartMs(2); + // Day 1: imageA scanned twice; the later scan replaces the earlier one. + seedCompleted({ imageRef: 'a:1', scannedAt: day1 + 3_600_000, critical: 5, high: 2 }); + seedCompleted({ imageRef: 'a:1', scannedAt: day1 + 7_200_000, critical: 3, high: 1 }); + seedCompleted({ imageRef: 'b:1', scannedAt: day1 + 3_600_000, critical: 1, high: 1 }); + // Day 2: a single image. + seedCompleted({ imageRef: 'a:1', scannedAt: day2 + 3_600_000, critical: 0, high: 4 }); + + const trend = db().getDailyRiskTrend(1, 30); + expect(trend).toHaveLength(2); + expect(trend[0]).toMatchObject({ critical: 4, high: 2 }); // latest a (3,1) + b (1,1) + expect(trend[1]).toMatchObject({ critical: 0, high: 4 }); + expect(trend[0].date < trend[1].date).toBe(true); + }); + + it('excludes other nodes and non-completed scans', () => { + const day = dayStartMs(1); + seedCompleted({ imageRef: 'a:1', scannedAt: day + 3_600_000, critical: 2, high: 1 }); + seedCompleted({ imageRef: 'other:1', scannedAt: day + 3_600_000, critical: 9, high: 9, nodeId: 2 }); + seedCompleted({ imageRef: 'failed:1', scannedAt: day + 3_600_000, critical: 7, high: 7, status: 'failed' }); + + const trend = db().getDailyRiskTrend(1, 30); + expect(trend).toHaveLength(1); + expect(trend[0]).toMatchObject({ critical: 2, high: 1 }); + }); +}); diff --git a/backend/src/__tests__/security-overview-route.test.ts b/backend/src/__tests__/security-overview-route.test.ts index c5e9ae05..3cbf7690 100644 --- a/backend/src/__tests__/security-overview-route.test.ts +++ b/backend/src/__tests__/security-overview-route.test.ts @@ -139,6 +139,38 @@ describe('GET /api/security/overview', () => { }); }); +describe('GET /api/security/overview/trend', () => { + beforeEach(() => resetSecurity()); + + const dayStart = (daysAgo: number): number => { + const d = new Date(Date.now() - daysAgo * DAY); + d.setUTCHours(0, 0, 0, 0); + return d.getTime(); + }; + + it('returns ascending daily critical/high points, node-scoped and completed only', async () => { + const d1 = dayStart(3); + const d2 = dayStart(2); + seedScan({ image_ref: 'a:1', scanned_at: d1 + 3_600_000, critical: 4, high: 2 }); + seedScan({ image_ref: 'a:1', scanned_at: d2 + 3_600_000, critical: 1, high: 5 }); + seedScan({ node_id: 2, image_ref: 'x:1', scanned_at: d2 + 3_600_000, critical: 9, high: 9 }); // other node + seedScan({ image_ref: 'f:1', scanned_at: d2 + 3_600_000, critical: 7, high: 7, status: 'failed' }); // failed + + const res = await request(app).get('/api/security/overview/trend').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(2); + expect(res.body[0]).toMatchObject({ critical: 4, high: 2 }); + expect(res.body[1]).toMatchObject({ critical: 1, high: 5 }); + expect(res.body[0].date < res.body[1].date).toBe(true); + }); + + it('requires authentication', async () => { + const res = await request(app).get('/api/security/overview/trend'); + expect(res.status).toBe(401); + }); +}); + describe('GET /api/security/policy-packs', () => { it('returns the 5 default packs with fully-formed rules (auth-only)', async () => { const res = await request(app).get('/api/security/policy-packs').set('Cookie', adminCookie); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 02ae627f..df8aa93a 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -530,6 +530,22 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v } }); +// Daily Critical/High risk trend for the Security overview chart. Auth-only +// (Community), node-scoped. ?days clamps to 1..365 in the DB layer. +securityRouter.get('/overview/trend', authMiddleware, (req: Request, res: Response): void => { + try { + const days = req.query.days ? Number(req.query.days) : 30; + const trend = DatabaseService.getInstance().getDailyRiskTrend( + req.nodeId, + Number.isFinite(days) ? days : 30, + ); + res.json(trend); + } catch (error) { + console.error('[Security] Failed to build risk trend:', error); + res.status(500).json({ error: 'Failed to build risk trend' }); + } +}); + // Static, read-only policy-pack catalog. Auth-only (Community), no DB, no // enforcement. The frontend fetches this with localOnly so the global catalog // is available regardless of which node is active. diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 79a70a50..a98df26c 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -4515,6 +4515,46 @@ export class DatabaseService { ).cnt; } + /** + * Daily Critical/High totals for the node over the last `days` days, for the + * Security overview risk-trend chart. For each calendar day with scans, takes + * the latest completed scan per image (so a re-scan replaces, not adds) and + * sums the critical and high counts across images. Days with no scans are + * omitted from the result. + */ + public getDailyRiskTrend( + nodeId: number, + days = 30, + ): Array<{ date: string; critical: number; high: number }> { + const window = Math.max(1, Math.min(days, 365)); + const cutoffMs = Date.now() - window * 24 * 60 * 60 * 1000; + const rows = this.db + .prepare( + `WITH daily_latest AS ( + SELECT + DATE(scanned_at / 1000, 'unixepoch') AS day, + image_ref, + critical_count, + high_count, + ROW_NUMBER() OVER ( + PARTITION BY DATE(scanned_at / 1000, 'unixepoch'), image_ref + ORDER BY scanned_at DESC + ) AS rn + FROM vulnerability_scans + WHERE node_id = ? AND status = 'completed' AND scanned_at >= ? + ) + SELECT day, + SUM(critical_count) AS critical, + SUM(high_count) AS high + FROM daily_latest + WHERE rn = 1 + GROUP BY day + ORDER BY day ASC`, + ) + .all(nodeId, cutoffMs) as Array<{ day: string; critical: number; high: number }>; + return rows.map((r) => ({ date: r.day, critical: r.critical ?? 0, high: r.high ?? 0 })); + } + /** * Count of enabled block-on-deploy policies that are eligible to apply to * this node: fleet-wide (node_id IS NULL) or scoped to this node. Built on diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 3b960682..dbf8259f 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -161,7 +161,6 @@ export default function EditorLayout() { activeView, setActiveView, settingsSection, setSettingsSection, securityTab, setSecurityTab, - securityHistoryOpen, setSecurityHistoryOpen, filterNodeId, setFilterNodeId, schedulePrefill, mobileNavOpen, setMobileNavOpen, @@ -729,8 +728,6 @@ export default function EditorLayout() { stackName={stackName} gitSourceOpen={gitSourceOpen} setGitSourceOpen={setGitSourceOpen} - securityHistoryOpen={securityHistoryOpen} - setSecurityHistoryOpen={setSecurityHistoryOpen} /> ); diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index a9a4d568..9cbb3ea2 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -1,6 +1,4 @@ -import { lazy, Suspense } from 'react'; import BashExecModal from '../BashExecModal'; -import LazyBoundary from '../LazyBoundary'; import { PolicyBlockDialog } from '../stack/PolicyBlockDialog'; import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog'; import { DeleteStackDialog } from './DeleteStackDialog'; @@ -14,14 +12,6 @@ import type { OverlayState } from './hooks/useOverlayState'; import type { StackActionsHook } from './hooks/useStackActions'; import type { PermissionAction } from '@/context/AuthContext'; -// SecurityHistoryView is the only lazy-loaded view that lives outside -// the ViewRouter switch -- it renders as an overlay sheet wired into the -// settings flow, not as a top-level tab. The other tab-level lazy views -// (HostConsole, FleetView, AuditLogView, etc.) live inside ViewRouter. -const SecurityHistoryView = lazy(() => - import('../SecurityHistoryView').then(m => ({ default: m.SecurityHistoryView })), -); - interface ShellOverlaysProps { overlayState: OverlayState; stackActions: StackActionsHook; @@ -32,8 +22,6 @@ interface ShellOverlaysProps { stackName: string; gitSourceOpen: boolean; setGitSourceOpen: (open: boolean) => void; - securityHistoryOpen: boolean; - setSecurityHistoryOpen: (open: boolean) => void; } export function ShellOverlays({ @@ -46,8 +34,6 @@ export function ShellOverlays({ stackName, gitSourceOpen, setGitSourceOpen, - securityHistoryOpen, - setSecurityHistoryOpen, }: ShellOverlaysProps) { const { deleteDialogOpen, closeDeleteDialog, stackToDelete, @@ -171,21 +157,6 @@ export function ShellOverlays({ }} /> - {/* Scan history overlay. Conditionally mounted so the lazy chunk - only fetches when the user opens the overlay; an always-mounted - lazy component would fetch on EditorLayout's first render and - defeat the split. The overlay has no internal state that needs - to persist across opens. */} - {securityHistoryOpen ? ( - - - setSecurityHistoryOpen(false)} - /> - - - ) : null} ); } diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index ae10c9f2..03ce4fe4 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -15,8 +15,8 @@ import type { ScheduleTaskPrefill } from '../ScheduledOperationsView'; import type { ActiveView } from './hooks/useViewNavigationState'; import type { SecurityTab } from '@/lib/events'; -// Paid-tier views and the security-history overlay are loaded on demand. -// Their internal PaidGate / CapabilityGate wrappers render +// Paid-tier views are loaded on demand. Their internal PaidGate / +// CapabilityGate wrappers render // the upsell or capability-missing card with blurred children rather than // short-circuiting, so a tier-locked or capability-missing operator // opening one of these tabs still triggers the chunk fetch to render the diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index 0e1bffab..3c8f61e7 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -58,7 +58,6 @@ describe('useViewNavigationState', () => { const { result } = renderHook(() => useViewNavigationState()); expect(result.current.activeView).toBe('dashboard'); expect(result.current.settingsSection).toBe('appearance'); - expect(result.current.securityHistoryOpen).toBe(false); expect(result.current.filterNodeId).toBeNull(); expect(result.current.schedulePrefill).toBeNull(); expect(result.current.mobileNavOpen).toBe(false); @@ -156,18 +155,6 @@ describe('useViewNavigationState', () => { expect(result.current.filterNodeId).toBe(5); }); - it('SENCHO_NAVIGATE_EVENT with security-history opens the sheet without changing activeView', () => { - const { result } = renderHook(() => useViewNavigationState()); - act(() => { - window.dispatchEvent( - new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security-history', nodeId: 3 } }), - ); - }); - expect(result.current.securityHistoryOpen).toBe(true); - expect(result.current.filterNodeId).toBe(3); - expect(result.current.activeView).toBe('dashboard'); - }); - it('SENCHO_NAVIGATE_EVENT with no nodeId sets filterNodeId to null', () => { const { result } = renderHook(() => useViewNavigationState()); act(() => { diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index cc7927a8..21deae42 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -61,7 +61,6 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) const [activeView, setActiveView] = useState('dashboard'); const [settingsSection, setSettingsSection] = useState('appearance'); const [securityTab, setSecurityTab] = useState('overview'); - const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false); const [filterNodeId, setFilterNodeId] = useState(null); const [schedulePrefill, setSchedulePrefill] = useState(null); const [mobileNavOpen, setMobileNavOpen] = useState(false); @@ -89,11 +88,6 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.view) return; - if (detail.view === 'security-history') { - setSecurityHistoryOpen(true); - setFilterNodeId(detail.nodeId ?? null); - return; - } if (detail.view === 'security') { // Set the target tab before switching the view so the controlled // SecurityView lands on it deterministically (no mount race). @@ -152,7 +146,6 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) activeView, setActiveView, settingsSection, setSettingsSection, securityTab, setSecurityTab, - securityHistoryOpen, setSecurityHistoryOpen, filterNodeId, setFilterNodeId, schedulePrefill, setSchedulePrefill, mobileNavOpen, setMobileNavOpen, diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 873f7007..222144c9 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -30,7 +30,7 @@ interface NodeSchedulingSummary { export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate'; export interface SenchoNavigateDetail { - view: 'scheduled-ops' | 'auto-updates' | 'security-history' | 'security'; + view: 'scheduled-ops' | 'auto-updates' | 'security'; nodeId?: number; /** Target tab when navigating to the Security view. */ tab?: SecurityTab; diff --git a/frontend/src/components/SecurityHistoryView.tsx b/frontend/src/components/SecurityHistoryView.tsx deleted file mode 100644 index 5cb7f7aa..00000000 --- a/frontend/src/components/SecurityHistoryView.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Input } from '@/components/ui/input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { - ChevronLeft, - ChevronRight, - GitCompare, - RefreshCw, - Search, - ShieldCheck, -} from 'lucide-react'; -import { apiFetch } from '@/lib/api'; -import { toast } from '@/components/ui/toast-store'; -import { cn } from '@/lib/utils'; -import { ScanComparisonSheet } from './ScanComparisonSheet'; -import { SeverityChip } from './VulnerabilityScanSheet'; -import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; -import { CapabilityGate } from './CapabilityGate'; -import { useLicense } from '@/context/LicenseContext'; -import { useAuth } from '@/context/AuthContext'; -import { useNodes } from '@/context/NodeContext'; -import type { VulnerabilityScan } from '@/types/security'; - -const PAGE_SIZE = 100; - -interface GroupedScans { - image_ref: string; - scans: VulnerabilityScan[]; -} - -function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] { - const map = new Map(); - for (const s of scans) { - const list = map.get(s.image_ref) ?? []; - list.push(s); - map.set(s.image_ref, list); - } - const groups: GroupedScans[] = []; - for (const [image_ref, list] of map.entries()) { - list.sort((a, b) => b.scanned_at - a.scanned_at); - groups.push({ image_ref, scans: list }); - } - groups.sort((a, b) => (b.scans[0]?.scanned_at ?? 0) - (a.scans[0]?.scanned_at ?? 0)); - return groups; -} - -interface SecurityHistoryViewProps { - open: boolean; - onClose: () => void; -} - -export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) { - const { isPaid } = useLicense(); - const { isAdmin } = useAuth(); - const { activeNode, hasCapability } = useNodes(); - const scanningAvailable = hasCapability('vulnerability-scanning'); - const [scans, setScans] = useState([]); - const [total, setTotal] = useState(0); - const [capInfo, setCapInfo] = useState<{ perImageLimit: number; refs: Set } | null>(null); - const [loading, setLoading] = useState(false); - const [searchDraft, setSearchDraft] = useState(''); - const [search, setSearch] = useState(''); - const [selected, setSelected] = useState([]); - const [compareIds, setCompareIds] = useState<[number, number] | null>(null); - const [inspectScanId, setInspectScanId] = useState(null); - const [page, setPage] = useState(0); - - const load = useCallback(async (pageToLoad: number, searchTerm: string) => { - setLoading(true); - try { - const params = new URLSearchParams({ - status: 'completed', - limit: String(PAGE_SIZE), - offset: String(pageToLoad * PAGE_SIZE), - }); - if (searchTerm.trim()) params.set('imageRefLike', searchTerm.trim()); - const res = await apiFetch(`/security/scans?${params.toString()}`); - if (!res.ok) throw new Error('Failed to load scans'); - const body = await res.json(); - const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : []; - setScans(items); - setTotal(typeof body?.total === 'number' ? body.total : items.length); - const limit = typeof body?.perImageLimit === 'number' ? body.perImageLimit : 0; - const refs: string[] = Array.isArray(body?.cappedImageRefs) ? body.cappedImageRefs : []; - setCapInfo(limit > 0 ? { perImageLimit: limit, refs: new Set(refs) } : null); - } catch (err) { - toast.error((err as Error)?.message || 'Could not load scan history'); - } finally { - setLoading(false); - } - }, []); - - const lastNodeIdRef = useRef(activeNode?.id ?? null); - const [reloadToken, setReloadToken] = useState(0); - - useEffect(() => { - const id = activeNode?.id ?? null; - if (lastNodeIdRef.current === id) return; - lastNodeIdRef.current = id; - setSelected([]); - setPage(0); - setReloadToken((t) => t + 1); - }, [activeNode?.id]); - - useEffect(() => { - if (!open || !scanningAvailable) return; - load(page, search); - // reloadToken bumps when the active node changes even if page/search - // happen to match the previous values, so the fetch re-runs exactly once. - }, [open, scanningAvailable, load, page, search, reloadToken]); - - // Skip the initial mount: the effect fires once with the original - // searchDraft, and unconditionally resetting page to 0 after 300ms races - // with any pagination the user may have done in that window. - const prevSearchDraftRef = useRef(searchDraft); - useEffect(() => { - if (prevSearchDraftRef.current === searchDraft) return; - prevSearchDraftRef.current = searchDraft; - const t = setTimeout(() => { - setSearch(searchDraft); - setPage(0); - }, 300); - return () => clearTimeout(t); - }, [searchDraft]); - - const groups = useMemo(() => groupByImage(scans), [scans]); - - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const safePage = Math.min(page, totalPages - 1); - const needsPagination = total > PAGE_SIZE; - - const toggleSelect = (scanId: number) => { - setSelected((prev) => { - if (prev.includes(scanId)) return prev.filter((x) => x !== scanId); - if (prev.length >= 2) return [prev[1], scanId]; - return [...prev, scanId]; - }); - }; - - const compareSelected = () => { - if (selected.length !== 2) return; - const [aId, bId] = selected; - const a = scans.find((s) => s.id === aId); - const b = scans.find((s) => s.id === bId); - if (!a || !b) return; - const [older, newer] = a.scanned_at <= b.scanned_at ? [a, b] : [b, a]; - setCompareIds([older.id, newer.id]); - }; - - const compareDisabled = selected.length !== 2; - const meta = `${total} scan${total === 1 ? '' : 's'} · ${groups.length} image${groups.length === 1 ? '' : 's'}`; - const footerContext = `Node ${activeNode?.name ?? '—'}`; - - return ( - { if (!next) onClose(); }} - crumb={['Security', 'Scan history']} - name="Scan history" - meta={meta} - primaryAction={scanningAvailable ? { - label: `Compare (${selected.length}/2)`, - icon: GitCompare, - onClick: compareSelected, - disabled: compareDisabled, - } : undefined} - secondaryActions={scanningAvailable ? [{ - label: 'Refresh', - icon: RefreshCw, - onClick: () => load(safePage, search), - disabled: loading, - }] : []} - footerContext={footerContext} - size="xl" - > - - -
-
- - setSearchDraft(e.target.value)} - className="pl-8" - /> -
- {needsPagination && ( -
- - - {safePage + 1} / {totalPages} - - -
- )} -
- - {groups.length === 0 && !loading ? ( -
- -
- {search - ? 'No completed scans match your search.' - : 'No scans have completed on this node yet.'} -
-
- ) : ( - -
- {groups.map((group) => { - const isCapped = capInfo?.refs.has(group.image_ref) ?? false; - return ( -
-
- - {group.image_ref} - - - {group.scans.length} scan{group.scans.length === 1 ? '' : 's'} - - {isCapped && capInfo && ( - - Capped at {capInfo.perImageLimit} · older scans pruned - - )} -
- - - - - - Scanned - Trigger - Highest - Total - Fixable - - - - - {group.scans.map((scan) => { - const isSelected = selected.includes(scan.id); - return ( - - - toggleSelect(scan.id)} - aria-label={`Select scan ${scan.id}`} - /> - - - {new Date(scan.scanned_at).toLocaleString()} - - - {scan.triggered_by} - - - {scan.highest_severity ? ( - - ) : ( - none - )} - - - {scan.total_vulnerabilities} - - - {scan.fixable_count} - - - - - - ); - })} - -
-
-
- ); - })} -
-
- )} -
-
- - setCompareIds(null)} - /> - - setInspectScanId(null)} - canGenerateSbom={isAdmin} - canExportSarif={isPaid && isAdmin} - canCompare={false} - canManageSuppressions={isAdmin} - /> -
- ); -} diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index cf6b9ab4..1569ce9a 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { LayoutDashboard, Boxes, FileWarning, KeyRound, BookCheck, EyeOff, History as HistoryIcon, Wrench, Info, } from 'lucide-react'; -import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { PageMasthead } from '@/components/ui/PageMasthead'; import { CapabilityGate } from '@/components/CapabilityGate'; @@ -13,10 +12,10 @@ import { formatTimeAgo } from '@/lib/relativeTime'; import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; +import { useImageScan } from '@/hooks/useImageScan'; import type { SecurityTab } from '@/lib/events'; -import type { SecurityOverview, ScanSummary, ScanDetailTab, FleetRole } from '@/types/security'; +import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, FleetRole } from '@/types/security'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; -import { SecurityHistoryView } from './SecurityHistoryView'; import { SuppressionsPanel } from './settings/SuppressionsPanel'; import { MisconfigAckPanel } from './settings/MisconfigAckPanel'; import { OverviewTab } from './security/OverviewTab'; @@ -25,6 +24,20 @@ import { FindingsTab } from './security/FindingsTab'; import { PolicyPacksTab } from './security/PolicyPacksTab'; import { ScanPolicyManager } from './security/ScanPolicyManager'; import { ScannerSetupTab } from './security/ScannerSetupTab'; +import { HistoryTab } from './security/HistoryTab'; + +/** A /security/image-summaries 200 body must be a map of scan summaries. An + * unexpected shape is treated as an error, never as a benign "no findings". An + * empty object is valid (a node with no scans yet). */ +function isScanSummaryMap(v: unknown): v is Record { + if (!v || typeof v !== 'object' || Array.isArray(v)) return false; + return Object.values(v).every( + (s) => + !!s && typeof s === 'object' + && typeof (s as ScanSummary).image_ref === 'string' + && typeof (s as ScanSummary).scan_id === 'number', + ); +} interface SecurityViewProps { activeTab: SecurityTab; @@ -44,17 +57,25 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { const [summaries, setSummaries] = useState>({}); const [summariesLoading, setSummariesLoading] = useState(true); const [summariesError, setSummariesError] = useState(false); + const [trend, setTrend] = useState([]); const [isReplica, setIsReplica] = useState(false); const [inspectScanId, setInspectScanId] = useState(null); const [inspectInitialTab, setInspectInitialTab] = useState(undefined); - const [historyOpen, setHistoryOpen] = useState(false); const onInspect = useCallback((scanId: number, initialTab?: ScanDetailTab) => { setInspectInitialTab(initialTab); setInspectScanId(scanId); }, []); + // Scanner readiness gates the Images Actions column; an admin on a node whose + // scanner is available can trigger scans inline. + const canScan = isAdmin && !!overview?.scanner.available; + const { scanningRef, scanImage } = useImageScan({ + onComplete: (scanId) => onInspect(scanId, 'vulns'), + onSummaries: setSummaries, + }); + // Active-node scoped data: overview rollup + image summaries follow x-node-id. // A failed fetch (5xx, network, malformed body) must surface as an error, never // as a benign "clean / no findings" view, which for a security surface is the @@ -66,6 +87,14 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { setSummariesLoading(true); setOverviewLoadError(null); setSummariesError(false); + // The trend chart is non-critical: isolate its fetch entirely (transport + // failure included, not just a non-OK/malformed body) so it can never + // poison the overview/summaries error state. It degrades to an empty chart + // with its own "no history" message. + const trendPromise: Promise = apiFetch('/security/overview/trend') + .then((r) => (r.ok ? r.json() : [])) + .then((t) => (Array.isArray(t) ? t : [])) + .catch(() => []); try { const [overviewRes, summariesRes] = await Promise.all([ apiFetch('/security/overview'), @@ -82,7 +111,15 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { } } if (summariesRes.ok) { - setSummaries(await summariesRes.json()); + const body = await summariesRes.json(); + if (isScanSummaryMap(body)) { + setSummaries(body); + } else { + // A 200 with an unexpected shape must not read as "no findings". + setSummaries({}); + setSummariesError(true); + console.warn('[Security] image-summaries returned an unexpected shape'); + } } else { setSummaries({}); setSummariesError(true); @@ -98,6 +135,8 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { } finally { if (!cancelled) setSummariesLoading(false); } + const trend = await trendPromise; + if (!cancelled) setTrend(trend); })(); return () => { cancelled = true; }; }, [activeNode?.id]); @@ -122,17 +161,6 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { return () => { cancelled = true; }; }, [isRemote, activeNode?.id]); - // A deep-link to History (e.g. the Resources "Scan history" button, which - // mounts this view with the History tab active) auto-opens the sheet once on - // mount. Selecting the History tab manually shows the persistent launcher - // body instead, so the sheet does not pop on every tab click; closing it - // always leaves the launcher. - const deepLinkedToHistory = useRef(activeTab === 'history'); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - if (deepLinkedToHistory.current) setHistoryOpen(true); - }, []); - const { state, tone } = deriveMasthead(overview, overviewLoadError !== null); const pulsing = tone === 'live' && !!overview?.scanner.available; @@ -183,12 +211,27 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { - + - + @@ -206,8 +249,8 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
- +
@@ -232,20 +275,7 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { -
-
-

Scan history

-

- {overview - ? `${overview.scannedImages} image${overview.scannedImages === 1 ? '' : 's'} scanned · last scan ${overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never'}` - : 'Browse completed scans and compare them.'} -

-
- -
+
@@ -254,8 +284,6 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { - setHistoryOpen(false)} /> - ({ - apiFetch: vi.fn(), -})); - -vi.mock('@/components/ui/toast-store', () => ({ - toast: { - error: vi.fn(), - success: vi.fn(), - warning: vi.fn(), - info: vi.fn(), - loading: vi.fn(), - dismiss: vi.fn(), - }, -})); - -const licenseState = { isPaid: true }; -vi.mock('@/context/LicenseContext', () => ({ - useLicense: () => licenseState, -})); - -vi.mock('@/context/AuthContext', () => ({ - useAuth: () => ({ isAdmin: true }), -})); - -const nodesState: { - activeNode: { id: number; name?: string } | null; - hasCapability: (cap: string) => boolean; - activeNodeMeta: { version: string | null; capabilities: string[]; fetchedAt: number } | null; -} = { - activeNode: { id: 1 }, - hasCapability: () => true, - activeNodeMeta: null, -}; -vi.mock('@/context/NodeContext', () => ({ - useNodes: () => nodesState, -})); - -const compareProps: { baselineScanId: number | null; currentScanId: number | null }[] = []; -vi.mock('../ScanComparisonSheet', () => ({ - ScanComparisonSheet: (props: { baselineScanId: number | null; currentScanId: number | null }) => { - compareProps.push({ baselineScanId: props.baselineScanId, currentScanId: props.currentScanId }); - return null; - }, -})); - -vi.mock('../VulnerabilityScanSheet', () => ({ - SeverityChip: ({ severity }: { severity: string }) => {severity}, - VulnerabilityScanSheet: () => null, -})); - -import { apiFetch } from '@/lib/api'; -import { SecurityHistoryView } from '../SecurityHistoryView'; - -const mockedFetch = apiFetch as unknown as ReturnType; - -function scan(overrides: Partial = {}): VulnerabilityScan { - return { - id: 1, - node_id: 1, - image_ref: 'alpine:3.19', - image_digest: null, - scanned_at: 1_700_000_000_000, - total_vulnerabilities: 0, - critical_count: 0, - high_count: 0, - medium_count: 0, - low_count: 0, - unknown_count: 0, - fixable_count: 0, - secret_count: 0, - misconfig_count: 0, - scanners_used: 'vuln', - highest_severity: null, - os_info: null, - trivy_version: null, - scan_duration_ms: null, - triggered_by: 'manual', - status: 'completed', - error: null, - stack_context: null, - ...overrides, - }; -} - -function listResponse( - items: VulnerabilityScan[], - opts: { total?: number; cappedImageRefs?: string[]; perImageLimit?: number } = {}, -): Response { - return { - ok: true, - status: 200, - json: async () => ({ - items, - total: opts.total ?? items.length, - cappedImageRefs: opts.cappedImageRefs ?? [], - perImageLimit: opts.perImageLimit ?? 50, - }), - } as unknown as Response; -} - -beforeEach(() => { - mockedFetch.mockReset(); - compareProps.length = 0; - licenseState.isPaid = true; - nodesState.activeNode = { id: 1 }; - nodesState.hasCapability = () => true; - nodesState.activeNodeMeta = null; -}); - -afterEach(() => vi.clearAllMocks()); - -describe('SecurityHistoryView', () => { - it('fetches completed scans on mount with server-driven pagination params', async () => { - mockedFetch.mockResolvedValue(listResponse([scan()])); - render(); - await waitFor(() => expect(mockedFetch).toHaveBeenCalled()); - const url = mockedFetch.mock.calls[0][0] as string; - expect(url).toMatch(/^\/security\/scans\?/); - expect(url).toContain('status=completed'); - expect(url).toContain('offset=0'); - expect(url).toMatch(/limit=\d+/); - }); - - it('shows a lock card and does not fetch when the node lacks vulnerability-scanning', async () => { - nodesState.hasCapability = (cap: string) => cap !== 'vulnerability-scanning'; - nodesState.activeNodeMeta = { version: '0.80.0', capabilities: [], fetchedAt: 0 }; - mockedFetch.mockResolvedValue(listResponse([scan()])); - - render(); - - expect( - await screen.findByText('Vulnerability scanning is not available on this node'), - ).toBeInTheDocument(); - expect(mockedFetch).not.toHaveBeenCalled(); - // The header actions are gone too, so there is no Refresh button that could - // fire the gated fetch from behind the lock card. - expect(screen.queryByRole('button', { name: /refresh/i })).toBeNull(); - expect(screen.queryByRole('button', { name: /compare/i })).toBeNull(); - }); - - it('advances offset when the user pages forward', async () => { - mockedFetch.mockResolvedValue(listResponse([scan()], { total: 250 })); - const user = userEvent.setup(); - render(); - - await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1)); - - const nextBtn = screen.getAllByRole('button').find( - (b) => b.querySelector('.lucide-chevron-right'), - ); - expect(nextBtn).toBeDefined(); - await user.click(nextBtn!); - - await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2)); - const secondUrl = mockedFetch.mock.calls[1][0] as string; - expect(secondUrl).toContain('offset=100'); - }); - - it('re-fetches when activeNode.id changes', async () => { - mockedFetch.mockResolvedValue(listResponse([scan()])); - const { rerender } = render(); - await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1)); - - nodesState.activeNode = { id: 2 }; - rerender(); - await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2)); - }); - - it('caps selection at two scans, evicting the oldest', async () => { - mockedFetch.mockResolvedValue( - listResponse([ - scan({ id: 1, scanned_at: 1000 }), - scan({ id: 2, scanned_at: 2000 }), - scan({ id: 3, scanned_at: 3000 }), - ]), - ); - const user = userEvent.setup(); - render(); - - const checkboxes = await screen.findAllByRole('checkbox'); - expect(checkboxes).toHaveLength(3); - - await user.click(checkboxes[0]); - await user.click(checkboxes[1]); - await user.click(checkboxes[2]); - - expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeEnabled(); - expect(checkboxes[0].getAttribute('aria-checked')).toBe('false'); - expect(checkboxes[1].getAttribute('aria-checked')).toBe('true'); - expect(checkboxes[2].getAttribute('aria-checked')).toBe('true'); - }); - - it('passes older scan as baseline and newer as current on compare', async () => { - mockedFetch.mockResolvedValue( - listResponse([ - scan({ id: 10, scanned_at: 3000 }), - scan({ id: 20, scanned_at: 1000 }), - ]), - ); - const user = userEvent.setup(); - render(); - - const checkboxes = await screen.findAllByRole('checkbox'); - await user.click(checkboxes[0]); - await user.click(checkboxes[1]); - - await user.click(screen.getByRole('button', { name: /Compare \(2\/2\)/ })); - - const last = compareProps.at(-1); - expect(last?.baselineScanId).toBe(20); - expect(last?.currentScanId).toBe(10); - }); - - it('does not fetch when closed', async () => { - mockedFetch.mockResolvedValue(listResponse([scan()])); - render(); - - // Flush any microtasks; the fetch guard returns synchronously so no - // timer delay is required. - await Promise.resolve(); - expect(mockedFetch).not.toHaveBeenCalled(); - }); - - it('fires onClose when Escape is pressed and does not fetch again', async () => { - mockedFetch.mockResolvedValue(listResponse([scan()])); - const onClose = vi.fn(); - const user = userEvent.setup(); - render(); - - await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1)); - await user.keyboard('{Escape}'); - - await waitFor(() => expect(onClose).toHaveBeenCalled()); - expect(mockedFetch).toHaveBeenCalledTimes(1); - }); - - it('shows Compare button for community tier (scan compare is Community per PR #930)', async () => { - licenseState.isPaid = false; - mockedFetch.mockResolvedValue( - listResponse([ - scan({ id: 1, scanned_at: 1000 }), - scan({ id: 2, scanned_at: 2000 }), - ]), - ); - const user = userEvent.setup(); - render(); - - const checkboxes = await screen.findAllByRole('checkbox'); - await user.click(checkboxes[0]); - await user.click(checkboxes[1]); - - expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeEnabled(); - }); - - it('renders the cap hint only for images flagged in cappedImageRefs', async () => { - mockedFetch.mockResolvedValue( - listResponse( - [ - scan({ id: 1, image_ref: 'hot:latest', scanned_at: 1000 }), - scan({ id: 2, image_ref: 'cool:latest', scanned_at: 2000 }), - ], - { cappedImageRefs: ['hot:latest'], perImageLimit: 50 }, - ), - ); - render(); - - const cappedHint = await screen.findByText(/Capped at 50 . older scans pruned/); - expect(cappedHint).toBeInTheDocument(); - expect(screen.queryAllByText(/Capped at 50/)).toHaveLength(1); - }); -}); diff --git a/frontend/src/components/security/HistoryTab.tsx b/frontend/src/components/security/HistoryTab.tsx new file mode 100644 index 00000000..4355c156 --- /dev/null +++ b/frontend/src/components/security/HistoryTab.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { ChevronLeft, ChevronRight, GitCompare, RefreshCw, Search, ArrowUp, ArrowDown } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; +import { useNodes } from '@/context/NodeContext'; +import { FleetTabHeading } from '@/components/fleet/FleetEmptyState'; +import { SeverityChip } from '../VulnerabilityScanSheet'; +import { ScanComparisonSheet } from '../ScanComparisonSheet'; +import type { VulnerabilityScan, ScanDetailTab, VulnSeverity } from '@/types/security'; + +const PAGE_SIZE = 100; +const SEVERITY_RANK: Record = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 }; + +type SortKey = 'scanned_at' | 'image_ref' | 'severity' | 'total'; + +/** Sortable column header. Module-scoped so it is a stable component. */ +function SortHead({ label, k, sortKey, sortDir, onSort, align }: { + label: string; + k: SortKey; + sortKey: SortKey; + sortDir: 'asc' | 'desc'; + onSort: (k: SortKey) => void; + align?: 'right'; +}) { + return ( + + + + ); +} + +interface HistoryTabProps { + onInspect: (scanId: number, initialTab?: ScanDetailTab) => void; +} + +/** Inline scan-history table: search, sortable columns, two-scan compare, and + * server-paginated completed scans. Replaces the former history sheet. */ +export function HistoryTab({ onInspect }: HistoryTabProps) { + const { activeNode } = useNodes(); + const nodeId = activeNode?.id; + + const [scans, setScans] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [page, setPage] = useState(0); + const [searchDraft, setSearchDraft] = useState(''); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState([]); + const [compareIds, setCompareIds] = useState<[number, number] | null>(null); + const [sortKey, setSortKey] = useState('scanned_at'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const safePage = Math.min(page, totalPages - 1); + + const load = useCallback(async (pageToLoad: number, term: string) => { + setLoading(true); + setError(false); + try { + const params = new URLSearchParams({ + status: 'completed', + limit: String(PAGE_SIZE), + offset: String(pageToLoad * PAGE_SIZE), + }); + if (term.trim()) params.set('imageRefLike', term.trim()); + const res = await apiFetch(`/security/scans?${params.toString()}`); + if (!res.ok) { + setError(true); + return; + } + const data = await res.json(); + if (!data || !Array.isArray(data.items)) { + // A 200 with an unexpected shape must surface as an error, not as an + // empty "no completed scans yet" state. + setError(true); + return; + } + setScans(data.items); + setTotal(typeof data.total === 'number' ? data.total : data.items.length); + } catch (err) { + console.error('[Security] Failed to load scan history:', err); + toast.error('Failed to load scan history'); + setError(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(safePage, search); }, [load, safePage, search, nodeId]); + + const toggleSelect = (scanId: number) => { + setSelected((prev) => { + if (prev.includes(scanId)) return prev.filter((x) => x !== scanId); + if (prev.length >= 2) return [prev[1], scanId]; + return [...prev, scanId]; + }); + }; + + const compareSelected = () => { + if (selected.length !== 2) return; + const [aId, bId] = selected; + const a = scans.find((s) => s.id === aId); + const b = scans.find((s) => s.id === bId); + if (!a || !b) return; + const [older, newer] = a.scanned_at <= b.scanned_at ? [a, b] : [b, a]; + setCompareIds([older.id, newer.id]); + }; + + const toggleSort = (key: SortKey) => { + if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + else { setSortKey(key); setSortDir(key === 'image_ref' ? 'asc' : 'desc'); } + }; + + const sorted = useMemo(() => { + const dir = sortDir === 'asc' ? 1 : -1; + return [...scans].sort((a, b) => { + switch (sortKey) { + case 'image_ref': return a.image_ref.localeCompare(b.image_ref) * dir; + case 'severity': return (SEVERITY_RANK[a.highest_severity ?? 'UNKNOWN'] - SEVERITY_RANK[b.highest_severity ?? 'UNKNOWN']) * dir; + case 'total': return (a.total_vulnerabilities - b.total_vulnerabilities) * dir; + default: return (a.scanned_at - b.scanned_at) * dir; + } + }); + }, [scans, sortKey, sortDir]); + + return ( +
+ + + +
+ } + /> + +
+ + setSearchDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { setPage(0); setSearch(searchDraft); } }} + className="pl-8" + /> +
+ +
+ + + + + + + + Trigger + + + Fixable + Action + + + + {!loading && !error && sorted.map((scan) => { + const isSelected = selected.includes(scan.id); + return ( + + + toggleSelect(scan.id)} aria-label="Select scan to compare" /> + + {scan.image_ref} + {new Date(scan.scanned_at).toLocaleString()} + {scan.triggered_by} + + {scan.highest_severity ? : none} + + {scan.total_vulnerabilities} + {scan.fixable_count} + + + + + ); + })} + +
+ {loading &&
Loading scan history...
} + {!loading && error &&
Couldn't load scan history. Try again.
} + {!loading && !error && sorted.length === 0 && ( +
+ {search ? 'No scans match your search.' : 'No completed scans yet. Scan an image from the Images tab.'} +
+ )} +
+
+ + {total > PAGE_SIZE && ( +
+ + {safePage + 1} / {totalPages} + +
+ )} + + setCompareIds(null)} + /> + + ); +} diff --git a/frontend/src/components/security/ImagesTab.tsx b/frontend/src/components/security/ImagesTab.tsx index a37c5e3f..1bb035f4 100644 --- a/frontend/src/components/security/ImagesTab.tsx +++ b/frontend/src/components/security/ImagesTab.tsx @@ -1,8 +1,56 @@ -import { useMemo } from 'react'; -import { Boxes, AlertTriangle } from 'lucide-react'; +import { 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'; +import { Input } from '@/components/ui/input'; +import { Combobox } from '@/components/ui/combobox'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { SeverityBadge } from '@/components/ui/SeverityBadge'; -import type { ScanSummary, ScanDetailTab } from '@/types/security'; +import { getSeverityKey, type SeverityKey } from '@/lib/severityStyles'; +import { formatTimeAgo } from '@/lib/relativeTime'; +import { cn } from '@/lib/utils'; +import type { ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security'; + +const PAGE_SIZE = 12; + +type SortKey = 'image_ref' | 'scanned_at' | 'severity' | 'findings'; + +const SEVERITY_RANK: Record = { + CRITICAL: 6, HIGH: 5, MEDIUM: 4, LOW: 3, UNKNOWN: 2, FINDINGS: 1, CLEAN: 0, +}; + +/** Sortable column header. Module-scoped so it is a stable component. */ +function SortHead({ label, k, sortKey, sortDir, onSort, className }: { + label: string; + k: SortKey; + sortKey: SortKey; + sortDir: 'asc' | 'desc'; + onSort: (k: SortKey) => void; + className?: string; +}) { + return ( + + + + ); +} + +const FILTER_OPTIONS: Array<{ value: 'all' | SeverityKey; label: string }> = [ + { value: 'all', label: 'All severities' }, + { value: 'CRITICAL', label: 'Critical' }, + { value: 'HIGH', label: 'High' }, + { value: 'MEDIUM', label: 'Medium' }, + { value: 'LOW', label: 'Low' }, + { value: 'FINDINGS', label: 'Secrets / misconfigs' }, + { value: 'CLEAN', label: 'Clean' }, +]; + +const findingsCount = (s: ScanSummary) => s.total + (s.secret_count ?? 0) + (s.misconfig_count ?? 0); interface ImagesTabProps { summaries: Record; @@ -10,17 +58,50 @@ interface ImagesTabProps { /** True when the summaries fetch failed; render an error state, never a false "clean". */ error?: boolean; onInspect: (scanId: number, initialTab?: ScanDetailTab) => void; + /** Admin on a node with a ready scanner; gates the scan Actions column. */ + canScan: boolean; + /** image_ref of the scan currently in flight, for the per-row spinner. */ + scanningRef: string | null; + onScan: (imageRef: string, scanners: ScannerKind[]) => void; } /** Latest-scan index for real images (stack/config scans live in Compose risks). */ -export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabProps) { - const images = useMemo( - () => - Object.values(summaries) - .filter((s) => !s.image_ref.startsWith('stack:')) - .sort((a, b) => b.scanned_at - a.scanned_at), - [summaries], - ); +export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan }: ImagesTabProps) { + const [search, setSearch] = useState(''); + const [severity, setSeverity] = useState('all'); + const [sortKey, setSortKey] = useState('scanned_at'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const [page, setPage] = useState(0); + + const filtered = useMemo(() => { + const term = search.trim().toLowerCase(); + return Object.values(summaries) + .filter((s) => !s.image_ref.startsWith('stack:')) + .filter((s) => (term ? s.image_ref.toLowerCase().includes(term) : true)) + .filter((s) => (severity === 'all' ? true : getSeverityKey(s) === severity)); + }, [summaries, search, severity]); + + const sorted = useMemo(() => { + const dir = sortDir === 'asc' ? 1 : -1; + return [...filtered].sort((a, b) => { + switch (sortKey) { + case 'image_ref': return a.image_ref.localeCompare(b.image_ref) * dir; + case 'severity': return (SEVERITY_RANK[getSeverityKey(a)] - SEVERITY_RANK[getSeverityKey(b)]) * dir; + case 'findings': return (findingsCount(a) - findingsCount(b)) * dir; + default: return (a.scanned_at - b.scanned_at) * dir; + } + }); + }, [filtered, sortKey, sortDir]); + + const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages - 1); + const pageItems = sorted.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); + + const toggleSort = (key: SortKey) => { + if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + else { setSortKey(key); setSortDir(key === 'image_ref' ? 'asc' : 'desc'); } + setPage(0); + }; if (error) { return ( @@ -42,7 +123,8 @@ export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabPro ); } - if (images.length === 0) { + const noImagesAtAll = Object.values(summaries).every((s) => s.image_ref.startsWith('stack:')); + if (noImagesAtAll) { return (
@@ -53,38 +135,116 @@ export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabPro } return ( -
- - - - - - - - - - {images.map((s) => ( - - - - - - ))} - -
ImageFindingsSeverity
- - - {s.critical > 0 && {s.critical}C} - {s.high > 0 && {s.high}H} - {s.secret_count > 0 && {s.secret_count} secret} - {s.misconfig_count > 0 && {s.misconfig_count} misconfig} - {s.fixable > 0 && {s.fixable} fixable} - {s.total === 0 && s.secret_count === 0 && s.misconfig_count === 0 && clean} - - onInspect(s.scan_id, 'vulns')} /> -
+
+
+
+ + { setSearch(e.target.value); setPage(0); }} + className="pl-8" + /> +
+ { setSeverity(v || 'all'); setPage(0); }} + className="w-[180px]" + /> +
+ +
+ + + + + + + + + {canScan && Actions} + + + + {pageItems.map((s) => ( + + + + + + + + + {formatTimeAgo(s.scanned_at)} + + + onInspect(s.scan_id, 'vulns')} /> + + {canScan && ( + + + + + + + onScan(s.image_ref, ['vuln'])}> + Scan (vulnerabilities) + + onScan(s.image_ref, ['vuln', 'secret'])}> + Full scan (vulnerabilities + secrets) + + + + + )} + + ))} + +
+ {pageItems.length === 0 && ( +
+ No images match your search or filter. +
+ )} +
+
+ + {sorted.length > PAGE_SIZE && ( +
+ + {safePage + 1} / {totalPages} + +
+ )}
); } diff --git a/frontend/src/components/security/OverviewTab.tsx b/frontend/src/components/security/OverviewTab.tsx index 372342e4..e3c05eaf 100644 --- a/frontend/src/components/security/OverviewTab.tsx +++ b/frontend/src/components/security/OverviewTab.tsx @@ -1,15 +1,25 @@ import { ShieldOff } from 'lucide-react'; import { Skeleton } from '@/components/ui/skeleton'; import { SignalRail, type SignalTile } from '@/components/ui/SignalRail'; +import { cn } from '@/lib/utils'; import { formatTimeAgo } from '@/lib/relativeTime'; -import type { SecurityOverview } from '@/types/security'; +import type { SecurityOverview, ScanSummary, SecurityRiskTrendPoint } from '@/types/security'; import type { SecurityTab } from '@/lib/events'; +import { + SeverityDonutChart, + RiskTrendChart, + TopExposedImagesChart, + FindingsByTypeChart, +} from './SecurityCharts'; interface OverviewTabProps { overview: SecurityOverview | null; /** 'unsupported' = node has no overview endpoint (benign); 'failed' = a real error. */ loadError: 'unsupported' | 'failed' | null; + summaries: Record; + trend: SecurityRiskTrendPoint[]; onNavigate: (tab: SecurityTab) => void; + onInspect: (scanId: number) => void; } const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = { @@ -28,7 +38,16 @@ function StatusRow({ label, value, tone }: { label: string; value: string; tone? ); } -export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProps) { +function ChartCard({ title, className, children }: { title: string; className?: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, onInspect }: OverviewTabProps) { if (loadError === 'unsupported') { return (
@@ -56,12 +75,14 @@ export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProp if (!overview) { return (
- +
); } + const summaryList = Object.values(summaries); + const tiles: SignalTile[] = [ { kicker: 'Scanned images', value: String(overview.scannedImages) }, { kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value' }, @@ -77,8 +98,26 @@ export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProp return (
- {/* Signal rail of supporting counts. Wrapped so a phone scrolls the rail - instead of crushing the fixed columns. */} + {/* Charts lead the dashboard. */} +
+ + + + + + +
+ +
+ + + + + + +
+ + {/* Supporting counts + posture, secondary to the charts above. */}
diff --git a/frontend/src/components/security/PolicyPacksTab.tsx b/frontend/src/components/security/PolicyPacksTab.tsx index a7b21a43..aa59f550 100644 --- a/frontend/src/components/security/PolicyPacksTab.tsx +++ b/frontend/src/components/security/PolicyPacksTab.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; @@ -30,6 +31,15 @@ function EnforcementBadge({ enforcement }: { enforcement: PolicyPackRule['enforc export function PolicyPacksTab() { const [packs, setPacks] = useState(null); const [error, setError] = useState(false); + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = (id: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); useEffect(() => { let cancelled = false; @@ -62,8 +72,8 @@ export function PolicyPacksTab() { if (!packs) { return (
- - + +
); } @@ -75,38 +85,61 @@ export function PolicyPacksTab() { Community: they explain what good looks like. Block-on-deploy enforcement is an Admiral capability.

- {packs.map((pack) => ( -
-
-

{pack.name}

-

{pack.tagline}

-

{pack.tierCopy}

-
-
    - {pack.rules.map((rule) => ( -
  • -
    -
    - {rule.name} - - {rule.severity} - -
    - +
    + {packs.map((pack) => { + const isOpen = expanded.has(pack.id); + return ( +
    +
  • - ))} -
-
- ))} + + {pack.rules.length} rule{pack.rules.length === 1 ? '' : 's'} + + + + {isOpen && ( +
+

{pack.tierCopy}

+
    + {pack.rules.map((rule) => ( +
  • +
    +
    + {rule.name} + + {rule.severity} + +
    + +
    +
    +
    Checks
    +
    {rule.whatItChecks}
    +
    Why
    +
    {rule.why}
    +
    Fix
    +
    {rule.howToFix}
    +
    +
  • + ))} +
+
+ )} +
+ ); + })} +
); } diff --git a/frontend/src/components/security/ScanPolicyManager.tsx b/frontend/src/components/security/ScanPolicyManager.tsx index 19f34bfd..956b28c6 100644 --- a/frontend/src/components/security/ScanPolicyManager.tsx +++ b/frontend/src/components/security/ScanPolicyManager.tsx @@ -252,10 +252,10 @@ export function ScanPolicyManager() {

Deploy enforcement policies

{isAdmin && !isRemote && !isReplica && ( - - + )}
diff --git a/frontend/src/components/security/SecurityCharts.tsx b/frontend/src/components/security/SecurityCharts.tsx new file mode 100644 index 00000000..0eac9cac --- /dev/null +++ b/frontend/src/components/security/SecurityCharts.tsx @@ -0,0 +1,171 @@ +import { useMemo } from 'react'; +import { PieChart, Pie, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, LabelList } from 'recharts'; +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'; +import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security'; + +// Severity palette stays within the design's semantic tokens: --destructive +// (critical), --warning (high), a muted --warning (medium), --muted-foreground +// (low). No new chart hue. +const SEVERITY_CONFIG = { + critical: { label: 'Critical', color: 'var(--destructive)' }, + high: { label: 'High', color: 'var(--warning)' }, + medium: { label: 'Medium', color: 'color-mix(in oklch, var(--warning) 55%, var(--muted))' }, + low: { label: 'Low', color: 'var(--muted-foreground)' }, +} satisfies ChartConfig; + +// The trend and top-exposed charts both plot only the Critical + High slots. +const CRITICAL_HIGH_CONFIG = { + critical: SEVERITY_CONFIG.critical, + high: SEVERITY_CONFIG.high, +} satisfies ChartConfig; + +function EmptyChart({ label, height }: { label: string; height: number }) { + return ( +
+ {label} +
+ ); +} + +/** Donut of total findings by severity across the node's scanned images. */ +export function SeverityDonutChart({ summaries }: { summaries: ScanSummary[] }) { + const data = useMemo(() => { + const totals = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const s of summaries) { + totals.critical += s.critical; + totals.high += s.high; + totals.medium += s.medium; + totals.low += s.low; + } + return (['critical', 'high', 'medium', 'low'] as const) + .map((k) => ({ key: k, label: SEVERITY_CONFIG[k].label, value: totals[k], fill: `var(--color-${k})` })) + .filter((d) => d.value > 0); + }, [summaries]); + + const total = data.reduce((sum, d) => sum + d.value, 0); + if (total === 0) return ; + + return ( + + + } /> + + + + ); +} + +/** Stacked area of Critical + High findings by scan-day (days with no scans are omitted). */ +export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) { + if (trend.length === 0) return ; + + const fmtDate = (d: string) => d.slice(5); // MM-DD + + return ( + + + + + + + + + + + + + + + + } /> + + + + + ); +} + +interface TopImageDatum { name: string; critical: number; high: number; scanId: number } + +/** Horizontal stacked bars of the top images by Critical+High; click opens the scan. */ +export function TopExposedImagesChart({ + summaries, + onInspect, +}: { + summaries: ScanSummary[]; + onInspect: (scanId: number) => void; +}) { + const data: TopImageDatum[] = useMemo( + () => + summaries + .filter((s) => !s.image_ref.startsWith('stack:') && s.critical + s.high > 0) + .sort((a, b) => b.critical + b.high - (a.critical + a.high)) + .slice(0, 6) + .map((s) => ({ + name: s.image_ref.length > 28 ? `…${s.image_ref.slice(-27)}` : s.image_ref, + critical: s.critical, + high: s.high, + scanId: s.scan_id, + })), + [summaries], + ); + + if (data.length === 0) return ; + + const handleBarClick = (d: unknown) => { + const dd = d as TopImageDatum; + if (dd?.scanId != null) onInspect(dd.scanId); + }; + + return ( + + + + + } /> + + + + + ); +} + +/** Vertical bars comparing the three finding types. */ +export function FindingsByTypeChart({ summaries }: { summaries: ScanSummary[] }) { + const data = useMemo(() => { + let vulnerabilities = 0; + let secrets = 0; + let misconfigs = 0; + for (const s of summaries) { + vulnerabilities += s.total; + secrets += s.secret_count; + misconfigs += s.misconfig_count; + } + return [ + { type: 'Vulnerabilities', value: vulnerabilities, fill: 'var(--brand)' }, + { type: 'Secrets', value: secrets, fill: 'var(--destructive)' }, + { type: 'Misconfigs', value: misconfigs, fill: 'var(--warning)' }, + ]; + }, [summaries]); + + const total = data.reduce((sum, d) => sum + d.value, 0); + if (total === 0) return ; + + const config = { + value: { label: 'Findings' }, + } satisfies ChartConfig; + + return ( + + + + + + } /> + + + + + + ); +} diff --git a/frontend/src/components/security/__tests__/HistoryTab.test.tsx b/frontend/src/components/security/__tests__/HistoryTab.test.tsx new file mode 100644 index 00000000..e49439a1 --- /dev/null +++ b/frontend/src/components/security/__tests__/HistoryTab.test.tsx @@ -0,0 +1,151 @@ +/** + * HistoryTab is the inline scan-history table that replaced the history sheet. + * Locks: completed-scan fetch on mount with pagination params, Open -> inspect + * on the vulns tab, two-scan compare capped at two with oldest-first baseline + * ordering, search-by-image, and the load-failure error state. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { VulnerabilityScan } from '@/types/security'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); + +const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } }; +vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState })); + +const compareProps: { baselineScanId: number | null; currentScanId: number | null }[] = []; +vi.mock('../../ScanComparisonSheet', () => ({ + ScanComparisonSheet: (props: { baselineScanId: number | null; currentScanId: number | null }) => { + compareProps.push({ baselineScanId: props.baselineScanId, currentScanId: props.currentScanId }); + return null; + }, +})); +vi.mock('../../VulnerabilityScanSheet', () => ({ + SeverityChip: ({ severity }: { severity: string }) => {severity}, +})); + +import { apiFetch } from '@/lib/api'; +import { HistoryTab } from '../HistoryTab'; + +const mockedFetch = apiFetch as unknown as ReturnType; + +function scan(overrides: Partial = {}): VulnerabilityScan { + return { + id: 1, + node_id: 1, + image_ref: 'alpine:3.19', + image_digest: null, + scanned_at: 1_700_000_000_000, + total_vulnerabilities: 0, + critical_count: 0, + high_count: 0, + medium_count: 0, + low_count: 0, + unknown_count: 0, + fixable_count: 0, + secret_count: 0, + misconfig_count: 0, + scanners_used: 'vuln', + highest_severity: null, + os_info: null, + trivy_version: null, + scan_duration_ms: null, + triggered_by: 'manual', + status: 'completed', + error: null, + stack_context: null, + ...overrides, + }; +} + +function listResponse(items: VulnerabilityScan[], total?: number): Response { + return { ok: true, status: 200, json: async () => ({ items, total: total ?? items.length }) } as unknown as Response; +} + +beforeEach(() => { + mockedFetch.mockReset(); + compareProps.length = 0; + nodesState.activeNode = { id: 1 }; +}); + +afterEach(() => vi.clearAllMocks()); + +describe('HistoryTab', () => { + it('fetches completed scans on mount with pagination params', async () => { + mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })])); + render(); + await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument()); + const url = mockedFetch.mock.calls[0][0] as string; + expect(url).toContain('/security/scans?'); + expect(url).toContain('status=completed'); + expect(url).toContain('limit=100'); + expect(url).toContain('offset=0'); + }); + + it('opens the scan sheet on the vulns tab from Open', async () => { + const onInspect = vi.fn(); + mockedFetch.mockResolvedValue(listResponse([scan({ id: 42, image_ref: 'nginx:1' })])); + render(); + await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(onInspect).toHaveBeenCalledWith(42, 'vulns'); + }); + + it('compares two scans with the older as baseline and newer as current', async () => { + const older = scan({ id: 10, image_ref: 'a:1', scanned_at: 1000 }); + const newer = scan({ id: 20, image_ref: 'b:1', scanned_at: 2000 }); + mockedFetch.mockResolvedValue(listResponse([newer, older])); + render(); + await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument()); + const checks = screen.getAllByLabelText('Select scan to compare'); + await userEvent.click(checks[0]); + await userEvent.click(checks[1]); + await userEvent.click(screen.getByRole('button', { name: /Compare/ })); + const last = compareProps[compareProps.length - 1]; + expect(last.baselineScanId).toBe(10); + expect(last.currentScanId).toBe(20); + }); + + it('caps the compare selection at two', async () => { + mockedFetch.mockResolvedValue(listResponse([ + scan({ id: 1, image_ref: 'a:1', scanned_at: 3000 }), + scan({ id: 2, image_ref: 'b:1', scanned_at: 2000 }), + scan({ id: 3, image_ref: 'c:1', scanned_at: 1000 }), + ])); + render(); + await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument()); + const checks = screen.getAllByLabelText('Select scan to compare'); + await userEvent.click(checks[0]); + await userEvent.click(checks[1]); + await userEvent.click(checks[2]); + expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument(); + }); + + it('searches by image on Enter, adding imageRefLike to the request', async () => { + mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })])); + render(); + await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument()); + await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis{Enter}'); + await waitFor(() => { + const calls = mockedFetch.mock.calls.map((c) => c[0] as string); + expect(calls.some((u) => u.includes('imageRefLike=redis'))).toBe(true); + }); + }); + + it('renders the error state when the load fails', async () => { + mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as unknown as Response); + render(); + await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument()); + }); + + it('treats a malformed 200 response (no items array) as an error, not an empty list', async () => { + mockedFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ oops: true }) } as unknown as Response); + render(); + await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument()); + expect(screen.queryByText(/No completed scans yet/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/security/__tests__/ImagesTab.test.tsx b/frontend/src/components/security/__tests__/ImagesTab.test.tsx new file mode 100644 index 00000000..c45df96b --- /dev/null +++ b/frontend/src/components/security/__tests__/ImagesTab.test.tsx @@ -0,0 +1,122 @@ +/** + * ImagesTab is a prop-driven index over the node's image-scan summaries. It + * filters out stack/config scans, supports search + a severity filter, opens + * the scan sheet from the image name and the Findings cell, and exposes inline + * scan actions only when the caller can scan. + */ +import { it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ImagesTab } from '../ImagesTab'; +import type { ScanSummary } from '@/types/security'; + +function summary(o: Partial & { image_ref: string; scan_id: number }): ScanSummary { + return { + highest_severity: null, + scanned_at: Date.now(), + total: 0, + critical: 0, + high: 0, + medium: 0, + low: 0, + unknown: 0, + fixable: 0, + secret_count: 0, + misconfig_count: 0, + ...o, + }; +} + +function asMap(...list: ScanSummary[]): Record { + return Object.fromEntries(list.map((s) => [s.image_ref, s])); +} + +const base = { + loading: false, + error: false, + onInspect: vi.fn(), + canScan: false, + scanningRef: null as string | null, + onScan: vi.fn(), +}; + +beforeEach(() => vi.clearAllMocks()); + +it('renders real images and excludes stack/config scans', () => { + render( + , + ); + expect(screen.getByText('nginx:1')).toBeInTheDocument(); + expect(screen.queryByText('stack:web')).not.toBeInTheDocument(); +}); + +it('opens the scan sheet on the vulns tab from the image name', async () => { + const onInspect = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByText('nginx:1')); + expect(onInspect).toHaveBeenCalledWith(7, 'vulns'); +}); + +it('opens the scan sheet on the vulns tab from the Findings cell', async () => { + const onInspect = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByText('clean')); + expect(onInspect).toHaveBeenCalledWith(9, 'vulns'); +}); + +it('narrows the list with the search box', async () => { + render( + , + ); + await userEvent.type(screen.getByPlaceholderText('Search images...'), 'redis'); + expect(screen.getByText('redis:7')).toBeInTheDocument(); + expect(screen.queryByText('nginx:1')).not.toBeInTheDocument(); +}); + +it('narrows the list with the severity filter', async () => { + render( + , + ); + await userEvent.click(screen.getByText('All severities')); + await userEvent.click(screen.getByText('Critical')); + expect(screen.getByText('crit:1')).toBeInTheDocument(); + expect(screen.queryByText('low: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(); + expect(screen.queryByLabelText('Scan nginx:1')).not.toBeInTheDocument(); + rerender(); + expect(screen.getByLabelText('Scan nginx:1')).toBeInTheDocument(); +}); diff --git a/frontend/src/components/security/__tests__/PolicyPacksTab.test.tsx b/frontend/src/components/security/__tests__/PolicyPacksTab.test.tsx index 4ed1ab83..902332e8 100644 --- a/frontend/src/components/security/__tests__/PolicyPacksTab.test.tsx +++ b/frontend/src/components/security/__tests__/PolicyPacksTab.test.tsx @@ -5,6 +5,7 @@ */ import { it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); @@ -44,18 +45,27 @@ beforeEach(() => { mockedFetch.mockResolvedValue(jsonResponse(200, PACKS)); }); -it('fetches the catalog with localOnly and renders packs and rules', async () => { +it('fetches the catalog with localOnly and reveals rules when a pack is expanded', async () => { + const user = userEvent.setup(); render(); await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument()); expect(screen.getByText('Strict production')).toBeInTheDocument(); + expect(mockedFetch).toHaveBeenCalledWith('/security/policy-packs', { localOnly: true }); + + // Rules are collapsed behind the accordion until the pack header is clicked. + expect(screen.queryByText('Pin image tags')).not.toBeInTheDocument(); + await user.click(screen.getByText('Homelab baseline')); + await user.click(screen.getByText('Strict production')); expect(screen.getByText('Pin image tags')).toBeInTheDocument(); expect(screen.getByText('No privileged containers')).toBeInTheDocument(); - - expect(mockedFetch).toHaveBeenCalledWith('/security/policy-packs', { localOnly: true }); }); -it('labels rules as warning or enforceable', async () => { +it('labels expanded rules as warning or enforceable', async () => { + const user = userEvent.setup(); render(); - await waitFor(() => expect(screen.getByText('Warning')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument()); + await user.click(screen.getByText('Homelab baseline')); + await user.click(screen.getByText('Strict production')); + expect(screen.getByText('Warning')).toBeInTheDocument(); expect(screen.getByText('Enforceable')).toBeInTheDocument(); }); diff --git a/frontend/src/components/settings/MisconfigAckPanel.tsx b/frontend/src/components/settings/MisconfigAckPanel.tsx index d943ea53..36d25a86 100644 --- a/frontend/src/components/settings/MisconfigAckPanel.tsx +++ b/frontend/src/components/settings/MisconfigAckPanel.tsx @@ -6,9 +6,10 @@ 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, ShieldCheck, Trash2 } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; +import { FleetTabHeading } from '@/components/fleet/FleetEmptyState'; import type { MisconfigAcknowledgement } from '@/types/security'; import { useAuth } from '@/context/AuthContext'; @@ -145,54 +146,48 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) { }; return ( -
-
-
- - Misconfig Acknowledgements - - {rows.length} - -
-
- {needsPagination && ( - <> - - - {safePage + 1} / {totalPages} - - - - )} - {isAdmin && !isReplica && ( +
+ - Add Acknowledgement + Add acknowledgement - )} -
-
+ ) : undefined + } + /> -

- Accept known-benign misconfigurations so they stop triggering alerts. Acknowledgements apply at read time - across every instance in the fleet and never modify stored scan data. -

+
+ {needsPagination && !loading && rows.length > 0 && ( +
+ + + {safePage + 1} / {totalPages} + + +
+ )} {loading && (
@@ -248,6 +243,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) { )} +
-
-
- - CVE Suppressions - - {rows.length} - -
-
- {needsPagination && ( - <> - - - {safePage + 1} / {totalPages} - - - - )} - {isAdmin && !isReplica && ( +
+ - Add Suppression + Add suppression - )} -
-
+ ) : undefined + } + /> -

- Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across every - instance in the fleet and never modify stored scan data. -

+
+ {needsPagination && !loading && rows.length > 0 && ( +
+ + + {safePage + 1} / {totalPages} + + +
+ )} {loading && (
@@ -256,6 +251,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) { )} +
void }) { - // highest_severity is derived from vulnerabilities only, so a scan with - // secrets or misconfigurations but zero CVEs would otherwise read "Clean". - // Treat those as a non-clean "Findings" state. - const hasNonVulnFindings = (summary.secret_count ?? 0) > 0 || (summary.misconfig_count ?? 0) > 0; - const key: VulnSeverity | 'CLEAN' | 'FINDINGS' = - summary.highest_severity ?? (hasNonVulnFindings ? 'FINDINGS' : 'CLEAN'); +export function SeverityBadge({ summary, onClick, tooltip = true }: { summary: ScanSummary; onClick: () => void; tooltip?: boolean }) { + const key = getSeverityKey(summary); + const hasNonVulnFindings = key === 'FINDINGS'; const label = key === 'CLEAN' ? 'Clean' : key === 'FINDINGS' ? 'Findings' : key; const [relative, setRelative] = useState(''); useEffect(() => { + if (!tooltip) return; const compute = () => { const scanAge = Math.round((Date.now() - summary.scanned_at) / 60000); setRelative( @@ -32,23 +31,27 @@ export function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onCl compute(); const id = setInterval(compute, 60000); return () => clearInterval(id); - }, [summary.scanned_at]); + }, [summary.scanned_at, tooltip]); + + const pill = ( + + ); + + if (!tooltip) return pill; return ( - - - + {pill}
diff --git a/frontend/src/components/ui/__tests__/SeverityBadge.test.tsx b/frontend/src/components/ui/__tests__/SeverityBadge.test.tsx index 269abc0b..b96b4a7b 100644 --- a/frontend/src/components/ui/__tests__/SeverityBadge.test.tsx +++ b/frontend/src/components/ui/__tests__/SeverityBadge.test.tsx @@ -51,3 +51,11 @@ it('renders "Findings" for a misconfig-only scan', () => { render( {}} />); expect(screen.getByRole('button', { name: /Findings/ })).toBeInTheDocument(); }); + +it('renders the bare pill and fires onClick with tooltip disabled', async () => { + const onClick = vi.fn(); + render(); + const btn = screen.getByRole('button', { name: /HIGH/ }); + await userEvent.click(btn); + expect(onClick).toHaveBeenCalledOnce(); +}); diff --git a/frontend/src/hooks/__tests__/useImageScan.test.ts b/frontend/src/hooks/__tests__/useImageScan.test.ts new file mode 100644 index 00000000..818e4014 --- /dev/null +++ b/frontend/src/hooks/__tests__/useImageScan.test.ts @@ -0,0 +1,41 @@ +/** + * useImageScan triggers a scan and polls to completion. This covers the + * start-failure path: a non-OK scan POST surfaces an error toast (with the HTTP + * status, not a confusing JSON parse error) and clears the in-flight ref, rather + * than spinning until the poll timeout. + */ +import { it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), loading: vi.fn(() => 'id'), dismiss: vi.fn() }, +})); + +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { useImageScan } from '../useImageScan'; + +const mockedFetch = apiFetch as unknown as ReturnType; +const mockedToast = toast as unknown as { error: ReturnType }; + +beforeEach(() => { + mockedFetch.mockReset(); + mockedToast.error.mockReset(); +}); + +it('toasts the HTTP status and clears the in-flight ref when the scan POST fails', async () => { + mockedFetch.mockResolvedValue({ ok: false, status: 503, json: async () => ({}) } as unknown as Response); + + const onComplete = vi.fn(); + const onSummaries = vi.fn(); + const { result } = renderHook(() => useImageScan({ onComplete, onSummaries })); + + await act(async () => { + await result.current.scanImage('nginx:1', ['vuln']); + }); + + await waitFor(() => expect(mockedToast.error).toHaveBeenCalledWith(expect.stringContaining('503'))); + expect(onComplete).not.toHaveBeenCalled(); + expect(result.current.scanningRef).toBeNull(); +}); diff --git a/frontend/src/hooks/useImageScan.ts b/frontend/src/hooks/useImageScan.ts new file mode 100644 index 00000000..08243c12 --- /dev/null +++ b/frontend/src/hooks/useImageScan.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import type { ScanSummary, ScannerKind } from '@/types/security'; + +interface UseImageScanOptions { + /** Called with the finished scan's id (e.g. to open the detail sheet). */ + onComplete: (scanId: number) => void; + /** Called with the refreshed image-summaries map after a scan completes. */ + onSummaries: (summaries: Record) => void; +} + +/** + * Triggers a Trivy scan for an image and polls until it finishes, then refreshes + * the image-summaries and reports the completed scan id. A new scan supersedes + * any in-flight poll, and the poll is abandoned (server-side scan keeps running) + * on unmount. Mirrors the Resources image-scan flow so the Security Images tab + * can scan without re-implementing it. + */ +export function useImageScan({ onComplete, onSummaries }: UseImageScanOptions) { + const [scanningRef, setScanningRef] = useState(null); + const abortRef = useRef(null); + + useEffect(() => () => abortRef.current?.abort(), []); + + const scanImage = useCallback( + async (imageRef: string, scanners: ScannerKind[]) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + setScanningRef(imageRef); + const loadingId = toast.loading(`Scanning ${imageRef}...`); + try { + const res = await apiFetch('/security/scan', { + method: 'POST', + body: JSON.stringify({ imageRef, force: true, scanners }), + signal, + }); + // Check the HTTP status before parsing: a non-JSON error body (e.g. a + // proxy 502) would otherwise surface a confusing parse error instead of + // the real failure. + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error || `Failed to start scan (HTTP ${res.status})`); + } + const data = (await res.json()) as { scanId: number }; + const scanId = data.scanId; + + const deadline = Date.now() + 5 * 60 * 1000; + while (Date.now() < deadline) { + await new Promise((resolve) => { + if (signal.aborted) { resolve(); return; } + const timer = setTimeout(resolve, 3000); + signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); + }); + if (signal.aborted) return; + const poll = await apiFetch(`/security/scans/${scanId}`, { signal }); + if (signal.aborted) return; + if (!poll.ok) { + // A transient non-OK poll is retried, but a hard error (gone/auth) + // would otherwise masquerade as a 5-minute "timed out". + console.warn('[Security] scan status poll failed:', poll.status); + if (poll.status === 404 || poll.status === 401) { + throw new Error(`Scan status unavailable (HTTP ${poll.status})`); + } + continue; + } + const pollData = await poll.json(); + if (signal.aborted) return; + if (pollData.status === 'in_progress') continue; + if (pollData.status === 'completed') { + toast.success(`Scan complete: ${pollData.total_vulnerabilities ?? 0} vulnerabilities found`); + onComplete(scanId); + const summariesRes = await apiFetch('/security/image-summaries', { signal }); + if (signal.aborted) return; + if (summariesRes.ok) { + const summaries = await summariesRes.json(); + if (signal.aborted) return; + onSummaries(summaries ?? {}); + } else { + // The scan succeeded; only the summaries refresh failed. Keep the + // table from silently going stale by surfacing it. + console.warn('[Security] image-summaries refresh after scan failed:', summariesRes.status); + } + return; + } + // 'failed' or any unexpected/malformed status: never read as success. + throw new Error(pollData.error || `Scan failed (status: ${pollData.status ?? 'unknown'})`); + } + throw new Error('Scan timed out'); + } catch (error) { + if (signal.aborted) { + // A deliberately cancelled poll is not an error, but keep a breadcrumb + // so a real failure racing the abort is not lost. + console.debug('Scan poll aborted', error); + return; + } + toast.error((error as Error)?.message || 'Scan failed'); + } finally { + toast.dismiss(loadingId); + // Only the owning poll clears the shared state; a superseded poll leaves + // it to the scan that replaced it. + if (abortRef.current === controller) { + abortRef.current = null; + setScanningRef(null); + } + } + }, + [onComplete, onSummaries], + ); + + return { scanningRef, scanImage }; +} diff --git a/frontend/src/lib/__tests__/severityStyles.test.ts b/frontend/src/lib/__tests__/severityStyles.test.ts new file mode 100644 index 00000000..109bb719 --- /dev/null +++ b/frontend/src/lib/__tests__/severityStyles.test.ts @@ -0,0 +1,42 @@ +/** + * getSeverityKey is the single classifier shared by the severity badge, the + * Images severity sort, and the Images severity filter. Lock its mapping so the + * three consumers can never disagree: a CVE severity wins, a secret/misconfig-only + * scan is FINDINGS (not a false "Clean"), and an all-zero scan is CLEAN. + */ +import { it, expect } from 'vitest'; +import { getSeverityKey } from '../severityStyles'; +import type { ScanSummary } from '@/types/security'; + +function summary(o: Partial): ScanSummary { + return { + image_ref: 'x:1', + highest_severity: null, + scanned_at: 1, + scan_id: 1, + total: 0, + critical: 0, + high: 0, + medium: 0, + low: 0, + unknown: 0, + fixable: 0, + secret_count: 0, + misconfig_count: 0, + ...o, + }; +} + +it('returns the highest CVE severity when present', () => { + expect(getSeverityKey(summary({ highest_severity: 'CRITICAL' }))).toBe('CRITICAL'); + expect(getSeverityKey(summary({ highest_severity: 'LOW' }))).toBe('LOW'); +}); + +it('returns FINDINGS for a secret- or misconfig-only scan with no CVE severity', () => { + expect(getSeverityKey(summary({ highest_severity: null, secret_count: 1 }))).toBe('FINDINGS'); + expect(getSeverityKey(summary({ highest_severity: null, misconfig_count: 2 }))).toBe('FINDINGS'); +}); + +it('returns CLEAN when there are no findings of any kind', () => { + expect(getSeverityKey(summary({ highest_severity: null }))).toBe('CLEAN'); +}); diff --git a/frontend/src/lib/severityStyles.ts b/frontend/src/lib/severityStyles.ts index 4206f031..81425fe9 100644 --- a/frontend/src/lib/severityStyles.ts +++ b/frontend/src/lib/severityStyles.ts @@ -1,4 +1,17 @@ -import type { VulnSeverity } from '@/types/security'; +import type { ScanSummary, VulnSeverity } from '@/types/security'; + +export type SeverityKey = VulnSeverity | 'CLEAN' | 'FINDINGS'; + +/** + * The display "key" for a scan summary: its highest vulnerability severity, or + * `FINDINGS` when the scan has only secrets/misconfigurations (stored + * highest_severity is derived from CVEs alone), or `CLEAN` when nothing was + * found. Shared so the badge, sorting, and filtering all classify identically. + */ +export function getSeverityKey(summary: ScanSummary): SeverityKey { + const hasNonVulnFindings = (summary.secret_count ?? 0) > 0 || (summary.misconfig_count ?? 0) > 0; + return summary.highest_severity ?? (hasNonVulnFindings ? 'FINDINGS' : 'CLEAN'); +} export const SEVERITY_ROW_TINT: Record = { CRITICAL: 'bg-destructive/10 border-l-[3px] border-destructive/70', @@ -14,7 +27,7 @@ export const SEVERITY_ROW_TINT: Record = { * state for a scan that has secrets or misconfigurations but zero CVEs (the * stored highest_severity is derived from vulnerabilities only). */ -export const SEVERITY_BADGE_CLASSES: Record = { +export const SEVERITY_BADGE_CLASSES: Record = { CRITICAL: 'border-destructive/25 bg-destructive/8 text-destructive', HIGH: 'border-warning/25 bg-warning/8 text-warning', MEDIUM: 'border-warning/25 bg-warning/8 text-warning', @@ -25,7 +38,7 @@ export const SEVERITY_BADGE_CLASSES: Record = { +export const SEVERITY_DOT_CLASSES: Record = { CRITICAL: 'bg-destructive', HIGH: 'bg-warning', MEDIUM: 'bg-warning', diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 78bf8429..dd5d286a 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -205,6 +205,16 @@ export interface SecurityOverview { /** Which detail tab the scan sheet opens on. Matches VulnerabilityScanSheet's tabs. */ export type ScanDetailTab = 'vulns' | 'secrets' | 'misconfigs'; +/** Scanner kinds a scan request can run. Mirrors the backend's accepted set. */ +export type ScannerKind = 'vuln' | 'secret'; + +/** One day's Critical/High totals for the Security overview risk-trend chart. */ +export interface SecurityRiskTrendPoint { + date: string; + critical: number; + high: number; +} + export type PolicyRuleEnforcement = 'warning' | 'enforceable'; export interface PolicyPackRule {