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 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 type { GpoTechnicalPolicyIdentity } from '../../src/domain/gpo-policy-identity'; 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 { compareBaselineVariants } from '../../src/evaluation/policy-baseline-variants'; import { selectBaselineTemplates } from '../../src/evaluation/baseline-template-context'; import { RELEASED_POLICY_EVALUATION_RULE_CATALOG, policyEvaluationRuleCatalogValidationErrors, type PolicyEvaluationRuleCatalog } from '../../src/domain/policy-evaluation-rules'; 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)); } const VALUE_INDEPENDENT_BASELINE_DIAGNOSTICS = new Set([ 'microsoft-baseline-admx-identity-ambiguous', 'microsoft-baseline-admx-metadata-not-found', 'adml-without-admx', 'gpo-metadata-domain-missing', 'gpo-metadata-backup-id-missing', 'gpo-metadata-backup-time-missing', 'gpo-metadata-backup-time-zone-missing' ]); function baselinePartialityBlocksCandidates( analysis: StoredMicrosoftSecurityBaselineAnalysis, candidates: MicrosoftSecurityBaselineSetting[] ): boolean { if (analysis.analysis.status === 'complete') return false; if (!analysis.analysis.diagnosticCodes.length) return true; const mechanisms = new Set(candidates.map(setting => setting.observation.deliveryMechanism)); return analysis.analysis.diagnosticCodes.some(code => { if (code.startsWith('admx-') || VALUE_INDEPENDENT_BASELINE_DIAGNOSTICS.has(code)) return false; // Unsupported sections have no comparable identity. Their coverage gap remains // visible at run level but cannot invalidate a separately resolved native value. if (code === 'gpo-security-template-section-unsupported') return false; if (code.startsWith('gpo-security-template-')) return mechanisms.has('security-template'); if (code.startsWith('gpo-advanced-audit-')) return mechanisms.has('advanced-audit-policy'); if (code.startsWith('registry-policy-')) return mechanisms.has('registry-policy'); return true; }); } 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 evaluateBaselineIdentity( runId: string, analysis: StoredMicrosoftSecurityBaselineAnalysis, baseline: MicrosoftSecurityBaselineRecord, candidates: MicrosoftSecurityBaselineSetting[], state: AggregatedPolicyState | undefined, relatedRegistryValueStates: AggregatedPolicyState[], profile: EnvironmentProfile, permitsAbsence: boolean, aggregationBlocked: boolean ): { result: PolicyEvaluationResult; evidence: PolicyEvaluationEvidence[] } { const diagnostics = new Set(); const selection = selectBaselineTemplates(candidates, profile); const settings = selection.settings; if (selection.status === 'selected') diagnostics.add('policy-evaluation-template-context-selected'); if (selection.status === 'no-match') diagnostics.add('policy-evaluation-template-context-no-match'); const identity = settings[0].identity.status === 'resolved' ? settings[0].identity.identity : undefined; if (!identity) throw new RepositoryValidationError('Eine Baselinegruppe besitzt keine aufgeloeste technische Identitaet.'); const baselineBlocked = baselinePartialityBlocksCandidates(analysis, settings); if (baselineBlocked || analysis.analysis.admxCatalog.status !== 'complete') { diagnostics.add('policy-evaluation-baseline-incomplete'); } const recommendationSignatures = new Set(settings.map(setting => canonicalJson(setting.observation.values))); if (recommendationSignatures.size > 1) diagnostics.add('policy-evaluation-baseline-variants-differ'); const configuredChildStates = !state && identity.target.kind === 'registry-key' ? relatedRegistryValueStates.filter(item => item.status === 'configured' || item.status === 'conflicted') : []; const evidenceStates = state ? [state] : configuredChildStates; const actualRegistryState = configuredChildStates.length ? { kind: 'registry-state' as const, state: 'key-present' as const } : undefined; const actualValues = evidenceStates.flatMap(item => item.values); const customerSignatures = new Set(actualValues.map(value => canonicalJson(value))); if (evidenceStates.length && customerSignatures.size !== 1) diagnostics.add('policy-evaluation-customer-value-ambiguous'); // An explicit Registry.pol delete instruction is configured evidence. Only // the complete absence of a state is a missing customer setting. const customerState: PolicyEvaluationResult['customerState'] = !state && !configuredChildStates.length ? 'missing' : state?.status === 'conflicted' || configuredChildStates.some(item => item.status === 'conflicted') ? 'conflicted' : state?.status === 'indeterminate' ? 'indeterminate' : 'configured'; if (customerState === 'conflicted') diagnostics.add('policy-evaluation-customer-conflict'); if (customerState === 'indeterminate') diagnostics.add('policy-evaluation-customer-state-incomplete'); const blockers: string[] = []; // Several instructions inside one baseline GPO are not independent role alternatives. // Without a uniquely reduced recommendation, their precedence remains unresolved. const firstByBackup = new Map(); for (const setting of settings) { const first = firstByBackup.get(setting.backupId); if (first && compareBaselineVariants(first.observation.values, [setting], false, []).decision !== 'all-pass') { blockers.push('policy-evaluation-template-values-ambiguous'); break; } firstByBackup.set(setting.backupId, setting); } if (selection.status === 'no-match') blockers.push('policy-evaluation-template-context-no-match'); // Missing explanations or baseline domain/time metadata do not make resolved native values unreadable. // Unknown/structural partiality still blocks conclusions across the candidate set. if (baselineBlocked) blockers.push('policy-evaluation-baseline-incomplete'); if (aggregationBlocked) blockers.push('policy-evaluation-aggregation-blocked'); if (customerState === 'missing' && !permitsAbsence) { // The verdict is scoped to the analyzed backup set. It deliberately does // not claim domain-wide or effective-device absence without RSoP. diagnostics.add('policy-evaluation-customer-absence-scope-limited'); } if (customerState === 'indeterminate' || evidenceStates.some(item => item.completeness !== 'complete')) { blockers.push('policy-evaluation-customer-state-incomplete'); } const assessment = compareBaselineVariants(customerState === 'missing' ? [] : actualValues, settings, customerState === 'missing', blockers, actualRegistryState); assessment.variants.flatMap(variant => variant.comparator.diagnosticCodes).forEach(code => diagnostics.add(code)); blockers.forEach(code => diagnostics.add(code)); if (assessment.decision === 'mixed' && selection.status === 'unresolved') diagnostics.add('policy-evaluation-template-context-required'); const review = assessment.decision === 'mixed' || assessment.decision === 'unavailable'; const actualComparisons = assessment.variants.map(variant => variant.comparator.actual); const actualComparisonSignatures = new Set(actualComparisons.map(value => canonicalJson(value))); // Retain a scalar baseline only when it is genuinely shared. Never synthesize a union. const compared: PolicyComparatorOutcome = { version: 1, comparator: { kind: 'equality' }, relation: review ? 'incomparable' : assessment.decision === 'all-pass' ? 'match' : 'different', actual: assessment.actualValues.length === 1 || (actualComparisons.length && actualComparisonSignatures.size === 1) ? actualComparisons[0] : undefined, baseline: recommendationSignatures.size === 1 ? assessment.variants[0]?.comparator.baseline : undefined, diagnosticCodes: [] }; const classification: PolicyEvaluationResult['classification'] = review ? 'review_required' : compared.relation === 'match' ? 'configured_match' : 'confirmed_configured_deviation'; const resultId = stableId('policy-evaluation-result', runId + '\0' + analysis.analysis.id + '\0' + identity.id); const baselineAnalysisIds = [analysis.analysis.id]; const baselineSettingIds = sortedUnique(settings.map(setting => setting.id)); const result: PolicyEvaluationResult = { version: 1, id: resultId, evaluationRunId: runId, aggregationStateId: customerState === 'missing' ? undefined : state?.id, aggregationStateIds: customerState === 'missing' || state ? undefined : sortedUnique(evidenceStates.map(item => item.id)), baselineAnalysisId: analysis.analysis.id, baselineId: baseline.id, baselineLabel: baseline.fileName.replace(/\.zip$/i, ''), customerState, identity, classification, baselineState: ['all-pass', 'all-fail'].includes(assessment.decision) ? 'explicit-recommendation' : assessment.variants.some(variant => variant.comparator.diagnosticCodes.length) ? 'unsupported-setting' : 'explicit-recommendation', comparator: compared, baselineVariantAssessment: assessment, ruleIds: [], dependencyRuleIds: [], riskRuleIds: [], contextIds: sortedUnique([...selection.contextIds, ...(state?.applicableContextIds ?? [])]), observationIds: customerState === 'missing' ? [] : sortedUnique(evidenceStates.flatMap(item => item.observationIds)), backupInstanceIds: customerState === 'missing' ? [] : sortedUnique(evidenceStates.flatMap(item => item.backupInstanceIds)), baselineAnalysisIds, baselineSettingIds, baselineTemplateSelection: { status: selection.status, contextIds: selection.contextIds, candidateSettingIds: sortedUnique(candidates.map(setting => setting.id)) }, findingEligibility: '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: result.baselineTemplateSelection!.candidateSettingIds }, { 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 statesByIdentity = new Map(input.aggregation.states.map(state => [state.identity.id, state])); const relatedRegistryValueStates = (identity: GpoTechnicalPolicyIdentity) => identity.target.kind !== 'registry-key' ? [] : input.aggregation.states.filter(state => state.identity.scope === identity.scope && state.identity.target.kind === 'registry-value' && state.identity.target.hive === identity.target.hive && state.identity.target.key.toLocaleLowerCase('en-US') === identity.target.key.toLocaleLowerCase('en-US')); const baselinesById = new Map(input.baselineRecords.map(record => [record.id, record])); const built = input.baselineAnalyses.flatMap(analysis => { const baseline = baselinesById.get(analysis.analysis.baselineId); if (!baseline) throw new RepositoryValidationError('Eine Baselineanalyse verweist auf keinen Baselinekatalogeintrag.'); const grouped = new Map(); for (const setting of analysis.analysis.settings) { if (setting.identity.status !== 'resolved') continue; const id = setting.identity.identity.id; grouped.set(id, [...(grouped.get(id) ?? []), setting]); } return [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right)) // Wholly unconfigured identities are not benchmarks. An unreadable variant // beside a recommendation must remain visible and block false unanimity. .filter(([, settings]) => settings.some(setting => setting.observation.values.length > 0)) .map(([identityId, settings]) => { const identity = settings[0].identity.status === 'resolved' ? settings[0].identity.identity : undefined; if (!identity) throw new RepositoryValidationError('Eine Baselinegruppe besitzt keine aufgeloeste technische Identitaet.'); return evaluateBaselineIdentity(runId, analysis, baseline, settings, statesByIdentity.get(identityId), relatedRegistryValueStates(identity), input.profile, input.aggregation.snapshot.permitsSetAbsenceConclusions, input.aggregation.run.status === 'blocked'); }); }); 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 || 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); } }