292 lines
15 KiB
TypeScript
292 lines
15 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 {
|
|
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 { 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));
|
|
}
|
|
|
|
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,
|
|
profile: EnvironmentProfile
|
|
): { 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.');
|
|
if (analysis.analysis.status !== 'complete' || analysis.analysis.admxCatalog.status !== 'complete') {
|
|
diagnostics.add('policy-evaluation-baseline-incomplete');
|
|
}
|
|
const recommendationSignatures = new Set(settings.map(setting => canonicalJson(setting.observation.values[0])));
|
|
if (recommendationSignatures.size > 1) diagnostics.add('policy-evaluation-baseline-consensus-conflict');
|
|
if (recommendationSignatures.size > 1 && selection.status === 'unresolved') {
|
|
diagnostics.add('policy-evaluation-template-context-required');
|
|
}
|
|
const customerSignatures = new Set(state?.values.map(value => canonicalJson(value)) ?? []);
|
|
if (state && customerSignatures.size !== 1) diagnostics.add('policy-evaluation-customer-value-ambiguous');
|
|
const customerState: PolicyEvaluationResult['customerState'] = !state || state.status === 'deleted'
|
|
? 'missing'
|
|
: state.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');
|
|
let compared: PolicyComparatorOutcome = {
|
|
version: 1,
|
|
comparator: { kind: 'equality' },
|
|
relation: customerState === 'missing' ? 'different' : 'incomparable',
|
|
baseline: undefined,
|
|
diagnosticCodes: []
|
|
};
|
|
if (selection.status !== 'no-match' && recommendationSignatures.size === 1 && customerState === 'missing') {
|
|
compared = comparePolicyValues(undefined, settings[0].observation.values[0], { kind: 'equality' });
|
|
if (compared.baseline !== undefined) {
|
|
compared = { ...compared, relation: 'different', diagnosticCodes: [] };
|
|
} else {
|
|
compared.diagnosticCodes.forEach(code => diagnostics.add(code));
|
|
}
|
|
} else if (selection.status !== 'no-match' && recommendationSignatures.size === 1 && customerState === 'configured' && customerSignatures.size === 1 && state) {
|
|
compared = comparePolicyValues(state.values[0], settings[0].observation.values[0], { kind: 'equality' });
|
|
compared.diagnosticCodes.forEach(code => diagnostics.add(code));
|
|
}
|
|
const review = selection.status === 'no-match' || recommendationSignatures.size !== 1 || ['conflicted', 'indeterminate'].includes(customerState)
|
|
|| compared.relation === 'incomparable';
|
|
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,
|
|
baselineAnalysisId: analysis.analysis.id,
|
|
baselineId: baseline.id,
|
|
baselineLabel: baseline.fileName.replace(/\.zip$/i, ''),
|
|
customerState,
|
|
identity,
|
|
classification,
|
|
baselineState: compared.diagnosticCodes.length ? 'unsupported-setting' : 'explicit-recommendation',
|
|
comparator: compared,
|
|
ruleIds: [],
|
|
dependencyRuleIds: [],
|
|
riskRuleIds: [],
|
|
contextIds: sortedUnique([...selection.contextIds, ...(state?.applicableContextIds ?? [])]),
|
|
observationIds: customerState === 'missing' ? [] : sortedUnique(state?.observationIds ?? []),
|
|
backupInstanceIds: customerState === 'missing' ? [] : sortedUnique(state?.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 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;
|
|
// A structurally parsed baseline row without an actual Microsoft
|
|
// recommendation is not a benchmark and must not produce PASS/FAIL.
|
|
if (setting.observation.values[0] === undefined) continue;
|
|
const id = setting.identity.identity.id;
|
|
grouped.set(id, [...(grouped.get(id) ?? []), setting]);
|
|
}
|
|
return [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([identityId, settings]) => evaluateBaselineIdentity(runId, analysis, baseline, settings, statesByIdentity.get(identityId), input.profile));
|
|
});
|
|
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);
|
|
}
|
|
}
|