feat: add an on-demand node-wide security scan with live progress (#1367)

Add a "Scan this node" action on the Security overview that scans, in one pass,
any combination of three types: image vulnerabilities, image secrets, and
compose misconfigurations. Progress streams live into the deploy-feedback modal.

- TrivyService.scanNode runs the selected scanners across the node's images and,
  for misconfig, every stack's compose file, behind a per-node lock and tolerant
  of per-item failures. The existing scanAllNodeImages becomes a thin vuln-only
  wrapper over the shared image loop, so scheduled scans are unchanged.
- POST /api/security/scan-node (admin, scanner-gated) streams sanitized progress
  to the deploy terminal and returns a combined summary. Secret scans stream
  counts only, never matched values.
- Frontend adds a "scan" action verb and a ScanNodeLauncher wired into the
  overview; the scan stays bound to the node it started on even if the active
  node changes mid-run.
This commit is contained in:
Anso
2026-06-12 19:07:45 -04:00
committed by GitHub
parent ebf66fd92a
commit ef5a3f00a7
11 changed files with 641 additions and 6 deletions
+5 -1
View File
@@ -59,6 +59,8 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
const [summariesError, setSummariesError] = useState(false);
const [trend, setTrend] = useState<SecurityRiskTrendPoint[]>([]);
const [isReplica, setIsReplica] = useState(false);
// Bumped after a node-wide scan completes to refetch the active node's posture.
const [reloadToken, setReloadToken] = useState(0);
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
const [inspectInitialTab, setInspectInitialTab] = useState<ScanDetailTab | undefined>(undefined);
@@ -139,7 +141,7 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
if (!cancelled) setTrend(trend);
})();
return () => { cancelled = true; };
}, [activeNode?.id]);
}, [activeNode?.id, reloadToken]);
// Governance panels (suppressions/acks) are control-governed; probe the local
// fleet role so a replica renders them read-only, mirroring Settings.
@@ -222,6 +224,8 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
trend={trend}
onNavigate={onTabChange}
onInspect={onInspect}
canScan={canScan}
onScanComplete={() => setReloadToken((t) => t + 1)}
/>
</TabsContent>
@@ -11,6 +11,7 @@ import {
TopExposedImagesChart,
FindingsByTypeChart,
} from './SecurityCharts';
import { ScanNodeLauncher } from './ScanNodeLauncher';
interface OverviewTabProps {
overview: SecurityOverview | null;
@@ -20,6 +21,10 @@ interface OverviewTabProps {
trend: SecurityRiskTrendPoint[];
onNavigate: (tab: SecurityTab) => void;
onInspect: (scanId: number) => void;
/** Admin on a node with a ready scanner; enables the node-scan launcher. */
canScan: boolean;
/** Refresh the overview after a node-wide scan completes. */
onScanComplete: () => void;
}
const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = {
@@ -47,7 +52,7 @@ function ChartCard({ title, className, children }: { title: string; className?:
);
}
export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, onInspect }: OverviewTabProps) {
export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, onInspect, canScan, onScanComplete }: OverviewTabProps) {
if (loadError === 'unsupported') {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
@@ -98,6 +103,17 @@ export function OverviewTab({ overview, loadError, summaries, trend, onNavigate,
return (
<div className="space-y-6">
{canScan && (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-stat-subtitle">
{overview.scannedImages === 0
? 'No images scanned on this node yet.'
: `${overview.scannedImages} image${overview.scannedImages === 1 ? '' : 's'} scanned.`}
</p>
<ScanNodeLauncher canScan={canScan} onComplete={onScanComplete} />
</div>
)}
{/* Charts lead the dashboard. */}
<div className="grid gap-4 lg:grid-cols-3">
<ChartCard title="Risk trend · 30 days · critical + high" className="lg:col-span-2">
@@ -0,0 +1,109 @@
import { useState } from 'react';
import { ShieldCheck, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch, withDeploySession } from '@/lib/api';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { useNodes } from '@/context/NodeContext';
import { toast } from '@/components/ui/toast-store';
interface ScanNodeLauncherProps {
/** Admin on a node with a ready scanner; the launcher hides otherwise. */
canScan: boolean;
/** Fired after a scan finishes so the caller can refresh the overview. */
onComplete?: () => void;
}
const TYPES = [
{ key: 'vulns', label: 'Image vulnerabilities' },
{ key: 'secrets', label: 'Image secrets' },
{ key: 'misconfig', label: 'Compose misconfigurations' },
] as const;
type TypeKey = (typeof TYPES)[number]['key'];
/**
* "Scan this node" launcher: pick any of the three scan types, then run the
* node-wide scan with live progress in the deploy-feedback modal. The node is
* captured once so the request and the progress stream stay bound to it even if
* the active node changes mid-scan.
*/
export function ScanNodeLauncher({ canScan, onComplete }: ScanNodeLauncherProps) {
const { runWithLog } = useDeployFeedback();
const { activeNode } = useNodes();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Record<TypeKey, boolean>>({ vulns: true, secrets: true, misconfig: true });
const [running, setRunning] = useState(false);
if (!canScan) return null;
const anySelected = Object.values(selected).some(Boolean);
const start = async () => {
if (!anySelected || running) return;
setOpen(false);
setRunning(true);
const opNodeId = activeNode?.id ?? null;
const nodeLabel = activeNode?.name ?? 'this node';
try {
await runWithLog(
{ stackName: nodeLabel, action: 'scan', nodeId: opNodeId },
async (started, sessionId) => {
if (started) await started;
const res = await apiFetch('/security/scan-node', withDeploySession(sessionId, {
method: 'POST',
nodeId: opNodeId,
body: JSON.stringify({ vulns: selected.vulns, secrets: selected.secrets, misconfig: selected.misconfig }),
}));
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const message = err?.error || 'Node scan failed';
toast.error(message);
return { ok: false, errorMessage: message };
}
// A 200 can still carry per-image/stack failures (the batch is
// failure-tolerant); surface them so a partial scan does not read as clean.
const result = await res.json().catch(() => null);
const failed = (result?.images?.failed ?? 0) + (result?.stacks?.failed ?? 0);
if (failed > 0) toast.warning(`Scan completed with ${failed} failure${failed === 1 ? '' : 's'}.`);
return { ok: true };
},
);
onComplete?.();
} finally {
setRunning(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button size="sm" disabled={running}>
{running
? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
: <ShieldCheck className="w-4 h-4 mr-1.5" strokeWidth={1.5} />}
Scan this node
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 space-y-3">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Scan types</p>
<div className="space-y-2">
{TYPES.map((t) => (
<label key={t.key} className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={selected[t.key]}
onCheckedChange={(c) => setSelected((s) => ({ ...s, [t.key]: c === true }))}
aria-label={t.label}
/>
<span className="text-sm">{t.label}</span>
</label>
))}
</div>
<Button size="sm" className="w-full" onClick={start} disabled={!anySelected}>
Start scan
</Button>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,78 @@
/**
* ScanNodeLauncher: hidden unless the caller can scan; opens a three-type
* selector; starting posts to /security/scan-node with the selected types, the
* deploy session, and the node captured at launch (so the request and the
* progress stream stay bound to the same node).
*/
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const apiFetch = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetch(...args),
withDeploySession: (id: string, opts: Record<string, unknown>) => ({ ...opts, __session: id }),
}));
const runWithLog = vi.fn(
(_params: unknown, run: (started: Promise<void>, sessionId: string) => Promise<unknown>) =>
run(Promise.resolve(), 'sess-1'),
);
vi.mock('@/context/DeployFeedbackContext', () => ({ useDeployFeedback: () => ({ runWithLog }) }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 3, name: 'local' } }) }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), warning: vi.fn() } }));
import { ScanNodeLauncher } from '../ScanNodeLauncher';
import { toast } from '@/components/ui/toast-store';
beforeEach(() => {
apiFetch.mockReset();
apiFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
runWithLog.mockClear();
(toast.error as ReturnType<typeof vi.fn>).mockClear();
});
it('renders nothing when scanning is not allowed', () => {
const { container } = render(<ScanNodeLauncher canScan={false} />);
expect(container).toBeEmptyDOMElement();
});
it('shows the launcher button when scanning is allowed', () => {
render(<ScanNodeLauncher canScan />);
expect(screen.getByRole('button', { name: /Scan this node/i })).toBeInTheDocument();
});
it('opens a three-type selector and scans the captured node with the chosen types', async () => {
const onComplete = vi.fn();
render(<ScanNodeLauncher canScan onComplete={onComplete} />);
await userEvent.click(screen.getByRole('button', { name: /Scan this node/i }));
expect(screen.getByLabelText('Image vulnerabilities')).toBeInTheDocument();
expect(screen.getByLabelText('Image secrets')).toBeInTheDocument();
expect(screen.getByLabelText('Compose misconfigurations')).toBeInTheDocument();
// Drop secrets, keep vulns + misconfig.
await userEvent.click(screen.getByLabelText('Image secrets'));
await userEvent.click(screen.getByRole('button', { name: /Start scan/i }));
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
const [url, opts] = apiFetch.mock.calls[0] as [string, Record<string, unknown>];
expect(url).toBe('/security/scan-node');
expect(opts.method).toBe('POST');
expect(opts.nodeId).toBe(3);
expect(opts.__session).toBe('sess-1');
expect(JSON.parse(opts.body as string)).toEqual({ vulns: true, secrets: false, misconfig: true });
await waitFor(() => expect(onComplete).toHaveBeenCalled());
});
it('toasts the server error when the scan request fails and still refreshes', async () => {
apiFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'Already scanning this node' }) });
const onComplete = vi.fn();
render(<ScanNodeLauncher canScan onComplete={onComplete} />);
await userEvent.click(screen.getByRole('button', { name: /Scan this node/i }));
await userEvent.click(screen.getByRole('button', { name: /Start scan/i }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Already scanning this node'));
await waitFor(() => expect(onComplete).toHaveBeenCalled());
});
@@ -1,4 +1,4 @@
import { Rocket, RefreshCw, CircleStop, AlertTriangle, Clock, Activity } from 'lucide-react';
import { Rocket, RefreshCw, CircleStop, AlertTriangle, Clock, Activity, ShieldCheck } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatTimeAgo, formatAgeShort } from '@/lib/relativeTime';
@@ -33,6 +33,7 @@ const VERB_ICON: Record<ActionVerb, LucideIcon> = {
restart: RefreshCw,
down: CircleStop,
stop: CircleStop,
scan: ShieldCheck,
};
function formatClockHHMM(unixSecs: number): string {