Files
sencho/backend/src/__tests__/trivy-scan-node.test.ts
T
Anso ef5a3f00a7 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.
2026-06-12 19:07:45 -04:00

119 lines
5.8 KiB
TypeScript

/**
* 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 });
});
});