253 lines
19 KiB
TypeScript
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([]);
|
|
});
|
|
});
|