177 lines
14 KiB
TypeScript
177 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|