119 lines
8.9 KiB
TypeScript
119 lines
8.9 KiB
TypeScript
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);
|
|
});
|
|
});
|