Assessor_2.0/server/gpo/gpo-policy-finding-service.ts

189 lines
8.5 KiB
TypeScript

import { createHash } from 'node:crypto';
import type { FindingDefinition } from '../../src/domain/canonical-model';
import {
POLICY_FINDING_FACTORY,
policyFindingCandidateInvariantCodes,
storedPolicyFindingRunValidationErrors,
type CreatePolicyFindingRunRequest,
type PolicyFindingOccurrence,
type PolicyFindingRejection,
type StoredPolicyFindingRun
} from '../../src/domain/policy-finding';
import type { EnvironmentProfile, StoredPolicySnapshotAggregation } from '../../src/domain/gpo-snapshot-aggregation';
import type { StoredPolicyEvaluation } from '../../src/domain/policy-evaluation';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
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 PolicyFindingBuildInput {
evaluation: StoredPolicyEvaluation;
aggregation: StoredPolicySnapshotAggregation;
profile: EnvironmentProfile;
startedAt: string;
completedAt: string;
}
function definitionFor(
result: StoredPolicyEvaluation['results'][number],
riskRuleId: string,
createdAt: string
): FindingDefinition {
const canonicalKey = `policy-risk:${riskRuleId}:${result.identity.id}`;
return {
id: stableId('finding-definition', canonicalKey),
canonicalKey,
identity: {
version: 1,
strategy: 'tool-rule-id',
durable: true,
evidence: [riskRuleId, result.identity.id]
},
title: `Konfigurierte Policy-Abweichung (${riskRuleId})`,
description: 'Die eindeutig konfigurierte Policy-Einstellung weicht von der ausdruecklichen Baselineempfehlung ab. Scope unbekannt; Wirksamkeit nicht verifiziert.',
category: 'group-policy-baseline',
frameworkReferences: [],
createdAt,
updatedAt: createdAt
};
}
export function buildPolicyFindingRun(input: PolicyFindingBuildInput): StoredPolicyFindingRun {
if (input.evaluation.run.projectId !== input.aggregation.run.projectId
|| input.evaluation.run.projectId !== input.profile.projectId
|| input.evaluation.run.aggregationRunId !== input.aggregation.run.id
|| input.evaluation.run.environmentProfileId !== input.profile.id) {
throw new RepositoryValidationError('Policy-Evaluation, Snapshot und Environment Profile gehoeren nicht zusammen.');
}
const runId = stableId('policy-finding-run', input.evaluation.run.id + '\0' + POLICY_FINDING_FACTORY.version);
const eligible = input.evaluation.results
.filter(result => result.findingEligibility === 'eligible')
.sort((left, right) => left.id.localeCompare(right.id));
const definitions = new Map<string, FindingDefinition>();
const occurrences: PolicyFindingOccurrence[] = [];
const rejections: PolicyFindingRejection[] = [];
const states = new Map(input.aggregation.states.map(state => [state.id, state]));
for (const result of eligible) {
const codes = new Set(policyFindingCandidateInvariantCodes(input.evaluation, result));
const state = states.get(result.aggregationStateId);
if (!state || state.identity.id !== result.identity.id) codes.add('policy-finding-aggregation-state-missing');
else {
if (state.status !== 'configured') codes.add('policy-finding-customer-state-not-configured');
if (state.completeness !== 'complete') codes.add('policy-finding-customer-state-incomplete');
if (state.targeting !== 'none') codes.add('policy-finding-targeting-unresolved');
if (state.effectiveState !== 'unknown') codes.add('policy-finding-effective-state-invalid');
}
if (input.aggregation.snapshot.completeness !== 'complete') codes.add('policy-finding-snapshot-incomplete');
const riskRuleId = result.riskRuleIds[0];
const riskRules = input.evaluation.run.ruleCatalog.rules
.filter(rule => rule.riskRule?.id === riskRuleId && result.ruleIds.includes(rule.id))
.map(rule => rule.riskRule!);
const severities = new Set(riskRules.map(rule => rule.severity));
if (!riskRuleId || riskRules.length < 1 || severities.size !== 1) codes.add('policy-finding-risk-rule-unresolved');
if (codes.size) {
rejections.push({
version: 1,
evaluationResultId: result.id,
diagnosticCodes: [...codes].sort()
});
continue;
}
const definition = definitionFor(result, riskRuleId, input.evaluation.run.completedAt);
definitions.set(definition.id, definition);
occurrences.push({
version: 1,
id: stableId('policy-finding-occurrence', result.id + '\0' + POLICY_FINDING_FACTORY.version),
factoryRunId: runId,
projectId: input.evaluation.run.projectId,
evaluationRunId: input.evaluation.run.id,
evaluationResultId: result.id,
aggregationRunId: input.aggregation.run.id,
aggregationStateId: result.aggregationStateId,
snapshotId: input.aggregation.run.snapshotId,
environmentProfileId: input.profile.id,
environmentProfileRevision: input.profile.revision,
definitionId: definition.id,
identityId: result.identity.id,
riskRuleId,
severity: [...severities][0],
classification: 'confirmed_configured_deviation',
outcome: 'fail',
scopeState: 'unknown',
effectiveState: 'not-verified',
evidenceIds: sortedUnique(input.evaluation.evidence
.filter(item => item.evaluationResultId === result.id).map(item => item.id)),
createdAt: input.completedAt
});
}
const record: StoredPolicyFindingRun = {
version: 1,
run: {
version: 1,
id: runId,
projectId: input.evaluation.run.projectId,
evaluationRunId: input.evaluation.run.id,
factory: POLICY_FINDING_FACTORY,
status: rejections.length ? 'partial' : 'complete',
eligibleResultCount: eligible.length,
findingCount: occurrences.length,
rejectedResultCount: rejections.length,
startedAt: input.startedAt,
completedAt: input.completedAt
},
definitions: [...definitions.values()].sort((left, right) => left.id.localeCompare(right.id)),
occurrences: occurrences.sort((left, right) => left.id.localeCompare(right.id)),
rejections: rejections.sort((left, right) => left.evaluationResultId.localeCompare(right.evaluationResultId))
};
const errors = storedPolicyFindingRunValidationErrors(record);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return record;
}
export class GpoPolicyFindingService {
private readonly now: () => Date;
constructor(private readonly repository: AssessorRepository, options: { now?: () => Date } = {}) {
this.now = options.now ?? (() => new Date());
}
async create(projectId: string, request: CreatePolicyFindingRunRequest): Promise<StoredPolicyFindingRun> {
if (!request || typeof request.evaluationRunId !== 'string' || !request.evaluationRunId.trim()) {
throw new RepositoryValidationError('Die Policy-Finding-Anfrage ist ungueltig.');
}
const evaluation = (await this.repository.listPolicyEvaluations(projectId))
.find(record => record.run.id === request.evaluationRunId);
if (!evaluation) throw new RepositoryNotFoundError('Die Policy-Evaluation ist im Projekt nicht vorhanden.');
const expectedId = stableId('policy-finding-run', evaluation.run.id + '\0' + POLICY_FINDING_FACTORY.version);
const existing = (await this.repository.listPolicyFindingRuns(projectId))
.find(record => record.run.id === expectedId);
if (existing) return existing;
const aggregation = (await this.repository.listPolicySnapshotAggregations(projectId))
.find(record => record.run.id === evaluation.run.aggregationRunId);
if (!aggregation) throw new RepositoryNotFoundError('Der Policy-Snapshot der Evaluation ist nicht vorhanden.');
const profile = (await this.repository.listEnvironmentProfiles(projectId))
.find(record => record.id === evaluation.run.environmentProfileId);
if (!profile) throw new RepositoryNotFoundError('Das Environment Profile der Evaluation ist nicht vorhanden.');
const startedAt = this.now().toISOString();
const record = buildPolicyFindingRun({
evaluation,
aggregation,
profile,
startedAt,
completedAt: this.now().toISOString()
});
await this.repository.savePolicyFindingRun(record);
return record;
}
list(projectId: string): Promise<StoredPolicyFindingRun[]> {
return this.repository.listPolicyFindingRuns(projectId);
}
}