mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
b1c5fe8391
* fix: harden deploy enforcement paths * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories * fix(test): add execFile to child_process mock in compose-images test * fix: resolve merge conflicts with main * fix: resolve merge conflicts with main * fix: resolve merge conflicts with main
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
/**
|
|
* Covers the pre-deploy policy gate across the six behaviours defined in the
|
|
* PR 1 plan: no-policy, disabled-policy, Trivy-missing (fail open),
|
|
* violation, admin bypass (audit-logged), compose-parse-failure (fail closed).
|
|
*
|
|
* The gate is the only code path that can block a `docker compose up`, so
|
|
* regressions here are high-impact. We stub the four collaborators the helper
|
|
* talks to rather than spinning up the real services, since the contracts
|
|
* between them are stable and already exercised by their own tests.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { ScanPolicy, VulnerabilityScan } from '../services/DatabaseService';
|
|
|
|
interface TrivyStub {
|
|
isTrivyAvailable: ReturnType<typeof vi.fn>;
|
|
scanImagePreflight: ReturnType<typeof vi.fn>;
|
|
}
|
|
interface ComposeStub {
|
|
listStackImages: ReturnType<typeof vi.fn>;
|
|
}
|
|
interface DbStub {
|
|
getMatchingPolicy: ReturnType<typeof vi.fn>;
|
|
insertAuditLog: ReturnType<typeof vi.fn>;
|
|
}
|
|
interface NotificationStub {
|
|
dispatchAlert: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
const trivyStub: TrivyStub = {
|
|
isTrivyAvailable: vi.fn(),
|
|
scanImagePreflight: vi.fn(),
|
|
};
|
|
const composeStub: ComposeStub = {
|
|
listStackImages: vi.fn(),
|
|
};
|
|
const dbStub: DbStub = {
|
|
getMatchingPolicy: vi.fn(),
|
|
insertAuditLog: vi.fn(),
|
|
};
|
|
const notificationStub: NotificationStub = {
|
|
dispatchAlert: vi.fn(),
|
|
};
|
|
|
|
vi.mock('../services/TrivyService', () => ({
|
|
default: { getInstance: () => trivyStub },
|
|
}));
|
|
vi.mock('../services/ComposeService', () => ({
|
|
ComposeService: { getInstance: () => composeStub },
|
|
}));
|
|
vi.mock('../services/DatabaseService', () => ({
|
|
DatabaseService: { getInstance: () => dbStub },
|
|
}));
|
|
vi.mock('../services/NotificationService', () => ({
|
|
NotificationService: { getInstance: () => notificationStub },
|
|
}));
|
|
vi.mock('../services/FleetSyncService', () => ({
|
|
FleetSyncService: { getSelfIdentity: () => 'self-node' },
|
|
}));
|
|
|
|
import { enforcePolicyForImageRefs, enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
|
|
|
function mkPolicy(overrides: Partial<ScanPolicy> = {}): ScanPolicy {
|
|
return {
|
|
id: 1,
|
|
name: 'block-high',
|
|
node_id: null,
|
|
node_identity: 'self-node',
|
|
stack_pattern: '*',
|
|
max_severity: 'HIGH',
|
|
block_on_deploy: 1,
|
|
enabled: 1,
|
|
replicated_from_control: 0,
|
|
created_at: Date.now(),
|
|
updated_at: Date.now(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mkScan(overrides: Partial<VulnerabilityScan> = {}): VulnerabilityScan {
|
|
return {
|
|
id: 1,
|
|
node_id: 1,
|
|
image_ref: 'nginx:1.14',
|
|
image_digest: null,
|
|
scanned_at: Date.now(),
|
|
total_vulnerabilities: 0,
|
|
critical_count: 0,
|
|
high_count: 0,
|
|
medium_count: 0,
|
|
low_count: 0,
|
|
unknown_count: 0,
|
|
fixable_count: 0,
|
|
secret_count: 0,
|
|
misconfig_count: 0,
|
|
scanners_used: 'vuln',
|
|
highest_severity: 'LOW',
|
|
os_info: null,
|
|
trivy_version: '0.50.0',
|
|
scan_duration_ms: null,
|
|
triggered_by: 'deploy-preflight',
|
|
status: 'completed',
|
|
error: null,
|
|
stack_context: 'web',
|
|
policy_evaluation: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('enforcePolicyPreDeploy', () => {
|
|
beforeEach(() => {
|
|
trivyStub.isTrivyAvailable.mockReset();
|
|
trivyStub.scanImagePreflight.mockReset();
|
|
composeStub.listStackImages.mockReset();
|
|
dbStub.getMatchingPolicy.mockReset();
|
|
dbStub.insertAuditLog.mockReset();
|
|
notificationStub.dispatchAlert.mockReset();
|
|
});
|
|
|
|
it('allows deploy when no matching policy exists', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(null);
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.bypassed).toBe(false);
|
|
expect(result.violations).toEqual([]);
|
|
expect(result.policy).toBeUndefined();
|
|
expect(trivyStub.isTrivyAvailable).not.toHaveBeenCalled();
|
|
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows deploy when the matching policy is disabled', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ enabled: 0 }));
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.bypassed).toBe(false);
|
|
expect(result.violations).toEqual([]);
|
|
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows deploy when the matching policy does not block on deploy', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_deploy: 0 }));
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.violations).toEqual([]);
|
|
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows deploy without scanning when paid-tier blocking is disabled', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, {
|
|
bypass: false,
|
|
actor: 'u',
|
|
blockingEnabled: false,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.violations).toEqual([]);
|
|
expect(trivyStub.isTrivyAvailable).not.toHaveBeenCalled();
|
|
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails open with a warning alert when Trivy is not installed', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
|
trivyStub.isTrivyAvailable.mockReturnValue(false);
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.trivyMissing).toBe(true);
|
|
expect(result.violations).toEqual([]);
|
|
expect(notificationStub.dispatchAlert).toHaveBeenCalledTimes(1);
|
|
expect(notificationStub.dispatchAlert.mock.calls[0][0]).toBe('warning');
|
|
expect(notificationStub.dispatchAlert.mock.calls[0][1]).toBe('scan_finding');
|
|
expect(notificationStub.dispatchAlert.mock.calls[0][2]).toContain('Trivy not installed');
|
|
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks deploy when a scanned image exceeds the policy severity', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
|
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
|
composeStub.listStackImages.mockResolvedValue(['nginx:1.14']);
|
|
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({
|
|
id: 99,
|
|
highest_severity: 'CRITICAL',
|
|
critical_count: 2,
|
|
high_count: 5,
|
|
}));
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.bypassed).toBe(false);
|
|
expect(result.violations).toHaveLength(1);
|
|
expect(result.violations[0]).toMatchObject({
|
|
imageRef: 'nginx:1.14',
|
|
severity: 'CRITICAL',
|
|
criticalCount: 2,
|
|
highCount: 5,
|
|
scanId: 99,
|
|
});
|
|
expect(dbStub.insertAuditLog).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows deploy on admin bypass and records a policy.bypass audit entry', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
|
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
|
composeStub.listStackImages.mockResolvedValue(['nginx:1.14']);
|
|
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({
|
|
id: 99,
|
|
highest_severity: 'CRITICAL',
|
|
critical_count: 2,
|
|
}));
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, {
|
|
bypass: true,
|
|
actor: 'admin',
|
|
ip: '10.0.0.1',
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.bypassed).toBe(true);
|
|
expect(result.violations).toHaveLength(1);
|
|
expect(dbStub.insertAuditLog).toHaveBeenCalledTimes(1);
|
|
const entry = dbStub.insertAuditLog.mock.calls[0][0];
|
|
expect(entry.username).toBe('admin');
|
|
expect(entry.ip_address).toBe('10.0.0.1');
|
|
expect(entry.node_id).toBe(1);
|
|
expect(entry.summary).toContain('policy.bypass');
|
|
expect(entry.summary).toContain('nginx:1.14');
|
|
});
|
|
|
|
it('fails closed with a synthetic violation when compose parse fails', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
|
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
|
composeStub.listStackImages.mockRejectedValue(new Error('compose file missing'));
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.violations).toHaveLength(1);
|
|
expect(result.violations[0].imageRef).toBe('(compose parse error)');
|
|
expect(result.violations[0].severity).toBe('UNKNOWN');
|
|
expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('records a violation when an individual image scan throws', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
|
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
|
composeStub.listStackImages.mockResolvedValue(['nginx:1.14']);
|
|
trivyStub.scanImagePreflight.mockRejectedValue(new Error('trivy crashed'));
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.violations).toHaveLength(1);
|
|
expect(result.violations[0].imageRef).toBe('nginx:1.14');
|
|
expect(result.violations[0].severity).toBe('UNKNOWN');
|
|
expect(result.violations[0].scanId).toBe(0);
|
|
});
|
|
|
|
it('skips image refs that fail validation without calling the scanner', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
|
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
|
composeStub.listStackImages.mockResolvedValue(['not a valid ref!!!', 'nginx:1.14']);
|
|
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ highest_severity: 'LOW' }));
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.violations).toEqual([]);
|
|
expect(trivyStub.scanImagePreflight).toHaveBeenCalledTimes(1);
|
|
expect(trivyStub.scanImagePreflight).toHaveBeenCalledWith('nginx:1.14', 1, 'web');
|
|
});
|
|
|
|
it('allows deploy when all scans fall below the policy threshold', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ max_severity: 'CRITICAL' }));
|
|
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
|
composeStub.listStackImages.mockResolvedValue(['nginx:1.14', 'redis:7']);
|
|
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ highest_severity: 'HIGH' }));
|
|
|
|
const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.violations).toEqual([]);
|
|
expect(trivyStub.scanImagePreflight).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('enforces a supplied image list without reading compose from disk', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
|
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
|
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({
|
|
id: 42,
|
|
highest_severity: 'HIGH',
|
|
high_count: 1,
|
|
}));
|
|
|
|
const result = await enforcePolicyForImageRefs('blueprint-web', 1, ['nginx:1.27-alpine'], { bypass: false, actor: 'u' });
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.violations).toHaveLength(1);
|
|
expect(result.violations[0]).toMatchObject({
|
|
imageRef: 'nginx:1.27-alpine',
|
|
severity: 'HIGH',
|
|
highCount: 1,
|
|
scanId: 42,
|
|
});
|
|
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails closed on invalid supplied image refs when requested', async () => {
|
|
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
|
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
|
|
|
const result = await enforcePolicyForImageRefs('blueprint-web', 1, ['${IMAGE}'], { bypass: false, actor: 'u' }, undefined, true);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.violations).toHaveLength(1);
|
|
expect(result.violations[0]).toMatchObject({
|
|
imageRef: '${IMAGE}',
|
|
severity: 'UNKNOWN',
|
|
scanId: 0,
|
|
});
|
|
expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled();
|
|
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
|
});
|
|
});
|