import { createHash } from 'node:crypto'; import type { AssessorRepository } from '../../src/persistence/assessor-repository'; import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors'; import { canonicalJson } from '../../src/shared/canonical-json'; import { baselineProductContexts, type MicrosoftSecurityBaselineRecord } from '../../src/domain/microsoft-security-baseline'; import type { StoredMicrosoftSecurityBaselineAnalysis, MicrosoftSecurityBaselineSetting } from '../../src/domain/microsoft-security-baseline-analysis'; import type { AggregatedPolicyState, EnvironmentProfile, StoredPolicySnapshotAggregation } from '../../src/domain/gpo-snapshot-aggregation'; import { POLICY_COMPARATOR_CATALOG, POLICY_DEPENDENCY_RULESET, POLICY_EVALUATION_ENGINE, POLICY_RISK_RULESET, storedPolicyEvaluationValidationErrors, type CreatePolicyEvaluationRequest, type PolicyComparatorOutcome, type PolicyEvaluationEvidence, type PolicyEvaluationResult, type StoredPolicyEvaluation } from '../../src/domain/policy-evaluation'; import { comparePolicyValues } from '../../src/evaluation/policy-comparator'; import { RELEASED_POLICY_EVALUATION_RULE_CATALOG, policyEvaluationRuleCatalogValidationErrors, type PolicyEvaluationRuleCatalog } from '../../src/domain/policy-evaluation-rules'; import { resolvePolicyEvaluationRule } from './gpo-policy-evaluation-rule-engine'; function stableId(prefix: string, value: string): string { return prefix + '-' + createHash('sha256').update(value).digest('hex').slice(0, 32); } function sortedUnique(values: string[]): string[] { return [...new Set(values)].sort((left, right) => left.localeCompare(right)); } function normalized(value: string): string { return value.trim().toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, ' ').trim(); } function baselineMatchesContext(record: MicrosoftSecurityBaselineRecord, context: EnvironmentProfile['contexts'][number], scope: 'computer' | 'user'): boolean { return baselineProductContexts(record.metadata).some(candidate => normalized(candidate.productFamily) === normalized(context.productFamily) && normalized(candidate.productVersion) === normalized(context.productVersion) && candidate.roles.some(role => normalized(role) === 'any' || normalized(role) === normalized(context.role)) && (candidate.scopes.includes('mixed') || candidate.scopes.includes(scope)) ); } interface EvaluationBuildInput { profile: EnvironmentProfile; aggregation: StoredPolicySnapshotAggregation; baselineRecords: MicrosoftSecurityBaselineRecord[]; baselineAnalyses: StoredMicrosoftSecurityBaselineAnalysis[]; startedAt: string; completedAt: string; ruleCatalog: PolicyEvaluationRuleCatalog; } interface EvaluationEvidenceInput { kind: PolicyEvaluationEvidence['kind']; sourceIds: string[]; } function evidenceFor(resultId: string, inputs: EvaluationEvidenceInput[]): PolicyEvaluationEvidence[] { return inputs.flatMap(input => sortedUnique(input.sourceIds).map(sourceId => ({ version: 1 as const, id: stableId('policy-evaluation-evidence', resultId + '\0' + input.kind + '\0' + sourceId), evaluationResultId: resultId, kind: input.kind, sourceId }))); } function resolvedSettings(analysis: StoredMicrosoftSecurityBaselineAnalysis, identityId: string): MicrosoftSecurityBaselineSetting[] { return analysis.analysis.settings.filter(setting => setting.identity.status === 'resolved' && setting.identity.identity.id === identityId ).sort((left, right) => left.id.localeCompare(right.id)); } function evaluateState( runId: string, input: EvaluationBuildInput, state: AggregatedPolicyState, analysesById: Map, recordsById: Map ): { result: PolicyEvaluationResult; evidence: PolicyEvaluationEvidence[] } { const diagnostics = new Set(); const rule = resolvePolicyEvaluationRule(input.ruleCatalog, input.profile, input.aggregation, state); rule.diagnosticCodes.forEach(code => diagnostics.add(code)); let baselineState = rule.baselineState; const applicableContexts = state.applicableContextIds.map(id => input.profile.contexts.find(context => context.id === id)); if (applicableContexts.some(context => !context)) diagnostics.add('policy-evaluation-context-missing'); if (input.aggregation.run.status !== 'complete' || input.aggregation.snapshot.completeness !== 'complete') { diagnostics.add('policy-evaluation-snapshot-incomplete'); } if (state.status !== 'configured' || state.completeness !== 'complete' || state.targeting !== 'none') { diagnostics.add(state.status === 'conflicted' ? 'policy-evaluation-customer-conflict' : state.status === 'deleted' ? 'policy-evaluation-deleted-state-review' : 'policy-evaluation-customer-state-incomplete'); } const usedAnalyses: StoredMicrosoftSecurityBaselineAnalysis[] = []; const usedSettings: MicrosoftSecurityBaselineSetting[] = []; for (const context of applicableContexts) { if (!context) continue; const candidates = input.baselineAnalyses.filter(analysis => { const record = recordsById.get(analysis.analysis.baselineId); return Boolean(record && baselineMatchesContext(record, context, state.identity.scope)); }); if (candidates.length !== 1) { diagnostics.add(candidates.length ? 'policy-evaluation-baseline-ambiguous' : 'policy-evaluation-baseline-missing'); continue; } const analysis = analysesById.get(candidates[0].analysis.id)!; usedAnalyses.push(analysis); if (analysis.analysis.status !== 'complete' || analysis.analysis.admxCatalog.status !== 'complete') { diagnostics.add('policy-evaluation-baseline-incomplete'); baselineState = 'parser-gap'; } if (rule.baselineState === 'explicit-recommendation') { const settings = resolvedSettings(analysis, state.identity.id); if (!settings.length) { diagnostics.add('policy-evaluation-baseline-recommendation-missing'); baselineState = 'catalog-gap'; } usedSettings.push(...settings); } } const uniqueAnalyses = [...new Map(usedAnalyses.map(analysis => [analysis.analysis.id, analysis])).values()]; const uniqueSettings = [...new Map(usedSettings.map(setting => [setting.id, setting])).values()]; const recommendationSignatures = new Set(uniqueSettings.map(setting => canonicalJson(setting.observation.values[0]))); if (recommendationSignatures.size > 1) diagnostics.add('policy-evaluation-baseline-consensus-conflict'); const customerSignatures = new Set(state.values.map(value => canonicalJson(value))); if (rule.baselineState === 'explicit-recommendation' && customerSignatures.size !== 1) { diagnostics.add('policy-evaluation-customer-value-ambiguous'); } let compared: PolicyComparatorOutcome = { version: 1, comparator: rule.comparator, relation: 'incomparable', diagnosticCodes: [] }; if (!diagnostics.size && rule.baselineState === 'explicit-recommendation' && !rule.notApplicable) { compared = comparePolicyValues(state.values[0], uniqueSettings[0]?.observation.values[0], rule.comparator); if (compared.diagnosticCodes.length) baselineState = 'unsupported-setting'; compared.diagnosticCodes.forEach(code => diagnostics.add(code)); } const review = diagnostics.size > 0 || (!rule.notApplicable && rule.baselineState === 'explicit-recommendation' && compared.relation === 'incomparable'); const classification: PolicyEvaluationResult['classification'] = review ? 'review_required' : rule.notApplicable ? 'not_applicable' : rule.baselineState !== 'explicit-recommendation' ? 'review_required' : compared.relation === 'match' || compared.relation === 'stronger' ? 'configured_match' : 'confirmed_configured_deviation'; const resultId = stableId('policy-evaluation-result', runId + '\0' + state.id); const baselineAnalysisIds = sortedUnique(uniqueAnalyses.map(analysis => analysis.analysis.id)); const baselineSettingIds = sortedUnique(uniqueSettings.map(setting => setting.id)); const result: PolicyEvaluationResult = { version: 1, id: resultId, evaluationRunId: runId, aggregationStateId: state.id, identity: state.identity, classification, baselineState, comparator: compared, ruleIds: rule.ruleIds, dependencyRuleIds: rule.dependencyRuleIds, riskRuleIds: rule.riskRuleIds, contextIds: sortedUnique(state.applicableContextIds), observationIds: sortedUnique(state.observationIds), backupInstanceIds: sortedUnique(state.backupInstanceIds), baselineAnalysisIds, baselineSettingIds, findingEligibility: classification === 'confirmed_configured_deviation' && rule.riskRuleIds.length ? 'eligible' : 'ineligible', effectiveState: 'unknown', diagnosticCodes: [...diagnostics].sort() }; return { result, evidence: evidenceFor(resultId, [ { kind: 'customer-observation', sourceIds: result.observationIds }, { kind: 'gpo-backup', sourceIds: result.backupInstanceIds }, { kind: 'baseline-setting', sourceIds: baselineSettingIds }, { kind: 'baseline-analysis', sourceIds: baselineAnalysisIds }, { kind: 'environment-context', sourceIds: result.contextIds } ]) }; } export function buildPolicyEvaluation(input: EvaluationBuildInput): StoredPolicyEvaluation { const ruleCatalogErrors = policyEvaluationRuleCatalogValidationErrors(input.ruleCatalog); if (ruleCatalogErrors.length) throw new RepositoryValidationError(ruleCatalogErrors.join(' ')); const baselineAnalysisIds = sortedUnique(input.baselineAnalyses.map(record => record.analysis.id)); const runId = stableId('policy-evaluation-run', [ input.profile.projectId, input.aggregation.run.id, POLICY_EVALUATION_ENGINE.version, POLICY_COMPARATOR_CATALOG.version, POLICY_DEPENDENCY_RULESET.version, POLICY_RISK_RULESET.version, canonicalJson(input.ruleCatalog), ...baselineAnalysisIds ].join('\0')); const analysesById = new Map(input.baselineAnalyses.map(record => [record.analysis.id, record])); const recordsById = new Map(input.baselineRecords.map(record => [record.id, record])); const built = input.aggregation.states.map(state => evaluateState(runId, input, state, analysesById, recordsById)); const results = built.map(item => item.result).sort((left, right) => left.id.localeCompare(right.id)); const evidence = built.flatMap(item => item.evidence).sort((left, right) => left.id.localeCompare(right.id)); const diagnostics = sortedUnique([ ...input.aggregation.run.diagnosticCodes, ...results.flatMap(result => result.diagnosticCodes) ]); const blocked = input.aggregation.run.status === 'blocked'; const partial = blocked || input.aggregation.run.status !== 'complete' || results.some(result => result.classification === 'review_required'); const record: StoredPolicyEvaluation = { version: 1, run: { version: 1, id: runId, projectId: input.profile.projectId, aggregationRunId: input.aggregation.run.id, environmentProfileId: input.profile.id, baselineAnalysisIds, evaluator: POLICY_EVALUATION_ENGINE, comparatorCatalog: POLICY_COMPARATOR_CATALOG, ruleCatalog: input.ruleCatalog, dependencyRules: POLICY_DEPENDENCY_RULESET, riskRules: POLICY_RISK_RULESET, status: blocked ? 'blocked' : partial ? 'partial' : 'complete', resultCount: results.length, notApplicableCount: results.filter(result => result.classification === 'not_applicable').length, matchCount: results.filter(result => result.classification === 'configured_match').length, deviationCount: results.filter(result => result.classification === 'confirmed_configured_deviation').length, reviewCount: results.filter(result => result.classification === 'review_required').length, projectedFindingCount: 0, diagnosticCodes: diagnostics, startedAt: input.startedAt, completedAt: input.completedAt }, results, evidence }; const errors = storedPolicyEvaluationValidationErrors(record); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); return record; } export class GpoPolicyEvaluationService { private readonly now: () => Date; private readonly ruleCatalog: PolicyEvaluationRuleCatalog; constructor(private readonly repository: AssessorRepository, options: { now?: () => Date; ruleCatalog?: PolicyEvaluationRuleCatalog; } = {}) { this.now = options.now ?? (() => new Date()); this.ruleCatalog = structuredClone(options.ruleCatalog ?? RELEASED_POLICY_EVALUATION_RULE_CATALOG); const errors = policyEvaluationRuleCatalogValidationErrors(this.ruleCatalog); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); } async evaluate(projectId: string, request: CreatePolicyEvaluationRequest): Promise { if (!request || typeof request.aggregationRunId !== 'string' || !request.aggregationRunId.trim() || !Array.isArray(request.baselineAnalysisIds) || request.baselineAnalysisIds.some(id => typeof id !== 'string' || !id.trim())) { throw new RepositoryValidationError('Die Policy-Evaluation-Anfrage ist ungueltig.'); } const baselineAnalysisIds = sortedUnique(request.baselineAnalysisIds); if (!baselineAnalysisIds.length || baselineAnalysisIds.length !== request.baselineAnalysisIds.length) { throw new RepositoryValidationError('Mindestens ein eindeutiger Baseline-Analysis-Run ist erforderlich.'); } const aggregation = (await this.repository.listPolicySnapshotAggregations(projectId)) .find(record => record.run.id === request.aggregationRunId); if (!aggregation) throw new RepositoryNotFoundError('Der Policy-Snapshot-Aggregationslauf ist im Projekt nicht vorhanden.'); const profile = (await this.repository.listEnvironmentProfiles(projectId)) .find(record => record.id === aggregation.run.environmentProfileId); if (!profile) throw new RepositoryNotFoundError('Das Environment Profile des Aggregationslaufs ist nicht vorhanden.'); const allAnalyses = await this.repository.listMicrosoftSecurityBaselineAnalyses(); const analyses = baselineAnalysisIds.map(id => allAnalyses.find(record => record.analysis.id === id)); if (analyses.some(record => !record)) throw new RepositoryNotFoundError('Mindestens eine Baselineanalyse ist nicht vorhanden.'); const selected = analyses as StoredMicrosoftSecurityBaselineAnalysis[]; const expectedId = stableId('policy-evaluation-run', [ projectId, aggregation.run.id, POLICY_EVALUATION_ENGINE.version, POLICY_COMPARATOR_CATALOG.version, POLICY_DEPENDENCY_RULESET.version, POLICY_RISK_RULESET.version, canonicalJson(this.ruleCatalog), ...baselineAnalysisIds ].join('\0')); const existing = (await this.repository.listPolicyEvaluations(projectId)).find(record => record.run.id === expectedId); if (existing) return existing; const baselineRecords = await this.repository.listMicrosoftSecurityBaselines(); const startedAt = this.now().toISOString(); const record = buildPolicyEvaluation({ profile, aggregation, baselineRecords, baselineAnalyses: selected, ruleCatalog: this.ruleCatalog, startedAt, completedAt: this.now().toISOString() }); await this.repository.savePolicyEvaluation(record); return record; } list(projectId: string): Promise { return this.repository.listPolicyEvaluations(projectId); } catalog(): PolicyEvaluationRuleCatalog { return structuredClone(this.ruleCatalog); } }