693 lines
54 KiB
TypeScript
693 lines
54 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { JsonValue } from '../../src/domain/canonical-model';
|
|
import type { StoredGpoPolicyAnalysis } from '../../src/domain/gpo-analysis-persistence';
|
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|
import {
|
|
assessmentContextCatalogErrors, RELEASED_ASSESSMENT_CONTEXT_CATALOG, type AssessmentContextCatalog
|
|
} from '../../src/assessment-context/context-catalog';
|
|
import { buildAssessmentContext } from '../../src/assessment-context/context-engine';
|
|
import { contextHeatmap, contextHeatmapFindingCount } from '../../src/assessment-context/context-heatmap';
|
|
import { ContextStateChangedError, createAssessmentContext, type ContextReadPort } from '../../src/assessment-context/context-run';
|
|
|
|
type Target =
|
|
| { kind: 'security-template'; section: string; key: string }
|
|
| { kind: 'registry-value'; hive: 'HKEY_LOCAL_MACHINE'; key: string; valueName: string };
|
|
|
|
interface Declared { id: string; backup: string; link?: 'linked' | 'not-linked'; target: Target; values: JsonValue[]; state?: string; resolved?: boolean }
|
|
|
|
const template = (key: string): Target => ({ kind: 'security-template', section: 'Registry Values', key });
|
|
const dword = (raw: string): JsonValue => ({ kind: 'registry', typeCode: 4, rawData: raw });
|
|
const LSA = 'MACHINE\\System\\CurrentControlSet\\Control\\Lsa\\LmCompatibilityLevel';
|
|
const SMB_SERVER = 'MACHINE\\System\\CurrentControlSet\\Services\\LanManServer\\Parameters\\RequireSecuritySignature';
|
|
const LDAP_SERVER = 'MACHINE\\System\\CurrentControlSet\\Services\\NTDS\\Parameters\\LDAPServerIntegrity';
|
|
const LDAP_CLIENT = 'MACHINE\\System\\CurrentControlSet\\Services\\LDAP\\LDAPClientIntegrity';
|
|
const llmnr: Target = { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'Software\\Policies\\Microsoft\\Windows NT\\DNSClient', valueName: 'EnableMulticast' };
|
|
|
|
function fixture(declared: Declared[]): { observations: UnifiedResultProjection[]; analyses: StoredGpoPolicyAnalysis[] } {
|
|
const observations = declared.map(item => ({
|
|
resultProjectionId: `policy-observation:${item.id}`, sourceKind: 'policy-observation', sourceResultId: item.id,
|
|
projectId: 'dummy-project', policyScope: 'computer', policyState: item.state ?? 'configured',
|
|
policyIdentityStatus: item.resolved === false ? 'unresolved' : 'resolved',
|
|
policyIdentityKey: `identity-${JSON.stringify(item.target).length}-${'key' in item.target ? item.target.key.split('\\').at(-1) : ''}-${'valueName' in item.target ? item.target.valueName : ''}`,
|
|
policyValues: item.values, policyBackupInstanceId: item.backup, policyGpoId: `{${item.backup}}`,
|
|
...(item.link ? { policyBackupLinkStatus: item.link } : {})
|
|
})) as unknown as UnifiedResultProjection[];
|
|
const analyses = [{
|
|
run: { id: 'dummy-analysis' },
|
|
resolutions: declared.filter(item => item.resolved !== false).map((item, index) => ({
|
|
observationId: item.id,
|
|
resolution: { version: 1, status: 'resolved', identity: { id: observations[declared.indexOf(item)].policyIdentityKey, target: item.target } },
|
|
index
|
|
}))
|
|
}] as unknown as StoredGpoPolicyAnalysis[];
|
|
return { observations, analyses };
|
|
}
|
|
|
|
function context(declared: Declared[], catalog: AssessmentContextCatalog = RELEASED_ASSESSMENT_CONTEXT_CATALOG) {
|
|
const { observations, analyses } = fixture(declared);
|
|
return buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z',
|
|
repositoryRevision: 4, observations, analyses, catalog
|
|
});
|
|
}
|
|
|
|
const condition = (result: ReturnType<typeof context>, id: string) =>
|
|
result.clusters[0].conditions.find(item => item.conditionId === id)!;
|
|
|
|
describe('context catalog', () => {
|
|
it('is valid, sourced and phrased as favouring only', () => {
|
|
expect(assessmentContextCatalogErrors(RELEASED_ASSESSMENT_CONTEXT_CATALOG)).toEqual([]);
|
|
expect(RELEASED_ASSESSMENT_CONTEXT_CATALOG.semantics).toHaveLength(10);
|
|
for (const relationship of RELEASED_ASSESSMENT_CONTEXT_CATALOG.relationships) {
|
|
expect(relationship.statement.de).toMatch(/begünstig(?:t|en)/);
|
|
expect(relationship.statement.de).not.toMatch(/ausnutzbar|bestätigt|kompromittiert/i);
|
|
}
|
|
});
|
|
|
|
it('rejects overlapping value sets, missing sources and confirmed wording', () => {
|
|
const broken: AssessmentContextCatalog = structuredClone(RELEASED_ASSESSMENT_CONTEXT_CATALOG);
|
|
broken.semantics[0].conditions[0].notSatisfiedValues.push(0);
|
|
broken.semantics[1].sources = [];
|
|
broken.relationships[0].statement = { de: 'Der Angriff ist bestätigt.', en: 'The attack is confirmed.' };
|
|
broken.clusters[0].minimumSatisfied = 1;
|
|
const errors = assessmentContextCatalogErrors(broken).join(' ');
|
|
expect(errors).toMatch(/overlap/);
|
|
expect(errors).toMatch(/source is required/);
|
|
expect(errors).toMatch(/favouring/);
|
|
expect(errors).toMatch(/several conditions/);
|
|
expect(() => context([], broken)).toThrow(/catalog is invalid/);
|
|
});
|
|
});
|
|
|
|
/** A value that refutes every condition of one setting. */
|
|
const hardenedValue = (semantic: AssessmentContextCatalog['semantics'][number]) =>
|
|
semantic.conditions[0].notSatisfiedValues.find(value => semantic.conditions.every(item => item.notSatisfiedValues.includes(value)))!;
|
|
|
|
describe('context engine', () => {
|
|
const declared: Declared[] = [
|
|
{ id: 'obs-lm', backup: 'backup-a', link: 'linked', target: template(LSA), values: [dword('2')] },
|
|
{ id: 'obs-smb-a', backup: 'backup-a', link: 'linked', target: template(SMB_SERVER), values: [dword('0')] },
|
|
{ id: 'obs-smb-b', backup: 'backup-b', link: 'linked', target: template(SMB_SERVER.toUpperCase()), values: [dword('1')] },
|
|
{ id: 'obs-llmnr', backup: 'backup-c', link: 'not-linked', target: llmnr,
|
|
values: [{ kind: 'registry-policy', operation: 'set-value', key: llmnr.kind === 'registry-value' ? llmnr.key : '', valueName: 'EnableMulticast', typeCode: 4, data: { kind: 'uint32', value: 1 } }] },
|
|
{ id: 'obs-ldap-server', backup: 'backup-a', target: template(LDAP_SERVER), values: [dword('2')] },
|
|
{ id: 'obs-ldap-client', backup: 'backup-a', target: template(LDAP_CLIENT), values: [dword('7')] },
|
|
{ id: 'obs-unrelated', backup: 'backup-a', target: template('MACHINE\\Software\\Dummy\\Unrelated'), values: [dword('1')] },
|
|
{ id: 'obs-unresolved', backup: 'backup-a', target: template(LSA), values: [dword('0')], resolved: false }
|
|
];
|
|
|
|
it('derives declared facts per GPO backup and aggregates conditions without assuming precedence', () => {
|
|
const result = context(declared);
|
|
expect(result.engine.version).toBe('1.13.0');
|
|
expect(result.coverage).toEqual({ observations: 8, semanticallyMapped: 6, notSemanticallyMapped: 1, unresolvedIdentity: 1,
|
|
scannerFindings: 0, scannerMapped: 0, scannerNotInCatalog: 0, scannerGuardRejected: 0, gpoEvaluatedSettings: 0, gpoMappedByVerdict: 0, gpoUncatalogued: 0, scannerMappedBySection: 0, scannerUncatalogued: 0, scannerWithDeclaredReferences: 0, scannerWithCisControlArea: 0,
|
|
scannerMappedByDeclaredTechnique: 0, scannerRecognized: 0, scannerEstimated: 0, gpoRecognized: 0, gpoEstimated: 0, uncataloguedWithoutRecognition: 0 });
|
|
expect(result.facts.every(fact => fact.assertion === 'declared-in-gpo-backup')).toBe(true);
|
|
|
|
expect(condition(result, 'ntlm-legacy-responses-sent')).toMatchObject({ state: 'satisfied', conflictingDeclarations: false });
|
|
expect(condition(result, 'smb-server-signing-not-required')).toMatchObject({ state: 'satisfied', conflictingDeclarations: true });
|
|
expect(condition(result, 'smb-server-signing-not-required').notSatisfiedBy).toHaveLength(1);
|
|
expect(condition(result, 'llmnr-not-turned-off')).toMatchObject({ state: 'satisfied', onlyUnlinkedSources: true, declaredVersusObserved: false });
|
|
expect(condition(result, 'ldap-server-signing-not-required').state).toBe('not-satisfied');
|
|
expect(condition(result, 'ldap-client-signing-none')).toMatchObject({ state: 'unknown' });
|
|
expect(condition(result, 'ldap-client-signing-none').unknownBy).toHaveLength(1);
|
|
expect(condition(result, 'ldap-channel-binding-not-always')).toMatchObject({ state: 'unknown', satisfiedBy: [], unknownBy: [] });
|
|
|
|
expect(result.clusters[0]).toMatchObject({ clusterId: 'credential-relay-exposure', state: 'present', satisfied: 4, notSatisfied: 1, unknown: 26 });
|
|
expect(JSON.stringify(result)).not.toMatch(/"(?:verdict|pass|fail|severity|priority|rank)"/i);
|
|
});
|
|
|
|
it('offers attach points by Work identity and scope and plain-count levers', () => {
|
|
const result = context(declared);
|
|
const smb = result.settings.filter(item => item.semanticId === 'windows.smb.server.signing_required');
|
|
expect(smb.every(item => item.scope === 'computer' && item.clusterIds.includes('credential-relay-exposure'))).toBe(true);
|
|
const lever = result.levers.find(item => item.semanticId === 'windows.smb.server.signing_required')!;
|
|
expect(lever).toEqual({ semanticId: 'windows.smb.server.signing_required', settingName: 'Microsoft network server: Digitally sign communications (always)',
|
|
satisfiedConditions: 1, clusters: 1, backupInstanceIds: ['backup-a'] });
|
|
expect(result.levers.some(item => item.semanticId === 'windows.ldap.server.signing_requirement')).toBe(false);
|
|
});
|
|
|
|
it('keeps deleted, conflicted and unsupported values unknown and distinguishes not-present from undetermined', () => {
|
|
const deleted = context([
|
|
{ id: 'obs-llmnr-deleted', backup: 'backup-a', target: llmnr,
|
|
values: [{ kind: 'registry-policy', operation: 'delete-value', key: 'Software\\Policies\\Microsoft\\Windows NT\\DNSClient', valueName: 'EnableMulticast' }] },
|
|
{ id: 'obs-lm-conflicted', backup: 'backup-a', target: template(LSA), values: [dword('0')], state: 'conflicted' }
|
|
]);
|
|
expect(deleted.facts.map(fact => fact.value.state).sort()).toEqual(['deleted', 'unknown']);
|
|
expect(deleted.clusters[0].state).toBe('undetermined');
|
|
|
|
const hardened = context(RELEASED_ASSESSMENT_CONTEXT_CATALOG.semantics.flatMap(semantic => semantic.targets.map((target, index) => ({
|
|
id: `obs-${semantic.id}-${index}`, backup: 'backup-a',
|
|
target: target.mechanism === 'security-template' ? template(target.key)
|
|
: { kind: 'registry-value' as const, hive: 'HKEY_LOCAL_MACHINE' as const, key: target.key, valueName: target.valueName },
|
|
values: target.mechanism === 'security-template' ? [dword(String(hardenedValue(semantic)))]
|
|
: [{ kind: 'registry-policy', operation: 'set-value', key: target.key, valueName: target.valueName, typeCode: 4,
|
|
data: { kind: 'uint32', value: hardenedValue(semantic) } }]
|
|
}))));
|
|
// Conditions without a GPO setting (spooler coercion, ESC8, name resolution, hardened paths, SMB, DNS, TLS, report sections) stay unknown.
|
|
expect(hardened.clusters[0]).toMatchObject({ state: 'undetermined', satisfied: 0, notSatisfied: 8, unknown: 23 });
|
|
expect(hardened.levers).toEqual([]);
|
|
const { observations, analyses } = fixture(RELEASED_ASSESSMENT_CONTEXT_CATALOG.semantics.flatMap(semantic => semantic.targets.map((target, index) => ({
|
|
id: `obs-h-${semantic.id}-${index}`, backup: 'backup-a',
|
|
target: target.mechanism === 'security-template' ? template(target.key)
|
|
: { kind: 'registry-value' as const, hive: 'HKEY_LOCAL_MACHINE' as const, key: target.key, valueName: target.valueName },
|
|
values: target.mechanism === 'security-template' ? [dword(String(hardenedValue(semantic)))]
|
|
: [{ kind: 'registry-policy', operation: 'set-value', key: target.key, valueName: target.valueName, typeCode: 4,
|
|
data: { kind: 'uint32', value: hardenedValue(semantic) } }]
|
|
}))));
|
|
const passing = (tool: string, ruleId: string, title = ruleId) => ({ resultProjectionId: `scanner-finding:${ruleId}`, sourceKind: 'scanner-finding',
|
|
sourceResultId: ruleId, sourceTool: tool, sourceRuleId: ruleId, outcomeNormalized: 'pass', title }) as unknown as UnifiedResultProjection;
|
|
const fullyHardened = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 4,
|
|
observations, analyses, catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
scannerFindings: [passing('purple-knight', 'SI000077'), passing('purple-knight', 'SI000095'),
|
|
passing('cis-cat', '18.6.4.1', "18.6.4.1 Ensure 'Configure multicast DNS (mDNS) protocol' is set to 'Disabled'"),
|
|
passing('cis-cat', '18.6.4.2', "18.6.4.2 Ensure 'Configure NetBIOS settings' is set to 'Enabled'"),
|
|
passing('cis-cat', '18.11.1', "18.11.1 Ensure 'Disable HTTP proxy features: Disable WPAD' is set to 'Enabled: Checked'"),
|
|
passing('pingcastle', 'A-HardenedPaths'), passing('purple-knight', 'SI000085'),
|
|
passing('cis-cat', '18.6.14.1', "18.6.14.1 Ensure 'Hardened UNC Paths' is set to 'Enabled'"),
|
|
passing('cis-cat', '18.6.8.4', "18.6.8.4 Ensure 'Enable insecure guest logons' is set to 'Disabled'")]
|
|
});
|
|
// Refuted conditions no longer count; report-section and newly catalogued conditions without evidence stay unknown.
|
|
expect(fullyHardened.clusters[0]).toMatchObject({ state: 'undetermined', satisfied: 0, notSatisfied: 17, unknown: 14 });
|
|
});
|
|
|
|
it('is deterministic and never changes its inputs', () => {
|
|
const { observations, analyses } = fixture(declared);
|
|
const input = {
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z',
|
|
repositoryRevision: 4, observations, analyses, catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG
|
|
};
|
|
const before = structuredClone({ observations, analyses });
|
|
const deepFreeze = (value: unknown): void => {
|
|
if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); Object.values(value).forEach(deepFreeze); }
|
|
};
|
|
deepFreeze(observations); deepFreeze(analyses);
|
|
expect(buildAssessmentContext(input)).toEqual(buildAssessmentContext(input));
|
|
expect({ observations, analyses }).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe('context run', () => {
|
|
const port = (revisions: number[], data = fixture([{ id: 'obs-lm', backup: 'backup-a', target: template(LSA), values: [dword('1')] }])): ContextReadPort => ({
|
|
currentRevision: async () => revisions.length > 1 ? revisions.shift()! : revisions[0],
|
|
queryUnifiedResults: async query => {
|
|
const items = query?.sourceKind?.includes('policy-observation') ? data.observations : [];
|
|
return { items, total: items.length, offset: 0, limit: 500 };
|
|
},
|
|
listGpoPolicyAnalyses: async () => data.analyses,
|
|
listReferenceRuns: async () => [],
|
|
listPolicyEvaluations: async () => [],
|
|
getEnvironmentSnapshot: async () => { throw new Error('no CIS findings in scope'); }
|
|
});
|
|
|
|
it('repeats the read after a revision change and records the committed revision', async () => {
|
|
const result = await createAssessmentContext(port([6, 7, 7, 7]), { projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z' });
|
|
expect(result.repositoryRevision).toBe(7);
|
|
expect(result.facts).toHaveLength(1);
|
|
});
|
|
|
|
it('refuses instead of mixing states when the data keeps changing', async () => {
|
|
let revision = 0;
|
|
const changing = { ...port([0]), currentRevision: async () => revision++ };
|
|
await expect(createAssessmentContext(changing, { projectId: 'dummy-project', assessmentIds: ['a'], generatedAt: '2026-09-30T08:00:00Z' }))
|
|
.rejects.toBeInstanceOf(ContextStateChangedError);
|
|
});
|
|
});
|
|
|
|
describe('context engine scanner observations', () => {
|
|
const finding = (tool: string, ruleId: string, outcome: string, occurrence: string, title: string) => ({
|
|
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence,
|
|
sourceTool: tool, sourceRuleId: ruleId, outcomeNormalized: outcome, title, sourceObservedAt: '2026-09-01T00:00:00Z'
|
|
}) as unknown as UnifiedResultProjection;
|
|
const scannerFindings = [
|
|
finding('purple-knight', 'SI000154', 'fail', 'occ-pk', 'SMB Signing is not required on Domain Controllers'),
|
|
finding('cis-cat', '2.3.9.2', 'pass', 'occ-cis-server', "2.3.9.2 Ensure 'Microsoft network server: Digitally sign communications (always)' is set to 'Enabled'"),
|
|
finding('cis-cat', '2.3.8.1', 'fail', 'occ-cis-renumbered', "2.3.8.1 Ensure 'Some other setting' is set to 'Enabled'"),
|
|
finding('cis-cat', '9.9.9', 'fail', 'occ-cis-lookalike', "9.9.9 Ensure 'Microsoft network client: Digitally sign communications (always)' is set to 'Enabled'"),
|
|
finding('pingcastle', 'S-OldNtlm', 'fail', 'occ-pc', 'The LAN Manager Authentication Level allows the use of NTLMv1 or LM.'),
|
|
finding('cis-cat', '18.6.4.4', 'not_run', 'occ-cis-llmnr', "18.6.4.4 Ensure 'Turn off multicast name resolution' is set to 'Enabled'"),
|
|
finding('maester', 'MT.0001', 'fail', 'occ-maester', 'Unrelated cloud check')
|
|
];
|
|
|
|
function withScanners(declared: Declared[]) {
|
|
const { observations, analyses } = fixture(declared);
|
|
return buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z',
|
|
repositoryRevision: 4, observations, analyses, scannerFindings, catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG
|
|
});
|
|
}
|
|
|
|
it('maps exact rule IDs, guards renumbered benchmarks, keeps every deviation and leaves not-run checks unknown', () => {
|
|
const result = withScanners([]);
|
|
// User decision 2026-09-30: every fail is a finding. A renumbered or uncatalogued deviation is never dropped and
|
|
// never linked to the wrong setting's condition. User decision 2026-10-01: it receives a recognized or estimated
|
|
// cluster when its text carries a usable signal; without one it stays an uncatalogued deviation.
|
|
expect(result.coverage).toMatchObject({ scannerFindings: 7, scannerMapped: 6, scannerNotInCatalog: 0, scannerGuardRejected: 1, scannerUncatalogued: 2, scannerRecognized: 1 });
|
|
expect(result.observations.filter(item => item.matchedBy === 'rule-id').map(item => item.occurrenceId).sort()).toEqual(['occ-cis-llmnr', 'occ-cis-server', 'occ-pc', 'occ-pk']);
|
|
expect(result.observations.filter(item => item.matchedBy === 'uncatalogued-deviation').map(item => `${item.occurrenceId}:${item.conditionId}`).sort()).toEqual([
|
|
'occ-cis-renumbered:uncatalogued-deviations-cis-cat', 'occ-maester:uncatalogued-deviations-maester']);
|
|
// The look-alike reaches the cluster of its terms, not the condition of the setting it only resembles.
|
|
expect(result.recognitions.map(item => [item.occurrenceId, item.clusterId, item.confidence, item.method])).toEqual([
|
|
['occ-cis-lookalike', 'credential-relay-exposure', 'recognized', 'title-term']]);
|
|
expect(result.findings.find(item => item.occurrenceId === 'occ-cis-lookalike')).toMatchObject({
|
|
conditionIds: [], clusterIds: ['credential-relay-exposure'], clusterConfidence: { 'credential-relay-exposure': 'recognized' } });
|
|
expect(result.observations.every(item => item.assertion === 'observed-by-scanner')).toBe(true);
|
|
expect(condition(result, 'llmnr-not-turned-off')).toMatchObject({ state: 'unknown', satisfiedBy: [], notSatisfiedBy: [] });
|
|
expect(condition(result, 'ntlm-legacy-responses-sent').state).toBe('satisfied');
|
|
expect(condition(result, 'smb-client-signing-not-required').state).toBe('unknown');
|
|
});
|
|
|
|
it('combines declared and observed evidence and flags when they disagree', () => {
|
|
const result = withScanners([{ id: 'obs-smb', backup: 'backup-a', link: 'linked', target: template(SMB_SERVER), values: [dword('1')] }]);
|
|
const smb = condition(result, 'smb-server-signing-not-required');
|
|
expect(smb).toMatchObject({ state: 'satisfied', declaredVersusObserved: true, conflictingDeclarations: false, onlyUnlinkedSources: false });
|
|
expect(smb.satisfiedBy).toEqual(['context-observation:scanner-finding:occ-pk:smb-server-signing-not-required']);
|
|
expect(smb.notSatisfiedBy).toHaveLength(2);
|
|
expect(result.clusters[0]).toMatchObject({ state: 'present', satisfied: 2 });
|
|
const attachment = result.findings.find(item => item.occurrenceId === 'occ-pk')!;
|
|
expect(attachment).toMatchObject({ tool: 'purple-knight', sourceRuleId: 'SI000154', conditionIds: ['smb-server-signing-not-required'], clusterIds: ['credential-relay-exposure'] });
|
|
expect(result.levers.find(item => item.semanticId === 'windows.smb.server.signing_required')?.backupInstanceIds).toEqual([]);
|
|
});
|
|
|
|
it('restricts only scanner findings to the timeslot scan runs', async () => {
|
|
const queries: Array<{ sourceKind?: string[]; scanRunIds?: string[] }> = [];
|
|
const port: ContextReadPort = {
|
|
currentRevision: async () => 1,
|
|
queryUnifiedResults: async query => { queries.push(query ?? {}); return { items: [], total: 0, offset: 0, limit: 500 }; },
|
|
listGpoPolicyAnalyses: async () => [],
|
|
listReferenceRuns: async () => [],
|
|
listPolicyEvaluations: async () => [],
|
|
getEnvironmentSnapshot: async () => { throw new Error('no CIS findings in scope'); }
|
|
};
|
|
const result = await createAssessmentContext(port, { projectId: 'dummy-project', assessmentIds: ['a'], scanRunIds: ['run-1'], generatedAt: '2026-09-30T08:00:00Z' });
|
|
expect(queries.find(query => query.sourceKind?.includes('policy-observation'))?.scanRunIds).toBeUndefined();
|
|
expect(queries.find(query => query.sourceKind?.includes('scanner-finding'))?.scanRunIds).toEqual(['run-1']);
|
|
expect(result.scanRunIds).toEqual(['run-1']);
|
|
});
|
|
|
|
it('rejects scanner rules without condition or text and with an invalid optional link', () => {
|
|
const broken: AssessmentContextCatalog = structuredClone(RELEASED_ASSESSMENT_CONTEXT_CATALOG);
|
|
broken.scannerRules.push({ ...broken.scannerRules[0] });
|
|
broken.scannerRules[1] = { ...broken.scannerRules[1], conditionId: 'missing', ruleText: ' ', sources: [{ title: 'Broken', url: 'not-a-link', publisher: 'CIS' }] };
|
|
const errors = assessmentContextCatalogErrors(broken).join(' ');
|
|
expect(errors).toMatch(/duplicate scanner rule/);
|
|
expect(errors).toMatch(/unknown condition/);
|
|
expect(errors).toMatch(/native rule text/);
|
|
});
|
|
});
|
|
|
|
describe('context catalog clusters', () => {
|
|
it('gives every cluster condition at least one evidence source and accepts scanner rules from the report alone', () => {
|
|
const catalog = RELEASED_ASSESSMENT_CONTEXT_CATALOG;
|
|
expect(catalog.clusters.map(cluster => cluster.id)).toEqual([
|
|
'credential-relay-exposure', 'cloud-privileged-access-exposure', 'cloud-sign-in-protection-gaps', 'ad-privileged-account-exposure',
|
|
'kerberos-delegation-abuse', 'adcs-escalation', 'kerberoasting-exposure',
|
|
'lateral-movement-exposure', 'host-credential-privilege-exposure', 'detection-blind-spots', 'password-guessing-exposure',
|
|
'malicious-code-execution-exposure', 'ad-persistence-indicators', 'ad-reconnaissance-exposure', 'ad-recovery-gaps',
|
|
'cloud-app-consent-exposure', 'cloud-external-access-exposure', 'data-sharing-exposure', 'physical-local-access-exposure',
|
|
'patch-configuration-drift', 'email-spoofing-phishing-exposure', 'other-deviations'
|
|
]);
|
|
const semanticConditions = new Set(catalog.semantics.flatMap(semantic => semantic.conditions.map(condition => condition.id)));
|
|
const scannerConditions = new Set([...catalog.scannerRules.map(rule => rule.conditionId), ...catalog.cisSectionRules.map(rule => rule.conditionId),
|
|
...catalog.gpoVerdictRules.map(rule => rule.conditionId), ...Object.values(catalog.uncataloguedDeviationConditions)]);
|
|
// Catalogue 1.9.0: the email conditions are reached through automatic recognition until a scan report supplies the
|
|
// exact rule texts of matching scanner rules (the report is authoritative; texts are never written from memory).
|
|
const recognitionOnly = new Set(['email-sender-authentication-incomplete', 'email-threat-protection-weak', 'email-external-forwarding-allowed']);
|
|
const orphaned = catalog.clusters.flatMap(cluster => cluster.conditionIds)
|
|
.filter(id => !semanticConditions.has(id) && !scannerConditions.has(id) && !recognitionOnly.has(id));
|
|
expect(orphaned).toEqual([]);
|
|
// ATT&CK IDs only where MITRE documents the technique; delegation abuse has none and is backed by Microsoft documentation.
|
|
expect(catalog.relationships.flatMap(item => item.techniqueIds).every(id => /^T\d{4}(?:\.\d{3})?$/.test(id))).toBe(true);
|
|
// User decision 2026-09-30: the scan report is authoritative; a rule needs no documentation source.
|
|
const reportOnly: AssessmentContextCatalog = structuredClone(catalog);
|
|
reportOnly.scannerRules[0].sources = [];
|
|
expect(assessmentContextCatalogErrors(reportOnly)).toEqual([]);
|
|
});
|
|
|
|
it('evaluates scanner-only clusters from exact rule IDs without a GPO setting behind them', () => {
|
|
const finding = (tool: string, ruleId: string, outcome: string, occurrence: string) => ({
|
|
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence,
|
|
sourceTool: tool, sourceRuleId: ruleId, outcomeNormalized: outcome, title: `Synthetic ${ruleId}`
|
|
}) as unknown as UnifiedResultProjection;
|
|
const result = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1,
|
|
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
scannerFindings: [
|
|
finding('maester', 'CISA.MS.AAD.7.4', 'fail', 'occ-74'),
|
|
finding('maester', 'MT.1025', 'fail', 'occ-1025'),
|
|
finding('maester', 'CIS.M365.1.1.1', 'pass', 'occ-cloud-only'),
|
|
finding('maester', 'MT.1032', 'fail', 'occ-ga-count'),
|
|
finding('pingcastle', 'P-Delegated', 'fail', 'occ-delegated'),
|
|
finding('purple-knight', 'SI000019', 'pass', 'occ-spn')
|
|
]
|
|
});
|
|
const cluster = (id: string) => result.clusters.find(item => item.clusterId === id)!;
|
|
expect(cluster('cloud-privileged-access-exposure')).toMatchObject({ state: 'present', satisfied: 3, notSatisfied: 1 });
|
|
expect(cluster('ad-privileged-account-exposure')).toMatchObject({ state: 'undetermined', satisfied: 1, notSatisfied: 1, unknown: 11 });
|
|
expect(cluster('cloud-sign-in-protection-gaps')).toMatchObject({ state: 'undetermined', satisfied: 0, unknown: 19 });
|
|
const permanent = cluster('cloud-privileged-access-exposure').conditions.find(item => item.conditionId === 'entra-permanent-privileged-assignments')!;
|
|
expect(permanent.semanticId).toBeUndefined();
|
|
expect(permanent.relationships[0].techniqueIds).toEqual(['T1078.004']);
|
|
// User decision 2026-09-30: every fail is a finding. The Global Administrator count is worded as stated: outside the recommendation.
|
|
expect(result.findings.find(item => item.occurrenceId === 'occ-ga-count')).toMatchObject({
|
|
conditionIds: ['entra-global-admin-count-outside-range'], clusterIds: ['cloud-privileged-access-exposure'] });
|
|
// The catalogue lists delegatable admins in two clusters; the finding still counts only once.
|
|
const delegated = result.findings.find(item => item.occurrenceId === 'occ-delegated');
|
|
// One finding, one cluster (user decision 2026-10-04): the declared primary cluster counts, the other stays traceable.
|
|
expect(delegated?.clusterIds).toEqual(['kerberos-delegation-abuse']);
|
|
expect(delegated?.secondaryClusterIds).toEqual(['ad-privileged-account-exposure']);
|
|
// Permanent acceptance (user decision 2026-10-04): heatmap cells never count a finding twice.
|
|
const cells = contextHeatmap(result);
|
|
expect(cells.reduce((sum, cell) => sum + cell.scannerDeviations + cell.gpoDeviations, 0)).toBe(contextHeatmapFindingCount(result));
|
|
expect(cells.find(cell => cell.cluster.clusterId === 'ad-privileged-account-exposure')?.scannerDeviations).toBe(0);
|
|
expect(cells.find(cell => cell.cluster.clusterId === 'kerberos-delegation-abuse')?.scannerDeviations).toBe(1);
|
|
});
|
|
|
|
it('rejects a condition shared by several clusters without a declared primary cluster', () => {
|
|
const shared: AssessmentContextCatalog = structuredClone(RELEASED_ASSESSMENT_CONTEXT_CATALOG);
|
|
delete shared.primaryClusters!['ad-admins-delegatable'];
|
|
expect(assessmentContextCatalogErrors(shared)).toEqual([
|
|
'ad-admins-delegatable: shared by ad-privileged-account-exposure, kerberos-delegation-abuse without a primary cluster.']);
|
|
shared.primaryClusters!['ad-admins-delegatable'] = 'adcs-escalation';
|
|
expect(assessmentContextCatalogErrors(shared)).toEqual(['ad-admins-delegatable: primary cluster adcs-escalation does not list the condition.']);
|
|
});
|
|
});
|
|
|
|
describe('context engine declared ATT&CK references', () => {
|
|
const finding = (occurrence: string, tool: string, outcome: string) => ({
|
|
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence,
|
|
sourceTool: tool, sourceRuleId: `RULE-${occurrence}`, outcomeNormalized: outcome, title: occurrence
|
|
}) as unknown as UnifiedResultProjection;
|
|
const reference = (id: string, evidence: 'declared' | 'mentioned', status = 'active', framework = 'mitre-attack') => ({
|
|
framework, id, title: `Title ${id}`, kind: id.startsWith('TA') ? 'tactic' : 'technique', tactics: [], status, evidence,
|
|
matchedBy: 'id', catalogVersion: '19.2', catalogSha256: 'x', source: {}
|
|
});
|
|
const run = (id: string, scanRunId: string, createdAt: string, results: Array<[string, ReturnType<typeof reference>[]]>) => ({
|
|
schemaVersion: 1, id, projectId: 'dummy-project', assessmentId: 'dummy-assessment', scanRunId, recognizerVersion: '1.0.0',
|
|
inputSha256: 'x', catalogHashes: [], createdAt,
|
|
results: results.map(([occurrenceId, references]) => ({ occurrenceId, importFileId: 'f', sourcePosition: {}, references }))
|
|
});
|
|
|
|
it('groups only references the scanner declares, from the latest run of each scan, within the timeslot', () => {
|
|
const result = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1,
|
|
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
scannerFindings: [finding('occ-a', 'purple-knight', 'fail'), finding('occ-b', 'pingcastle', 'fail'), finding('occ-c', 'maester', 'pass')],
|
|
referenceRuns: [
|
|
run('old', 'run-pk', '2026-09-01T00:00:00Z', [['occ-a', [reference('TA0008', 'declared')]]]),
|
|
run('new', 'run-pk', '2026-09-20T00:00:00Z', [['occ-a', [reference('TA0006', 'declared'), reference('TA0006', 'declared'), reference('T1003', 'mentioned')]]]),
|
|
run('pc', 'run-pc', '2026-09-20T00:00:00Z', [['occ-b', [reference('TA0006', 'declared'), reference('T9999', 'declared', 'not-in-catalog'), reference('D3-AL', 'declared', 'active', 'mitre-d3fend')]]]),
|
|
run('ma', 'run-ma', '2026-09-20T00:00:00Z', [['occ-c', [reference('TA0006', 'declared')]], ['occ-outside', [reference('TA0006', 'declared')]]])
|
|
] as never
|
|
}) as ReturnType<typeof buildAssessmentContext>;
|
|
// D3FEND countermeasures the scanner declares group separately from ATT&CK (user decision 2026-09-30).
|
|
expect(result.declaredReferences.map(group => `${group.framework}:${group.id}`)).toEqual(['mitre-attack:TA0006', 'mitre-d3fend:D3-AL']);
|
|
expect(result.declaredReferences[0].findings.map(item => [item.occurrenceId, item.tool, item.outcome]))
|
|
.toEqual([['occ-a', 'purple-knight', 'fail'], ['occ-b', 'pingcastle', 'fail'], ['occ-c', 'maester', 'pass']]);
|
|
expect(result.findings.find(item => item.occurrenceId === 'occ-a')).toMatchObject({ declaredReferenceIds: ['TA0006'], clusterIds: ['other-deviations'] });
|
|
expect(result.coverage.scannerWithDeclaredReferences).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('context engine CIS control areas', () => {
|
|
const cis = (occurrence: string, ruleId: string, outcome: string, scanRunId = 'run-server', tool = 'cis-cat') => ({
|
|
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence, scanRunId,
|
|
sourceTool: tool, sourceRuleId: ruleId, outcomeNormalized: outcome, title: `${ruleId} Ensure synthetic setting`
|
|
}) as unknown as UnifiedResultProjection;
|
|
const scores = (scanRunId: string, rows: string[][]) => ({
|
|
scanRunId, sectionKey: 'cis-cat-section-scores',
|
|
tables: [{ name: 'Section scores', headers: ['Description', 'Pass', 'Fail', 'Error', 'Score'], rows }]
|
|
});
|
|
const build = (scannerFindings: UnifiedResultProjection[], cisReportSections: ReturnType<typeof scores>[]) => buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1,
|
|
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG, scannerFindings, cisReportSections
|
|
});
|
|
|
|
it('groups CIS findings by the exact number hierarchy of their own scan report, with its titles and stated counts', () => {
|
|
const result = build([
|
|
cis('occ-1', '9.1.1', 'fail'), cis('occ-2', '9.1.2', 'fail'), cis('occ-3', '9.2.1', 'pass'), cis('occ-4', '9.1.3', 'not_run'),
|
|
cis('occ-5', '9.1.1', 'pass', 'run-client'), cis('occ-6', 'SI000001', 'fail', 'run-server', 'purple-knight'), cis('occ-7', 'no-number', 'fail')
|
|
], [
|
|
scores('run-server', [['9 Windows Firewall', '1', '2', '0', '33%'], ['9.1 Domain Profile', '0', '7', '0', '0%'],
|
|
['9.2 Private Profile', 'x', '1', '0', '?'], ['9.1.1 Ensure synthetic setting', '0', '1', '0', '0%']]),
|
|
scores('run-client', [['9.1 Firewall: Domain', '3', '0', '0', '100%']])
|
|
]);
|
|
expect(result.engine.version).toBe('1.13.0');
|
|
const area = (id: string) => result.cisControlAreas.find(item => item.id === id);
|
|
expect(area('cis:run-server:9.1')).toEqual({ id: 'cis:run-server:9.1', scanRunId: 'run-server', sectionId: '9.1', parentSectionId: '9',
|
|
title: 'Domain Profile', findings: 3, fail: 2, pass: 0, other: 1, reported: { pass: 0, fail: 7 } });
|
|
expect(area('cis:run-server:9')).toMatchObject({ title: 'Windows Firewall', findings: 4, fail: 2, pass: 1, other: 1, reported: { pass: 1, fail: 2 } });
|
|
// A row whose stated counts are not whole numbers keeps its title but gets no counts.
|
|
expect(area('cis:run-server:9.2')).toMatchObject({ title: 'Private Profile', findings: 1 });
|
|
expect(area('cis:run-server:9.2')?.reported).toBeUndefined();
|
|
// Another benchmark reuses the numbering: never merged across scan runs.
|
|
expect(area('cis:run-client:9.1')).toMatchObject({ title: 'Firewall: Domain', findings: 1, pass: 1 });
|
|
expect(area('cis:run-client:9')?.title).toBeUndefined();
|
|
expect(result.findings.find(item => item.occurrenceId === 'occ-1')?.cisControlAreaIds).toEqual(['cis:run-server:9', 'cis:run-server:9.1']);
|
|
// Findings without a CIS number get no control area; as deviations they still reach the uncatalogued cluster.
|
|
expect(result.findings.filter(item => item.occurrenceId === 'occ-6' || item.occurrenceId === 'occ-7').map(item => item.cisControlAreaIds)).toEqual([[], []]);
|
|
expect(result.coverage.scannerWithCisControlArea).toBe(5);
|
|
});
|
|
|
|
it('assigns every CIS deviation through the most specific section title of its own report, else keeps it visible', () => {
|
|
const result = build([
|
|
cis('occ-print', '18.7.3', 'fail'), cis('occ-print-pass', '18.7.4', 'pass'), cis('occ-audit', '17.6.2', 'fail'),
|
|
cis('occ-autoplay', '18.10.8.1', 'fail'), cis('occ-unknown-section', '42.1.1', 'fail'), cis('occ-unknown-pass', '42.1.2', 'pass'),
|
|
{ ...cis('occ-exact', '1.1.4', 'fail'), title: "1.1.4 Ensure 'Minimum password length' is set to '14 or more character(s)'" } as UnifiedResultProjection
|
|
], [scores('run-server', [['18 Administrative Templates (Computer)', '0', '1', '0', '0%'], ['18.7 Printers', '1', '1', '0', '50%'],
|
|
['17 Advanced Audit Policy Configuration', '0', '1', '0', '0%'], ['17.6 Object Access', '0', '1', '0', '0%'],
|
|
['18.10 Windows Components', '0', '1', '0', '0%'], ['18.10.8 AutoPlay Policies', '0', '1', '0', '0%'], ['42 Unknown Area', '0', '1', '0', '0%']])]);
|
|
const observed = (occurrence: string) => result.observations.filter(item => item.occurrenceId === occurrence)
|
|
.map(item => `${item.matchedBy}:${item.conditionId}${item.sectionTitle ? `@${item.sectionTitle}` : ''}`);
|
|
expect(observed('occ-print')).toEqual(['report-section:cis-print-hardening-unmet@Printers']);
|
|
expect(observed('occ-print-pass')).toEqual(['report-section:cis-print-hardening-unmet@Printers']);
|
|
// The parent 'Advanced Audit Policy Configuration' decides when 'Object Access' has no own rule.
|
|
expect(observed('occ-audit')).toEqual(['report-section:cis-audit-policy-unmet@Advanced Audit Policy Configuration']);
|
|
expect(observed('occ-autoplay')).toEqual(['report-section:autorun-enabled@AutoPlay Policies']);
|
|
expect(observed('occ-unknown-section')).toEqual(['uncatalogued-deviation:uncatalogued-deviations-cis-cat']);
|
|
expect(observed('occ-unknown-pass')).toEqual([]);
|
|
// An exact rule ID keeps precedence over its section (the rule text in the finding title is the guard).
|
|
expect(result.observations.find(item => item.occurrenceId === 'occ-exact')?.matchedBy).toBe('rule-id');
|
|
const print = result.clusters.flatMap(item => item.conditions).find(item => item.conditionId === 'cis-print-hardening-unmet')!;
|
|
expect(print).toMatchObject({ state: 'satisfied', satisfiedBy: ['context-observation:scanner-finding:occ-print:cis-print-hardening-unmet'] });
|
|
const printFinding = result.findings.find(item => item.occurrenceId === 'occ-print');
|
|
expect(printFinding?.clusterIds).toEqual(['host-credential-privilege-exposure']);
|
|
expect(printFinding?.secondaryClusterIds).toEqual(['credential-relay-exposure']);
|
|
expect(result.coverage).toMatchObject({ scannerMappedBySection: 4, scannerUncatalogued: 1 });
|
|
});
|
|
|
|
it('leaves out a title or count the scan report states ambiguously', () => {
|
|
const result = build([cis('occ-1', '2.3.1', 'fail')], [
|
|
scores('run-server', [['2.3 Security Options', '1', '1', '0', '50%'], ['2.3 Other Title', '2', '1', '0', '66%']])
|
|
]);
|
|
const area = result.cisControlAreas.find(item => item.id === 'cis:run-server:2.3');
|
|
expect(area?.title).toBeUndefined();
|
|
expect(area?.reported).toBeUndefined();
|
|
expect(area?.findings).toBe(1);
|
|
});
|
|
|
|
it('reads the section scores only for the CIS scan runs in scope', async () => {
|
|
const snapshots: unknown[] = [];
|
|
const port: ContextReadPort = {
|
|
currentRevision: async () => 1,
|
|
queryUnifiedResults: async query => {
|
|
const items = query?.sourceKind?.includes('scanner-finding') ? [cis('occ-1', '9.1.1', 'fail'), cis('occ-2', 'SI1', 'fail', 'run-pk', 'purple-knight')] : [];
|
|
return { items, total: items.length, offset: 0, limit: 500 };
|
|
},
|
|
listGpoPolicyAnalyses: async () => [],
|
|
listReferenceRuns: async () => [],
|
|
listPolicyEvaluations: async () => [],
|
|
getEnvironmentSnapshot: async query => {
|
|
snapshots.push(query);
|
|
return { version: 1, projectId: query.projectId, scanRunIds: query.scanRunIds ?? [], generatedAt: '2026-09-30T08:00:00Z',
|
|
sections: [{ ...scores('run-server', [['9.1 Domain Profile', '0', '1', '0', '0%']]) }, { scanRunId: 'run-server', sectionKey: 'other', tables: [] }],
|
|
observations: [] } as never;
|
|
}
|
|
};
|
|
const result = await createAssessmentContext(port, { projectId: 'dummy-project', assessmentIds: ['a'], generatedAt: '2026-09-30T08:00:00Z' });
|
|
expect(snapshots).toEqual([{ projectId: 'dummy-project', scanRunIds: ['run-server'] }]);
|
|
expect(result.cisControlAreas.find(item => item.sectionId === '9.1')?.title).toBe('Domain Profile');
|
|
});
|
|
});
|
|
|
|
describe('context password guessing exposure', () => {
|
|
const account = (key: string): Target => ({ kind: 'security-template', section: 'System Access', key });
|
|
const scanner = (tool: string, ruleId: string, outcome: string, title = ruleId) => ({
|
|
resultProjectionId: `scanner-finding:${tool}-${ruleId}`, sourceKind: 'scanner-finding', sourceResultId: `${tool}-${ruleId}`,
|
|
sourceTool: tool, sourceRuleId: ruleId, outcomeNormalized: outcome, title
|
|
}) as unknown as UnifiedResultProjection;
|
|
|
|
it('counts the ASSESSOR deviation of a value-semantic setting in the heatmap, as Work and Client Info count it', () => {
|
|
// Synthetic regression (Dummy 2026-10-05): MinimumPasswordLength had a declared GPO value and a Fail verdict,
|
|
// was assigned to its cluster, but the heatmap total omitted it (474 instead of 477).
|
|
const { observations, analyses } = fixture([
|
|
{ id: 'obs-len', backup: 'backup-default-domain', target: account('MinimumPasswordLength'), values: [{ kind: 'scalar', raw: '7' }] }
|
|
]);
|
|
const identity = observations[0].policyIdentityKey!;
|
|
const verdict = (outcome: string, isDeviation: boolean) => ({
|
|
resultProjectionId: `policy-evaluation:${outcome}`, sourceKind: 'policy-evaluation', sourceResultId: `evaluation-${outcome}`,
|
|
policyScope: 'computer', policyIdentityKey: identity, outcomeNormalized: outcome, isDeviation, title: 'MinimumPasswordLength'
|
|
}) as unknown as UnifiedResultProjection;
|
|
const build = (evaluations: UnifiedResultProjection[]) => buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1,
|
|
observations, analyses, evaluations, catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG
|
|
});
|
|
const failed = build([verdict('fail', true)]);
|
|
expect(failed.declaredDeviations).toEqual([{ scope: 'computer', identityKey: identity, resultProjectionIds: ['policy-evaluation:fail'] }]);
|
|
expect(contextHeatmapFindingCount(failed)).toBe(1);
|
|
const cell = contextHeatmap(failed).find(item => item.cluster.clusterId === 'password-guessing-exposure')!;
|
|
expect(cell.gpoDeviations).toBe(1);
|
|
expect(contextHeatmap(failed).reduce((sum, item) => sum + item.gpoDeviations, 0)).toBe(1);
|
|
// A passed verdict is no deviation.
|
|
const passed = build([verdict('pass', false)]);
|
|
expect(passed.declaredDeviations).toEqual([]);
|
|
expect(contextHeatmapFindingCount(passed)).toBe(0);
|
|
});
|
|
|
|
it('reads exact account policy integers from GPO backups and links the password findings of every scanner', () => {
|
|
const { observations, analyses } = fixture([
|
|
{ id: 'obs-len', backup: 'backup-default-domain', target: account('MinimumPasswordLength'), values: [{ kind: 'scalar', raw: '7' }] },
|
|
{ id: 'obs-lockout', backup: 'backup-default-domain', target: account('LockoutBadCount'), values: [{ kind: 'scalar', raw: '0' }] },
|
|
{ id: 'obs-complexity', backup: 'backup-default-domain', target: account('PasswordComplexity'), values: [{ kind: 'scalar', raw: 'enabled' }] }
|
|
]);
|
|
const result = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1,
|
|
observations, analyses, catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
scannerFindings: [
|
|
scanner('purple-knight', 'SI000096', 'fail'), scanner('pingcastle', 'A-MinPwdLen', 'fail'),
|
|
scanner('cis-cat', '1.1.4', 'pass', "1.1.4 Ensure 'Minimum password length' is set to '14 or more character(s)'"),
|
|
scanner('cis-cat', '1.2.1', 'not_run', "1.2.1 Ensure 'Account lockout duration' is set to '15 or more minute(s)'"),
|
|
scanner('maester', 'EIDSCA.PR06', 'fail')
|
|
]
|
|
});
|
|
const cluster = result.clusters.find(item => item.clusterId === 'password-guessing-exposure')!;
|
|
const state = (id: string) => cluster.conditions.find(item => item.conditionId === id)!;
|
|
expect(cluster.state).toBe('present');
|
|
// A declared length of 7 meets both length conditions; CIS-CAT passing 14+ disagrees with the declaration.
|
|
expect(state('password-length-below-8')).toMatchObject({ state: 'satisfied', declaredVersusObserved: false });
|
|
expect(state('password-length-below-14')).toMatchObject({ state: 'satisfied', declaredVersusObserved: true });
|
|
expect(state('account-lockout-threshold-weak').state).toBe('satisfied');
|
|
// A non-integer instruction and a not-run check stay unknown.
|
|
expect(state('password-complexity-off').state).toBe('unknown');
|
|
expect(state('account-lockout-duration-short').state).toBe('unknown');
|
|
expect(state('ad-privileged-weak-password-policy').state).toBe('satisfied');
|
|
expect(result.facts.find(fact => fact.observationIds.includes('obs-complexity'))?.value).toEqual({ state: 'unknown', reason: 'unsupported-value' });
|
|
// The Entra smart lockout belongs to the cloud sign-in path, not to the AD password path.
|
|
expect(result.findings.find(item => item.occurrenceId === 'maester-EIDSCA.PR06')?.clusterIds).toEqual(['cloud-sign-in-protection-gaps']);
|
|
expect(result.settings.map(item => item.settingName).sort()).toEqual(['Account lockout threshold', 'Minimum password length', 'Password must meet complexity requirements']);
|
|
});
|
|
});
|
|
|
|
describe('context GPO verdicts', () => {
|
|
const evaluation = (id: string, identity: string, outcome: string, isDeviation: boolean, title: string) => ({
|
|
resultProjectionId: `policy-evaluation:${id}`, sourceKind: 'policy-evaluation', sourceResultId: id, policyScope: 'computer',
|
|
policyIdentityKey: identity, outcomeNormalized: outcome, isDeviation, title
|
|
}) as unknown as UnifiedResultProjection;
|
|
const observation = (id: string, identity: string) => ({ resultProjectionId: `policy-observation:${id}`, sourceKind: 'policy-observation', sourceResultId: id,
|
|
policyScope: 'computer', policyState: 'configured', policyIdentityStatus: 'resolved', policyIdentityKey: identity, policyValues: [], policyBackupInstanceId: 'backup-a' }) as unknown as UnifiedResultProjection;
|
|
const resolution = (id: string, identity: string, target: Record<string, string>) => ({ observationId: id, resolution: { status: 'resolved', identity: { id: identity, target } } });
|
|
|
|
it('uses the authoritative rolled-up GPO verdict, assigns the most specific rule and keeps every other deviation visible', () => {
|
|
const targets: Array<[string, Record<string, string>]> = [
|
|
['id-backup', { kind: 'security-template', section: 'Privilege Rights', key: 'SeBackupPrivilege' }],
|
|
['id-fw-log', { kind: 'registry-key', hive: 'HKEY_LOCAL_MACHINE', key: 'Software\\Policies\\Microsoft\\WindowsFirewall\\DomainProfile\\Logging' }],
|
|
// A subkey of SrpV2 matches the SrpV2 rule; the firewall Logging subkey wins over its profile key.
|
|
['id-srp', { kind: 'registry-key', hive: 'HKEY_LOCAL_MACHINE', key: 'Software\\Policies\\Microsoft\\Windows\\SrpV2\\Script\\rule-1' }],
|
|
['id-other', { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'Software\\Policies\\Synthetic', valueName: 'Value' }],
|
|
['id-passed', { kind: 'security-template', section: 'System Access', key: 'LockoutDuration' }]
|
|
];
|
|
const result = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1,
|
|
observations: targets.map(([identity]) => observation(`obs-${identity}`, identity)),
|
|
analyses: [{ run: { id: 'a' }, resolutions: targets.map(([identity, target]) => resolution(`obs-${identity}`, identity, target)) }] as never,
|
|
evaluations: [
|
|
// One failing member makes the whole setting a deviation, as ASSESSOR rolls it up.
|
|
evaluation('e1', 'id-backup', 'pass', false, 'Privilege Rights · SeBackupPrivilege'), evaluation('e2', 'id-backup', 'fail', true, 'Privilege Rights · SeBackupPrivilege'),
|
|
evaluation('e3', 'id-fw-log', 'fail', true, 'DomainProfile Logging'), evaluation('e4', 'id-srp', 'fail', true, 'SrpV2 Script'),
|
|
evaluation('e5', 'id-other', 'fail', true, 'Synthetic value'), evaluation('e6', 'id-passed', 'pass', false, 'System Access · LockoutDuration'),
|
|
evaluation('e7', 'id-not-configured', 'not_evaluated', false, 'Not configured')
|
|
],
|
|
catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG
|
|
});
|
|
expect(result.evaluations.map(item => `${item.identityKey}:${item.verdict}:${item.conditionId}`)).toEqual([
|
|
'id-backup:deviation:gpo-privileged-rights-deviate',
|
|
'id-fw-log:deviation:gpo-firewall-logging-deviates',
|
|
'id-other:deviation:uncatalogued-deviations-gpo',
|
|
'id-passed:passed:gpo-lockout-timing-deviates',
|
|
'id-srp:deviation:gpo-application-control-deviates'
|
|
]);
|
|
expect(result.coverage).toMatchObject({ gpoEvaluatedSettings: 6, gpoMappedByVerdict: 4, gpoUncatalogued: 1 });
|
|
const setting = (identity: string) => result.settings.find(item => item.identityKey === identity);
|
|
expect(setting('id-backup')).toMatchObject({ clusterIds: ['host-credential-privilege-exposure'], evaluationIds: ['context-evaluation:computer:id-backup'] });
|
|
expect(setting('id-other')?.clusterIds).toEqual(['other-deviations']);
|
|
expect(setting('id-backup')?.semanticId).toBeUndefined();
|
|
const conditions = result.clusters.flatMap(item => item.conditions);
|
|
expect(conditions.find(item => item.conditionId === 'gpo-lockout-timing-deviates')?.state).toBe('not-satisfied');
|
|
expect(conditions.find(item => item.conditionId === 'gpo-privileged-rights-deviate')?.state).toBe('satisfied');
|
|
});
|
|
|
|
it('catalogues convenience PIN sign-in and the redirected default OU by their exact identities (catalog 1.11.0)', () => {
|
|
const pinTarget = { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'SOFTWARE\\Policies\\Microsoft\\Windows\\System', valueName: 'AllowDomainPINLogon' };
|
|
const siblingTarget = { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'SOFTWARE\\Policies\\Microsoft\\Windows\\System', valueName: 'SyntheticSibling' };
|
|
const result = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-13T08:00:00Z', repositoryRevision: 1,
|
|
observations: [observation('obs-pin', 'id-pin'), observation('obs-sibling', 'id-sibling')],
|
|
analyses: [{ run: { id: 'a' }, resolutions: [resolution('obs-pin', 'id-pin', pinTarget), resolution('obs-sibling', 'id-sibling', siblingTarget)] }] as never,
|
|
evaluations: [evaluation('e-pin', 'id-pin', 'fail', true, 'AllowDomainPINLogon'), evaluation('e-sibling', 'id-sibling', 'fail', true, 'SyntheticSibling')],
|
|
scannerFindings: [{
|
|
resultProjectionId: 'scanner-finding:occ-ou', sourceKind: 'scanner-finding', sourceResultId: 'occ-ou', sourceTool: 'pingcastle',
|
|
sourceRuleId: 'S-DefaultOUChanged', outcomeNormalized: 'fail', title: 'Check if default OU location has been changed within the domain.',
|
|
sourceObservedAt: '2026-10-01T00:00:00Z'
|
|
}] as unknown as UnifiedResultProjection[],
|
|
catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG
|
|
});
|
|
// Only the exact value name is catalogued; a sibling value under the same key stays an uncatalogued deviation.
|
|
expect(result.evaluations.map(item => `${item.identityKey}:${item.conditionId}`)).toEqual([
|
|
'id-pin:gpo-convenience-pin-sign-in-deviates', 'id-sibling:uncatalogued-deviations-gpo']);
|
|
expect(result.settings.find(item => item.identityKey === 'id-pin')?.clusterIds).toEqual(['physical-local-access-exposure']);
|
|
expect(result.observations.find(item => item.occurrenceId === 'occ-ou')).toMatchObject({ matchedBy: 'rule-id', conditionId: 'ad-default-container-redirected' });
|
|
expect(result.findings.find(item => item.occurrenceId === 'occ-ou')?.clusterIds).toEqual(['detection-blind-spots']);
|
|
});
|
|
|
|
it('places the catalog 1.12.0 rules of every kind by exact identity, with trusts in recovery', () => {
|
|
const targets: Array<[string, Record<string, string>]> = [
|
|
['id-rdp', { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'Software\\Policies\\Microsoft\\Windows NT\\Terminal Services', valueName: 'fAllowFullControl' }],
|
|
['id-anon', { kind: 'security-template', section: 'System Access', key: 'LSAAnonymousNameLookup' }],
|
|
['id-lmhash', { kind: 'security-template', section: 'Registry Values', key: 'MACHINE\\System\\CurrentControlSet\\Control\\Lsa\\NoLMHash' }],
|
|
['id-printers', { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint', valueName: 'RestrictDriverInstallationToAdministrators' }],
|
|
['id-smartscreen', { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'Software\\Policies\\Microsoft\\Windows\\System', valueName: 'EnableSmartScreen' }]
|
|
];
|
|
const finding = (tool: string, ruleId: string, occurrence: string, title: string) => ({
|
|
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence, sourceTool: tool,
|
|
sourceRuleId: ruleId, outcomeNormalized: 'fail', title, sourceObservedAt: '2026-10-01T00:00:00Z'
|
|
}) as unknown as UnifiedResultProjection;
|
|
const result = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-13T08:00:00Z', repositoryRevision: 1,
|
|
observations: targets.map(([identity]) => observation(`obs-${identity}`, identity)),
|
|
analyses: [{ run: { id: 'a' }, resolutions: targets.map(([identity, target]) => resolution(`obs-${identity}`, identity, target)) }] as never,
|
|
evaluations: targets.map(([identity], index) => evaluation(`e${index}`, identity, 'fail', true, identity)),
|
|
scannerFindings: [
|
|
finding('purple-knight', 'SI000027', 'occ-asrep', 'Users with Kerberos pre-authentication disabled'),
|
|
finding('purple-knight', 'SI000088', 'occ-trust', 'Outbound forest trust with SID History enabled'),
|
|
finding('maester', 'EIDSCA.AM03', 'occ-number-matching', 'Authentication Method - Microsoft Authenticator - Require number matching for push notifications.')
|
|
],
|
|
catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG
|
|
});
|
|
const setting = (identity: string) => result.settings.find(item => item.identityKey === identity)?.clusterIds;
|
|
expect(setting('id-rdp')).toEqual(['lateral-movement-exposure']);
|
|
expect(setting('id-anon')).toEqual(['ad-reconnaissance-exposure']);
|
|
expect(setting('id-lmhash')).toEqual(['password-guessing-exposure']);
|
|
// The Microsoft baseline area table places the printer policy tree in credential relay.
|
|
expect(setting('id-printers')).toEqual(['credential-relay-exposure']);
|
|
// A value-specific rule under Windows\System does not take over its sibling AllowDomainPINLogon.
|
|
expect(setting('id-smartscreen')).toEqual(['malicious-code-execution-exposure']);
|
|
const placed = (occurrence: string) => result.findings.find(item => item.occurrenceId === occurrence)?.clusterIds;
|
|
expect(placed('occ-asrep')).toEqual(['kerberoasting-exposure']);
|
|
// User decision 2026-10-12: trusts belong to recovery.
|
|
expect(placed('occ-trust')).toEqual(['ad-recovery-gaps']);
|
|
expect(placed('occ-number-matching')).toEqual(['cloud-sign-in-protection-gaps']);
|
|
expect(result.coverage).toMatchObject({ gpoUncatalogued: 0, scannerUncatalogued: 0 });
|
|
});
|
|
});
|