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:
Anso
2026-04-21 00:14:11 -04:00
committed by GitHub
parent aa10db1d09
commit 661b9c638b
17 changed files with 1772 additions and 44 deletions
@@ -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: [],
};
}