Assessor_2.0/tests/assessment-context/context-assignment.test.ts

253 lines
19 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { RELEASED_ASSESSMENT_CONTEXT_CATALOG as catalog } from '../../src/assessment-context/context-catalog';
import { buildAssessmentContext } from '../../src/assessment-context/context-engine';
// User decision 2026-10-01: every deviation receives context in a fixed, deterministic order of tiers; recognized and
// estimated assignments count for their clusters and always carry confidence, method and evidence.
const finding = (occurrence: string, tool: string, outcome: string, title: string, extra: Partial<UnifiedResultProjection> = {}) => ({
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence, scanRunId: 'run-a',
sourceTool: tool, sourceRuleId: `RULE-${occurrence}`, outcomeNormalized: outcome, title, categories: [], ...extra
}) as unknown as UnifiedResultProjection;
const category = (label: string) => [{ namespace: 'x', categoryCode: label, categoryLabel: label, categoryType: 'source' as const, isPrimary: true }];
const reference = (id: string) => ({
framework: 'mitre-attack', id, title: id, kind: id.startsWith('TA') ? 'tactic' : 'technique', tactics: [], status: 'active', evidence: 'declared',
matchedBy: 'id', catalogVersion: '19.2', catalogSha256: 'x', source: {}
});
const build = (scannerFindings: UnifiedResultProjection[], referenceRuns: unknown[] = []) => buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 1,
observations: [], analyses: [], catalog, scannerFindings, referenceRuns: referenceRuns as never
});
const attachment = (result: ReturnType<typeof build>, occurrence: string) => result.findings.find(item => item.occurrenceId === occurrence);
const tier = (result: ReturnType<typeof build>, occurrence: string) => result.recognitions
.filter(item => item.occurrenceId === occurrence).map(item => `${item.clusterId}:${item.confidence}:${item.method}`);
describe('context engine assignment tiers', () => {
const findings = [
finding('occ-title', 'purple-knight', 'fail', 'Users with SPN defined'),
finding('occ-description', 'purple-knight', 'fail', 'Synthetic indicator A',
{ description: 'Accounts can be roasted offline.', recommendation: 'Use gMSA accounts and remove RC4 from service accounts.' }),
finding('occ-anchor', 'cis-cat', 'fail', "Ensure 'Network security: LAN Manager authentication level' is reviewed"),
finding('occ-category-0', 'pingcastle', 'fail', 'Kerberoast service accounts', { categories: category('Synthetic Category') }),
finding('occ-category-1', 'pingcastle', 'fail', 'Users with SPN defined in forest', { categories: category('Synthetic Category') }),
finding('occ-category-2', 'pingcastle', 'fail', 'Quarterly widget inventory', { categories: category('Synthetic Category') }),
finding('occ-none', 'maester', 'fail', 'Unrelated widget check'),
finding('occ-passed', 'purple-knight', 'pass', 'Users with SPN defined')
];
it('places each deviation in the first tier that yields a cluster and keeps only signal-free ones uncatalogued', () => {
const result = build(findings);
expect(tier(result, 'occ-title')).toEqual(['kerberoasting-exposure:recognized:title-term']);
expect(tier(result, 'occ-description')).toEqual(['kerberoasting-exposure:recognized:description-terms']);
expect(tier(result, 'occ-anchor')).toEqual(['credential-relay-exposure:recognized:title-term']);
expect(tier(result, 'occ-category-2')).toEqual(['kerberoasting-exposure:estimated:scanner-category']);
expect(tier(result, 'occ-none')).toEqual([]);
expect(result.observations.filter(item => item.matchedBy === 'uncatalogued-deviation').map(item => item.occurrenceId)).toEqual(['occ-none']);
// A passed result is no deviation and receives no automatic assignment.
expect(tier(result, 'occ-passed')).toEqual([]);
});
it('counts automatic assignments for their clusters with their confidence', () => {
const result = build(findings);
expect(attachment(result, 'occ-title')).toMatchObject({ clusterIds: ['kerberoasting-exposure'], clusterConfidence: { 'kerberoasting-exposure': 'recognized' } });
expect(attachment(result, 'occ-category-2')).toMatchObject({ clusterIds: ['kerberoasting-exposure'], clusterConfidence: { 'kerberoasting-exposure': 'estimated' } });
expect(attachment(result, 'occ-none')).toMatchObject({ clusterIds: ['other-deviations'] });
expect(attachment(result, 'occ-none')?.clusterConfidence).toBeUndefined();
expect(result.recognitions.find(item => item.occurrenceId === 'occ-category-2')).toMatchObject({ matchedText: 'Synthetic Category', assertion: 'automatically-estimated' });
expect(result.coverage).toMatchObject({ scannerRecognized: 5, scannerEstimated: 1, scannerUncatalogued: 1, uncataloguedWithoutRecognition: 1 });
});
it('yields identical assignments for identical input in any order', () => {
const forward = build(findings);
const reversed = build([...findings].reverse());
expect(reversed.recognitions).toEqual(forward.recognitions);
expect(reversed.observations).toEqual(forward.observations);
expect([...reversed.findings].sort((left, right) => left.occurrenceId.localeCompare(right.occurrenceId)))
.toEqual([...forward.findings].sort((left, right) => left.occurrenceId.localeCompare(right.occurrenceId)));
});
it('estimates from the most similar placed finding only when two informative words are shared', () => {
const result = build([
finding('occ-known', 'purple-knight', 'fail', 'Users with SPN defined'),
finding('occ-similar', 'pingcastle', 'fail', 'Service principal names registered on interactive users', { description: 'Users with SPN defined can be attacked.' }),
finding('occ-one-word', 'maester', 'fail', 'Unrelated cloud widget')
]);
expect(tier(result, 'occ-similar')).toEqual(['kerberoasting-exposure:estimated:nearest-finding']);
expect(tier(result, 'occ-one-word')).toEqual([]);
});
it('keeps tier 1 limits: a declared technique assigns only when unambiguous, a tactic never', () => {
const run = [{
schemaVersion: 1, id: 'refs', projectId: 'dummy-project', assessmentId: 'dummy-assessment', scanRunId: 'run-a', recognizerVersion: '1.0.0',
inputSha256: 'x', catalogHashes: [], createdAt: '2026-10-01T00:00:00Z',
results: [['occ-relay', [reference('T1557.001')]], ['occ-tactic', [reference('TA0006')]], ['occ-ambiguous', [reference('T1078.004')]]]
.map(([occurrenceId, references]) => ({ occurrenceId, importFileId: 'f', sourcePosition: {}, references }))
}];
const result = build([finding('occ-relay', 'pingcastle', 'fail', 'Quarterly widget relay'), finding('occ-tactic', 'purple-knight', 'fail', 'Tactic declaration sample'),
finding('occ-ambiguous', 'maester', 'fail', 'Ambiguous technique sample')], run);
expect(attachment(result, 'occ-relay')?.clusterIds).toEqual(['credential-relay-exposure']);
expect(attachment(result, 'occ-relay')?.clusterConfidence).toBeUndefined();
expect(result.techniqueAssignments.map(item => item.occurrenceId)).toEqual(['occ-relay']);
expect(attachment(result, 'occ-tactic')?.clusterIds).toEqual(['other-deviations']);
expect(attachment(result, 'occ-ambiguous')?.clusterIds).toEqual(['other-deviations']);
});
});
describe('email cluster, vocabulary 1.1.0 and rule families (2026-10-01)', () => {
it('recognizes sender authentication, phishing protection and forwarding as the email cluster', () => {
const result = build([
finding('occ-dkim', 'maester', 'fail', '(L1) Ensure that DKIM is enabled for all Exchange Online Domains'),
finding('occ-forward', 'maester', 'fail', 'Automatic forwarding to external domains SHALL be disabled'),
finding('occ-safe-links', 'maester', 'fail', 'Safe Links SHOULD be enabled for email messages'),
finding('occ-phishing-mfa', 'maester', 'fail', 'Phishing-resistant MFA SHALL be enforced for all users')
]);
for (const occurrence of ['occ-dkim', 'occ-forward', 'occ-safe-links']) {
expect(tier(result, occurrence)).toEqual(['email-spoofing-phishing-exposure:recognized:title-term']);
}
// "Phishing-resistant MFA" is a sign-in control, not a mail filter.
expect(tier(result, 'occ-phishing-mfa')).toEqual(['cloud-sign-in-protection-gaps:recognized:title-term']);
});
it('estimates from a declared rule family when nothing else places the deviation', () => {
const result = build([
finding('occ-family', 'maester', 'fail', 'Synthetic quarterly widget', { sourceRuleId: 'CIS.M365.2.9.9' }),
finding('occ-teams', 'maester', 'fail', 'Synthetic widget review', { sourceRuleId: 'MS.TEAMS.9.9' }),
finding('occ-other-tool', 'pingcastle', 'fail', 'Synthetic gadget', { sourceRuleId: 'CIS.M365.2.9.9' })
]);
expect(tier(result, 'occ-family')).toEqual(['email-spoofing-phishing-exposure:estimated:scanner-category']);
expect(result.recognitions.find(item => item.occurrenceId === 'occ-family')?.matchedText).toContain('CIS M365 section 2');
expect(tier(result, 'occ-teams')).toEqual(['cloud-external-access-exposure:estimated:scanner-category']);
// A family belongs to its tool; the same ID from another tool means nothing.
expect(tier(result, 'occ-other-tool')).toEqual([]);
});
it('never contradicts a catalogued Maester rule with its rule family', async () => {
const { CONTEXT_RULE_FAMILIES, ruleFamilyOf } = await import('../../src/assessment-context/context-recognition');
const clustersOf = (conditionId: string) => catalog.clusters.filter(cluster => cluster.conditionIds.includes(conditionId)).map(cluster => cluster.id);
const contradictions = catalog.scannerRules.flatMap(rule => {
const family = ruleFamilyOf(rule.tool, rule.ruleId, CONTEXT_RULE_FAMILIES);
return family && !clustersOf(rule.conditionId).includes(family.clusterId) ? [`${rule.ruleId} -> ${family.clusterId}`] : [];
});
expect(contradictions).toEqual([]);
expect(CONTEXT_RULE_FAMILIES.every(family => catalog.clusters.some(cluster => cluster.id === family.clusterId))).toBe(true);
});
});
describe('complete record and GPO technical signals (2026-10-01)', () => {
it('reads every original field of a finding: one term in a structured field, two terms in long text', () => {
const result = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 1,
observations: [], analyses: [], catalog,
scannerFindings: [
finding('occ-tag', 'maester', 'fail', 'Alpha sample entry'),
finding('occ-script', 'maester', 'fail', 'Beta probe record'),
finding('occ-one-term-in-script', 'maester', 'fail', 'Gamma trial item')
],
recordFields: [
{ occurrenceId: 'occ-tag', fields: [{ label: 'Block', value: 'Maester/Exchange' }, { label: 'Tag', value: 'Maester, Exchange, MyCustomApps' }] },
{ occurrenceId: 'occ-script', fields: [{ label: 'TestScript', value: `${'x'.repeat(300)} Get-Mailbox | Where DKIM signing disabled; mail flow rule allows external forwarding` }] },
{ occurrenceId: 'occ-one-term-in-script', fields: [{ label: 'TestScript', value: `${'x'.repeat(300)} checks the DKIM selector` }] }
]
});
expect(tier(result, 'occ-tag')).toEqual(['cloud-app-consent-exposure:recognized:title-term']);
expect(result.recognitions.find(item => item.occurrenceId === 'occ-tag')?.matchedText).toBe('Tag: MyCustomApps');
expect(tier(result, 'occ-script')).toEqual(['email-spoofing-phishing-exposure:recognized:description-terms']);
// One term in a long script is not enough.
expect(tier(result, 'occ-one-term-in-script')).toEqual([]);
});
it('uses the registry path and Microsoft baseline template names of a GPO setting', () => {
const evaluation = (identity: string, title: string) => ({
resultProjectionId: `policy-evaluation:${identity}`, sourceKind: 'policy-evaluation', sourceResultId: identity, policyScope: 'computer',
policyIdentityKey: identity, isDeviation: true, outcomeNormalized: 'fail', title
}) as unknown as UnifiedResultProjection;
const result = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 1,
observations: [], analyses: [], catalog,
evaluations: [evaluation('identity-power', 'ACSettingIndex'), evaluation('identity-template', 'SyntheticValueName')],
identityTargets: [
{ id: 'identity-power', scope: 'computer', target: { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'SOFTWARE\\Policies\\Microsoft\\Power\\PowerSettings\\00000000-0000-0000-0000-000000000000', valueName: 'ACSettingIndex' } },
{ id: 'identity-template', scope: 'computer', target: { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'SOFTWARE\\Policies\\Synthetic', valueName: 'SyntheticValueName' } }
],
baselineTemplates: [{ identityKey: 'identity-template', names: ['MSFT Windows 11 - BitLocker'] }]
});
const settingTier = (identity: string) => result.recognitions.filter(item => item.identityKey === identity).map(item => `${item.clusterId}:${item.confidence}:${item.method}`);
expect(settingTier('identity-power')).toEqual(['physical-local-access-exposure:recognized:technical-anchor']);
expect(settingTier('identity-template')).toEqual(['physical-local-access-exposure:recognized:technical-anchor']);
expect(result.recognitions.find(item => item.identityKey === 'identity-template')?.matchedText).toBe('BitLocker');
expect(result.settings.find(item => item.identityKey === 'identity-power')).toMatchObject({ clusterIds: ['physical-local-access-exposure'] });
});
});
describe('everything Raw Data shows for a GPO setting (2026-10-01)', () => {
it('reads the structured fields of the setting record when name and path carry no signal', () => {
const evaluation = {
resultProjectionId: 'policy-evaluation:identity-raw', sourceKind: 'policy-evaluation', sourceResultId: 'identity-raw', policyScope: 'computer',
policyIdentityKey: 'identity-raw', isDeviation: true, outcomeNormalized: 'fail', title: 'SyntheticValueName'
} as unknown as UnifiedResultProjection;
const result = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 1,
observations: [], analyses: [], catalog, evaluations: [evaluation],
settingRecordFields: [{ identityKey: 'identity-raw', scope: 'computer', fields: [
{ label: 'Title', value: 'SyntheticValueName' }, { label: 'Baseline', value: 'Synthetic Security Baseline — MSFT Synthetic - BitLocker' }] }]
});
expect(result.recognitions.map(item => [item.clusterId, item.confidence, item.method, item.matchedText])).toEqual([
['physical-local-access-exposure', 'recognized', 'title-term', 'Baseline: BitLocker']]);
});
});
describe('Raw Data read in the backend run (2026-10-01)', () => {
it('reads Raw Data only when needed and keeps the context when that read fails', async () => {
const { createAssessmentContext } = await import('../../src/assessment-context/context-run');
const uncatalogued = finding('occ-raw', 'maester', 'fail', 'Alpha sample entry');
const port = (loadAssessmentModel: () => Promise<unknown>) => ({
currentRevision: async () => 1,
queryUnifiedResults: async (query?: { sourceKind?: string[] }) => {
const items = query?.sourceKind?.includes('scanner-finding') ? [uncatalogued] : [];
return { items, total: items.length, offset: 0, limit: 500 };
},
listGpoPolicyAnalyses: async () => [], listReferenceRuns: async () => [], listPolicyEvaluations: async () => [],
getEnvironmentSnapshot: async () => ({ sections: [] }),
listAssessments: async () => [{ id: 'dummy-assessment', projectId: 'dummy-project', name: 'Dummy' }],
loadAssessmentModel
});
const scope = { projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z' };
const failing = await createAssessmentContext(port(async () => { throw new Error('pool exhausted'); }) as never, scope);
expect(failing.rawDataRead).toBe(false);
expect(failing.findings.find(item => item.occurrenceId === 'occ-raw')?.clusterIds).toEqual(['other-deviations']);
});
});
describe('registry policy areas of the Microsoft baselines (2026-10-01)', () => {
const path = (...segments: string[]) => segments.join(String.fromCharCode(92));
it('recognizes Internet Explorer security zone actions as malicious code execution from the registry path', () => {
const evaluation = {
resultProjectionId: 'policy-evaluation:identity-zone', sourceKind: 'policy-evaluation', sourceResultId: 'identity-zone', policyScope: 'computer',
policyIdentityKey: 'identity-zone', isDeviation: true, outcomeNormalized: 'fail', title: '1201'
} as unknown as UnifiedResultProjection;
const result = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 1,
observations: [], analyses: [], catalog, evaluations: [evaluation],
identityTargets: [{ id: 'identity-zone', scope: 'computer', target: { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE',
key: path('Software', 'Policies', 'Microsoft', 'Windows', 'CurrentVersion', 'Internet Settings', 'Zones', '2'), valueName: '1201' } }]
});
expect(result.recognitions.map(item => [item.clusterId, item.confidence, item.method])).toEqual([
['malicious-code-execution-exposure', 'recognized', 'technical-anchor']]);
});
it('agrees with every registry target the catalogue assigns, and never maps a whole product tree', async () => {
const { recognizeClusters } = await import('../../src/assessment-context/context-recognition');
const clustersOf = (conditionId: string) => catalog.clusters.filter(cluster => cluster.conditionIds.includes(conditionId)).map(cluster => cluster.id);
const targets = catalog.gpoVerdictRules.flatMap(rule => {
const target = rule.target as { mechanism: string; key?: string; valueName?: string };
return target.mechanism === 'registry-key' && target.key ? [{ path: [target.key, target.valueName].filter(Boolean).join(String.fromCharCode(92)), clusters: clustersOf(rule.conditionId) }] : [];
});
expect(targets.length).toBeGreaterThan(5);
const contradictions = targets.flatMap(item => recognizeClusters(item.path)
.filter(match => !item.clusters.includes(match.clusterId)).map(match => `${item.path} -> ${match.clusterId}`));
expect(contradictions).toEqual([]);
// The catalogue places an Office setting under host credentials; an Office path alone therefore says nothing.
expect(recognizeClusters(path('Software', 'Policies', 'Microsoft', 'Office', '16.0', 'Common', 'SyntheticSetting'))).toEqual([]);
});
});