mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user