import { describe, expect, it } from 'vitest'; import { matchIntervention } from '../../src/remediation/intervention-catalog'; import { buildWorkPackages, policyFindingKey, scannerFindingKey, type PublishedContext, type RemediationFinding } from '../../src/remediation/remediation-engine'; function run(cluster: string, rows: Array<{ tool: string; title: string; conditions?: string[]; outcome?: string; policy?: boolean; confidence?: string }>) { const findings: RemediationFinding[] = rows.map((r, i) => ({ key: r.policy ? policyFindingKey('computer', `setting-${i}`) : scannerFindingKey(`occ-${i}`), ...(r.policy ? { scope: 'computer', identityKey: `setting-${i}` } : { occurrenceId: `occ-${i}` }), tool: r.tool, ruleId: 'synthetic', title: r.title, outcome: r.outcome ?? 'fail', })); const attachments = findings.map((f, i) => ({ occurrenceId: f.occurrenceId!, identityKey: f.identityKey!, scope: f.scope!, clusterIds: [cluster], conditionIds: rows[i].conditions ?? [], clusterConfidence: { [cluster]: rows[i].confidence ?? 'catalogued' } })); const context: 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 input = { projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: [], findings, context }; const before = structuredClone(input); const result = buildWorkPackages(input); expect(input).toEqual(before); expect(buildWorkPackages({ ...input, findings: [...findings].reverse(), context: { ...context, findings: [...context.findings].reverse(), settings: [...context.settings].reverse() } })).toEqual(result); return result; } describe('complete intervention catalogue', () => { it.each([ ['ad-persistence-indicators', 'ad-stale-admincount', 'adminsdholder-permissions-changed', 'Review protected'], ['ad-privileged-account-exposure', 'ad-admins-not-protected-users', 'ad-builtin-admin-used', 'Protect and restrict'], ['ad-reconnaissance-exposure', 'anonymous-ldap-bind', 'prewin2000-membership-changed', 'Restrict anonymous'], ['ad-recovery-gaps', 'ad-backup-outdated', 'ou-deletion-unprotected', 'Prepare directory'], ['adcs-escalation', 'adcs-template-requester-san', 'adcs-template-insecure-config', 'Review certificate'], ['cloud-app-consent-exposure', 'entra-users-can-consent-apps', 'entra-admin-consent-workflow-missing', 'Restrict application'], ['cloud-external-access-exposure', 'entra-guest-invites-unrestricted', 'entra-guest-invite-domains-unrestricted', 'Restrict guest'], ['kerberos-delegation-abuse', 'gpo-delegation-right-deviates', 'ad-rbcd-write-on-dc', 'Review sensitive'], ])('%s combines different source assertions for one intervention', (cluster, first, second, title) => { const result = run(cluster, [{ tool: 'pingcastle', title: 'Synthetic finding A', conditions: [first] }, { tool: 'purple-knight', title: 'Synthetic finding B', conditions: [second] }]); expect(result.packages).toHaveLength(1); expect(result.packages[0].method).toBe('catalogued'); expect(result.packages[0].title).toContain(title); expect(result.packages[0].findingKeys).toHaveLength(2); }); it.each([ ['cloud-sign-in-protection-gaps', 'Block legacy authentication clients', 'Block legacy authentication for all users', 'Block legacy'], ['credential-relay-exposure', 'Domain controller: LDAP server signing requirements', 'Domain controller: LDAP server channel binding token requirements', 'Require protected LDAP'], ['data-sharing-exposure', 'Allow Clipboard synchronization across devices', 'Allow upload of User Activities', 'Restrict cross-device'], ['host-credential-privilege-exposure', 'Privilege Rights ยท SeBackupPrivilege', 'Back up files and directories', 'Restrict backup'], ['kerberoasting-exposure', 'Configure encryption types allowed for Kerberos', 'RC4 or DES encryption type supported by Domain Controllers', 'Restrict weak Kerberos'], ['lateral-movement-exposure', 'Configure SMB v1 client driver', 'Configure SMB v1 server', 'Remove SMBv1'], ['malicious-code-execution-exposure', 'Configure Attack Surface Reduction rules', 'Attack Surface Reduction not configured', 'Configure Attack'], ['patch-configuration-drift', 'Configure security policy processing: Process even if unchanged', 'Configure security policy processing: background refresh', 'Ensure security'], ['physical-local-access-exposure', 'Interactive logon: Message title for users', 'Interactive logon: Message text for users', 'Configure the interactive'], ])('%s joins complementary retained setting names', (cluster, first, second, title) => { const result = run(cluster, [{ tool: 'group-policy', title: first, policy: true }, { tool: 'cis-cat', title: second }]); expect(result.packages).toHaveLength(1); expect(result.packages[0].title).toContain(title); expect(result.packages[0].findingKeys).toHaveLength(2); }); it('joins distinct password source assertions in one measure, split only at the platform boundary', () => { const result = run('password-guessing-exposure', [ { tool: '', title: 'Password must meet complexity requirements', conditions: ['password-complexity-off'], policy: true }, { tool: 'cis-cat', title: "Ensure 'Minimum password length' is set to '14 or more'", conditions: ['password-length-below-14'] }, { tool: 'pingcastle', title: 'Policy where the password length is less than 8 characters', conditions: ['password-length-below-8'] }, { tool: 'purple-knight', title: 'Privileged Users with Weak Password Policy', conditions: ['ad-privileged-weak-password-policy'] }, ]); expect(result.packages.map(p => [p.platform, p.findingKeys.length])).toEqual([['windows-host', 2], ['active-directory', 2]]); expect(new Set(result.packages.flatMap(p => p.measures.map(m => m.id)))).toEqual(new Set(['intervention:password-policy'])); }); it('keeps different firewall profiles and their source assertions together only within each profile', () => { const rows = ['Domain', 'Private', 'Public'].flatMap(profile => [ { tool: '', title: `WindowsFirewall\\${profile}Profile\\Logging`, conditions: ['gpo-firewall-logging-deviates'], policy: true }, { tool: 'cis-cat', title: `Windows Firewall: ${profile}: Logging: Log dropped packets`, conditions: ['firewall-logging-off'] }, { tool: 'cis-cat', title: `Windows Firewall: ${profile}: Logging: Log successful connections`, conditions: ['firewall-logging-off'] }, ]); const result = run('detection-blind-spots', rows); const measures = result.packages.flatMap(p => p.measures); expect(measures.map(m => m.taskIds.length)).toEqual([3, 3, 3]); expect(new Set(measures.map(m => m.id)).size).toBe(3); // Each profile measure stays inside one package of at most eight tasks. expect(result.packages.every(p => p.tasks.length <= 15)).toBe(true); }); it('does not turn unrelated neighbours or broad network conditions into a common measure', () => { const result = run('lateral-movement-exposure', [ { tool: 'cis-cat', title: 'Enable Safe DLL search mode', conditions: ['cis-network-stack-unmet'] }, { tool: 'cis-cat', title: 'Security log WarningLevel', conditions: ['cis-network-stack-unmet'] }, { tool: 'pingcastle', title: 'LAPS is not installed', conditions: ['laps-not-deployed'] }, ]); expect(result.packages.map(p => p.platform)).toEqual(['windows-host', 'active-directory']); expect(result.packages.flatMap(p => p.measures).every(m => m.method === 'individual' && m.taskIds.length === 1)).toBe(true); }); it('prefers specific semantics over a term inside a conditional clause', () => { expect(matchIntervention('cloud-sign-in-protection-gaps', { tool: 'maester', outcome: 'fail', title: 'If phishing-resistant MFA has not been enforced, show login context information.' }, ['entra-mfa-fatigue-protection-weak'])?.id).toBe('authenticator-fatigue'); }); it('does not treat estimated context or an unsupported warning as a shared fix', () => { const result = run('password-guessing-exposure', [ { tool: 'cis-cat', title: 'Minimum password length', confidence: 'estimated' }, { tool: 'cis-cat', title: 'Minimum password length', outcome: 'warning' }, { tool: 'cis-cat', title: 'Minimum password length' }, ]); expect(result.packages.flatMap(p => p.measures).map(m => m.taskIds.length)).toEqual([1, 1, 1]); }); it.each([6, 11, 16])('avoids an artificial singleton tail for %s related findings', count => { const result = run('password-guessing-exposure', Array.from({ length: count }, () => ({ tool: 'cis-cat', title: 'Minimum password length' }))); expect(result.packages.every(p => p.findingKeys.length >= 2 && p.findingKeys.length <= 15)).toBe(true); const keys = result.packages.flatMap(p => p.findingKeys); expect(keys).toHaveLength(count); expect(new Set(keys).size).toBe(count); }); });