mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
feat(security): enforce scan policies as a pre-deploy gate (#719)
Policies with block_on_deploy=1 now scan every stack image before docker compose up runs and reject the deploy with HTTP 409 on violation. The UI opens a dialog listing offending images; admins can override per deploy with ?ignorePolicy=true, and every bypass is recorded in the audit log with the originating route, actor, policy, and image list. When Trivy is not installed on the target node the gate fails open with a warning notification, so teams are never locked out by tooling state. Post-deploy and scheduled scans still evaluate matching policies and dispatch warnings on violations to surface drift on long-running stacks. Public API additions: policy and suppression CRUD under /api/security, plus the documented 409 block-response shape on all deploy paths.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Exercises ComposeService.listStackImages, the helper the policy gate calls
|
||||
* to enumerate the images a stack will pull before `docker compose up`.
|
||||
*
|
||||
* The stdout from `docker compose config --images` can contain duplicates
|
||||
* (multiple services running the same image), trailing whitespace, blank
|
||||
* lines, and `sha256:` digest lines we must not pass to Trivy. The gate
|
||||
* feeds this list directly to `scanImagePreflight`, so dedupe + filter
|
||||
* correctness here directly affects what gets scanned and what silently
|
||||
* passes through.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: mockSpawn }));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getDefaultNodeId: () => 1,
|
||||
getComposeDir: () => '/test/compose',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/DockerController', () => ({
|
||||
default: {
|
||||
getInstance: () => ({
|
||||
getContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
removeContainers: vi.fn().mockResolvedValue([]),
|
||||
getDocker: () => ({
|
||||
listContainers: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: { getInstance: () => ({ getRegistries: () => [] }) },
|
||||
}));
|
||||
|
||||
vi.mock('../services/RegistryService', () => ({
|
||||
RegistryService: {
|
||||
getInstance: () => ({
|
||||
resolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/FileSystemService', () => ({
|
||||
FileSystemService: {
|
||||
getInstance: () => ({
|
||||
backupStackFiles: vi.fn().mockResolvedValue(undefined),
|
||||
restoreStackFiles: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/LogFormatter', () => ({
|
||||
LogFormatter: { formatLine: (line: string) => line },
|
||||
}));
|
||||
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
|
||||
function mockComposeConfig(stdout: string, exitCode = 0): void {
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
Promise.resolve().then(() => {
|
||||
if (stdout) proc.stdout.emit('data', Buffer.from(stdout));
|
||||
proc.emit('close', exitCode);
|
||||
});
|
||||
return proc;
|
||||
});
|
||||
}
|
||||
|
||||
describe('ComposeService.listStackImages', () => {
|
||||
beforeEach(() => {
|
||||
mockSpawn.mockReset();
|
||||
});
|
||||
|
||||
it('returns the list of images, trimmed and deduped', async () => {
|
||||
mockComposeConfig('nginx:1.14\nredis:7\nnginx:1.14\n');
|
||||
|
||||
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
|
||||
|
||||
expect(images).toEqual(['nginx:1.14', 'redis:7']);
|
||||
});
|
||||
|
||||
it('invokes `docker compose config --images` in the stack directory', async () => {
|
||||
mockComposeConfig('nginx:1.14\n');
|
||||
|
||||
await ComposeService.getInstance(1).listStackImages('my-stack');
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'config', '--images'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('my-stack') }),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters out sha256 digest lines', async () => {
|
||||
mockComposeConfig('nginx:1.14\nsha256:deadbeefcafebabe\nredis:7\n');
|
||||
|
||||
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
|
||||
|
||||
expect(images).toEqual(['nginx:1.14', 'redis:7']);
|
||||
});
|
||||
|
||||
it('handles trailing / leading whitespace and CRLF endings', async () => {
|
||||
mockComposeConfig(' nginx:1.14 \r\n\r\n\tredis:7\r\n');
|
||||
|
||||
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
|
||||
|
||||
expect(images).toEqual(['nginx:1.14', 'redis:7']);
|
||||
});
|
||||
|
||||
it('returns an empty list when stdout is empty', async () => {
|
||||
mockComposeConfig('');
|
||||
|
||||
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
|
||||
|
||||
expect(images).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects stack names that traverse outside the compose base', async () => {
|
||||
await expect(
|
||||
ComposeService.getInstance(1).listStackImages('../evil'),
|
||||
).rejects.toThrow(/Invalid stack path/);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when docker compose exits non-zero', async () => {
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
Promise.resolve().then(() => {
|
||||
proc.stderr.emit('data', Buffer.from('compose file missing'));
|
||||
proc.emit('close', 1);
|
||||
});
|
||||
return proc;
|
||||
});
|
||||
|
||||
await expect(
|
||||
ComposeService.getInstance(1).listStackImages('my-stack'),
|
||||
).rejects.toThrow(/compose file missing/);
|
||||
});
|
||||
|
||||
it('preserves image-ref ordering for deterministic downstream scans', async () => {
|
||||
mockComposeConfig('redis:7\npostgres:15\nnginx:1.14\n');
|
||||
|
||||
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
|
||||
|
||||
expect(images).toEqual(['redis:7', 'postgres:15', 'nginx:1.14']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 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 { 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('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]).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);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ function mkScan(overrides: Partial<VulnerabilityScan> = {}): VulnerabilityScan {
|
||||
status: 'completed',
|
||||
error: null,
|
||||
stack_context: null,
|
||||
policy_evaluation: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Pins the scheduler's policy-alert fan-out.
|
||||
*
|
||||
* After `trivy.scanAllNodeImages` resolves with one or more policy
|
||||
* violations, `SchedulerService.executeScan` must dispatch a warning-level
|
||||
* notification for each violation so an operator can triage them. Scheduled
|
||||
* scans never auto-quarantine in the current scope; any regression that
|
||||
* silently swallows violations turns the feature back into post-hoc logging.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetDueScheduledTasks, mockCreateScheduledTaskRun, mockUpdateScheduledTaskRun,
|
||||
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
|
||||
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
|
||||
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
|
||||
mockGetTier, mockGetVariant,
|
||||
mockDispatchAlert,
|
||||
mockGetProxyTarget,
|
||||
mockIsTrivyAvailable,
|
||||
mockScanAllNodeImages,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetDueScheduledTasks: vi.fn().mockReturnValue([]),
|
||||
mockCreateScheduledTaskRun: vi.fn().mockReturnValue(1),
|
||||
mockUpdateScheduledTaskRun: vi.fn(),
|
||||
mockUpdateScheduledTask: vi.fn(),
|
||||
mockCleanupOldTaskRuns: vi.fn(),
|
||||
mockGetScheduledTask: vi.fn(),
|
||||
mockGetNodes: vi.fn().mockReturnValue([]),
|
||||
mockGetNode: vi.fn().mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' }),
|
||||
mockCreateSnapshot: vi.fn().mockReturnValue(1),
|
||||
mockInsertSnapshotFiles: vi.fn(),
|
||||
mockClearStackUpdateStatus: vi.fn(),
|
||||
mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0),
|
||||
mockDeleteOldScans: vi.fn().mockReturnValue(0),
|
||||
mockGetTier: vi.fn().mockReturnValue('paid'),
|
||||
mockGetVariant: vi.fn().mockReturnValue('admiral'),
|
||||
mockDispatchAlert: vi.fn().mockResolvedValue(undefined),
|
||||
mockGetProxyTarget: vi.fn().mockReturnValue(null),
|
||||
mockIsTrivyAvailable: vi.fn().mockReturnValue(true),
|
||||
mockScanAllNodeImages: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getDueScheduledTasks: mockGetDueScheduledTasks,
|
||||
createScheduledTaskRun: mockCreateScheduledTaskRun,
|
||||
updateScheduledTaskRun: mockUpdateScheduledTaskRun,
|
||||
updateScheduledTask: mockUpdateScheduledTask,
|
||||
cleanupOldTaskRuns: mockCleanupOldTaskRuns,
|
||||
getScheduledTask: mockGetScheduledTask,
|
||||
getNodes: mockGetNodes,
|
||||
getNode: mockGetNode,
|
||||
createSnapshot: mockCreateSnapshot,
|
||||
insertSnapshotFiles: mockInsertSnapshotFiles,
|
||||
clearStackUpdateStatus: mockClearStackUpdateStatus,
|
||||
markStaleRunsAsFailed: mockMarkStaleRunsAsFailed,
|
||||
deleteOldScans: mockDeleteOldScans,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/LicenseService', () => ({
|
||||
LicenseService: {
|
||||
getInstance: () => ({
|
||||
getTier: mockGetTier,
|
||||
getVariant: mockGetVariant,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NotificationService', () => ({
|
||||
NotificationService: {
|
||||
getInstance: () => ({ dispatchAlert: mockDispatchAlert }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getDefaultNodeId: () => 1,
|
||||
getNode: mockGetNode,
|
||||
getProxyTarget: mockGetProxyTarget,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/TrivyService', () => ({
|
||||
default: {
|
||||
getInstance: () => ({
|
||||
isTrivyAvailable: mockIsTrivyAvailable,
|
||||
scanAllNodeImages: mockScanAllNodeImages,
|
||||
getSource: () => 'managed',
|
||||
detectTrivy: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { SchedulerService } from '../services/SchedulerService';
|
||||
|
||||
function makeScanTask() {
|
||||
return {
|
||||
id: 300,
|
||||
name: 'policy-scan',
|
||||
action: 'scan',
|
||||
cron_expression: '0 2 * * *',
|
||||
enabled: true,
|
||||
target_id: null,
|
||||
node_id: 1,
|
||||
created_by: 'admin',
|
||||
last_status: null,
|
||||
};
|
||||
}
|
||||
|
||||
function summaryWith(violations: Array<{
|
||||
imageRef: string;
|
||||
policyId: number;
|
||||
policyName: string;
|
||||
maxSeverity: string;
|
||||
severity: string;
|
||||
scanId: number;
|
||||
}>) {
|
||||
return {
|
||||
scanned: violations.length,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
severity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 },
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SchedulerService - scheduled scan policy alerts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(SchedulerService as unknown as { instance?: SchedulerService }).instance = undefined;
|
||||
});
|
||||
|
||||
it('dispatches a warning alert for every violated scan', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask());
|
||||
mockScanAllNodeImages.mockResolvedValue(summaryWith([
|
||||
{
|
||||
imageRef: 'nginx:1.14',
|
||||
policyId: 1,
|
||||
policyName: 'prod-high-gate',
|
||||
maxSeverity: 'HIGH',
|
||||
severity: 'CRITICAL',
|
||||
scanId: 42,
|
||||
},
|
||||
{
|
||||
imageRef: 'redis:6',
|
||||
policyId: 1,
|
||||
policyName: 'prod-high-gate',
|
||||
maxSeverity: 'HIGH',
|
||||
severity: 'HIGH',
|
||||
scanId: 43,
|
||||
},
|
||||
]));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(300);
|
||||
|
||||
const warningCalls = mockDispatchAlert.mock.calls.filter((c) => c[0] === 'warning');
|
||||
expect(warningCalls).toHaveLength(2);
|
||||
expect(warningCalls[0][1]).toContain('prod-high-gate');
|
||||
expect(warningCalls[0][1]).toContain('nginx:1.14');
|
||||
expect(warningCalls[0][1]).toContain('CRITICAL');
|
||||
expect(warningCalls[0][1]).toContain('HIGH');
|
||||
expect(warningCalls[1][1]).toContain('redis:6');
|
||||
});
|
||||
|
||||
it('does not dispatch any policy alert when no violations occur', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask());
|
||||
mockScanAllNodeImages.mockResolvedValue(summaryWith([]));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(300);
|
||||
|
||||
const warningCalls = mockDispatchAlert.mock.calls.filter(
|
||||
(c) => c[0] === 'warning' && typeof c[1] === 'string' && c[1].includes('Policy'),
|
||||
);
|
||||
expect(warningCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('takes no quarantine action (alerts only, no stack lifecycle calls)', async () => {
|
||||
// The current scope explicitly rejects auto-quarantine. The scheduler
|
||||
// must never call DockerController / ComposeService off the scan path.
|
||||
// We verify by asserting only the DB task-run + notification surfaces
|
||||
// were touched, not any docker or compose mock.
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask());
|
||||
mockScanAllNodeImages.mockResolvedValue(summaryWith([
|
||||
{
|
||||
imageRef: 'nginx:1.14',
|
||||
policyId: 1,
|
||||
policyName: 'block-critical',
|
||||
maxSeverity: 'CRITICAL',
|
||||
severity: 'CRITICAL',
|
||||
scanId: 44,
|
||||
},
|
||||
]));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(300);
|
||||
|
||||
// The violation produced a warning alert.
|
||||
const warningCalls = mockDispatchAlert.mock.calls.filter((c) => c[0] === 'warning');
|
||||
expect(warningCalls.length).toBeGreaterThan(0);
|
||||
// Task-run record written (happy path completion), not an error.
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ const {
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
severity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 },
|
||||
violations: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -842,6 +843,7 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
low: opts.low ?? 0,
|
||||
unknown: opts.unknown ?? 0,
|
||||
},
|
||||
violations: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+113
-20
@@ -77,7 +77,7 @@ import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLev
|
||||
import SelfUpdateService from './services/SelfUpdateService';
|
||||
import TrivyService, { SbomFormat, DIGEST_CACHE_TTL_MS } from './services/TrivyService';
|
||||
import TrivyInstaller from './services/TrivyInstaller';
|
||||
import { severityRank } from './utils/severity';
|
||||
import { enforcePolicyPreDeploy } from './services/PolicyEnforcement';
|
||||
import { validateImageRef } from './utils/image-ref';
|
||||
import { applySuppressions } from './utils/suppression-filter';
|
||||
import { generateSarif } from './services/SarifExporter';
|
||||
@@ -1610,6 +1610,58 @@ const requireScheduledTaskTier = (action: string, req: Request, res: Response):
|
||||
return requireAdmiral(req, res);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the bypass-context options for `enforcePolicyPreDeploy` from a route
|
||||
* request. Centralizes the "bypass requires admin + ignorePolicy=true" rule
|
||||
* and the audit-log attribution fields so every call site is consistent.
|
||||
*
|
||||
* Bypass requires `?ignorePolicy=true` AND `req.user.role === 'admin'`. The
|
||||
* `stack:deploy` permission alone is not sufficient for bypass because the
|
||||
* `deployer` role has that permission for day-to-day deploys.
|
||||
*/
|
||||
function buildPolicyGateOptions(
|
||||
req: Request,
|
||||
overrides: { bypass?: boolean; actor?: string } = {},
|
||||
): { bypass: boolean; actor: string; ip: string; auditMethod: string; auditPath: string } {
|
||||
const defaultBypass = req.query.ignorePolicy === 'true' && req.user?.role === 'admin';
|
||||
return {
|
||||
bypass: overrides.bypass ?? defaultBypass,
|
||||
actor: overrides.actor ?? req.user?.username ?? 'unknown',
|
||||
ip: (req.ip ?? req.socket.remoteAddress ?? '') as string,
|
||||
auditMethod: req.method,
|
||||
auditPath: req.originalUrl || req.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pre-deploy policy gate for a route handler.
|
||||
*
|
||||
* Returns true if the deploy may proceed (allow, bypass, or no matching
|
||||
* policy). Returns false if the route has already sent an HTTP 409; callers
|
||||
* must return immediately in that case.
|
||||
*/
|
||||
async function runPolicyGate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
): Promise<boolean> {
|
||||
const gate = await enforcePolicyPreDeploy(stackName, nodeId, buildPolicyGateOptions(req));
|
||||
if (!gate.ok) {
|
||||
res.status(409).json({
|
||||
error: `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`,
|
||||
policy: gate.policy && {
|
||||
id: gate.policy.id,
|
||||
name: gate.policy.name,
|
||||
maxSeverity: gate.policy.max_severity,
|
||||
},
|
||||
violations: gate.violations,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function triggerPostDeployScan(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
@@ -1629,7 +1681,6 @@ async function triggerPostDeployScan(
|
||||
if (imageRefs.size === 0) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
|
||||
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
@@ -1638,6 +1689,9 @@ async function triggerPostDeployScan(
|
||||
const cached = db.getLatestScanByDigest(digest, 'vuln');
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) continue;
|
||||
}
|
||||
// Blocking enforcement has already run in enforcePolicyPreDeploy.
|
||||
// The post-deploy scan persists a fresh drift result and `finishScan`
|
||||
// attaches a PolicyEvaluation, which the UI surfaces as a banner.
|
||||
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
|
||||
|
||||
if (scan.critical_count > 0 || scan.high_count > 0) {
|
||||
@@ -1647,17 +1701,6 @@ async function triggerPostDeployScan(
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
policy &&
|
||||
severityRank(scan.highest_severity) >= severityRank(policy.max_severity)
|
||||
) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
policy.block_on_deploy ? 'error' : 'warning',
|
||||
`Policy "${policy.name}" triggered for ${imageRef}: ${scan.highest_severity} exceeds ${policy.max_severity}`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
|
||||
@@ -4322,6 +4365,16 @@ app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Res
|
||||
for (const stackName of validStacks) {
|
||||
try {
|
||||
if (action === 'deploy') {
|
||||
const gate = await enforcePolicyPreDeploy(
|
||||
stackName,
|
||||
req.nodeId,
|
||||
buildPolicyGateOptions(req),
|
||||
);
|
||||
if (!gate.ok) {
|
||||
const blockedMsg = `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`;
|
||||
results.push({ stackName, success: false, error: blockedMsg });
|
||||
continue;
|
||||
}
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, undefined, false);
|
||||
} else {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
@@ -4916,13 +4969,22 @@ app.post('/api/stacks/from-git', async (req: Request, res: Response) => {
|
||||
let deployed = false;
|
||||
let deployError: string | undefined;
|
||||
if (deploy_now === true) {
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stack_name);
|
||||
deployed = true;
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
} catch (e) {
|
||||
deployError = getErrorMessage(e, 'Deploy failed');
|
||||
console.error(`[Stacks] Deploy after create-from-git failed for ${stack_name}:`, deployError);
|
||||
const gate = await enforcePolicyPreDeploy(
|
||||
stack_name,
|
||||
req.nodeId,
|
||||
buildPolicyGateOptions(req),
|
||||
);
|
||||
if (!gate.ok) {
|
||||
deployError = `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`;
|
||||
} else {
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stack_name);
|
||||
deployed = true;
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
} catch (e) {
|
||||
deployError = getErrorMessage(e, 'Deploy failed');
|
||||
console.error(`[Stacks] Deploy after create-from-git failed for ${stack_name}:`, deployError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5087,6 +5149,7 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) =>
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
|
||||
@@ -5223,6 +5286,7 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) =>
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
|
||||
@@ -7423,6 +7487,16 @@ app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Resp
|
||||
|
||||
// 4. Deploy the stack with atomic rollback
|
||||
try {
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) {
|
||||
// Gate blocked: clean up the files we just wrote so the user can
|
||||
// retry after remediating the vulnerable image.
|
||||
try {
|
||||
await fsService.deleteStack(stackName);
|
||||
} catch (cleanupErr) {
|
||||
console.error(`[Templates] Cleanup after policy block failed for ${stackName}:`, cleanupErr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
@@ -7653,6 +7727,25 @@ app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: R
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-update is a background action initiated by the scheduler. A
|
||||
// policy bypass is never appropriate here: if updated images fail
|
||||
// the gate, skip this stack and raise a notification so an operator
|
||||
// can review before retrying manually.
|
||||
const autoUpdateGate = await enforcePolicyPreDeploy(
|
||||
stackName,
|
||||
req.nodeId,
|
||||
buildPolicyGateOptions(req, {
|
||||
bypass: false,
|
||||
actor: `auto-update:${req.user?.username ?? 'scheduler'}`,
|
||||
}),
|
||||
);
|
||||
if (!autoUpdateGate.ok) {
|
||||
const blockedMsg = `Policy "${autoUpdateGate.policy?.name}" blocked auto-update: ${autoUpdateGate.violations.length} image(s) exceed ${autoUpdateGate.policy?.max_severity}`;
|
||||
NotificationService.getInstance().dispatchAlert('warning', blockedMsg, stackName);
|
||||
results.push(`Stack "${stackName}": ${blockedMsg}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await compose.updateStack(stackName, undefined, atomic);
|
||||
db.clearStackUpdateStatus(req.nodeId, stackName);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NodeRegistry } from './NodeRegistry';
|
||||
import { RegistryService } from './RegistryService';
|
||||
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
|
||||
/**
|
||||
* ComposeService - local docker compose CLI execution.
|
||||
@@ -407,4 +408,56 @@ export class ComposeService {
|
||||
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate image references declared in a stack's compose file.
|
||||
*
|
||||
* Used by the pre-deploy policy gate to decide which images to scan before
|
||||
* `docker compose up` runs. Path traversal is guarded against the node's
|
||||
* compose base directory; missing / unreadable compose files or `.env`
|
||||
* interpolation failures surface as a rejected Promise so the gate can
|
||||
* block the deploy rather than silently allow it.
|
||||
*/
|
||||
public async listStackImages(stackName: string): Promise<string[]> {
|
||||
if (!isValidStackName(stackName)) {
|
||||
throw new Error('Invalid stack path');
|
||||
}
|
||||
const stackDir = path.resolve(this.baseDir, stackName);
|
||||
if (!isPathWithinBase(stackDir, this.baseDir) || path.resolve(this.baseDir) === stackDir) {
|
||||
throw new Error('Invalid stack path');
|
||||
}
|
||||
const stdout = await this.captureCompose(['config', '--images'], stackDir);
|
||||
const seen = new Set<string>();
|
||||
const images: string[] = [];
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('sha256:')) continue;
|
||||
if (seen.has(line)) continue;
|
||||
seen.add(line);
|
||||
images.push(line);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
private captureCompose(args: string[], cwd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('docker', ['compose', ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
},
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
child.on('error', (err) => reject(err));
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve(stdout);
|
||||
else reject(new Error(stderr.trim() || `docker compose ${args.join(' ')} failed with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { isSeverityAtLeast } from '../utils/severity';
|
||||
|
||||
export interface Agent {
|
||||
id?: number;
|
||||
@@ -309,7 +310,22 @@ export interface NotificationRoute {
|
||||
|
||||
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
|
||||
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
|
||||
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy';
|
||||
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
|
||||
|
||||
/**
|
||||
* Decision recorded when a scan is evaluated against the matching policy.
|
||||
* Persisted as JSON on `vulnerability_scans.policy_evaluation` so the UI
|
||||
* can surface a banner on the scan details sheet without re-running the
|
||||
* match. `violated=false` rows exist too (informational), which is why
|
||||
* presence of the field does not mean "blocked".
|
||||
*/
|
||||
export interface PolicyEvaluation {
|
||||
policyId: number;
|
||||
policyName: string;
|
||||
maxSeverity: VulnSeverity;
|
||||
violated: boolean;
|
||||
evaluatedAt: number;
|
||||
}
|
||||
|
||||
export interface VulnerabilityScan {
|
||||
id: number;
|
||||
@@ -335,6 +351,21 @@ export interface VulnerabilityScan {
|
||||
status: VulnScanStatus;
|
||||
error: string | null;
|
||||
stack_context: string | null;
|
||||
// JSON-encoded PolicyEvaluation; null if never evaluated.
|
||||
policy_evaluation: string | null;
|
||||
}
|
||||
|
||||
export function parsePolicyEvaluation(raw: string | null | undefined): PolicyEvaluation | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as PolicyEvaluation;
|
||||
if (typeof parsed.policyId !== 'number' || typeof parsed.policyName !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface VulnerabilityDetail {
|
||||
@@ -450,6 +481,7 @@ export class DatabaseService {
|
||||
this.migrateScanPolicyFleetColumns();
|
||||
this.migrateSecretMisconfigColumns();
|
||||
this.migrateAgentsAndNotificationsNodeId();
|
||||
this.migratePolicyEvaluationColumn();
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseService {
|
||||
@@ -1145,6 +1177,16 @@ export class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
private migratePolicyEvaluationColumn(): void {
|
||||
try {
|
||||
this.db
|
||||
.prepare('ALTER TABLE vulnerability_scans ADD COLUMN policy_evaluation TEXT')
|
||||
.run();
|
||||
} catch {
|
||||
/* column already present */
|
||||
}
|
||||
}
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
public getAgents(nodeId: number): Agent[] {
|
||||
@@ -2451,7 +2493,9 @@ export class DatabaseService {
|
||||
// --- Vulnerability Scans ---
|
||||
|
||||
public createVulnerabilityScan(
|
||||
scan: Omit<VulnerabilityScan, 'id'>,
|
||||
scan: Omit<VulnerabilityScan, 'id' | 'policy_evaluation'> & {
|
||||
policy_evaluation?: string | null;
|
||||
},
|
||||
): number {
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO vulnerability_scans (
|
||||
@@ -2460,8 +2504,8 @@ export class DatabaseService {
|
||||
low_count, unknown_count, fixable_count,
|
||||
secret_count, misconfig_count, scanners_used,
|
||||
highest_severity, os_info, trivy_version, scan_duration_ms,
|
||||
triggered_by, status, error, stack_context
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
triggered_by, status, error, stack_context, policy_evaluation
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const result = stmt.run(
|
||||
scan.node_id,
|
||||
@@ -2486,6 +2530,7 @@ export class DatabaseService {
|
||||
scan.status,
|
||||
scan.error,
|
||||
scan.stack_context,
|
||||
scan.policy_evaluation ?? null,
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
@@ -2500,7 +2545,7 @@ export class DatabaseService {
|
||||
'medium_count', 'low_count', 'unknown_count', 'fixable_count',
|
||||
'secret_count', 'misconfig_count', 'scanners_used',
|
||||
'highest_severity', 'os_info', 'trivy_version', 'scan_duration_ms',
|
||||
'triggered_by', 'status', 'error', 'stack_context',
|
||||
'triggered_by', 'status', 'error', 'stack_context', 'policy_evaluation',
|
||||
]);
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
@@ -3038,6 +3083,45 @@ export class DatabaseService {
|
||||
return scoped[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a completed scan against the matching policy for its node and
|
||||
* stack context. Returns the evaluation that should be persisted to the
|
||||
* scan row, or null when no policy matches.
|
||||
*
|
||||
* The result is informational. `violated=false` means a policy matched
|
||||
* but the scan was within limits; the UI surfaces a banner only when
|
||||
* `violated=true`. Blocking enforcement lives in the pre-deploy gate.
|
||||
*/
|
||||
public evaluateScanAgainstPolicies(
|
||||
nodeId: number,
|
||||
scan: VulnerabilityScan,
|
||||
selfIdentity: string,
|
||||
): PolicyEvaluation | null {
|
||||
const policy = this.getMatchingPolicy(nodeId, scan.stack_context, selfIdentity);
|
||||
if (!policy) return null;
|
||||
return {
|
||||
policyId: policy.id,
|
||||
policyName: policy.name,
|
||||
maxSeverity: policy.max_severity,
|
||||
violated: isSeverityAtLeast(scan.highest_severity, policy.max_severity),
|
||||
evaluatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a PolicyEvaluation onto a scan row. Pass null to clear.
|
||||
* Encoded as JSON so consumers round-trip through parsePolicyEvaluation().
|
||||
*/
|
||||
public setScanPolicyEvaluation(
|
||||
scanId: number,
|
||||
evaluation: PolicyEvaluation | null,
|
||||
): void {
|
||||
const json = evaluation ? JSON.stringify(evaluation) : null;
|
||||
this.db
|
||||
.prepare('UPDATE vulnerability_scans SET policy_evaluation = ? WHERE id = ?')
|
||||
.run(json, scanId);
|
||||
}
|
||||
|
||||
// --- Fleet Sync Status ---
|
||||
|
||||
public getFleetSyncStatuses(): FleetSyncStatus[] {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Pre-deploy policy gate.
|
||||
*
|
||||
* Extracted from `index.ts` so route handlers and the scheduler can call a
|
||||
* single, unit-testable function rather than copy-paste the gate logic.
|
||||
*
|
||||
* The gate fails open when Trivy is missing (users are never locked out by
|
||||
* tooling state) and fails closed when the compose file cannot be parsed
|
||||
* (a broken stack must not silently bypass a block policy).
|
||||
*/
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import type { ScanPolicy, VulnSeverity } from './DatabaseService';
|
||||
import { FleetSyncService } from './FleetSyncService';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import TrivyService from './TrivyService';
|
||||
import { isSeverityAtLeast } from '../utils/severity';
|
||||
import { validateImageRef } from '../utils/image-ref';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
export interface PolicyViolation {
|
||||
imageRef: string;
|
||||
severity: VulnSeverity;
|
||||
criticalCount: number;
|
||||
highCount: number;
|
||||
scanId: number;
|
||||
}
|
||||
|
||||
export interface PolicyEnforcementOptions {
|
||||
bypass: boolean;
|
||||
actor: string;
|
||||
ip?: string;
|
||||
/** HTTP method of the originating request; used for audit attribution. */
|
||||
auditMethod?: string;
|
||||
/** Request path of the originating route; used for audit attribution. */
|
||||
auditPath?: string;
|
||||
}
|
||||
|
||||
export interface PolicyEnforcementResult {
|
||||
ok: boolean;
|
||||
bypassed: boolean;
|
||||
policy?: ScanPolicy;
|
||||
violations: PolicyViolation[];
|
||||
trivyMissing?: boolean;
|
||||
}
|
||||
|
||||
export async function enforcePolicyPreDeploy(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
opts: PolicyEnforcementOptions,
|
||||
): Promise<PolicyEnforcementResult> {
|
||||
const svc = TrivyService.getInstance();
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
|
||||
|
||||
if (!policy || !policy.enabled || !policy.block_on_deploy) {
|
||||
return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] };
|
||||
}
|
||||
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
`Pre-deploy scan for "${stackName}" skipped: Trivy not installed on this node`,
|
||||
stackName,
|
||||
);
|
||||
return { ok: true, bypassed: false, policy, violations: [], trivyMissing: true };
|
||||
}
|
||||
|
||||
let imageRefs: string[] = [];
|
||||
try {
|
||||
imageRefs = await ComposeService.getInstance(nodeId).listStackImages(stackName);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'compose parse failed');
|
||||
console.error(`[Policy] listStackImages failed for ${stackName}:`, message);
|
||||
return {
|
||||
ok: false,
|
||||
bypassed: false,
|
||||
policy,
|
||||
violations: [{
|
||||
imageRef: '(compose parse error)',
|
||||
severity: 'UNKNOWN',
|
||||
criticalCount: 0,
|
||||
highCount: 0,
|
||||
scanId: 0,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const violations: PolicyViolation[] = [];
|
||||
for (const imageRef of imageRefs) {
|
||||
if (!validateImageRef(imageRef)) continue;
|
||||
try {
|
||||
const scan = await svc.scanImagePreflight(imageRef, nodeId, stackName);
|
||||
const severity = scan.highest_severity ?? 'UNKNOWN';
|
||||
if (isSeverityAtLeast(severity, policy.max_severity)) {
|
||||
violations.push({
|
||||
imageRef,
|
||||
severity,
|
||||
criticalCount: scan.critical_count,
|
||||
highCount: scan.high_count,
|
||||
scanId: scan.id,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'pre-flight scan failed');
|
||||
console.error(`[Policy] scanImagePreflight failed for ${imageRef}:`, message);
|
||||
violations.push({
|
||||
imageRef,
|
||||
severity: 'UNKNOWN',
|
||||
criticalCount: 0,
|
||||
highCount: 0,
|
||||
scanId: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
return { ok: true, bypassed: false, policy, violations: [] };
|
||||
}
|
||||
|
||||
if (opts.bypass) {
|
||||
try {
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: opts.actor,
|
||||
method: opts.auditMethod ?? 'POST',
|
||||
path: opts.auditPath ?? `/api/stacks/${stackName}/deploy`,
|
||||
status_code: 200,
|
||||
node_id: nodeId,
|
||||
ip_address: opts.ip ?? '',
|
||||
summary: `policy.bypass stack="${stackName}" policy="${policy.name}" violations=${violations.length} images=[${violations.map((v) => v.imageRef).join(',')}]`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Policy] Failed to record bypass audit entry:', err);
|
||||
}
|
||||
return { ok: true, bypassed: true, policy, violations };
|
||||
}
|
||||
|
||||
return { ok: false, bypassed: false, policy, violations };
|
||||
}
|
||||
@@ -659,7 +659,17 @@ export class SchedulerService {
|
||||
console.log(
|
||||
`[SchedulerService:debug] executeScan summary: scanned=${summary.scanned} skipped=${summary.skipped} failed=${summary.failed} ` +
|
||||
`critical=${summary.severity.critical} high=${summary.severity.high} medium=${summary.severity.medium} ` +
|
||||
`low=${summary.severity.low} unknown=${summary.severity.unknown} durationMs=${Date.now() - scanStart}`,
|
||||
`low=${summary.severity.low} unknown=${summary.severity.unknown} violations=${summary.violations.length} durationMs=${Date.now() - scanStart}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Scheduled scans never auto-quarantine; violations surface as alerts
|
||||
// so an operator can review and remediate. One alert per violation so
|
||||
// the notification panel keeps per-image granularity.
|
||||
for (const v of summary.violations ?? []) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
`Policy "${v.policyName}" violated by ${v.imageRef}: ${v.severity} exceeds ${v.maxSeverity}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FileSystemService } from './FileSystemService';
|
||||
import { RegistryService } from './RegistryService';
|
||||
import { disableCapability, enableCapability } from './CapabilityRegistry';
|
||||
import TrivyInstaller, { type TrivySource } from './TrivyInstaller';
|
||||
import { FleetSyncService } from './FleetSyncService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { SEVERITY_ORDER } from '../utils/severity';
|
||||
@@ -85,11 +86,24 @@ export interface ScanAllNodeImagesSeverityTotals {
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export interface ScanAllNodeImagesViolation {
|
||||
imageRef: string;
|
||||
scanId: number;
|
||||
severity: VulnSeverity;
|
||||
policyName: string;
|
||||
maxSeverity: VulnSeverity;
|
||||
}
|
||||
|
||||
export interface ScanAllNodeImagesResult {
|
||||
scanned: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
severity: ScanAllNodeImagesSeverityTotals;
|
||||
/**
|
||||
* Policy violations observed across the freshly-scanned or cached rows.
|
||||
* The scheduler uses this to dispatch alerts without re-querying the DB.
|
||||
*/
|
||||
violations: ScanAllNodeImagesViolation[];
|
||||
}
|
||||
|
||||
export interface TrivyVulnerability {
|
||||
@@ -725,6 +739,27 @@ class TrivyService {
|
||||
);
|
||||
const stored = db.getVulnerabilityScan(scanId);
|
||||
if (!stored) throw new Error('Scan vanished after write');
|
||||
// Evaluate against matching policy and persist the result so the
|
||||
// UI can render a violation banner without re-running the match.
|
||||
// This runs for every trigger (manual, deploy, deploy-preflight,
|
||||
// scheduled, drift) so downstream surfaces stay consistent.
|
||||
try {
|
||||
const evaluation = db.evaluateScanAgainstPolicies(
|
||||
nodeId,
|
||||
stored,
|
||||
FleetSyncService.getSelfIdentity(),
|
||||
);
|
||||
if (evaluation) {
|
||||
db.setScanPolicyEvaluation(scanId, evaluation);
|
||||
stored.policy_evaluation = JSON.stringify(evaluation);
|
||||
}
|
||||
} catch (err) {
|
||||
// Never fail the scan because policy evaluation stumbled.
|
||||
console.warn(
|
||||
`[Trivy] policy evaluation failed for scanId=${scanId}:`,
|
||||
getErrorMessage(err, 'unknown error'),
|
||||
);
|
||||
}
|
||||
diag(
|
||||
`finishScan: scanId=${scanId} completed vulns=${result.totalVulnerabilities} secrets=${result.secretCount} highest=${result.highestSeverity ?? 'none'} durationMs=${result.metadata.scanDurationMs}`,
|
||||
);
|
||||
@@ -752,6 +787,29 @@ class TrivyService {
|
||||
return this.finishScan(scanId, imageRef, nodeId, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single image for the pre-deploy policy gate.
|
||||
*
|
||||
* Reuses the 24h digest cache (useCache=true) so repeat deploys of a
|
||||
* known-safe image do not pay full scan cost. Only runs the vulnerability
|
||||
* scanner (secrets/misconfig are irrelevant to the gate and add latency).
|
||||
* The scan is persisted as a normal row with triggered_by=deploy-preflight
|
||||
* so the history and compare views continue to work unchanged.
|
||||
*/
|
||||
async scanImagePreflight(
|
||||
imageRef: string,
|
||||
nodeId: number,
|
||||
stackName: string | null,
|
||||
): Promise<VulnerabilityScan> {
|
||||
return this.runScanAndPersist(
|
||||
imageRef,
|
||||
nodeId,
|
||||
'deploy-preflight',
|
||||
stackName,
|
||||
{ useCache: true, scanners: ['vuln'] },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a compose stack directory for misconfigurations. A new scan
|
||||
* row is persisted with image_ref='stack:<name>' so misconfigs share
|
||||
@@ -871,6 +929,22 @@ class TrivyService {
|
||||
);
|
||||
const stored = db.getVulnerabilityScan(scanId);
|
||||
if (!stored) throw new Error('Scan vanished after write');
|
||||
try {
|
||||
const evaluation = db.evaluateScanAgainstPolicies(
|
||||
nodeId,
|
||||
stored,
|
||||
FleetSyncService.getSelfIdentity(),
|
||||
);
|
||||
if (evaluation) {
|
||||
db.setScanPolicyEvaluation(scanId, evaluation);
|
||||
stored.policy_evaluation = JSON.stringify(evaluation);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Trivy] policy evaluation failed for stack scanId=${scanId}:`,
|
||||
getErrorMessage(err, 'unknown error'),
|
||||
);
|
||||
}
|
||||
return stored;
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -906,6 +980,7 @@ class TrivyService {
|
||||
let failed = 0;
|
||||
const severity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
||||
const countedDigests = new Set<string>();
|
||||
const violations: ScanAllNodeImagesViolation[] = [];
|
||||
|
||||
const addSeverity = (row: VulnerabilityScan | null): void => {
|
||||
if (!row) return;
|
||||
@@ -916,6 +991,28 @@ class TrivyService {
|
||||
severity.unknown += row.unknown_count;
|
||||
};
|
||||
|
||||
const collectViolation = (row: VulnerabilityScan | null): void => {
|
||||
if (!row || !row.policy_evaluation) return;
|
||||
try {
|
||||
const parsed = JSON.parse(row.policy_evaluation) as {
|
||||
violated: boolean;
|
||||
policyName: string;
|
||||
maxSeverity: VulnSeverity;
|
||||
};
|
||||
if (parsed.violated) {
|
||||
violations.push({
|
||||
imageRef: row.image_ref,
|
||||
scanId: row.id,
|
||||
severity: row.highest_severity ?? 'UNKNOWN',
|
||||
policyName: parsed.policyName,
|
||||
maxSeverity: parsed.maxSeverity,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed evaluation JSON; presence is informational.
|
||||
}
|
||||
};
|
||||
|
||||
for (const ref of imageRefs) {
|
||||
try {
|
||||
const digest = await this.getImageDigest(ref, nodeId);
|
||||
@@ -926,12 +1023,14 @@ class TrivyService {
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
|
||||
skipped++;
|
||||
addSeverity(cached);
|
||||
collectViolation(cached);
|
||||
countedDigests.add(digest);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const fresh = await this.runScanAndPersist(ref, nodeId, triggeredBy, null);
|
||||
addSeverity(fresh);
|
||||
collectViolation(fresh);
|
||||
scanned++;
|
||||
if (digest) countedDigests.add(digest);
|
||||
} catch (err) {
|
||||
@@ -940,7 +1039,7 @@ class TrivyService {
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
return { scanned, skipped, failed, severity };
|
||||
return { scanned, skipped, failed, severity, violations };
|
||||
}
|
||||
|
||||
async generateSBOM(imageRef: string, format: SbomFormat): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user