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

125 lines
10 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 { ASSESSMENT_CONTEXT_ENGINE, ASSESSMENT_CONTEXT_ENGINE_VOCABULARY, buildAssessmentContext } from '../../src/assessment-context/context-engine';
import { CONTEXT_RECOGNITION_TERMS, CONTEXT_RECOGNITION_VOCABULARY, recognizeClusters } from '../../src/assessment-context/context-recognition';
// User decision 2026-10-01: the context engine recognizes context on its own, as validly as possible.
// Tier 1 uses ATT&CK techniques the scanner declares; tier 2 suggests clusters from distinctive terms of the rule text.
const clustersOf = (conditionId: string) => catalog.clusters.filter(cluster => cluster.conditionIds.includes(conditionId)).map(cluster => cluster.id);
/** Every catalogued text with the clusters the catalogue assigns it: the ground truth of the vocabulary. */
const truth = [
...catalog.scannerRules.map(rule => ({ text: rule.ruleText, clusters: clustersOf(rule.conditionId) })),
...catalog.cisSectionRules.map(rule => ({ text: rule.sectionTitle, clusters: clustersOf(rule.conditionId) })),
...catalog.semantics.map(semantic => ({ text: semantic.settingName, clusters: [...new Set(semantic.conditions.flatMap(condition => clustersOf(condition.id)))] }))
];
describe('context recognition vocabulary (tier 2)', () => {
it('never contradicts the catalogue: every recognized cluster of a catalogued text is one the catalogue assigns', () => {
const contradictions = truth.flatMap(item => recognizeClusters(item.text)
.filter(match => !item.clusters.includes(match.clusterId))
.map(match => `${item.text} -> ${match.clusterId} (${match.matchedText})`));
expect(contradictions).toEqual([]);
});
it('recognizes most catalogued texts, so it is useful for the ones not yet catalogued', () => {
const recognized = truth.filter(item => recognizeClusters(item.text).some(match => item.clusters.includes(match.clusterId)));
expect(truth.length).toBeGreaterThan(300);
expect(recognized.length / truth.length).toBeGreaterThanOrEqual(0.7);
});
it('only names real clusters, never the uncatalogued cluster, with unique term IDs', () => {
const clusterIds = new Set(catalog.clusters.map(cluster => cluster.id));
expect(CONTEXT_RECOGNITION_TERMS.filter(term => !clusterIds.has(term.clusterId))).toEqual([]);
expect(CONTEXT_RECOGNITION_TERMS.filter(term => term.clusterId === 'other-deviations')).toEqual([]);
expect(new Set(CONTEXT_RECOGNITION_TERMS.map(term => term.id)).size).toBe(CONTEXT_RECOGNITION_TERMS.length);
});
it('suggests the expected clusters for public scanner rule titles that are not catalogued yet', () => {
const named = (text: string) => recognizeClusters(text).map(match => match.clusterId);
expect(named('Users with SPN defined')).toEqual(['kerberoasting-exposure']);
expect(named('Users with Kerberos pre-authentication disabled')).toEqual(['kerberoasting-exposure']);
expect(named('Objects with constrained delegation configured')).toEqual(['kerberos-delegation-abuse']);
expect(named('Accounts with Constrained Delegation configured to ghost SPN')).toEqual(['kerberos-delegation-abuse']);
expect(named('Non-default principals with DC Sync rights on the domain')).toEqual(['ad-privileged-account-exposure']);
expect(named('Domains with obsolete functional levels')).toEqual(['patch-configuration-drift']);
// Change indicators count as possible persistence traces (user decision 2026-10-01: context for every deviation).
expect(named('AD objects created within the last 10 days')).toEqual(['ad-persistence-indicators']);
expect(named('SYSVOL Executable Changes')).toEqual(['ad-persistence-indicators']);
// Words that only look related still stay unrecognized instead of being guessed.
expect(named('Relatively high number of inactive computer accounts')).toEqual([]);
});
it('does not repeat the false matches measured on real CIS benchmark titles (2026-10-01)', () => {
const named = (text: string) => recognizeClusters(text).map(match => match.clusterId);
// User rights and rename options only share a word with another attack surface.
expect(named("Ensure 'Manage auditing and security log' is set to 'Administrators' (DC only)")).toEqual([]);
expect(named("Ensure 'Generate security audits' is set to 'LOCAL SERVICE, NETWORK SERVICE'")).toEqual([]);
expect(named("Ensure 'Deny log on locally' to include 'Guests'")).toEqual([]);
expect(named("Ensure 'Allow log on through Remote Desktop Services' is set to 'Administrators' (DC only)")).toEqual([]);
expect(named("Configure 'Accounts: Rename administrator account'")).toEqual([]);
expect(named("Configure 'Accounts: Rename guest account'")).toEqual([]);
expect(named("Ensure 'Audit Account Lockout' is set to include 'Failure'")).toEqual(['detection-blind-spots']);
expect(named("Ensure 'Audit client does not support signing' is set to 'Enabled'")).toEqual([]);
expect(named("Ensure 'Set TLS/SSL security policy for IPP printers: Disallow invalid certificate authority'")).toEqual([]);
expect(named("Ensure 'Network security: Minimum session security for NTLM SSP based (including secure RPC) clients'")).toEqual(['credential-relay-exposure']);
expect(named("Ensure 'MSS: (NoNameReleaseOnDemand) Allow the computer to ignore NetBIOS name release requests'")).toEqual([]);
expect(named("Ensure 'Enable OneSettings Auditing' is set to 'Enabled'")).toEqual([]);
});
});
describe('vocabulary 1.1.0 for security templates, user rights and cloud findings (2026-10-01)', () => {
const named = (text: string) => recognizeClusters(text).map(match => match.clusterId);
it('recognizes public setting and rule names that carried no cluster word before', () => {
expect(named('System Access · LockoutBadCount')).toEqual(['password-guessing-exposure']);
expect(named('Privilege Rights · SeTcbPrivilege')).toEqual(['host-credential-privilege-exposure']);
expect(named('Privilege Rights · SeDenyNetworkLogonRight')).toEqual(['lateral-movement-exposure']);
expect(named('Privilege Rights · SeEnableDelegationPrivilege')).toEqual(['kerberos-delegation-abuse']);
expect(named('POL_AdmPwd_Enabled')).toEqual(['lateral-movement-exposure']);
expect(named('Presence of non-supported versions of Windows 10 or Windows 11')).toEqual(['patch-configuration-drift']);
expect(named('The password used in Azure AD SSO has not been changed for at least one year')).toEqual(['cloud-sign-in-protection-gaps']);
expect(named('GPO linking delegation at the domain level')).toEqual(['ad-privileged-account-exposure']);
expect(named('Security defaults not enabled')).toEqual(['cloud-sign-in-protection-gaps']);
});
it('places the public scanner titles measured as unplaced on 2026-10-12 (vocabulary 1.2.0)', () => {
expect(named('Full Scan Removable Drives should be enabled.')).toEqual(['malicious-code-execution-exposure']);
expect(named('Full Scan Mapped Drives should be disabled for performance.')).toEqual(['malicious-code-execution-exposure']);
expect(named('Cleaned Malware should be retained for at least 30 days.')).toEqual(['malicious-code-execution-exposure']);
expect(named('Number of DC inactive: 2')).toEqual(['ad-recovery-gaps']);
// User decision 2026-10-12: trusts belong to recovery.
expect(named('At least one inactive trust has been found: 1')).toEqual(['ad-recovery-gaps']);
expect(named('Risky delegated permission on AD OUs')).toEqual(['ad-privileged-account-exposure']);
expect(named('Set a honeytoken account.')).toEqual(['detection-blind-spots']);
expect(named('Configure VPN integration.')).toEqual(['detection-blind-spots']);
// User decision 2026-10-12: governance and workload identity findings use the existing cloud clusters.
expect(named('Pending approvals for Critical Asset Management should not be present.')).toEqual(['cloud-privileged-access-exposure']);
expect(named('No catalog should contain resources without any associated access packages.')).toEqual(['cloud-privileged-access-exposure']);
expect(named('Review who can change attributes used by dynamic group rules.')).toEqual(['cloud-privileged-access-exposure']);
expect(named('Convert your application(s) to Managed Identity.')).toEqual(['cloud-app-consent-exposure']);
expect(named('Resolve unsecure account attributes.')).toEqual(['password-guessing-exposure']);
expect(named('Resolve unsecure domain configurations.')).toEqual(['credential-relay-exposure']);
// Neighbouring readings stay with their own cluster or unplaced.
expect(named('Ensure the default malware filter policy blocks common attachment types')).not.toContain('malicious-code-execution-exposure');
expect(named('Trust computer and user accounts for delegation')).not.toContain('ad-recovery-gaps');
expect(named('Trusted locations should be configured for Conditional Access')).toEqual([]);
expect(named('Apps with delegated permissions on Microsoft Graph')).not.toContain('ad-privileged-account-exposure');
expect(named('Relatively high number of inactive computer accounts')).toEqual([]);
});
it('publishes every vocabulary release with a new engine version, so stored work-package snapshots refresh (2026-10-12)', () => {
// Regression: vocabulary 1.2.0 placed findings in clusters while the stored snapshot kept them unassigned,
// because that snapshot is keyed by engine and catalogue version only.
expect(ASSESSMENT_CONTEXT_ENGINE_VOCABULARY).toBe(CONTEXT_RECOGNITION_VOCABULARY.version);
expect(ASSESSMENT_CONTEXT_ENGINE.version).toBe('1.12.0');
});
it('keeps conditional access trusted locations apart from Office trusted locations', () => {
expect(named('Trusted locations should be configured for Conditional Access')).toEqual([]);
expect(named(['software', 'policies', 'microsoft', 'office', '16.0', 'excel', 'security', 'trusted locations'].join(String.fromCharCode(92)) + ' · allownetworklocations'))
.toEqual(['malicious-code-execution-exposure']);
});
});