Assessor_2.0/server/gpo/gpo-policy-evaluation-servi...

320 lines
16 KiB
TypeScript

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<string, StoredMicrosoftSecurityBaselineAnalysis>,
recordsById: Map<string, MicrosoftSecurityBaselineRecord>
): { result: PolicyEvaluationResult; evidence: PolicyEvaluationEvidence[] } {
const diagnostics = new Set<string>();
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<StoredPolicyEvaluation> {
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<StoredPolicyEvaluation[]> {
return this.repository.listPolicyEvaluations(projectId);
}
catalog(): PolicyEvaluationRuleCatalog {
return structuredClone(this.ruleCatalog);
}
}