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

366 lines
20 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 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<string>();
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<string, MicrosoftSecurityBaselineSetting>();
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<string, MicrosoftSecurityBaselineSetting[]>();
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<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);
}
}