mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user