import { describe, expect, it } from 'vitest'; import { buildWorkPackages, scannerFindingKey, policyFindingKey, maxTasksFor, type PublishedContext, type RemediationFinding } from '../../src/remediation/remediation-engine'; import { MemoryRemediationStore } from '../../server/remediation/remediation-store'; import { remediationReadPortFrom } from '../../src/remediation/remediation-read-port'; import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository'; const cloud = 'cloud-privileged-access-exposure'; const rules = ['MT.1005','MT.1024.adminMFAV2','CISA.MS.AAD.3.6','MT.1014','CIS.M365.1.1.3','CISA.MS.AAD.7.1','CISA.MS.AAD.7.2','MT.1032', 'CIS.M365.1.1.1','CISA.MS.AAD.7.3','MT.1028','CISA.MS.AAD.7.4','MT.1025','MT.1030','MT.INV.003','MT.1056','MT.1064','MT.1027','MT.INV.001']; const findings: RemediationFinding[] = rules.map((ruleId, i) => ({ key: scannerFindingKey(`occ-${i}`), occurrenceId: `occ-${i}`, tool: 'maester', ruleId, title: `Synthetic ${ruleId}`, outcome: ruleId.startsWith('MT.INV') ? 'warning' : 'fail' })); const context: PublishedContext = { engine: { version: '1.11.1' }, catalog: { version: '1.10.0' }, repositoryRevision: 4, clusters: [{ clusterId: cloud, label: { en: 'Cloud Privileged Access Exposure' } }, { clusterId: 'other-deviations', label: { en: 'Other Deviations' } }], findings: findings.map(f => ({ occurrenceId: f.occurrenceId!, clusterIds: [cloud], conditionIds: [] })), settings: [] }; const input = { projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings, context }; type Row = { tool: string; title?: string; conditions?: string[]; policy?: boolean; severity?: string; outcome?: string }; /** Synthetic scope with one cluster; checks order independence and immutable inputs on every run. */ function run(cluster: string, rows: Row[]) { const fs: RemediationFinding[] = rows.map((r, i) => ({ key: r.policy ? policyFindingKey('computer', `setting-${i}`) : scannerFindingKey(`occ-${String(i).padStart(2, '0')}`), ...(r.policy ? { scope: 'computer', identityKey: `setting-${i}` } : { occurrenceId: `occ-${String(i).padStart(2, '0')}` }), tool: r.tool, ruleId: `R${i}`, title: r.title ?? `Synthetic distinct finding ${i}`, outcome: r.outcome ?? 'fail', ...(r.severity ? { severity: r.severity } : {}) })); const attachments = fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, identityKey: f.identityKey!, scope: f.scope!, clusterIds: [cluster], conditionIds: rows[i].conditions ?? [] })); const ctx: PublishedContext = { engine: { version: 'published' }, catalog: { version: 'published' }, repositoryRevision: 7, clusters: [{ clusterId: cluster, label: { en: cluster } }], findings: attachments.filter(a => a.occurrenceId), settings: attachments.filter(a => a.identityKey) }; const scoped = { projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: [], findings: fs, context: ctx }; const before = structuredClone(scoped); const result = buildWorkPackages(scoped); expect(scoped).toEqual(before); expect(buildWorkPackages({ ...scoped, findings: [...fs].reverse(), context: { ...ctx, findings: [...ctx.findings].reverse(), settings: [...ctx.settings].reverse() } })).toEqual(result); const keys = result.packages.flatMap(p => p.findingKeys); expect(new Set(keys).size).toBe(keys.length); expect(result.packages.every(p => p.tasks.length <= maxTasksFor(p.platform) && p.facts.tasks === p.tasks.length)).toBe(true); return { result, keys: fs.map(f => f.key) }; } describe('Remediation Engine', () => { it('keeps the seven reviewed interventions as measures in packages of at most eight tasks with exclusive coverage', () => { const result = buildWorkPackages(input); const measures = result.packages.flatMap(p => p.measures).filter(m => m.method === 'catalogued'); expect(measures.map(m => m.taskIds.length).sort()).toEqual([1,1,2,3,4,4,4]); expect(new Set(measures.map(m => m.id)).size).toBe(7); expect(result.packages.every(p => p.tasks.length <= 8 && p.platform === 'cloud')).toBe(true); expect(result.packages).toHaveLength(3); expect(new Set(result.packages.flatMap(p => p.findingKeys)).size).toBe(19); expect(result.unassignedFindingKeys).toEqual([]); }); it('is independent of input order and leaves Core, Context, verdicts and source text unchanged', () => { const before = structuredClone(input); const first = buildWorkPackages(input); expect(buildWorkPackages({ ...input, findings: [...findings].reverse(), context: { ...context, findings: [...context.findings].reverse() } })).toEqual(first); expect(input).toEqual(before); expect(buildWorkPackages({ ...input, findings: [...findings, findings[0]] })).toEqual(first); }); it('splits a larger common intervention evenly and keeps non-deviations out', () => { const many = Array.from({ length: 12 }, (_, i) => ({ ...findings[0], key: scannerFindingKey(`many-${i}`), occurrenceId: `many-${i}` })); const expanded = { ...context, findings: many.map(f => ({ occurrenceId: f.occurrenceId!, clusterIds: [cloud], conditionIds: [] })) }; const result = buildWorkPackages({ ...input, context: expanded, findings: [...many, { ...many[0], key: 'pass', outcome: 'pass' }] }); expect(result.packages.map(p => p.findingKeys.length)).toEqual([6,6]); expect(result.packages.flatMap(p => p.findingKeys)).not.toContain('pass'); }); it('does not claim a common measure for neighbours or estimated context; unknown context remains visibly unassigned', () => { const identical = 'Apply the retained source remediation to the same technical setting.'; const fs = findings.slice(0, 3).map(f => ({ ...f, tool: 'synthetic', remediation: identical })); const ctx = { ...context, findings: context.findings.map((f, i) => i === 1 ? { ...f, clusterConfidence: { [cloud]: 'estimated' } } : i === 2 ? { ...f, clusterIds: ['other-deviations'] } : f) }; const result = buildWorkPackages({ ...input, findings: fs, context: ctx }); expect(result.packages).toHaveLength(1); // The estimated finding stays individual; identical text alone joins nothing across confidence levels. expect(result.packages[0].measures.map(m => [m.method, m.taskIds.length])).toEqual([['same-source-remediation', 1], ['individual', 1]]); expect(result.packages[0].tasks.every(t => !t.redundant)).toBe(true); expect(result.unassignedFindingKeys).toEqual([fs[2].key]); }); it('counts a rolled-up GPO setting once and keeps computer and user scopes separate', () => { const computer = { key: policyFindingKey('computer', 'setting'), identityKey: 'setting', scope: 'computer', tool: 'group-policy', ruleId: '', title: 'Setting', outcome: 'deviation' }; const user = { ...computer, key: policyFindingKey('user', 'setting'), scope: 'user' }; const ctx = { ...context, settings: [computer, user].map(f => ({ identityKey: f.identityKey, scope: f.scope, clusterIds: [cloud], conditionIds: [] })) }; expect(buildWorkPackages({ ...input, findings: [computer, computer, user], context: ctx }).packages.flatMap(p => p.findingKeys)).toHaveLength(2); }); it('keeps storage separate and exposes only declared Core reads', async () => { const repository = new LocalAssessorRepository(); const before = repository.revision; const port = remediationReadPortFrom(repository, async () => repository.revision); expect('saveProject' in port).toBe(false); expect('saveReview' in port).toBe(false); const store = new MemoryRemediationStore(); await store.save('scope', buildWorkPackages(input)); expect((await store.read('scope'))?.packages).toHaveLength(3); expect(repository.revision).toBe(before); }); }); describe('Remediation packaging 2.0 (user decision 2026-10-09)', () => { it('merges findings that each source reports once for the same specific condition into one task', () => { const { result, keys } = run('password-guessing-exposure', [ { tool: 'cis-cat', conditions: ['password-length-below-14'] }, { tool: '', policy: true, conditions: ['password-length-below-14', 'password-length-below-8'] }, { tool: 'pingcastle', conditions: ['password-length-below-8'] }, ]); expect(result.packages).toHaveLength(1); const [task] = result.packages[0].tasks; expect(result.packages[0].tasks).toHaveLength(1); expect(task.redundant).toBe(true); expect([...task.findingKeys].sort()).toEqual([...keys].sort()); expect(result.packages[0].platform).toBe('windows-host'); expect(result.packages[0].facts).toMatchObject({ tasks: 1, findings: 3, redundantFindings: 3, conditionsClosed: 2, conditionsTouched: 2 }); }); it('never treats an area condition, an umbrella finding or matching titles as redundancy', () => { const area = run('detection-blind-spots', [ { tool: 'cis-cat', conditions: ['cis-audit-policy-unmet'] }, { tool: 'cis-cat', conditions: ['cis-audit-policy-unmet'] }, { tool: 'cis-cat', conditions: ['cis-audit-policy-unmet'] }, ]).result; expect(area.packages.flatMap(p => p.tasks).every(t => !t.redundant && t.findingKeys.length === 1)).toBe(true); const umbrella = run('host-credential-privilege-exposure', [ { tool: 'purple-knight', conditions: ['dangerous-user-rights-granted'] }, { tool: 'cis-cat', conditions: ['dangerous-user-rights-granted'] }, { tool: 'cis-cat', conditions: ['dangerous-user-rights-granted'] }, { tool: 'cis-cat', conditions: ['dangerous-user-rights-granted'] }, ]).result; expect(umbrella.packages).toHaveLength(1); expect(umbrella.packages[0].platform).toBe('windows-host'); expect(umbrella.packages[0].tasks).toHaveLength(4); expect(umbrella.packages[0].tasks.every(t => !t.redundant && t.relatedConditionId === 'dangerous-user-rights-granted')).toBe(true); const titles = run('lateral-movement-exposure', [ { tool: 'cis-cat', title: 'Identical wording', conditions: ['condition-a'] }, { tool: 'group-policy', policy: true, title: 'Identical wording', conditions: ['condition-b'] }, ]).result; expect(titles.packages.flatMap(p => p.tasks)).toHaveLength(2); }); it('packs at most eight tasks, balances packages and leaves no artificial single-task tail', () => { const rows = (n: number) => Array.from({ length: n }, () => ({ tool: 'pingcastle' })); expect(run('ad-recovery-gaps', rows(20)).result.packages.map(p => p.tasks.length)).toEqual([7, 7, 6]); expect(run('ad-recovery-gaps', rows(9)).result.packages.map(p => p.tasks.length).sort()).toEqual([4, 5]); expect(run('ad-recovery-gaps', rows(8)).result.packages.map(p => p.tasks.length)).toEqual([8]); }); it('allows fifteen tasks for Windows host/GPO settings only (user decision 2026-10-09)', () => { expect(maxTasksFor('windows-host')).toBe(15); expect(maxTasksFor('active-directory')).toBe(8); expect(maxTasksFor('cloud')).toBe(8); const host = (n: number) => Array.from({ length: n }, (_, i) => i % 3 ? { tool: 'cis-cat' } : { tool: 'group-policy', policy: true }); expect(run('data-sharing-exposure', host(15)).result.packages.map(p => [p.platform, p.tasks.length])).toEqual([['windows-host', 15]]); expect(run('data-sharing-exposure', host(20)).result.packages.map(p => p.tasks.length)).toEqual([10, 10]); // 31 tasks need three packages; balanced, none near the limit of fifteen. const sizes = run('data-sharing-exposure', host(31)).result.packages.map(p => p.tasks.length); expect(sizes).toHaveLength(3); expect(sizes.reduce((n, size) => n + size, 0)).toBe(31); expect(Math.max(...sizes) - Math.min(...sizes)).toBeLessThanOrEqual(2); // The same cluster keeps eight for directory tasks next to fifteen for host settings. const mixed = run('data-sharing-exposure', [...host(15), ...Array.from({ length: 9 }, () => ({ tool: 'purple-knight' }))]).result; expect(mixed.packages.map(p => [p.platform, p.tasks.length])).toEqual([['windows-host', 15], ['active-directory', 5], ['active-directory', 4]]); }); it('orders tasks with a common measure before individual tasks inside a package', () => { const { result } = run('cloud-sign-in-protection-gaps', [ { tool: 'maester', title: 'Unmatched synthetic check A' }, { tool: 'maester', title: 'Block legacy authentication clients' }, { tool: 'maester', title: 'Block legacy authentication for all users' }, ]); expect(result.packages).toHaveLength(1); expect(result.packages[0].measures.map(m => m.method)).toEqual(['catalogued', 'individual']); const individual = result.packages[0].measures[1].taskIds[0]; expect(result.packages[0].tasks.at(-1)?.id).toBe(individual); }); it('keeps the cluster and platform boundary hard', () => { const { result } = run('ad-privileged-account-exposure', [{ tool: 'cis-cat' }, { tool: 'cis-cat' }, { tool: 'pingcastle' }, { tool: 'purple-knight' }]); expect(result.packages.map(p => [p.platform, p.tasks.length])).toEqual([['windows-host', 2], ['active-directory', 2]]); expect(result.packages.map(p => p.code)).toEqual(['WP-01', 'WP-02']); }); it('reports source-backed facts without a score or rank', () => { const { result } = run('ad-privileged-account-exposure', [ { tool: 'pingcastle', severity: 'High', conditions: ['condition-a'] }, { tool: 'purple-knight', severity: 'Critical', conditions: ['condition-b'] }, { tool: 'pingcastle', severity: 'unknown', conditions: ['condition-b'] }, ]); expect(result.packages[0].facts).toEqual({ tasks: 2, findings: 3, redundantFindings: 2, highestSeverity: 'critical', criticalOrHighFindings: 2, conditionsClosed: 2, conditionsTouched: 2, sources: ['pingcastle', 'purple-knight'] }); expect(Object.keys(result.packages[0])).not.toContain('score'); expect(Object.keys(result.packages[0])).not.toContain('priority'); }); });