mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-23 08:29:20 +00:00
fcd44f5693
* fix(security): tie fixable CVE posture to image-update evidence Stop treating Trivy fixed_version alone as an Update affected images CTA. Reuse persisted ImageUpdateService status so Security only offers Review update when an applicable image update is confirmed, and otherwise surfaces waiting or uncertain remediation with truthful affordances. * fix(security): move image-update recheck helper out of OverviewTab Satisfy react-refresh/only-export-components so Frontend lint passes. * fix(security): preserve posture reason image targets in Images drill-down Carry affected image refs on overview reasons so public exposure and related CTAs open a clearable targeted Images list instead of an unfiltered hunt. * fix(security): attach Networking exposure intent to posture targets Preserve stack/service context and intentional classification on network-exposed Security reasons without suppressing risk or claiming Internet reachability. * fix(security): persist Images exposure intent and triage scope Standing image summaries carry Networking intent context with cap-safe aggregates, Anatomy Networking links, and scan-sheet triage that defaults to the current image. * fix(security): clear CI lint errors for exposure helpers * fix(security): stop intentional exposure from forcing Action needed Separate exposure fact, intent correctness, and vulnerability drivers so package fixed_version cannot recreate a permanent public_exposure blocker. * fix(security): define Monitoring residual-risk narrative * fix(security): define Secure via residual Crit/High triage Replace triage-blind raw Crit/High Secure gating with residual material risk so accepted and ignored stay Monitoring, while not_affected, false positive, and fixed can clear residual without claiming no detections. * fix(security): exclude rollback-hold images from Security scans Hold-only sencho-rb tags are recovery state; keep them out of Trivy node scans, Security inventory, and Overview posture while dual-tagged images remain under their registry tag. * fix(security): keep authoritative no-update rows after preview Opening a stack page must not delete ok+false stack_update_status evidence; Security treats a missing row as uncertain and would flip waiting-upstream to unknown.
142 lines
6.9 KiB
TypeScript
142 lines
6.9 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('skips Sencho rollback-hold tags while still scanning dual-tagged images via the registry tag', async () => {
|
|
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
|
getImages: async () => [
|
|
{ RepoTags: ['sencho-rb/aaaaaaaaaaaa/web:hold'] },
|
|
{ RepoTags: ['myregistry/app:1.4', 'sencho-rb/bbbbbbbbbbbb/app:hold'] },
|
|
],
|
|
} as never);
|
|
const run = vi.spyOn(svc(), 'runScanAndPersist').mockResolvedValue(fakeRow({ image_ref: 'myregistry/app:1.4' }));
|
|
|
|
await svc().scanNode(1, { vulns: true, secrets: false, misconfig: false });
|
|
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
expect(run).toHaveBeenCalledWith('myregistry/app:1.4', 1, 'manual', null, { scanners: ['vuln'] });
|
|
});
|
|
|
|
it('rejects scanImage for Sencho rollback-hold refs before touching Docker or Trivy', async () => {
|
|
const digest = vi.spyOn(svc() as unknown as { getImageDigest: (r: string, n: number) => Promise<string | null> }, 'getImageDigest');
|
|
await expect(svc().scanImage('sencho-rb/aaaaaaaaaaaa/web:hold', 1)).rejects.toThrow(
|
|
/rollback-hold images are recovery state/i,
|
|
);
|
|
expect(digest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|