mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +00:00
1de49f8b1a
The pre-deploy gate names the inputs that matched a scan policy (a known-exploited CVE, a fixable Critical/High, or a severity threshold), but the informational post-scan surfaces still framed every violation as a severity ceiling. The scan detail banner read "blocks severities at or above X, highest severity is Y" and the scheduled-scan alert read "<severity> exceeds <maxSeverity>", which is wrong for a KEV- or fixable-only policy that never gated on severity. Persist the matched reasons on the policy evaluation, carry them on the scheduled-scan violation, and render them on the banner so every policy surface names the input that actually matched. Evaluations persisted before this change carry no reasons: the parser defaults the field to an empty array and the banner falls back to a plain violation notice.
218 lines
7.1 KiB
TypeScript
218 lines
7.1 KiB
TypeScript
/**
|
|
* 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,
|
|
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'),
|
|
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,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
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;
|
|
reasons: Array<'severity' | 'kev' | 'fixable'>;
|
|
}>) {
|
|
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: 'MEDIUM',
|
|
scanId: 42,
|
|
reasons: ['kev'],
|
|
},
|
|
{
|
|
imageRef: 'redis:6',
|
|
policyId: 1,
|
|
policyName: 'prod-high-gate',
|
|
maxSeverity: 'HIGH',
|
|
severity: 'HIGH',
|
|
scanId: 43,
|
|
reasons: ['severity'],
|
|
},
|
|
]));
|
|
|
|
const svc = SchedulerService.getInstance();
|
|
await svc.triggerTask(300);
|
|
|
|
const warningCalls = mockDispatchAlert.mock.calls.filter((c) => c[0] === 'warning');
|
|
expect(warningCalls).toHaveLength(2);
|
|
// The first violation matched KEV, so the alert names the known-exploited
|
|
// input and never the severity ceiling it did not enforce.
|
|
expect(warningCalls[0][2]).toContain('prod-high-gate');
|
|
expect(warningCalls[0][2]).toContain('nginx:1.14');
|
|
expect(warningCalls[0][2]).toContain('known-exploited CVE (KEV)');
|
|
expect(warningCalls[0][2]).not.toContain('HIGH');
|
|
// The second matched the severity threshold.
|
|
expect(warningCalls[1][2]).toContain('redis:6');
|
|
expect(warningCalls[1][2]).toContain('severity threshold');
|
|
});
|
|
|
|
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[2] === 'string' && c[2].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,
|
|
reasons: ['severity'],
|
|
},
|
|
]));
|
|
|
|
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();
|
|
});
|
|
});
|