mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* POST /api/security/scan-node -> on-demand node-wide scan (admin, scanner-gated).
|
||||
* Covers the route contract: auth, admin, scanner availability, strict body
|
||||
* validation, the success summary, and the per-node-busy conflict. The scan
|
||||
* engine itself is mocked; TrivyService.scanNode is unit-tested separately.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import type { ScanNodeResult } from '../services/TrivyService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let TrivyService: typeof import('../services/TrivyService').default;
|
||||
|
||||
const SUMMARY: ScanNodeResult = {
|
||||
images: { scanned: 2, skipped: 1, failed: 0, totalImages: 3, processedImages: 3, truncated: false, severity: { critical: 1, high: 2, medium: 0, low: 0, unknown: 0 }, violations: [] },
|
||||
stacks: { scanned: 1, failed: 0, total: 1 },
|
||||
severity: { critical: 1, high: 2, medium: 0, low: 0, unknown: 0 },
|
||||
violations: [],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
TrivyService = (await import('../services/TrivyService')).default;
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const viewerHash = await bcrypt.hash('snviewer1', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'sn-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const res = await request(app).post('/api/auth/login').send({ username: 'sn-viewer', password: 'snviewer1' });
|
||||
const cookies = res.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function svc() {
|
||||
return TrivyService.getInstance();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
vi.spyOn(svc(), 'isTrivyAvailable').mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('POST /api/security/scan-node', () => {
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).post('/api/security/scan-node').send({ vulns: true });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('requires admin (viewer is rejected)', async () => {
|
||||
const res = await request(app).post('/api/security/scan-node').set('Cookie', viewerCookie).send({ vulns: true });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 503 when the scanner is unavailable', async () => {
|
||||
vi.spyOn(svc(), 'isTrivyAvailable').mockReturnValue(false);
|
||||
const res = await request(app).post('/api/security/scan-node').set('Cookie', adminCookie).send({ vulns: true });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('returns 400 when no scan type is selected', async () => {
|
||||
const scanNode = vi.spyOn(svc(), 'scanNode');
|
||||
const res = await request(app).post('/api/security/scan-node').set('Cookie', adminCookie)
|
||||
.send({ vulns: false, secrets: false, misconfig: false });
|
||||
expect(res.status).toBe(400);
|
||||
expect(scanNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when a scan-type flag is not a boolean', async () => {
|
||||
const res = await request(app).post('/api/security/scan-node').set('Cookie', adminCookie).send({ vulns: 'yes' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('runs the scan and returns the summary, passing the selected types', async () => {
|
||||
const scanNode = vi.spyOn(svc(), 'scanNode').mockResolvedValue(SUMMARY);
|
||||
const res = await request(app).post('/api/security/scan-node').set('Cookie', adminCookie)
|
||||
.send({ vulns: true, secrets: false, misconfig: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ stacks: { scanned: 1 }, severity: { critical: 1, high: 2 } });
|
||||
// No progress socket is opened in the test, so onProgress resolves undefined.
|
||||
expect(scanNode).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
{ vulns: true, secrets: false, misconfig: true },
|
||||
'manual',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 409 when the node is already being scanned', async () => {
|
||||
vi.spyOn(svc(), 'scanNode').mockRejectedValue(new Error('Already scanning this node'));
|
||||
const res = await request(app).post('/api/security/scan-node').set('Cookie', adminCookie).send({ vulns: true });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Unit tests for TrivyService.scanNode: scan-type selection, the per-node
|
||||
* concurrency lock, scanner-availability and empty-selection guards, and
|
||||
* partial-failure tolerance. The actual Trivy/Docker calls are mocked so the
|
||||
* orchestration is exercised without a scanner.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { DatabaseService, type VulnerabilityScan } from '../services/DatabaseService';
|
||||
|
||||
let tmpDir: string;
|
||||
let TrivyService: typeof import('../services/TrivyService').default;
|
||||
let DockerController: typeof import('../services/DockerController').default;
|
||||
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
TrivyService = (await import('../services/TrivyService')).default;
|
||||
DockerController = (await import('../services/DockerController')).default;
|
||||
({ FileSystemService } = await import('../services/FileSystemService'));
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function svc() {
|
||||
return TrivyService.getInstance();
|
||||
}
|
||||
|
||||
function fakeRow(over: Partial<VulnerabilityScan> = {}): VulnerabilityScan {
|
||||
return {
|
||||
id: 1, node_id: 1, image_ref: 'a:1', image_digest: null, scanned_at: Date.now(),
|
||||
total_vulnerabilities: 0, critical_count: 1, high_count: 2, medium_count: 0, low_count: 0, unknown_count: 0,
|
||||
fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', highest_severity: 'HIGH',
|
||||
os_info: null, trivy_version: null, scan_duration_ms: null, triggered_by: 'manual', status: 'completed',
|
||||
error: null, stack_context: null, policy_evaluation: null, ...over,
|
||||
} as VulnerabilityScan;
|
||||
}
|
||||
|
||||
let prevSource: unknown;
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
prevSource = (svc() as unknown as { source: unknown }).source;
|
||||
(svc() as unknown as { source: string }).source = 'managed';
|
||||
(svc() as unknown as { scanningNodes: Set<number> }).scanningNodes.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
(svc() as unknown as { source: unknown }).source = prevSource;
|
||||
});
|
||||
|
||||
describe('TrivyService.scanNode', () => {
|
||||
it('rejects when no scan type is selected', async () => {
|
||||
await expect(svc().scanNode(1, { vulns: false, secrets: false, misconfig: false })).rejects.toThrow(/at least one/i);
|
||||
});
|
||||
|
||||
it('throws when the scanner is unavailable', async () => {
|
||||
(svc() as unknown as { source: string }).source = 'none';
|
||||
await expect(svc().scanNode(1, { vulns: true, secrets: false, misconfig: false })).rejects.toThrow(/not available/i);
|
||||
});
|
||||
|
||||
it('refuses a second scan while the node is already scanning', async () => {
|
||||
(svc() as unknown as { scanningNodes: Set<number> }).scanningNodes.add(1);
|
||||
await expect(svc().scanNode(1, { vulns: true, secrets: false, misconfig: false })).rejects.toThrow(/already scanning/i);
|
||||
});
|
||||
|
||||
it('scans images for the selected scanners and skips stacks when misconfig is off', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({ getImages: async () => [{ RepoTags: ['a:1'] }] } as never);
|
||||
const run = vi.spyOn(svc(), 'runScanAndPersist').mockResolvedValue(fakeRow());
|
||||
const stack = vi.spyOn(svc(), 'scanComposeStack');
|
||||
|
||||
const result = await svc().scanNode(1, { vulns: true, secrets: true, misconfig: false });
|
||||
|
||||
expect(run).toHaveBeenCalledWith('a:1', 1, 'manual', null, { scanners: ['vuln', 'secret'] });
|
||||
expect(stack).not.toHaveBeenCalled();
|
||||
expect(result.images).not.toBeNull();
|
||||
expect(result.stacks).toBeNull();
|
||||
expect(result.severity).toMatchObject({ critical: 1, high: 2 });
|
||||
});
|
||||
|
||||
it('scans secrets only and keys the digest cache on the scanner set', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({ getImages: async () => [{ RepoTags: ['a:1'] }] } as never);
|
||||
// Force a digest so the cache lookup runs; return null so the scan proceeds.
|
||||
vi.spyOn(svc() as unknown as { getImageDigest: (r: string, n: number) => Promise<string | null> }, 'getImageDigest')
|
||||
.mockResolvedValue('sha256:abc');
|
||||
const cacheLookup = vi.spyOn(DatabaseService.getInstance(), 'getLatestScanByDigest').mockReturnValue(null);
|
||||
const run = vi.spyOn(svc(), 'runScanAndPersist').mockResolvedValue(fakeRow({ scanners_used: 'secret' }));
|
||||
|
||||
await svc().scanNode(1, { vulns: false, secrets: true, misconfig: false });
|
||||
|
||||
// A secrets-only scan must not reuse a vuln-only cached row.
|
||||
expect(cacheLookup).toHaveBeenCalledWith('sha256:abc', 'secret');
|
||||
expect(run).toHaveBeenCalledWith('a:1', 1, 'manual', null, { scanners: ['secret'] });
|
||||
});
|
||||
|
||||
it('scans every stack for misconfig and skips images when vulns/secrets are off', async () => {
|
||||
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ getStacks: async () => ['web', 'db'] } as never);
|
||||
const stack = vi.spyOn(svc(), 'scanComposeStack').mockResolvedValue(fakeRow({ misconfig_count: 3, scanners_used: 'config' }));
|
||||
const run = vi.spyOn(svc(), 'runScanAndPersist');
|
||||
|
||||
const result = await svc().scanNode(1, { vulns: false, secrets: false, misconfig: true });
|
||||
|
||||
expect(stack).toHaveBeenCalledTimes(2);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(result.stacks).toMatchObject({ scanned: 2, failed: 0, total: 2 });
|
||||
expect(result.images).toBeNull();
|
||||
});
|
||||
|
||||
it('counts a failed stack without aborting the batch', async () => {
|
||||
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ getStacks: async () => ['ok', 'bad'] } as never);
|
||||
vi.spyOn(svc(), 'scanComposeStack').mockImplementation(async (_nodeId: number, name: string) => {
|
||||
if (name === 'bad') throw new Error('boom');
|
||||
return fakeRow({ misconfig_count: 1 });
|
||||
});
|
||||
|
||||
const result = await svc().scanNode(1, { vulns: false, secrets: false, misconfig: true });
|
||||
|
||||
expect(result.stacks).toMatchObject({ scanned: 1, failed: 1, total: 2 });
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { blockIfReplica } from '../middleware/fleetSyncGuards';
|
||||
import { validateStackPatternForRedos } from './fleet';
|
||||
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
|
||||
import { DEFAULT_POLICY_PACKS } from '../services/policy-packs';
|
||||
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
|
||||
|
||||
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
|
||||
// Trivy emits misconfig rule ids in two shapes that Sencho persists verbatim:
|
||||
@@ -329,6 +330,73 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res
|
||||
}
|
||||
});
|
||||
|
||||
// On-demand node-wide scan: images for the selected scanners (vuln/secret) and,
|
||||
// when requested, every stack's compose config for misconfigurations. Streams
|
||||
// sanitized progress to the deploy-feedback terminal when the client opened one.
|
||||
securityRouter.post('/scan-node', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
res.status(503).json({ error: 'Trivy is not available on this host' });
|
||||
return;
|
||||
}
|
||||
const body = req.body ?? {};
|
||||
if (![body.vulns, body.secrets, body.misconfig].every((v) => v === undefined || typeof v === 'boolean')) {
|
||||
res.status(400).json({ error: 'vulns, secrets and misconfig must be booleans' });
|
||||
return;
|
||||
}
|
||||
const vulns = body.vulns === true;
|
||||
const secrets = body.secrets === true;
|
||||
const misconfig = body.misconfig === true;
|
||||
if (!vulns && !secrets && !misconfig) {
|
||||
res.status(400).json({ error: 'Select at least one scan type' });
|
||||
return;
|
||||
}
|
||||
const nodeId = req.nodeId;
|
||||
|
||||
// Stream progress to the deploy-feedback terminal only when the client supplied
|
||||
// a session id: a bare getTerminalWs(undefined) falls back to the most recent
|
||||
// terminal and could cross-stream this scan into an unrelated deploy. scanNode
|
||||
// emits only counts and rule ids, never raw secret values; the wrapper here
|
||||
// additionally strips CR/LF and caps line length before sending.
|
||||
const sessionId = req.get(DEPLOY_SESSION_HEADER);
|
||||
const ws = sessionId ? getTerminalWs(sessionId) : undefined;
|
||||
const onProgress = ws
|
||||
? (line: string): void => {
|
||||
try { ws.send(line.replace(/[\r\n]+/g, ' ').slice(0, 2000) + '\r\n'); }
|
||||
catch { /* socket closed mid-scan; the scan still runs to completion */ }
|
||||
}
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
DatabaseService.getInstance().insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: req.user?.username ?? 'unknown',
|
||||
method: req.method,
|
||||
path: req.originalUrl || req.url,
|
||||
status_code: 200,
|
||||
node_id: nodeId,
|
||||
ip_address: req.ip ?? '',
|
||||
summary: `node-wide scan triggered (vulns=${vulns} secrets=${secrets} misconfig=${misconfig})`,
|
||||
});
|
||||
} catch (auditErr) {
|
||||
console.warn('[Security] failed to record node-scan audit log:', getErrorMessage(auditErr, 'unknown'));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await svc.scanNode(nodeId, { vulns, secrets, misconfig }, 'manual', onProgress);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'Failed to run node scan');
|
||||
if (message === 'Already scanning this node') {
|
||||
res.status(409).json({ error: message });
|
||||
return;
|
||||
}
|
||||
console.error('[Security] node-wide scan failed:', sanitizeForLog(message));
|
||||
res.status(500).json({ error: 'Failed to run node scan' });
|
||||
}
|
||||
});
|
||||
|
||||
securityRouter.get('/scans', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined;
|
||||
|
||||
@@ -150,6 +150,25 @@ export interface ScanAllNodeImagesResult {
|
||||
violations: ScanAllNodeImagesViolation[];
|
||||
}
|
||||
|
||||
/** Which scan types a node-wide scan should run. At least one must be true. */
|
||||
export interface ScanNodeOptions {
|
||||
vulns: boolean;
|
||||
secrets: boolean;
|
||||
misconfig: boolean;
|
||||
}
|
||||
|
||||
/** Combined result of a node-wide scan (images for vuln/secret + stacks for misconfig). */
|
||||
export interface ScanNodeResult {
|
||||
/** Image scan totals, or null when neither vuln nor secret scanning ran. */
|
||||
images: ScanAllNodeImagesResult | null;
|
||||
/** Stack misconfig totals, or null when misconfig scanning did not run. */
|
||||
stacks: { scanned: number; failed: number; total: number } | null;
|
||||
/** Severity totals across images AND stacks (a superset of `images.severity`). */
|
||||
severity: ScanAllNodeImagesSeverityTotals;
|
||||
/** Policy violations; only image scans contribute, stacks never do. */
|
||||
violations: ScanAllNodeImagesViolation[];
|
||||
}
|
||||
|
||||
export interface TrivyVulnerability {
|
||||
vulnerabilityId: string;
|
||||
pkgName: string;
|
||||
@@ -347,6 +366,9 @@ class TrivyService {
|
||||
private binaryPath: string | null = null;
|
||||
private source: TrivySource = 'none';
|
||||
private scanningImages: Set<string> = new Set();
|
||||
// Per-node lock so two node-wide scans (or a node scan and the scheduled
|
||||
// sweep) never overlap an expensive Trivy run on the same node.
|
||||
private scanningNodes: Set<number> = new Set();
|
||||
private cacheDirEnsured: string | null = null;
|
||||
private detectionTimestamp = 0;
|
||||
|
||||
@@ -1033,13 +1055,34 @@ class TrivyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vuln-only image sweep for the scheduler. Thin compatibility wrapper over
|
||||
* the generalized image loop; the signature, semantics, limits, cache, and
|
||||
* ScanAllNodeImagesResult shape are unchanged.
|
||||
*/
|
||||
async scanAllNodeImages(
|
||||
nodeId: number,
|
||||
triggeredBy: VulnScanTrigger = 'scheduled',
|
||||
): Promise<ScanAllNodeImagesResult> {
|
||||
return this.scanNodeImages(nodeId, triggeredBy, ['vuln']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan every image on a node with the given scanners, reusing the digest
|
||||
* cache (keyed by the scanner set), throttle, and image/duration caps. Emits
|
||||
* one sanitized progress line per image when `onProgress` is supplied. A
|
||||
* failed image increments `failed` and does not abort the batch.
|
||||
*/
|
||||
private async scanNodeImages(
|
||||
nodeId: number,
|
||||
triggeredBy: VulnScanTrigger,
|
||||
scanners: readonly TrivyScanner[],
|
||||
onProgress?: (line: string) => void,
|
||||
): Promise<ScanAllNodeImagesResult> {
|
||||
if (this.source === 'none') {
|
||||
throw new Error('Trivy is not available on this host');
|
||||
}
|
||||
const scannersUsed = normalizeScanners(scanners).join(',');
|
||||
const batchStartedAt = Date.now();
|
||||
const images = await DockerController.getInstance(nodeId).getImages();
|
||||
const imageRefs = new Set<string>();
|
||||
@@ -1106,27 +1149,31 @@ class TrivyService {
|
||||
break;
|
||||
}
|
||||
processedImages++;
|
||||
onProgress?.(`scanning ${ref}`);
|
||||
try {
|
||||
const digest = await this.getImageDigest(ref, nodeId);
|
||||
if (digest) {
|
||||
if (countedDigests.has(digest)) continue;
|
||||
const cached =
|
||||
DatabaseService.getInstance().getLatestScanByDigest(digest, 'vuln');
|
||||
DatabaseService.getInstance().getLatestScanByDigest(digest, scannersUsed);
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
|
||||
skipped++;
|
||||
addSeverity(cached);
|
||||
collectViolation(cached);
|
||||
countedDigests.add(digest);
|
||||
onProgress?.(`${ref}: cached (${cached.critical_count}C ${cached.high_count}H)`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const fresh = await this.runScanAndPersist(ref, nodeId, triggeredBy, null);
|
||||
const fresh = await this.runScanAndPersist(ref, nodeId, triggeredBy, null, { scanners });
|
||||
addSeverity(fresh);
|
||||
collectViolation(fresh);
|
||||
scanned++;
|
||||
if (digest) countedDigests.add(digest);
|
||||
onProgress?.(`${ref}: ${fresh.critical_count}C ${fresh.high_count}H${fresh.secret_count ? ` ${fresh.secret_count} secret` : ''}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
onProgress?.(`${ref}: scan failed`);
|
||||
console.warn(`[Trivy] Failed to scan ${ref}:`, getErrorMessage(err, 'unknown error'));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
@@ -1149,6 +1196,84 @@ class TrivyService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand node-wide scan: images for the selected scanners (vuln/secret)
|
||||
* and, when requested, every stack's compose config for misconfigurations.
|
||||
* Streams sanitized progress lines via `onProgress`. A per-node lock prevents
|
||||
* an overlapping sweep; a failed image/stack is counted, not fatal.
|
||||
*/
|
||||
async scanNode(
|
||||
nodeId: number,
|
||||
opts: ScanNodeOptions,
|
||||
triggeredBy: VulnScanTrigger = 'manual',
|
||||
onProgress?: (line: string) => void,
|
||||
): Promise<ScanNodeResult> {
|
||||
if (this.source === 'none') {
|
||||
throw new Error('Trivy is not available on this host');
|
||||
}
|
||||
if (!opts.vulns && !opts.secrets && !opts.misconfig) {
|
||||
throw new Error('Select at least one scan type');
|
||||
}
|
||||
if (this.scanningNodes.has(nodeId)) {
|
||||
throw new Error('Already scanning this node');
|
||||
}
|
||||
this.scanningNodes.add(nodeId);
|
||||
const severity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
||||
const addSeverity = (s: { critical: number; high: number; medium: number; low: number; unknown: number }): void => {
|
||||
severity.critical += s.critical;
|
||||
severity.high += s.high;
|
||||
severity.medium += s.medium;
|
||||
severity.low += s.low;
|
||||
severity.unknown += s.unknown;
|
||||
};
|
||||
const violations: ScanAllNodeImagesViolation[] = [];
|
||||
let images: ScanAllNodeImagesResult | null = null;
|
||||
let stacks: { scanned: number; failed: number; total: number } | null = null;
|
||||
try {
|
||||
const scanners: TrivyScanner[] = [];
|
||||
if (opts.vulns) scanners.push('vuln');
|
||||
if (opts.secrets) scanners.push('secret');
|
||||
|
||||
if (scanners.length > 0) {
|
||||
images = await this.scanNodeImages(nodeId, triggeredBy, scanners, onProgress);
|
||||
addSeverity(images.severity);
|
||||
violations.push(...images.violations);
|
||||
}
|
||||
|
||||
if (opts.misconfig) {
|
||||
const stackNames = await FileSystemService.getInstance(nodeId).getStacks();
|
||||
let scanned = 0;
|
||||
let failed = 0;
|
||||
for (const name of stackNames) {
|
||||
onProgress?.(`scanning stack:${name}`);
|
||||
try {
|
||||
const row = await this.scanComposeStack(nodeId, name, triggeredBy);
|
||||
addSeverity({
|
||||
critical: row.critical_count, high: row.high_count, medium: row.medium_count,
|
||||
low: row.low_count, unknown: row.unknown_count,
|
||||
});
|
||||
scanned++;
|
||||
onProgress?.(`stack:${name}: ${row.misconfig_count} misconfigurations`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
onProgress?.(`stack:${name}: scan failed`);
|
||||
console.warn(`[Trivy] Failed to scan stack ${name}:`, getErrorMessage(err, 'unknown error'));
|
||||
}
|
||||
}
|
||||
stacks = { scanned, failed, total: stackNames.length };
|
||||
}
|
||||
|
||||
const imagePart = images
|
||||
? `${images.scanned} scanned / ${images.skipped} cached / ${images.failed} failed images`
|
||||
: 'images skipped';
|
||||
const stackPart = stacks ? `, ${stacks.scanned} stacks (${stacks.failed} failed)` : '';
|
||||
onProgress?.(`Scan complete: ${imagePart}${stackPart}`);
|
||||
return { images, stacks, severity, violations };
|
||||
} finally {
|
||||
this.scanningNodes.delete(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
async generateSBOM(imageRef: string, format: SbomFormat): Promise<string> {
|
||||
const binary = this.binaryPath;
|
||||
if (!binary) {
|
||||
|
||||
@@ -48,6 +48,15 @@ Open the **Resources** tab and switch to the **Images** panel. When Trivy is ava
|
||||
|
||||
A spinner replaces the shield while the scan runs. Most vulnerability scans finish in 10 to 60 seconds depending on image size and whether the Trivy database is cached. Full scans add the time needed to read the filesystem. When the scan completes, a severity badge appears in the row's Status column. Click the badge to open the results drawer.
|
||||
|
||||
### Scanning a whole node at once
|
||||
|
||||
The **Security** page → **Overview** tab has a **Scan this node** button for admins when the node's scanner is ready. It runs every scan type you select in one pass:
|
||||
|
||||
- **Image vulnerabilities** and **Image secrets** scan every image on the node.
|
||||
- **Compose misconfigurations** scan every stack's compose file.
|
||||
|
||||
All three are selected by default; pick any combination, then **Start scan**. Progress streams live in a modal as each image and stack is processed, and the Overview refreshes when the run finishes. The scan stays bound to the node you started it on, so switching the active node mid-scan does not redirect it. Results feed the same severity badges, history, and charts as a single-image scan, and image results reuse the 24-hour digest cache.
|
||||
|
||||
### Reading severity badges
|
||||
|
||||
Hovering a badge reveals the breakdown by severity and the scan timestamp. The badge color reflects the highest severity found:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type ParsedLogRow, parseLogChunk } from '../components/log-rendering/co
|
||||
import { useDeployFeedbackEnabled } from '../hooks/use-deploy-feedback-enabled';
|
||||
import { readDeployFeedbackStyle } from '../hooks/use-deploy-feedback-style';
|
||||
|
||||
export type ActionVerb = 'deploy' | 'update' | 'down' | 'restart' | 'stop' | 'install';
|
||||
export type ActionVerb = 'deploy' | 'update' | 'down' | 'restart' | 'stop' | 'install' | 'scan';
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const VERB_LABELS: Record<ActionVerb, { present: string; past: string }> = {
|
||||
@@ -14,6 +14,7 @@ export const VERB_LABELS: Record<ActionVerb, { present: string; past: string }>
|
||||
restart: { present: 'Restarting', past: 'Restarted' },
|
||||
stop: { present: 'Stopping', past: 'Stopped' },
|
||||
install: { present: 'Installing', past: 'Installed' },
|
||||
scan: { present: 'Scanning', past: 'Scanned' },
|
||||
};
|
||||
|
||||
export interface DeployPanelState {
|
||||
|
||||
Reference in New Issue
Block a user