import { appendRiskAcceptanceReview, assertRiskAcceptanceUnchanged, type RiskAcceptanceCommand } from '../domain/risk-acceptance'; import type { Assessment, AssessmentProject, CanonicalAssessmentModel, EvidenceRecord, FindingDefinition, FindingOccurrence, FindingReview, ImportDiagnostic, ImportFileRecord, ScanRun, SourceTableRecord } from '../domain/canonical-model'; import type { LocalComparisonRule } from '../domain/comparison'; import type { ImportFingerprint, ImportFingerprintInput } from '../domain/import-workflow'; import type { ImportArtifact, ImportProcessingRun, IngestionJob, IngestionJobClaim } from '../domain/ingestion-persistence'; import type { StoredGpoBackupInstance, StoredGpoPolicyAnalysis } from '../domain/gpo-analysis-persistence'; import type { MicrosoftSecurityBaselineClassification, MicrosoftSecurityBaselineRecord } from '../domain/microsoft-security-baseline'; import type { DbStructureResponse } from '../domain/db-structure'; import type { StoredMicrosoftSecurityBaselineAnalysis } from '../domain/microsoft-security-baseline-analysis'; import type { EnvironmentProfile, StoredPolicySnapshotAggregation } from '../domain/gpo-snapshot-aggregation'; import type { StoredPolicyEvaluation } from '../domain/policy-evaluation'; import { storedPolicyEvaluationValidationErrors } from '../domain/policy-evaluation'; import type { StoredPolicyFindingRun } from '../domain/policy-finding'; import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../domain/environment-context'; import { environmentCoverage } from '../domain/environment-context'; import { storedPolicyFindingRunValidationErrors } from '../domain/policy-finding'; import { environmentProfileValidationErrors, storedPolicySnapshotAggregationValidationErrors } from '../domain/gpo-snapshot-aggregation'; import { storedMicrosoftSecurityBaselineAnalysisValidationErrors } from '../domain/microsoft-security-baseline-analysis'; import { storedGpoBackupInstanceValidationErrors, storedGpoPolicyAnalysisValidationErrors } from '../domain/gpo-analysis-persistence'; import { importArtifactValidationErrors, ingestionJobValidationErrors, processingRunValidationErrors } from '../domain/ingestion-persistence'; import { assertCanonicalAssessmentModel } from '../domain/canonical-validation'; import { processingScopeIssues } from '../domain/processing-compatibility'; import { compareReimportSourceHashes } from '../domain/reimport-source'; import { planReimportReviewTransfer, type ReimportReviewTransferSummary } from '../domain/reimport-review-transfer'; import { canonicalJson } from '../shared/canonical-json'; import { latestReferenceRuns, type ReferenceCatalogSnapshot, type ReferenceRecognitionRun } from '../domain/reference-recognition'; import { validateReferenceRun } from './reference-run-validation'; import { normalizeComparisonText } from '../comparison/comparison-normalization'; import { validateLocalComparisonRules } from '../comparison/local-mapping-rules'; import { createEnvironmentUnifiedResult, createCurrentPolicyEvaluationUnifiedResults, createCurrentPolicyObservationUnifiedResults, createPolicyUnifiedResult, createScannerUnifiedResult, deriveUnifiedResultFacets, filterUnifiedResults, listUnifiedResultCategories, queryUnifiedResultItems, type UnifiedResultCategory, type UnifiedResultFacetResult, type UnifiedResultProjection, type UnifiedResultQuery, type UnifiedResultQueryResult } from '../domain/unified-result'; import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../domain/result-db-disclosure'; import { createPolicyConsistencyUnifiedResults } from '../evaluation/policy-consistency-results'; import type { AssessorRepository, AssessmentDeleteConfirmation, ArtifactDeletionRequest, CanonicalImportCommit, ControlledDeleteConfirmation, FindingOccurrenceQuery, FindingOccurrenceQueryResult, ImportCommitResult, ImportSession, ImportSessionStart, ImportSessionStatus, ImportStagingData, ProfileRegistration, ProjectDataExport, ScanRunDeleteConfirmation, StoredComparison, StoredLocalComparisonRule } from './assessor-repository'; import { ImportSessionStateError, RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from './repository-errors'; interface LocalRepositoryState { referenceRuns: ReferenceRecognitionRun[]; referenceCatalogs: ReferenceCatalogSnapshot[]; projects: AssessmentProject[]; assessments: Assessment[]; scanRuns: ScanRun[]; importFiles: ImportFileRecord[]; sourceTables: SourceTableRecord[]; reportSections: ScannerReportSection[]; environmentObservations: EnvironmentObservation[]; findingDefinitions: FindingDefinition[]; findingOccurrences: FindingOccurrence[]; evidence: EvidenceRecord[]; reviews: FindingReview[]; diagnostics: ImportDiagnostic[]; profileRegistrations: ProfileRegistration[]; comparisons: StoredComparison[]; localComparisonRules: StoredLocalComparisonRule[]; importFingerprints: ImportFingerprint[]; importArtifacts: ImportArtifact[]; processingRuns: ImportProcessingRun[]; ingestionJobs: IngestionJob[]; gpoBackupInstances: StoredGpoBackupInstance[]; microsoftSecurityBaselines: MicrosoftSecurityBaselineRecord[]; gpoPolicyAnalyses: StoredGpoPolicyAnalysis[]; environmentProfiles: EnvironmentProfile[]; policySnapshotAggregations: StoredPolicySnapshotAggregation[]; policyEvaluations: StoredPolicyEvaluation[]; policyFindingRuns: StoredPolicyFindingRun[]; microsoftSecurityBaselineClassifications: MicrosoftSecurityBaselineClassification[]; artifactDeletionRequests: ArtifactDeletionRequest[]; microsoftSecurityBaselineAnalyses: StoredMicrosoftSecurityBaselineAnalysis[]; } const emptyState = (): LocalRepositoryState => ({ referenceRuns: [], referenceCatalogs: [], projects: [], assessments: [], scanRuns: [], importFiles: [], sourceTables: [], reportSections: [], environmentObservations: [], findingDefinitions: [], findingOccurrences: [], evidence: [], reviews: [], diagnostics: [], profileRegistrations: [], comparisons: [], localComparisonRules: [], importFingerprints: [], importArtifacts: [], processingRuns: [], ingestionJobs: [], gpoBackupInstances: [], microsoftSecurityBaselines: [], gpoPolicyAnalyses: [], microsoftSecurityBaselineClassifications: [], environmentProfiles: [], policySnapshotAggregations: [], policyEvaluations: [], policyFindingRuns: [], microsoftSecurityBaselineAnalyses: [], artifactDeletionRequests: [] }); function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function appendReferenceRun(state: LocalRepositoryState, run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[]): boolean { const existing = state.referenceRuns.find(item => item.id === run.id); if (existing) { if (canonicalJson(existing) !== canonicalJson(run)) throw new RepositoryConflictError('Referenzläufe sind unveränderlich.'); return false; } validateReferenceRun(run, catalogs, assessmentModel(state, run.assessmentId)); for (const catalog of catalogs) { const stored = state.referenceCatalogs.find(item => item.sha256 === catalog.sha256); if (stored && canonicalJson(stored) !== canonicalJson(catalog)) throw new RepositoryConflictError('Katalog-Snapshot ist unveränderlich.'); if (!stored) state.referenceCatalogs.push(clone(catalog)); } state.referenceRuns.push(clone(run)); return true; } function byId(items: T[]): T[] { return items.slice().sort((left, right) => left.id.localeCompare(right.id)); } function appendUnique(target: T[], additions: T[], label: string): void { const ids = new Set(target.map(item => item.id)); additions.forEach(item => { if (!item.id.trim()) throw new RepositoryValidationError(`${label}: leere ID.`); if (ids.has(item.id)) throw new RepositoryConflictError(`${label}: ID ${item.id} ist bereits vorhanden.`); ids.add(item.id); target.push(clone(item)); }); } function referencedDefinitionIds(state: LocalRepositoryState, occurrenceIds?: Set): Set { return new Set(state.findingOccurrences .filter(occurrence => !occurrenceIds || occurrenceIds.has(occurrence.id)) .map(occurrence => occurrence.definitionId)); } function assessmentModel(state: LocalRepositoryState, assessmentId: string): CanonicalAssessmentModel | null { const assessment = state.assessments.find(item => item.id === assessmentId); if (!assessment) return null; const project = state.projects.find(item => item.id === assessment.projectId); if (!project) return null; const scanRuns = state.scanRuns.filter(run => run.assessmentId === assessmentId && !run.supersededByScanRunId); const runIds = new Set(scanRuns.map(run => run.id)); const importFiles = state.importFiles.filter(file => runIds.has(file.scanRunId)); const importFileIds = new Set(importFiles.map(file => file.id)); const sourceTables = state.sourceTables.filter(table => importFileIds.has(table.importFileId)); const reportSections = state.reportSections.filter(section => runIds.has(section.scanRunId)); const environmentObservations = state.environmentObservations.filter(observation => runIds.has(observation.scanRunId)); const findingOccurrences = state.findingOccurrences.filter(occurrence => runIds.has(occurrence.scanRunId)); const occurrenceIds = new Set(findingOccurrences.map(occurrence => occurrence.id)); const definitionIds = referencedDefinitionIds(state, occurrenceIds); state.reviews.forEach(review => { if (review.target.kind === 'definition') { const definitionId = review.target.definitionId; if (state.findingOccurrences.some(occurrence => occurrence.definitionId === definitionId && occurrenceIds.has(occurrence.id))) { definitionIds.add(definitionId); } } }); return { schemaVersion: 1, referenceRuns: clone(latestReferenceRuns(state.referenceRuns.filter(run => runIds.has(run.scanRunId)))), project: clone(project), assessment: clone(assessment), scanRuns: byId(scanRuns).map(clone), importFiles: byId(importFiles).map(clone), sourceTables: byId(sourceTables).map(clone), reportSections: byId(reportSections).map(clone), environmentObservations: byId(environmentObservations).map(clone), findingDefinitions: byId(state.findingDefinitions.filter(definition => definitionIds.has(definition.id))).map(clone), findingOccurrences: byId(findingOccurrences).map(clone), evidence: byId(state.evidence.filter(record => occurrenceIds.has(record.occurrenceId))).map(clone), reviews: byId(state.reviews.filter(review => review.target.kind === 'occurrence' ? occurrenceIds.has(review.target.occurrenceId) : definitionIds.has(review.target.definitionId))).map(clone), diagnostics: byId(state.diagnostics.filter(diagnostic => importFileIds.has(diagnostic.importFileId))).map(clone) }; } class LocalImportSession implements ImportSession { private currentStatus: ImportSessionStatus = 'pending'; private staged: Required = { importFiles: [], sourceTables: [], reportSections: [], environmentObservations: [], findingDefinitions: [], findingOccurrences: [], evidence: [], diagnostics: [] }; constructor( readonly id: string, private readonly onCommit: (staged: Required) => Promise, private readonly onRollback: () => Promise ) {} get status(): ImportSessionStatus { return this.currentStatus; } private ensurePending(): void { if (this.currentStatus !== 'pending') throw new ImportSessionStateError(`Importsitzung ${this.id} ist bereits ${this.currentStatus}.`); } async stage(data: ImportStagingData): Promise { this.ensurePending(); this.staged.importFiles.push(...clone(data.importFiles ?? [])); this.staged.sourceTables.push(...clone(data.sourceTables ?? [])); this.staged.reportSections.push(...clone(data.reportSections ?? [])); this.staged.environmentObservations.push(...clone(data.environmentObservations ?? [])); this.staged.findingDefinitions.push(...clone(data.findingDefinitions ?? [])); this.staged.findingOccurrences.push(...clone(data.findingOccurrences ?? [])); this.staged.evidence.push(...clone(data.evidence ?? [])); this.staged.diagnostics.push(...clone(data.diagnostics ?? [])); } async commit(): Promise { this.ensurePending(); const result = await this.onCommit(clone(this.staged)); this.currentStatus = 'committed'; return result; } async rollback(): Promise { this.ensurePending(); await this.onRollback(); this.currentStatus = 'rolled-back'; this.staged = { importFiles: [], sourceTables: [], reportSections: [], environmentObservations: [], findingDefinitions: [], findingOccurrences: [], evidence: [], diagnostics: [] }; } } export class LocalAssessorRepository implements AssessorRepository { private state = emptyState(); private stateRevision = 0; private readonly activeImportSessions = new Set(); get revision(): number { return this.stateRevision; } private replaceState(next: LocalRepositoryState): void { this.state = clone(next); this.stateRevision += 1; } async listProjects(): Promise { return byId(this.state.projects).map(clone); } async getProject(projectId: string): Promise { const project = this.state.projects.find(item => item.id === projectId); return project ? clone(project) : null; } async saveProject(project: AssessmentProject): Promise { if (!project.id.trim() || !project.name.trim()) throw new RepositoryValidationError('Projekt-ID und Projektname sind erforderlich.'); const next = clone(this.state); const index = next.projects.findIndex(item => item.id === project.id); if (index >= 0) next.projects[index] = clone(project); else next.projects.push(clone(project)); this.replaceState(next); } async listAssessments(projectId: string): Promise { return byId(this.state.assessments.filter(assessment => assessment.projectId === projectId)).map(clone); } async listProjectScanRuns(projectId: string): Promise { const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === projectId).map(item => item.id)); return this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId) && !item.supersededByScanRunId).map(clone) .sort((left, right) => left.sequence - right.sequence || left.id.localeCompare(right.id)); } async listProjectImportFiles(projectId: string): Promise { const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === projectId).map(item => item.id)); const runIds = new Set(this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId) && !item.supersededByScanRunId).map(item => item.id)); return this.state.importFiles.filter(item => runIds.has(item.scanRunId)).map(clone) .sort((left, right) => Date.parse(right.lastModifiedAt ?? right.importedAt) - Date.parse(left.lastModifiedAt ?? left.importedAt)); } async saveAssessment(assessment: Assessment): Promise { if (!this.state.projects.some(project => project.id === assessment.projectId)) { throw new RepositoryNotFoundError(`Projekt ${assessment.projectId} ist nicht vorhanden.`); } if (!assessment.id.trim() || !assessment.name.trim()) throw new RepositoryValidationError('Assessment-ID und Assessmentname sind erforderlich.'); const next = clone(this.state); const index = next.assessments.findIndex(item => item.id === assessment.id); if (index >= 0 && next.assessments[index].projectId !== assessment.projectId) { throw new RepositoryConflictError('Ein Assessment kann nicht in ein anderes Projekt verschoben werden.'); } if (index >= 0) next.assessments[index] = clone(assessment); else next.assessments.push(clone(assessment)); this.replaceState(next); } async loadAssessmentModel(assessmentId: string): Promise { return assessmentModel(this.state, assessmentId); } async saveReferenceRun(run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[]): Promise { const next = clone(this.state); if (appendReferenceRun(next, run, catalogs)) this.replaceState(next); } async listReferenceRuns(projectId: string): Promise { return clone(this.state.referenceRuns.filter(run => run.projectId === projectId)); } async listReferenceCatalogs(projectId: string): Promise { const hashes = new Set(this.state.referenceRuns.filter(run => run.projectId === projectId).flatMap(run => run.catalogHashes)); return clone(this.state.referenceCatalogs.filter(catalog => hashes.has(catalog.sha256))); } async beginImport(start: ImportSessionStart): Promise { if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.'); if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`); const project = this.state.projects.find(item => item.id === start.projectId); const assessment = this.state.assessments.find(item => item.id === start.assessmentId); if (!project || !assessment || assessment.projectId !== project.id) throw new RepositoryNotFoundError('Projekt oder Assessment für den Import fehlt.'); if (start.scanRun.assessmentId !== assessment.id) throw new RepositoryValidationError('Der Scan-Lauf gehört nicht zum gewählten Assessment.'); if (this.state.scanRuns.some(run => run.id === start.scanRun.id)) throw new RepositoryConflictError(`Scan-Lauf ${start.scanRun.id} ist bereits vorhanden.`); if (this.state.scanRuns.some(run => run.assessmentId === assessment.id && run.sequence === start.scanRun.sequence)) { throw new RepositoryConflictError(`Scan-Reihenfolge ${start.scanRun.sequence} ist bereits belegt.`); } const expectedRevision = this.stateRevision; this.activeImportSessions.add(start.sessionId); return new LocalImportSession( start.sessionId, staged => this.commitImport(start, staged, expectedRevision), async () => { this.activeImportSessions.delete(start.sessionId); } ); } async commitCanonicalImport(input: CanonicalImportCommit): Promise { const { model } = input; const fingerprintHashes = new Set(input.fingerprints.map(entry => entry.sha256)); if (model.importFiles.some(file => !file.sha256 || !fingerprintHashes.has(file.sha256)) || input.fingerprints.some(entry => !model.importFiles.some(file => file.sha256 === entry.sha256))) { throw new RepositoryValidationError('Kanonische Importdateien und Fingerprint-Auswahl stimmen nicht überein.'); } if (model.scanRuns.length !== 1) throw new RepositoryValidationError('Ein kanonischer Batchimport benötigt genau einen Scan-Lauf.'); try { assertCanonicalAssessmentModel(model); } catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Importmodell ist ungültig.'); } const next = clone(this.state); let reimportReviewTransfer: ReimportReviewTransferSummary | undefined; const importFiles = clone(model.importFiles); if (input.replacesScanRunId) { const replaced = next.scanRuns.find(run => run.id === input.replacesScanRunId); if (!replaced || replaced.assessmentId !== model.assessment.id || replaced.supersededByScanRunId) { throw new RepositoryValidationError('Der zu ersetzende Scanbericht ist nicht mehr als aktiver Lauf vorhanden.'); } if (compareReimportSourceHashes( next.importFiles.filter(file => file.scanRunId === replaced.id).map(file => file.sha256), model.importFiles.map(file => file.sha256) ) !== 'match') { throw new RepositoryValidationError('Der Reimport muss kryptografisch exakt denselben Scanbericht ersetzen.'); } const replacement = model.scanRuns[0]; const transfer = planReimportReviewTransfer( next.findingOccurrences.filter(item => item.scanRunId === replaced.id), model.findingOccurrences, next.reviews, replacement.importedAt ); reimportReviewTransfer = transfer.summary; if (importFiles[0]) importFiles[0].reimportReviewTransfer = { replacesScanRunId: replaced.id, ...transfer.summary }; appendUnique(next.reviews, transfer.reviews, 'Finding-Bewertung'); replaced.supersededByScanRunId = replacement.id; replaced.supersededAt = replacement.importedAt; replacement.supersedesScanRunId = replaced.id; } const assessmentRunIds = new Set(next.scanRuns .filter(run => run.assessmentId === model.assessment.id && !run.supersededByScanRunId) .map(run => run.id)); const processingIssues = processingScopeIssues([ ...next.importFiles.filter(file => assessmentRunIds.has(file.scanRunId)), ...importFiles ]); if (!input.replacesScanRunId && processingIssues.length) { throw new RepositoryValidationError('Der Timeslot enthält unbekannte oder semantisch inkompatible Verarbeitungsstände. Bitte vollständig mit dem aktuellen Profil reimportieren oder einen getrennten Timeslot verwenden.'); } const projectIndex = next.projects.findIndex(project => project.id === model.project.id); if (projectIndex >= 0) next.projects[projectIndex] = clone(model.project); else appendUnique(next.projects, [model.project], 'Projekt'); const assessmentIndex = next.assessments.findIndex(item => item.id === model.assessment.id); if (assessmentIndex >= 0 && next.assessments[assessmentIndex].projectId !== model.assessment.projectId) { throw new RepositoryConflictError('Ein Assessment kann nicht in ein anderes Projekt verschoben werden.'); } if (assessmentIndex >= 0) next.assessments[assessmentIndex] = clone(model.assessment); else next.assessments.push(clone(model.assessment)); appendUnique(next.scanRuns, model.scanRuns, 'Scan-Lauf'); appendUnique(next.importFiles, importFiles, 'Importdatei'); appendUnique(next.sourceTables, model.sourceTables, 'Quelltabelle'); appendUnique(next.reportSections, model.reportSections ?? [], 'Scanner-Reportabschnitt'); appendUnique(next.environmentObservations, model.environmentObservations ?? [], 'Umgebungsbeobachtung'); model.findingDefinitions.forEach(definition => { const existing = next.findingDefinitions.find(item => item.id === definition.id); if (existing && existing.canonicalKey !== definition.canonicalKey) throw new RepositoryConflictError(`Finding-Definition ${definition.id} besitzt einen widersprüchlichen kanonischen Schlüssel.`); if (!existing) next.findingDefinitions.push(clone(definition)); }); appendUnique(next.findingOccurrences, model.findingOccurrences, 'Finding-Vorkommen'); appendUnique(next.evidence, model.evidence, 'Evidenz'); appendUnique(next.diagnostics, model.diagnostics, 'Importdiagnose'); for (const referenceRun of model.referenceRuns ?? []) appendReferenceRun(next, referenceRun, input.referenceCatalogs ?? []); const importedAt = model.scanRuns[0].importedAt; input.fingerprints.forEach(entry => { if (!/^[a-f0-9]{64}$/.test(entry.sha256)) throw new RepositoryValidationError('SHA-256-Fingerabdruck ist ungültig.'); const index = next.importFingerprints.findIndex(item => item.sha256 === entry.sha256); if (index >= 0) next.importFingerprints[index] = { ...next.importFingerprints[index], ...clone(entry), lastImportedAt: importedAt, importCount: next.importFingerprints[index].importCount + 1 }; else next.importFingerprints.push({ ...clone(entry), firstImportedAt: importedAt, lastImportedAt: importedAt, importCount: 1 }); }); this.replaceState(next); return { sessionId: input.sessionId, scanRunId: model.scanRuns[0].id, repositoryRevision: this.stateRevision, importedFiles: model.importFiles.length, importedOccurrences: model.findingOccurrences.length, reimportReviewTransfer }; } async findImportFingerprints(hashes: string[]): Promise { const requested = new Set(hashes.map(hash => hash.toLowerCase())); return this.state.importFingerprints.filter(item => requested.has(item.sha256)).map(clone); } async recordImportFingerprints(entries: ImportFingerprintInput[], importedAt = new Date().toISOString()): Promise { const next = clone(this.state); entries.forEach(entry => { if (!/^[a-f0-9]{64}$/.test(entry.sha256)) throw new RepositoryValidationError('SHA-256-Fingerabdruck ist ungültig.'); const index = next.importFingerprints.findIndex(item => item.sha256 === entry.sha256); if (index >= 0) next.importFingerprints[index] = { ...next.importFingerprints[index], ...clone(entry), lastImportedAt: importedAt, importCount: next.importFingerprints[index].importCount + 1 }; else next.importFingerprints.push({ ...clone(entry), firstImportedAt: importedAt, lastImportedAt: importedAt, importCount: 1 }); }); this.replaceState(next); return this.findImportFingerprints(entries.map(entry => entry.sha256)); } private async commitImport(start: ImportSessionStart, staged: Required, expectedRevision: number): Promise { if (this.stateRevision !== expectedRevision) throw new RepositoryConflictError('Repository wurde seit Importbeginn verändert; der Import muss erneut gestartet werden.'); const next = clone(this.state); appendUnique(next.scanRuns, [start.scanRun], 'Scan-Lauf'); if (!staged.importFiles.length) throw new RepositoryValidationError('Ein abgeschlossener Import benötigt mindestens eine Importdatei.'); if (staged.importFiles.some(file => file.scanRunId !== start.scanRun.id)) throw new RepositoryValidationError('Alle Importdateien müssen zum gestarteten Scan-Lauf gehören.'); const stagedFileIds = new Set(staged.importFiles.map(file => file.id)); const stagedTableIds = new Set(staged.sourceTables.map(table => table.id)); const stagedOccurrenceIds = new Set(staged.findingOccurrences.map(occurrence => occurrence.id)); if (staged.sourceTables.some(table => !stagedFileIds.has(table.importFileId))) throw new RepositoryValidationError('Alle Quelltabellen müssen zu einer Datei derselben Importsitzung gehören.'); if (staged.reportSections.some(section => section.scanRunId !== start.scanRun.id || !stagedFileIds.has(section.importFileId))) { throw new RepositoryValidationError('Alle Scanner-Reportabschnitte müssen zum gestarteten Scan-Lauf und dessen Importdateien gehören.'); } if (staged.environmentObservations.some(observation => observation.scanRunId !== start.scanRun.id || !stagedFileIds.has(observation.importFileId))) { throw new RepositoryValidationError('Alle Umgebungsbeobachtungen müssen zum gestarteten Scan-Lauf und dessen Importdateien gehören.'); } if (staged.findingOccurrences.some(occurrence => occurrence.scanRunId !== start.scanRun.id || !stagedFileIds.has(occurrence.importFileId))) { throw new RepositoryValidationError('Alle Finding-Vorkommen müssen zum gestarteten Scan-Lauf und dessen Importdateien gehören.'); } if (staged.findingOccurrences.some(occurrence => occurrence.sourceTableId && !stagedTableIds.has(occurrence.sourceTableId))) { throw new RepositoryValidationError('Finding-Vorkommen dürfen nur Quelltabellen derselben Importsitzung referenzieren.'); } if (staged.evidence.some(record => !stagedOccurrenceIds.has(record.occurrenceId))) throw new RepositoryValidationError('Evidenz muss zu einem Finding derselben Importsitzung gehören.'); if (staged.diagnostics.some(diagnostic => !stagedFileIds.has(diagnostic.importFileId) || (diagnostic.sourceTableId && !stagedTableIds.has(diagnostic.sourceTableId)))) { throw new RepositoryValidationError('Importdiagnosen müssen zu Dateien und Tabellen derselben Importsitzung gehören.'); } appendUnique(next.importFiles, staged.importFiles, 'Importdatei'); appendUnique(next.sourceTables, staged.sourceTables, 'Quelltabelle'); appendUnique(next.reportSections, staged.reportSections, 'Scanner-Reportabschnitt'); appendUnique(next.environmentObservations, staged.environmentObservations, 'Umgebungsbeobachtung'); staged.findingDefinitions.forEach(definition => { const index = next.findingDefinitions.findIndex(item => item.id === definition.id); if (index < 0) next.findingDefinitions.push(clone(definition)); else if (next.findingDefinitions[index].canonicalKey !== definition.canonicalKey) { throw new RepositoryConflictError(`Finding-Definition ${definition.id} besitzt einen widersprüchlichen kanonischen Schlüssel.`); } else next.findingDefinitions[index] = clone(definition); }); appendUnique(next.findingOccurrences, staged.findingOccurrences, 'Finding-Vorkommen'); appendUnique(next.evidence, staged.evidence, 'Evidenz'); appendUnique(next.diagnostics, staged.diagnostics, 'Importdiagnose'); const model = assessmentModel(next, start.assessmentId); if (!model) throw new RepositoryValidationError('Das importierte Assessment konnte nicht aufgebaut werden.'); try { assertCanonicalAssessmentModel(model); } catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Importmodell ist ungültig.'); } this.replaceState(next); this.activeImportSessions.delete(start.sessionId); return { sessionId: start.sessionId, scanRunId: start.scanRun.id, repositoryRevision: this.stateRevision, importedFiles: staged.importFiles.length, importedOccurrences: staged.findingOccurrences.length }; } async findOccurrences(query: FindingOccurrenceQuery = {}): Promise { const assessmentIds = new Set(this.state.assessments .filter(assessment => !query.projectId || assessment.projectId === query.projectId) .filter(assessment => !query.assessmentId || assessment.id === query.assessmentId) .map(assessment => assessment.id)); const runIds = new Set(this.state.scanRuns .filter(run => assessmentIds.has(run.assessmentId)) .filter(run => !run.supersededByScanRunId) .filter(run => !query.scanRunId || run.id === query.scanRunId) .map(run => run.id)); const normalizedTool = query.toolId ? normalizeComparisonText(query.toolId) : ''; const normalizedSearch = query.search ? normalizeComparisonText(query.search) : ''; const filtered = this.state.findingOccurrences .filter(occurrence => runIds.has(occurrence.scanRunId)) .filter(occurrence => !query.definitionId || occurrence.definitionId === query.definitionId) .filter(occurrence => !normalizedTool || normalizeComparisonText(occurrence.sourceIdentity.tool.id) === normalizedTool) .filter(occurrence => !query.severity || occurrence.severity.normalizedValue === query.severity) .filter(occurrence => !query.status || occurrence.status.normalizedValue === query.status) .filter(occurrence => { if (!normalizedSearch) return true; const definition = this.state.findingDefinitions.find(item => item.id === occurrence.definitionId); return normalizeComparisonText(JSON.stringify({ occurrence, definition })).includes(normalizedSearch); }) .sort((left, right) => left.scanRunId.localeCompare(right.scanRunId) || left.id.localeCompare(right.id)); const offset = Math.max(0, query.offset ?? 0); const limit = Math.min(10000, Math.max(0, query.limit ?? 1000)); return { total: filtered.length, items: clone(filtered.slice(offset, offset + limit)) }; } private unifiedScannerResults(): UnifiedResultProjection[] { return this.state.findingOccurrences.flatMap(occurrence => { const scanRun = this.state.scanRuns.find(item => item.id === occurrence.scanRunId); const importFile = this.state.importFiles.find(item => item.id === occurrence.importFileId); const definition = this.state.findingDefinitions.find(item => item.id === occurrence.definitionId); const assessment = scanRun ? this.state.assessments.find(item => item.id === scanRun.assessmentId) : undefined; const project = assessment ? this.state.projects.find(item => item.id === assessment.projectId) : undefined; if (!scanRun || scanRun.supersededByScanRunId || !importFile || !definition || !assessment || !project) return []; return [createScannerUnifiedResult({ project, assessment, scanRun, importFile, definition, occurrence, reviews: this.state.reviews })]; }); } private unifiedResults(audience: UnifiedResultQuery['audience'] = 'result-db'): UnifiedResultProjection[] { const policy = this.state.policyFindingRuns.flatMap(record => record.occurrences.flatMap(occurrence => { const definition = this.state.findingDefinitions.find(item => item.id === occurrence.definitionId); const comparison = this.state.policyEvaluations.find(item => item.run.id === occurrence.evaluationRunId) ?.results.find(item => item.id === occurrence.evaluationResultId); return definition ? [createPolicyUnifiedResult({ definition, occurrence, comparison })] : []; })); const projectedEvaluationResultIds = new Set(this.state.policyFindingRuns .flatMap(record => record.occurrences.map(occurrence => occurrence.evaluationResultId))); const policyEvaluations = createCurrentPolicyEvaluationUnifiedResults( this.state.policyEvaluations, this.state.policySnapshotAggregations, this.state.gpoPolicyAnalyses, this.state.importArtifacts, projectedEvaluationResultIds ); const environment = this.state.environmentObservations.flatMap(observation => { const scanRun = this.state.scanRuns.find(item => item.id === observation.scanRunId); const importFile = this.state.importFiles.find(item => item.id === observation.importFileId); const assessment = scanRun ? this.state.assessments.find(item => item.id === scanRun.assessmentId) : undefined; const project = assessment ? this.state.projects.find(item => item.id === assessment.projectId) : undefined; if (!scanRun || scanRun.supersededByScanRunId || !importFile || !assessment || !project) return []; const result = createEnvironmentUnifiedResult({ project, assessment, scanRun, importFile, observation }); return result ? [result] : []; }); const currentPolicyObservations = createCurrentPolicyObservationUnifiedResults( this.state.importArtifacts, this.state.processingRuns, this.state.gpoBackupInstances, this.state.gpoPolicyAnalyses ); const policyObservations = audience === 'internal-analysis' ? currentPolicyObservations : []; const policyConsistency = audience === 'result-db' ? createPolicyConsistencyUnifiedResults(currentPolicyObservations) : []; const results = [...this.unifiedScannerResults(), ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy]; return audience === 'internal-analysis' ? results : results.map(discloseResultDbProjection); } async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise { const result = queryUnifiedResultItems(this.unifiedResults(query.audience), query); return clone(query.audience === 'internal-analysis' ? result : { ...result, items: result.items.map(removeResultDbScopeIdentifiers) }); } async queryUnifiedResultFacets(query: UnifiedResultQuery = {}): Promise { return clone(deriveUnifiedResultFacets(this.unifiedResults(query.audience), query)); } async getUnifiedResultById(resultProjectionId: string): Promise { const result = this.unifiedResults().find(item => item.resultProjectionId === resultProjectionId); return result ? clone(removeResultDbScopeIdentifiers(result)) : null; } async listResultCategories(query: UnifiedResultQuery = {}): Promise { return clone(listUnifiedResultCategories(filterUnifiedResults(this.unifiedResults(query.audience), query))); } async adoptScanRunReplacement(projectId: string, replacedScanRunId: string, replacementScanRunId: string, supersededAt: string): Promise { if (replacedScanRunId === replacementScanRunId || !Number.isFinite(Date.parse(supersededAt))) { throw new RepositoryValidationError('Die Scan-Ersatzverknüpfung ist ungültig.'); } const replaced = this.state.scanRuns.find(run => run.id === replacedScanRunId); const replacement = this.state.scanRuns.find(run => run.id === replacementScanRunId); const assessment = replaced && this.state.assessments.find(item => item.id === replaced.assessmentId); if (!replaced || !replacement || !assessment || assessment.projectId !== projectId) { throw new RepositoryNotFoundError('Die beiden Scanberichte sind im bestätigten Mandanten nicht vorhanden.'); } if (replaced.assessmentId !== replacement.assessmentId || replaced.tool.id !== replacement.tool.id) { throw new RepositoryValidationError('Als Ersatz ist nur ein Scan desselben Werkzeugs im selben Timeslot zulässig.'); } if (compareReimportSourceHashes( this.state.importFiles.filter(file => file.scanRunId === replacedScanRunId).map(file => file.sha256), this.state.importFiles.filter(file => file.scanRunId === replacementScanRunId).map(file => file.sha256) ) !== 'match') { throw new RepositoryValidationError('Als Ersatz ist nur kryptografisch exakt derselbe Scanbericht zulässig.'); } if (replaced.supersededByScanRunId || replacement.supersededByScanRunId || replacement.supersedesScanRunId || Date.parse(replacement.importedAt) <= Date.parse(replaced.importedAt)) { throw new RepositoryConflictError('Die Scanberichte sind nicht mehr als eindeutige aktive Vorgänger-/Nachfolgerkombination verfügbar.'); } const next = clone(this.state); const transfer = planReimportReviewTransfer( next.findingOccurrences.filter(item => item.scanRunId === replacedScanRunId), next.findingOccurrences.filter(item => item.scanRunId === replacementScanRunId), next.reviews, supersededAt ); appendUnique(next.reviews, transfer.reviews, 'Finding-Bewertung'); const replacementFile = next.importFiles.find(file => file.scanRunId === replacementScanRunId); if (replacementFile) replacementFile.reimportReviewTransfer = { replacesScanRunId: replacedScanRunId, ...transfer.summary }; const replacedNext = next.scanRuns.find(run => run.id === replacedScanRunId)!; const replacementNext = next.scanRuns.find(run => run.id === replacementScanRunId)!; replacedNext.supersededByScanRunId = replacementScanRunId; replacedNext.supersededAt = supersededAt; replacementNext.supersedesScanRunId = replacedScanRunId; this.replaceState(next); return transfer.summary; } async getEnvironmentSnapshot(query: EnvironmentSnapshotQuery): Promise { const project = this.state.projects.find(item => item.id === query.projectId); if (!project) throw new RepositoryNotFoundError(`Projekt ${query.projectId} ist nicht vorhanden.`); const assessmentIds = new Set(this.state.assessments .filter(item => item.projectId === query.projectId && (!query.assessmentId || item.id === query.assessmentId)) .map(item => item.id)); const allowedRuns = this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId) && !item.supersededByScanRunId); const requested = query.scanRunIds?.length ? new Set(query.scanRunIds) : undefined; if (requested && [...requested].some(id => !allowedRuns.some(run => run.id === id))) { throw new RepositoryValidationError('Environment Snapshot verweist auf einen Scan-Lauf außerhalb des Projekts oder Assessments.'); } const runIds = allowedRuns.map(run => run.id).filter(id => !requested || requested.has(id)); const selected = new Set(runIds); const sections = byId(this.state.reportSections.filter(item => selected.has(item.scanRunId))).map(clone); const observations = byId(this.state.environmentObservations.filter(item => selected.has(item.scanRunId))).map(clone); return { version: 1, projectId: query.projectId, assessmentId: query.assessmentId, scanRunIds: runIds.sort(), generatedAt: new Date().toISOString(), sections, observations, coverage: environmentCoverage(sections, observations) }; } async saveImportArtifact(artifact: ImportArtifact): Promise { const errors = importArtifactValidationErrors(artifact); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const project = this.state.projects.find(item => item.id === artifact.projectId); if (!project) throw new RepositoryNotFoundError(`Projekt ${artifact.projectId} ist nicht vorhanden.`); if (artifact.assessmentId && !this.state.assessments.some(item => item.id === artifact.assessmentId && item.projectId === artifact.projectId)) { throw new RepositoryNotFoundError('Artefakt-Assessment gehört nicht zum Projekt.'); } if (artifact.scanRunId) { const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === artifact.projectId).map(item => item.id)); if (!this.state.scanRuns.some(item => item.id === artifact.scanRunId && assessmentIds.has(item.assessmentId))) { throw new RepositoryNotFoundError('Artefakt-Scan-Lauf gehört nicht zum Projekt.'); } } if (artifact.importFileId) { const projectAssessmentIds = new Set(this.state.assessments.filter(item => item.projectId === artifact.projectId).map(item => item.id)); const projectRunIds = new Set(this.state.scanRuns.filter(item => projectAssessmentIds.has(item.assessmentId)).map(item => item.id)); if (!this.state.importFiles.some(item => item.id === artifact.importFileId && projectRunIds.has(item.scanRunId))) { throw new RepositoryNotFoundError('Referenzierte Importdatei gehört nicht zum Projekt.'); } } if (artifact.parentArtifactId && !this.state.importArtifacts.some(item => item.id === artifact.parentArtifactId && item.projectId === artifact.projectId)) { throw new RepositoryNotFoundError('Parent-Artefakt ist nicht im selben Projekt vorhanden.'); } const next = clone(this.state); appendUnique(next.importArtifacts, [artifact], 'Importartefakt'); this.replaceState(next); } async saveImportArtifactAndEnqueueJob(artifact: ImportArtifact, job: IngestionJob) { const errors = [...importArtifactValidationErrors(artifact), ...ingestionJobValidationErrors(job)]; if (job.status !== 'queued' || job.attempts !== 0) errors.push('Ein neuer Job muss ohne Versuche in queued beginnen.'); if (job.projectId !== artifact.projectId || job.artifactId !== artifact.id) errors.push('Artefakt und Job müssen dieselbe Projekt- und Artefaktidentität besitzen.'); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); if (!this.state.projects.some(item => item.id === artifact.projectId)) throw new RepositoryNotFoundError(`Projekt ${artifact.projectId} ist nicht vorhanden.`); if (artifact.assessmentId && !this.state.assessments.some(item => item.id === artifact.assessmentId && item.projectId === artifact.projectId)) { throw new RepositoryNotFoundError('Artefakt-Assessment gehört nicht zum Projekt.'); } if (artifact.scanRunId) { const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === artifact.projectId).map(item => item.id)); if (!this.state.scanRuns.some(item => item.id === artifact.scanRunId && assessmentIds.has(item.assessmentId))) throw new RepositoryNotFoundError('Artefakt-Scan-Lauf gehört nicht zum Projekt.'); } if (artifact.importFileId) { const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === artifact.projectId).map(item => item.id)); const runIds = new Set(this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId)).map(item => item.id)); if (!this.state.importFiles.some(item => item.id === artifact.importFileId && runIds.has(item.scanRunId))) throw new RepositoryNotFoundError('Referenzierte Importdatei gehört nicht zum Projekt.'); } if (artifact.parentArtifactId && !this.state.importArtifacts.some(item => item.id === artifact.parentArtifactId && item.projectId === artifact.projectId)) { throw new RepositoryNotFoundError('Parent-Artefakt ist nicht im selben Projekt vorhanden.'); } const storedArtifact = this.state.importArtifacts.find(item => item.id === artifact.id); if (storedArtifact && canonicalJson(storedArtifact) !== canonicalJson(artifact)) throw new RepositoryConflictError(`Importartefakt ${artifact.id} ist bereits mit abweichenden Daten vorhanden.`); const storedJob = this.state.ingestionJobs.find(item => item.projectId === job.projectId && item.idempotencyKey === job.idempotencyKey); if (storedJob && (storedJob.kind !== job.kind || storedJob.artifactId !== job.artifactId)) throw new RepositoryConflictError('Idempotenzschlüssel bezeichnet einen anderen Job.'); const next = clone(this.state); if (!storedArtifact) appendUnique(next.importArtifacts, [artifact], 'Importartefakt'); if (!storedJob) appendUnique(next.ingestionJobs, [job], 'Ingestion Job'); if (!storedArtifact || !storedJob) this.replaceState(next); return { artifact: clone(storedArtifact ?? artifact), job: clone(storedJob ?? job), artifactCreated: !storedArtifact, jobCreated: !storedJob }; } async listImportArtifacts(projectId: string): Promise { return byId(this.state.importArtifacts.filter(item => item.projectId === projectId)).map(clone); } async saveProcessingRun(run: ImportProcessingRun): Promise { const errors = processingRunValidationErrors(run); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const artifact = this.state.importArtifacts.find(item => item.id === run.artifactId && item.projectId === run.projectId); if (!artifact) throw new RepositoryNotFoundError('Processing-Artefakt ist nicht im Projekt vorhanden.'); if (run.supersedesRunId && !this.state.processingRuns.some(item => item.id === run.supersedesRunId && item.artifactId === run.artifactId)) { throw new RepositoryNotFoundError('Der ersetzte Processing Run gehört nicht zum Artefakt.'); } const next = clone(this.state); appendUnique(next.processingRuns, [run], 'Processing Run'); this.replaceState(next); } async listProcessingRuns(artifactId: string): Promise { return this.state.processingRuns.filter(item => item.artifactId === artifactId).map(clone) .sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt) || left.id.localeCompare(right.id)); } async listProjectProcessingRuns(projectId: string): Promise { return this.state.processingRuns.filter(item => item.projectId === projectId).map(clone) .sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt) || left.id.localeCompare(right.id)); } async saveGpoBackupAnalysis(run: ImportProcessingRun, instances: StoredGpoBackupInstance[], policyAnalysis?: StoredGpoPolicyAnalysis): Promise { const errors = [ ...processingRunValidationErrors(run), ...instances.flatMap(storedGpoBackupInstanceValidationErrors), ...(policyAnalysis ? storedGpoPolicyAnalysisValidationErrors(policyAnalysis) : []) ]; if (run.processors.every(processor => processor.kind !== 'artifact-parser')) errors.push('Ein GPO-Processing Run benötigt einen Artefaktparser.'); if (policyAnalysis && (policyAnalysis.run.projectId !== run.projectId || policyAnalysis.run.artifactId !== run.artifactId || policyAnalysis.run.sourceProcessingRunId !== run.id)) { errors.push('Die Policy-Identitätsanalyse gehört nicht zum GPO-Processing Run.'); } if (instances.some(record => record.projectId !== run.projectId || record.artifactId !== run.artifactId || record.processingRunId !== run.id)) { errors.push('GPO-Backupinstanzen gehören nicht vollständig zum Processing Run.'); } if (new Set(instances.map(record => record.id)).size !== instances.length || new Set(instances.map(record => record.instance.id)).size !== instances.length) { errors.push('GPO-Backupinstanzen eines Processing Runs müssen eindeutig sein.'); } if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const existingRun = this.state.processingRuns.find(item => item.id === run.id); if (existingRun) { const existingInstances = byId(this.state.gpoBackupInstances.filter(item => item.processingRunId === run.id)); const existingPolicy = this.state.gpoPolicyAnalyses.find(item => item.run.sourceProcessingRunId === run.id); if (canonicalJson(existingRun) === canonicalJson(run) && canonicalJson(existingInstances) === canonicalJson(byId(instances))) { if (!policyAnalysis && !existingPolicy) return; if (policyAnalysis && existingPolicy && canonicalJson(existingPolicy) === canonicalJson(policyAnalysis)) return; } throw new RepositoryConflictError(`GPO-Processing Run ${run.id} ist bereits mit abweichenden Daten vorhanden.`); } if (!this.state.importArtifacts.some(item => item.id === run.artifactId && item.projectId === run.projectId)) { throw new RepositoryNotFoundError('GPO-Processing-Artefakt ist nicht im Projekt vorhanden.'); } if (run.supersedesRunId && !this.state.processingRuns.some(item => item.id === run.supersedesRunId && item.projectId === run.projectId && item.artifactId === run.artifactId)) { throw new RepositoryNotFoundError('Der zu ersetzende GPO-Processing Run ist nicht vorhanden.'); } if (policyAnalysis?.run.supersedesAnalysisRunId && !this.state.gpoPolicyAnalyses.some(item => item.run.id === policyAnalysis.run.supersedesAnalysisRunId)) { throw new RepositoryNotFoundError('Der zu ersetzende Policy-Identitätslauf ist nicht vorhanden.'); } const next = clone(this.state); appendUnique(next.processingRuns, [run], 'Processing Run'); appendUnique(next.gpoBackupInstances, instances, 'GPO-Backupinstanz'); if (policyAnalysis) next.gpoPolicyAnalyses.push(clone(policyAnalysis)); this.replaceState(next); } async listGpoBackupInstances(projectId: string): Promise { return byId(this.state.gpoBackupInstances.filter(item => item.projectId === projectId)).map(clone); } async listGpoPolicyAnalyses(projectId: string): Promise { return this.state.gpoPolicyAnalyses.filter(item => item.run.projectId === projectId).map(clone) .sort((left, right) => Date.parse(left.run.completedAt) - Date.parse(right.run.completedAt) || left.run.id.localeCompare(right.run.id)); } async saveEnvironmentProfile(profile: EnvironmentProfile): Promise { const errors = environmentProfileValidationErrors(profile); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); if (!this.state.projects.some(project => project.id === profile.projectId)) { throw new RepositoryNotFoundError('Das Projekt des Environment Profiles ist nicht vorhanden.'); } const existing = this.state.environmentProfiles.find(item => item.id === profile.id); if (existing) { if (canonicalJson(existing) === canonicalJson(profile)) return; throw new RepositoryConflictError('Das Environment Profile ist bereits mit abweichenden Daten vorhanden.'); } const predecessor = profile.supersedesProfileId ? this.state.environmentProfiles.find(item => item.id === profile.supersedesProfileId && item.projectId === profile.projectId) : undefined; if (profile.supersedesProfileId && !predecessor) { throw new RepositoryNotFoundError('Das zu ersetzende Environment Profile ist nicht vorhanden.'); } if (profile.revision !== (predecessor?.revision ?? 0) + 1) { throw new RepositoryValidationError('Die Revision des Environment Profiles ist nicht fortlaufend.'); } const next = clone(this.state); next.environmentProfiles.push(clone(profile)); this.replaceState(next); } async listEnvironmentProfiles(projectId: string): Promise { return this.state.environmentProfiles.filter(item => item.projectId === projectId).map(clone) .sort((left, right) => left.revision - right.revision || Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.id.localeCompare(right.id)); } async savePolicySnapshotAggregation(record: StoredPolicySnapshotAggregation): Promise { const errors = storedPolicySnapshotAggregationValidationErrors(record); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const profile = this.state.environmentProfiles.find(item => item.id === record.run.environmentProfileId && item.projectId === record.run.projectId); if (!profile) throw new RepositoryNotFoundError('Das Environment Profile des Aggregationslaufs ist nicht vorhanden.'); const sourceIds = new Set(record.run.sourceAnalysisRunIds); const sources = this.state.gpoPolicyAnalyses.filter(item => item.run.projectId === record.run.projectId && sourceIds.has(item.run.id)); if (sources.length !== sourceIds.size) { throw new RepositoryNotFoundError('Mindestens ein Quellanalyselauf der Policyaggregation ist nicht vorhanden.'); } const processingRunIds = new Set(sources.map(source => source.run.sourceProcessingRunId)); const validInstanceIds = new Set(this.state.gpoBackupInstances .filter(item => processingRunIds.has(item.processingRunId)).map(item => item.instance.id)); if (record.snapshot.instanceIds.some(id => !validInstanceIds.has(id))) { throw new RepositoryValidationError('Der Aggregationssnapshot verweist auf eine fremde Backupinstanz.'); } const validIdentityIds = new Set(sources.flatMap(source => source.resolutions.flatMap(resolution => resolution.resolution.status === 'resolved' ? [resolution.resolution.identity.id] : []))); if (record.states.some(state => !validIdentityIds.has(state.identity.id))) { throw new RepositoryValidationError('Ein aggregierter Policyzustand verweist auf eine fremde technische Identitaet.'); } const existing = this.state.policySnapshotAggregations.find(item => item.run.id === record.run.id); if (existing) { if (canonicalJson(existing) === canonicalJson(record)) return; throw new RepositoryConflictError('Der Policy-Snapshot-Aggregationslauf ist bereits mit abweichenden Daten vorhanden.'); } const next = clone(this.state); next.policySnapshotAggregations.push(clone(record)); this.replaceState(next); } async listPolicySnapshotAggregations(projectId: string): Promise { return this.state.policySnapshotAggregations.filter(item => item.run.projectId === projectId).map(clone) .sort((left, right) => Date.parse(left.run.completedAt) - Date.parse(right.run.completedAt) || left.run.id.localeCompare(right.run.id)); } async savePolicyEvaluation(record: StoredPolicyEvaluation): Promise { const errors = storedPolicyEvaluationValidationErrors(record); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const aggregation = this.state.policySnapshotAggregations.find(item => item.run.id === record.run.aggregationRunId && item.run.projectId === record.run.projectId); if (!aggregation) throw new RepositoryNotFoundError('Der Aggregationslauf der Policy-Evaluation ist nicht vorhanden.'); if (aggregation.run.environmentProfileId !== record.run.environmentProfileId) { throw new RepositoryValidationError('Die Policy-Evaluation verweist auf ein anderes Environment Profile.'); } const sourceIds = new Set(record.run.baselineAnalysisIds); const sources = this.state.microsoftSecurityBaselineAnalyses.filter(item => sourceIds.has(item.analysis.id)); if (sources.length !== sourceIds.size) { throw new RepositoryNotFoundError('Mindestens eine Baselineanalyse der Policy-Evaluation ist nicht vorhanden.'); } const states = new Map(aggregation.states.map(state => [state.id, state])); const baselineIdentities = new Set(sources.flatMap(source => source.analysis.settings.flatMap(setting => setting.identity.status === 'resolved' ? [setting.identity.identity.id] : []))); if (record.results.some(result => { if (!baselineIdentities.has(result.identity.id)) return true; if (!result.aggregationStateId) return result.customerState !== 'missing'; const state = states.get(result.aggregationStateId); return !state || state.identity.id !== result.identity.id; })) { throw new RepositoryValidationError('Ein Evaluationsergebnis verweist auf einen fremden Aggregationszustand.'); } const existing = this.state.policyEvaluations.find(item => item.run.id === record.run.id); if (existing) { if (canonicalJson(existing) === canonicalJson(record)) return; throw new RepositoryConflictError('Die Policy-Evaluation ist bereits mit abweichenden Daten vorhanden.'); } const next = clone(this.state); next.policyEvaluations.push(clone(record)); this.replaceState(next); } async listPolicyEvaluations(projectId: string): Promise { return this.state.policyEvaluations.filter(item => item.run.projectId === projectId).map(clone) .sort((left, right) => Date.parse(left.run.completedAt) - Date.parse(right.run.completedAt) || left.run.id.localeCompare(right.run.id)); } async savePolicyFindingRun(record: StoredPolicyFindingRun): Promise { const errors = storedPolicyFindingRunValidationErrors(record); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); const evaluation = this.state.policyEvaluations.find(item => item.run.id === record.run.evaluationRunId && item.run.projectId === record.run.projectId); if (!evaluation) throw new RepositoryNotFoundError('Die Evaluation des Policy-Finding-Laufs ist nicht vorhanden.'); const evaluationResults = new Set(evaluation.results.map(result => result.id)); if (record.occurrences.some(occurrence => !evaluationResults.has(occurrence.evaluationResultId))) { throw new RepositoryValidationError('Ein Policy-Finding verweist auf ein fremdes Evaluationsergebnis.'); } const existing = this.state.policyFindingRuns.find(item => item.run.id === record.run.id); if (existing) { if (canonicalJson(existing) === canonicalJson(record)) return; throw new RepositoryConflictError('Der Policy-Finding-Lauf ist bereits mit abweichenden Daten vorhanden.'); } for (const definition of record.definitions) { const byDefinitionId = this.state.findingDefinitions.find(item => item.id === definition.id); const byCanonicalKey = this.state.findingDefinitions.find(item => item.canonicalKey === definition.canonicalKey); if ((byDefinitionId && canonicalJson(byDefinitionId) !== canonicalJson(definition)) || (byCanonicalKey && byCanonicalKey.id !== definition.id)) { throw new RepositoryConflictError('Eine Policy-Finding-Definition kollidiert mit einer vorhandenen Definition.'); } } const next = clone(this.state); for (const definition of record.definitions) { if (!next.findingDefinitions.some(item => item.id === definition.id)) next.findingDefinitions.push(clone(definition)); } next.policyFindingRuns.push(clone(record)); this.replaceState(next); } async listPolicyFindingRuns(projectId: string): Promise { return this.state.policyFindingRuns.filter(item => item.run.projectId === projectId).map(clone) .sort((left, right) => Date.parse(left.run.completedAt) - Date.parse(right.run.completedAt) || left.run.id.localeCompare(right.run.id)); } async saveMicrosoftSecurityBaseline(record: MicrosoftSecurityBaselineRecord): Promise { if (!record.id.trim() || !/^[a-f0-9]{64}$/.test(record.sha256) || !record.storageKey.trim()) throw new RepositoryValidationError('Microsoft baseline record is invalid.'); const existing = this.state.microsoftSecurityBaselines.find(item => item.id === record.id || item.sha256 === record.sha256); if (existing) { if (canonicalJson(existing) === canonicalJson(record)) return; throw new RepositoryConflictError('Microsoft baseline already exists with different metadata.'); } const next = clone(this.state); next.microsoftSecurityBaselines.push(clone(record)); this.replaceState(next); } async listMicrosoftSecurityBaselines(): Promise { return this.state.microsoftSecurityBaselines.map(clone).sort((left, right) => Date.parse(right.importedAt) - Date.parse(left.importedAt) || left.id.localeCompare(right.id)); } async deleteMicrosoftSecurityBaseline(baselineId: string): Promise { const record = this.state.microsoftSecurityBaselines.find(item => item.id === baselineId); if (!record) return null; const analysisIds = new Set(this.state.microsoftSecurityBaselineAnalyses .filter(item => item.analysis.baselineId === baselineId).map(item => item.analysis.id)); const next = clone(this.state); const evaluationIds = new Set(next.policyEvaluations .filter(item => item.run.baselineAnalysisIds.some(id => analysisIds.has(id))) .map(item => item.run.id)); next.policyFindingRuns = next.policyFindingRuns.filter(item => !evaluationIds.has(item.run.evaluationRunId)); next.policyEvaluations = next.policyEvaluations.filter(item => !evaluationIds.has(item.run.id)); next.microsoftSecurityBaselines = next.microsoftSecurityBaselines.filter(item => item.id !== baselineId); next.microsoftSecurityBaselineClassifications = next.microsoftSecurityBaselineClassifications.filter(item => item.baselineId !== baselineId); next.microsoftSecurityBaselineAnalyses = next.microsoftSecurityBaselineAnalyses.filter(item => item.analysis.baselineId !== baselineId); this.replaceState(next); return clone(record); } async saveMicrosoftSecurityBaselineClassification(classification: MicrosoftSecurityBaselineClassification): Promise { if (!this.state.microsoftSecurityBaselines.some(item => item.id === classification.baselineId)) throw new RepositoryNotFoundError('Microsoft baseline package is not present.'); const existing = this.state.microsoftSecurityBaselineClassifications.find(item => item.id === classification.id || (item.baselineId === classification.baselineId && item.detectorVersion === classification.detectorVersion)); if (existing) { if (canonicalJson(existing) === canonicalJson(classification)) return; throw new RepositoryConflictError('Microsoft baseline classification already exists with different data.'); } if (classification.supersedesClassificationId && !this.state.microsoftSecurityBaselineClassifications.some(item => item.id === classification.supersedesClassificationId && item.baselineId === classification.baselineId)) throw new RepositoryNotFoundError('Superseded baseline classification is not present.'); const next = clone(this.state); next.microsoftSecurityBaselineClassifications.push(clone(classification)); this.replaceState(next); } async listMicrosoftSecurityBaselineClassifications(): Promise { return this.state.microsoftSecurityBaselineClassifications.map(clone).sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.id.localeCompare(right.id)); } async saveMicrosoftSecurityBaselineAnalysis(record: StoredMicrosoftSecurityBaselineAnalysis): Promise { const errors = storedMicrosoftSecurityBaselineAnalysisValidationErrors(record); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); if (!this.state.microsoftSecurityBaselines.some(item => item.id === record.analysis.baselineId && item.sha256 === record.analysis.packageSha256)) { throw new RepositoryNotFoundError('Das Microsoft-Baselinepaket der Analyse ist nicht vorhanden.'); } const existing = this.state.microsoftSecurityBaselineAnalyses.find(item => item.analysis.id === record.analysis.id); if (existing) { if (canonicalJson(existing) === canonicalJson(record)) return; throw new RepositoryConflictError('Die Microsoft-Baselineanalyse ist bereits mit abweichenden Daten vorhanden.'); } if (record.supersedesAnalysisId && !this.state.microsoftSecurityBaselineAnalyses.some(item => item.analysis.id === record.supersedesAnalysisId && item.analysis.baselineId === record.analysis.baselineId)) { throw new RepositoryNotFoundError('Die zu ersetzende Microsoft-Baselineanalyse ist nicht vorhanden.'); } const next = clone(this.state); next.microsoftSecurityBaselineAnalyses.push(clone(record)); this.replaceState(next); } async listMicrosoftSecurityBaselineAnalyses(baselineId?: string): Promise { return this.state.microsoftSecurityBaselineAnalyses.filter(item => !baselineId || item.analysis.baselineId === baselineId).map(clone) .sort((left, right) => Date.parse(left.analysis.analyzedAt) - Date.parse(right.analysis.analyzedAt) || left.analysis.id.localeCompare(right.analysis.id)); } async enqueueIngestionJob(job: IngestionJob): Promise { const errors = ingestionJobValidationErrors(job); if (errors.length) throw new RepositoryValidationError(errors.join(' ')); if (job.status !== 'queued' || job.attempts !== 0) throw new RepositoryValidationError('Ein neuer Job muss ohne Versuche in queued beginnen.'); if (!this.state.projects.some(item => item.id === job.projectId)) throw new RepositoryNotFoundError(`Projekt ${job.projectId} ist nicht vorhanden.`); if (job.artifactId && !this.state.importArtifacts.some(item => item.id === job.artifactId && item.projectId === job.projectId)) { throw new RepositoryNotFoundError('Job-Artefakt ist nicht im Projekt vorhanden.'); } const existing = this.state.ingestionJobs.find(item => item.projectId === job.projectId && item.idempotencyKey === job.idempotencyKey); if (existing) { if (existing.kind !== job.kind || existing.artifactId !== job.artifactId) throw new RepositoryConflictError('Idempotenzschlüssel bezeichnet einen anderen Job.'); return clone(existing); } const next = clone(this.state); appendUnique(next.ingestionJobs, [job], 'Ingestion Job'); this.replaceState(next); return clone(job); } async claimNextIngestionJob(claim: IngestionJobClaim): Promise { if (!claim.workerId.trim() || !Number.isInteger(claim.leaseDurationMs) || claim.leaseDurationMs < 1000 || claim.leaseDurationMs > 3600000 || !Number.isFinite(Date.parse(claim.now))) { throw new RepositoryValidationError('Worker, Zeitpunkt oder Lease-Dauer sind ungültig.'); } const now = Date.parse(claim.now); const allowedKinds = claim.kinds?.length ? new Set(claim.kinds) : undefined; const next = clone(this.state); let changed = false; next.ingestionJobs.forEach(job => { if (claim.projectId && job.projectId !== claim.projectId) return; if (job.status === 'running' && job.attempts >= job.maxAttempts && Date.parse(job.leaseExpiresAt ?? '') <= now) { job.status = 'dead-letter'; job.lastErrorCode = job.lastErrorCode ?? 'lease-expired'; job.updatedAt = claim.now; delete job.leaseOwner; delete job.leaseExpiresAt; changed = true; } }); const eligible = next.ingestionJobs.filter(job => job.attempts < job.maxAttempts && (!claim.projectId || job.projectId === claim.projectId) && (!allowedKinds || allowedKinds.has(job.kind)) && ((job.status === 'queued' && Date.parse(job.availableAt) <= now) || (job.status === 'running' && Date.parse(job.leaseExpiresAt ?? '') <= now)) ).sort((left, right) => Date.parse(left.availableAt) - Date.parse(right.availableAt) || left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))[0]; if (!eligible) { if (changed) this.replaceState(next); return null; } const index = next.ingestionJobs.findIndex(item => item.id === eligible.id); next.ingestionJobs[index] = { ...next.ingestionJobs[index], status: 'running', attempts: next.ingestionJobs[index].attempts + 1, leaseOwner: claim.workerId, leaseExpiresAt: new Date(now + claim.leaseDurationMs).toISOString(), updatedAt: claim.now }; this.replaceState(next); return clone(next.ingestionJobs[index]); } async completeIngestionJob(jobId: string, workerId: string, completedAt: string): Promise { const completed = Date.parse(completedAt); const current = this.state.ingestionJobs.find(item => item.id === jobId); if (!current) throw new RepositoryNotFoundError(`Ingestion Job ${jobId} ist nicht vorhanden.`); if (current.status !== 'running' || current.leaseOwner !== workerId || !Number.isFinite(completed) || completed > Date.parse(current.leaseExpiresAt ?? '')) { throw new RepositoryConflictError('Job-Lease ist nicht mehr gültig oder gehört einem anderen Worker.'); } const next = clone(this.state); const index = next.ingestionJobs.findIndex(item => item.id === jobId); next.ingestionJobs[index] = { ...next.ingestionJobs[index], status: 'succeeded', updatedAt: completedAt }; delete next.ingestionJobs[index].leaseOwner; delete next.ingestionJobs[index].leaseExpiresAt; this.replaceState(next); return clone(next.ingestionJobs[index]); } async failIngestionJob(jobId: string, workerId: string, failedAt: string, errorCode: string, nextAvailableAt: string, retryable = true): Promise { const failed = Date.parse(failedAt); const nextAvailable = Date.parse(nextAvailableAt); const current = this.state.ingestionJobs.find(item => item.id === jobId); if (!current) throw new RepositoryNotFoundError(`Ingestion Job ${jobId} ist nicht vorhanden.`); if (current.status !== 'running' || current.leaseOwner !== workerId || !errorCode.trim() || !Number.isFinite(failed) || !Number.isFinite(nextAvailable) || nextAvailable < failed || failed > Date.parse(current.leaseExpiresAt ?? '')) { throw new RepositoryConflictError('Job-Fehler kann ohne gültigen Lease und Retry-Zeitpunkt nicht gespeichert werden.'); } const next = clone(this.state); const index = next.ingestionJobs.findIndex(item => item.id === jobId); const exhausted = !retryable || current.attempts >= current.maxAttempts; next.ingestionJobs[index] = { ...next.ingestionJobs[index], status: exhausted ? 'dead-letter' : 'queued', availableAt: exhausted ? current.availableAt : nextAvailableAt, lastErrorCode: errorCode.trim(), updatedAt: failedAt }; delete next.ingestionJobs[index].leaseOwner; delete next.ingestionJobs[index].leaseExpiresAt; this.replaceState(next); return clone(next.ingestionJobs[index]); } async listIngestionJobs(projectId: string): Promise { return byId(this.state.ingestionJobs.filter(item => item.projectId === projectId)).map(clone); } async retryIngestionJob(projectId: string, jobId: string, retriedAt: string): Promise { if (!Number.isFinite(Date.parse(retriedAt))) throw new RepositoryValidationError('Der Retry-Zeitpunkt ist ungültig.'); const current = this.state.ingestionJobs.find(item => item.id === jobId && item.projectId === projectId && item.kind === 'analyze-gpo-backup'); if (!current) throw new RepositoryNotFoundError('Der GPO-Analysejob ist im Mandanten nicht vorhanden.'); if (current.status !== 'dead-letter') throw new RepositoryConflictError('Nur ein endgültig fehlgeschlagener GPO-Analysejob kann erneut gestartet werden.'); const next = clone(this.state); const index = next.ingestionJobs.findIndex(item => item.id === jobId); next.ingestionJobs[index] = { ...next.ingestionJobs[index], status: 'queued', attempts: 0, availableAt: retriedAt, updatedAt: retriedAt }; delete next.ingestionJobs[index].lastErrorCode; this.replaceState(next); return clone(next.ingestionJobs[index]); } async deleteGpoArtifact(projectId: string, artifactId: string, request: ArtifactDeletionRequest) { const artifact = this.state.importArtifacts.find(item => item.id === artifactId && item.projectId === projectId); if (!artifact || artifact.storage.kind !== 'backend-controlled' || artifact.kind !== 'source-file' || !artifact.relativePath.startsWith('gpo-backups/')) { throw new RepositoryNotFoundError('Das GPO-Artefakt ist im Mandanten nicht vorhanden.'); } const jobs = this.state.ingestionJobs.filter(item => item.artifactId === artifactId && item.projectId === projectId && item.kind === 'analyze-gpo-backup'); if (jobs.some(item => item.status === 'queued' || item.status === 'running')) throw new RepositoryConflictError('Ein laufender GPO-Import kann nicht gelöscht werden.'); if (request.version !== 1 || request.projectId !== projectId || request.artifactId !== artifactId || request.storageKey !== artifact.storage.storageKey || request.attempts !== 0 || !request.id.trim() || !Number.isFinite(Date.parse(request.createdAt)) || !Number.isFinite(Date.parse(request.nextAttemptAt))) { throw new RepositoryValidationError('Der Artefakt-Löschauftrag ist ungültig.'); } const next = clone(this.state); const processingRunIds = new Set(next.processingRuns.filter(item => item.artifactId === artifactId).map(item => item.id)); const analysisRunIds = new Set(next.gpoPolicyAnalyses .filter(item => item.run.artifactId === artifactId || processingRunIds.has(item.run.sourceProcessingRunId)) .map(item => item.run.id)); const aggregationRunIds = new Set(next.policySnapshotAggregations .filter(item => item.run.sourceAnalysisRunIds.some(id => analysisRunIds.has(id))) .map(item => item.run.id)); const evaluationRunIds = new Set(next.policyEvaluations .filter(item => aggregationRunIds.has(item.run.aggregationRunId)) .map(item => item.run.id)); const removedPolicyFindingRuns = next.policyFindingRuns.filter(item => evaluationRunIds.has(item.run.evaluationRunId)); const removedDefinitionIds = new Set(removedPolicyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId))); next.importArtifacts = next.importArtifacts.filter(item => item.id !== artifactId); next.processingRuns = next.processingRuns.filter(item => item.artifactId !== artifactId); next.ingestionJobs = next.ingestionJobs.filter(item => item.artifactId !== artifactId); next.gpoBackupInstances = next.gpoBackupInstances.filter(item => item.artifactId !== artifactId); next.gpoPolicyAnalyses = next.gpoPolicyAnalyses.filter(item => !analysisRunIds.has(item.run.id)); next.policySnapshotAggregations = next.policySnapshotAggregations.filter(item => !aggregationRunIds.has(item.run.id)); next.policyEvaluations = next.policyEvaluations.filter(item => !evaluationRunIds.has(item.run.id)); next.policyFindingRuns = next.policyFindingRuns.filter(item => !evaluationRunIds.has(item.run.evaluationRunId)); const remainingDefinitionIds = new Set([ ...next.findingOccurrences.map(item => item.definitionId), ...next.policyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId)) ]); next.findingDefinitions = next.findingDefinitions.filter(item => !removedDefinitionIds.has(item.id) || remainingDefinitionIds.has(item.id)); const existing = next.artifactDeletionRequests.find(item => item.storageKey === request.storageKey); if (!existing) appendUnique(next.artifactDeletionRequests, [request], 'Artefakt-Löschauftrag'); this.replaceState(next); return { artifact: clone(artifact), request: clone(existing ?? request) }; } async listArtifactDeletionRequests(dueAt: string, limit: number): Promise { if (!Number.isFinite(Date.parse(dueAt)) || !Number.isInteger(limit) || limit < 1 || limit > 1_000) throw new RepositoryValidationError('Löschabfrage ist ungültig.'); return this.state.artifactDeletionRequests.filter(item => Date.parse(item.nextAttemptAt) <= Date.parse(dueAt)) .sort((left, right) => Date.parse(left.nextAttemptAt) - Date.parse(right.nextAttemptAt) || left.id.localeCompare(right.id)).slice(0, limit).map(clone); } async completeArtifactDeletionRequest(requestId: string): Promise { const next = clone(this.state); const length = next.artifactDeletionRequests.length; next.artifactDeletionRequests = next.artifactDeletionRequests.filter(item => item.id !== requestId); if (next.artifactDeletionRequests.length === length) throw new RepositoryNotFoundError('Artefakt-Löschauftrag ist nicht vorhanden.'); this.replaceState(next); } async failArtifactDeletionRequest(requestId: string, failedAt: string, errorCode: string, nextAttemptAt: string): Promise { if (!errorCode.trim() || !Number.isFinite(Date.parse(failedAt)) || !Number.isFinite(Date.parse(nextAttemptAt)) || Date.parse(nextAttemptAt) < Date.parse(failedAt)) { throw new RepositoryValidationError('Fehlgeschlagener Löschauftrag ist ungültig.'); } const next = clone(this.state); const request = next.artifactDeletionRequests.find(item => item.id === requestId); if (!request) throw new RepositoryNotFoundError('Artefakt-Löschauftrag ist nicht vorhanden.'); request.attempts += 1; request.lastErrorCode = errorCode.trim(); request.nextAttemptAt = nextAttemptAt; this.replaceState(next); } async saveReview(review: FindingReview, expectedRevision?: number): Promise { const target = review.target; const targetExists = target.kind === 'occurrence' ? this.state.findingOccurrences.some(occurrence => occurrence.id === target.occurrenceId) : this.state.findingDefinitions.some(definition => definition.id === target.definitionId); if (!targetExists) throw new RepositoryNotFoundError('Reviewziel ist nicht vorhanden.'); const next = clone(this.state); const index = next.reviews.findIndex(item => item.id === review.id); assertRiskAcceptanceUnchanged(index >= 0 ? next.reviews[index] : undefined, review); const currentRevision = index >= 0 ? next.reviews[index].revision : 0; if (expectedRevision !== undefined && expectedRevision !== currentRevision) { throw new RepositoryConflictError(`Review ${review.id} wurde zwischenzeitlich geändert.`); } if (review.revision !== currentRevision + 1) { throw new RepositoryValidationError(`Reviewrevision muss ${currentRevision + 1} sein.`); } if (index >= 0) next.reviews[index] = clone(review); else next.reviews.push(clone(review)); this.replaceState(next); } async recordRiskAcceptance(projectId: string, occurrenceId: string, command: RiskAcceptanceCommand, expectedRevision: number): Promise { const occurrence = this.state.findingOccurrences.find(item => item.id === occurrenceId); const run = this.state.scanRuns.find(item => item.id === occurrence?.scanRunId); if (!this.state.assessments.some(item => item.id === run?.assessmentId && item.projectId === projectId)) throw new RepositoryNotFoundError('Finding is not in this project.'); const next = clone(this.state); const current = next.reviews.filter(item => item.target.kind === 'occurrence' && item.target.occurrenceId === occurrenceId) .sort((left, right) => right.revision - left.revision || right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id))[0]; if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== (current?.revision ?? 0)) throw new RepositoryConflictError('The review changed. Reload it before recording a decision.'); const review = appendRiskAcceptanceReview(current, occurrenceId, command, new Date().toISOString()); const index = next.reviews.findIndex(item => item.id === review.id); if (index >= 0) next.reviews[index] = review; else next.reviews.push(review); this.replaceState(next); return clone(review); } async listReviews(projectId: string): Promise { const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === projectId).map(item => item.id)); const runIds = new Set(this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId)).map(item => item.id)); const occurrences = this.state.findingOccurrences.filter(item => runIds.has(item.scanRunId)); const occurrenceIds = new Set(occurrences.map(item => item.id)); const definitionIds = new Set(occurrences.map(item => item.definitionId)); return byId(this.state.reviews.filter(review => review.target.kind === 'occurrence' ? occurrenceIds.has(review.target.occurrenceId) : definitionIds.has(review.target.definitionId))).map(clone); } async saveProfileRegistration(profile: ProfileRegistration): Promise { if (!profile.profileId.trim() || !profile.profileVersion.trim() || profile.schemaVersion < 1) { throw new RepositoryValidationError('Profil-ID, Version und Schemaversion sind erforderlich.'); } const next = clone(this.state); const index = next.profileRegistrations.findIndex(item => item.profileId === profile.profileId && item.profileVersion === profile.profileVersion); if (index >= 0) next.profileRegistrations[index] = clone(profile); else next.profileRegistrations.push(clone(profile)); this.replaceState(next); } async listProfileRegistrations(): Promise { return clone(this.state.profileRegistrations.slice().sort((left, right) => left.profileId.localeCompare(right.profileId) || left.profileVersion.localeCompare(right.profileVersion))); } async saveLocalComparisonRule(projectId: string, rule: LocalComparisonRule): Promise { if (!this.state.projects.some(project => project.id === projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`); const issues = validateLocalComparisonRules([rule]); if (issues.length) throw new RepositoryValidationError(issues.join(' ')); const next = clone(this.state); const index = next.localComparisonRules.findIndex(item => item.projectId === projectId && item.rule.id === rule.id); const stored = { projectId, rule: clone(rule) }; if (index >= 0) next.localComparisonRules[index] = stored; else next.localComparisonRules.push(stored); this.replaceState(next); } async listLocalComparisonRules(projectId: string): Promise { return clone(this.state.localComparisonRules.filter(item => item.projectId === projectId).map(item => item.rule).sort((left, right) => left.id.localeCompare(right.id))); } async saveComparison(comparison: StoredComparison): Promise { const assessment = this.state.assessments.find(item => item.id === comparison.assessmentId && item.projectId === comparison.projectId); if (!assessment) throw new RepositoryNotFoundError('Projekt oder Assessment für den Vergleich fehlt.'); const runs = new Set(this.state.scanRuns.filter(run => run.assessmentId === assessment.id).map(run => run.id)); if (!runs.has(comparison.comparison.baselineRunId) || !runs.has(comparison.comparison.targetRunId)) { throw new RepositoryValidationError('Vergleich verweist auf Scan-Läufe eines anderen Assessments.'); } const occurrenceIds = new Set(this.state.findingOccurrences.filter(item => runs.has(item.scanRunId)).map(item => item.id)); const invalidReference = comparison.comparison.results.some(result => [result.baselineOccurrenceId, result.targetOccurrenceId, result.historicalOccurrenceId].some(id => id && !occurrenceIds.has(id))); if (invalidReference) throw new RepositoryValidationError('Vergleich verweist auf unbekannte Finding-Vorkommen.'); const next = clone(this.state); const index = next.comparisons.findIndex(item => item.id === comparison.id); if (index >= 0 && (next.comparisons[index].projectId !== comparison.projectId || next.comparisons[index].assessmentId !== comparison.assessmentId)) { throw new RepositoryConflictError('Ein Vergleich kann nicht in ein anderes Projekt oder Assessment verschoben werden.'); } if (index >= 0) next.comparisons[index] = clone(comparison); else next.comparisons.push(clone(comparison)); this.replaceState(next); } async loadComparison(comparisonId: string): Promise { const comparison = this.state.comparisons.find(item => item.id === comparisonId); return comparison ? clone(comparison) : null; } async listComparisons(assessmentId: string): Promise { return byId(this.state.comparisons.filter(item => item.assessmentId === assessmentId)).map(clone); } async exportProject(projectId: string): Promise { const project = this.state.projects.find(item => item.id === projectId); if (!project) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`); const assessments = this.state.assessments.filter(item => item.projectId === projectId); const assessmentIds = new Set(assessments.map(item => item.id)); const scanRuns = this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId)); const runIds = new Set(scanRuns.map(item => item.id)); const importFiles = this.state.importFiles.filter(item => runIds.has(item.scanRunId)); const fileIds = new Set(importFiles.map(item => item.id)); const sourceTables = this.state.sourceTables.filter(item => fileIds.has(item.importFileId)); const findingOccurrences = this.state.findingOccurrences.filter(item => runIds.has(item.scanRunId)); const occurrenceIds = new Set(findingOccurrences.map(item => item.id)); const policyFindingRuns = this.state.policyFindingRuns.filter(item => item.run.projectId === projectId); const definitionIds = new Set([ ...findingOccurrences.map(item => item.definitionId), ...policyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId)) ]); return clone({ version: 1, project, referenceRuns: await this.listReferenceRuns(projectId), referenceCatalogs: await this.listReferenceCatalogs(projectId), assessments: byId(assessments), scanRuns: byId(scanRuns), importFiles: byId(importFiles), sourceTables: byId(sourceTables), reportSections: byId(this.state.reportSections.filter(item => runIds.has(item.scanRunId))), environmentObservations: byId(this.state.environmentObservations.filter(item => runIds.has(item.scanRunId))), findingDefinitions: byId(this.state.findingDefinitions.filter(item => definitionIds.has(item.id))), findingOccurrences: byId(findingOccurrences), evidence: byId(this.state.evidence.filter(item => occurrenceIds.has(item.occurrenceId))), reviews: await this.listReviews(projectId), diagnostics: byId(this.state.diagnostics.filter(item => fileIds.has(item.importFileId))), comparisons: byId(this.state.comparisons.filter(item => item.projectId === projectId)), localComparisonRules: this.state.localComparisonRules.filter(item => item.projectId === projectId).map(item => item.rule).sort((left, right) => left.id.localeCompare(right.id)), importArtifacts: byId(this.state.importArtifacts.filter(item => item.projectId === projectId)), processingRuns: byId(this.state.processingRuns.filter(item => item.projectId === projectId)), ingestionJobs: byId(this.state.ingestionJobs.filter(item => item.projectId === projectId)), gpoBackupInstances: byId(this.state.gpoBackupInstances.filter(item => item.projectId === projectId)), gpoPolicyAnalyses: this.state.gpoPolicyAnalyses.filter(item => item.run.projectId === projectId).map(clone), environmentProfiles: this.state.environmentProfiles.filter(item => item.projectId === projectId).map(clone), policySnapshotAggregations: this.state.policySnapshotAggregations.filter(item => item.run.projectId === projectId).map(clone), policyEvaluations: this.state.policyEvaluations.filter(item => item.run.projectId === projectId).map(clone), policyFindingRuns: policyFindingRuns.map(clone) }); } async deleteScanRun(projectId: string, scanRunId: string, confirmation: ScanRunDeleteConfirmation): Promise { if (confirmation.projectId !== projectId || confirmation.scanRunId !== scanRunId || confirmation.cascade !== true) { throw new RepositoryValidationError('Scanberichtslöschung wurde nicht für das exakte Ziel bestätigt.'); } const run = this.state.scanRuns.find(item => item.id === scanRunId); if (!run) return false; const assessment = this.state.assessments.find(item => item.id === run.assessmentId); if (!assessment || assessment.projectId !== projectId) { throw new RepositoryValidationError('Der Scanbericht gehört nicht zum bestätigten Projekt.'); } const next = clone(this.state); const fileIds = new Set(next.importFiles.filter(item => item.scanRunId === scanRunId).map(item => item.id)); const removedHashes = new Set(next.importFiles.filter(item => fileIds.has(item.id)).map(item => item.sha256).filter(Boolean) as string[]); const occurrenceIds = new Set(next.findingOccurrences.filter(item => item.scanRunId === scanRunId).map(item => item.id)); const removedDefinitionIds = new Set(next.findingOccurrences.filter(item => occurrenceIds.has(item.id)).map(item => item.definitionId)); const artifactIds = new Set(next.importArtifacts .filter(item => item.scanRunId === scanRunId || (item.importFileId ? fileIds.has(item.importFileId) : false)) .map(item => item.id)); let descendantAdded = true; while (descendantAdded) { descendantAdded = false; for (const artifact of next.importArtifacts) { if (artifact.parentArtifactId && artifactIds.has(artifact.parentArtifactId) && !artifactIds.has(artifact.id)) { artifactIds.add(artifact.id); descendantAdded = true; } } } const processingRunIds = new Set(next.processingRuns.filter(item => artifactIds.has(item.artifactId)).map(item => item.id)); next.scanRuns = next.scanRuns.filter(item => item.id !== scanRunId); next.referenceRuns = next.referenceRuns.filter(item => item.scanRunId !== scanRunId); next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id)); next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId)); next.reportSections = next.reportSections.filter(item => item.scanRunId !== scanRunId); next.environmentObservations = next.environmentObservations.filter(item => item.scanRunId !== scanRunId); next.findingOccurrences = next.findingOccurrences.filter(item => !occurrenceIds.has(item.id)); const remainingDefinitionIds = new Set([ ...next.findingOccurrences.map(item => item.definitionId), ...next.policyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId)) ]); next.findingDefinitions = next.findingDefinitions.filter(item => !removedDefinitionIds.has(item.id) || remainingDefinitionIds.has(item.id)); next.evidence = next.evidence.filter(item => !occurrenceIds.has(item.occurrenceId)); next.reviews = next.reviews.filter(review => review.target.kind === 'occurrence' ? !occurrenceIds.has(review.target.occurrenceId) : !removedDefinitionIds.has(review.target.definitionId) || remainingDefinitionIds.has(review.target.definitionId)); next.diagnostics = next.diagnostics.filter(item => !fileIds.has(item.importFileId)); next.comparisons = next.comparisons.filter(item => item.assessmentId !== assessment.id); if (!next.scanRuns.some(item => item.assessmentId === assessment.id)) { next.assessments = next.assessments.filter(item => item.id !== assessment.id); } next.importArtifacts = next.importArtifacts.filter(item => !artifactIds.has(item.id)); next.processingRuns = next.processingRuns.filter(item => !processingRunIds.has(item.id)); next.ingestionJobs = next.ingestionJobs.filter(item => !item.artifactId || !artifactIds.has(item.artifactId)); next.gpoBackupInstances = next.gpoBackupInstances.filter(item => !processingRunIds.has(item.processingRunId)); next.gpoPolicyAnalyses = next.gpoPolicyAnalyses.filter(item => !processingRunIds.has(item.run.sourceProcessingRunId)); next.artifactDeletionRequests = next.artifactDeletionRequests.filter(item => !artifactIds.has(item.artifactId)); const remainingHashes = new Set(next.importFiles.map(item => item.sha256).filter(Boolean) as string[]); next.importFingerprints = next.importFingerprints.filter(item => !removedHashes.has(item.sha256) || remainingHashes.has(item.sha256)); this.replaceState(next); return true; } async deleteAssessment(projectId: string, assessmentId: string, confirmation: AssessmentDeleteConfirmation): Promise { if (confirmation.projectId !== projectId || confirmation.assessmentId !== assessmentId || confirmation.cascade !== true) { throw new RepositoryValidationError('Timeslot deletion was not confirmed for the exact target.'); } const assessment = this.state.assessments.find(item => item.id === assessmentId); if (!assessment) return false; if (assessment.projectId !== projectId) throw new RepositoryValidationError('The timeslot does not belong to the confirmed client.'); const next = clone(this.state); const runIds = new Set(next.scanRuns.filter(item => item.assessmentId === assessmentId).map(item => item.id)); const fileIds = new Set(next.importFiles.filter(item => runIds.has(item.scanRunId)).map(item => item.id)); const removedHashes = new Set(next.importFiles.filter(item => fileIds.has(item.id)).map(item => item.sha256).filter(Boolean) as string[]); const occurrenceIds = new Set(next.findingOccurrences.filter(item => runIds.has(item.scanRunId)).map(item => item.id)); const removedDefinitionIds = new Set(next.findingOccurrences.filter(item => occurrenceIds.has(item.id)).map(item => item.definitionId)); const artifactIds = new Set(next.importArtifacts .filter(item => item.assessmentId === assessmentId || (item.scanRunId ? runIds.has(item.scanRunId) : false) || (item.importFileId ? fileIds.has(item.importFileId) : false)) .map(item => item.id)); let descendantAdded = true; while (descendantAdded) { descendantAdded = false; for (const artifact of next.importArtifacts) { if (artifact.parentArtifactId && artifactIds.has(artifact.parentArtifactId) && !artifactIds.has(artifact.id)) { artifactIds.add(artifact.id); descendantAdded = true; } } } const processingRunIds = new Set(next.processingRuns.filter(item => artifactIds.has(item.artifactId)).map(item => item.id)); const gpoAnalysisRunIds = new Set(next.gpoPolicyAnalyses .filter(item => artifactIds.has(item.run.artifactId) || processingRunIds.has(item.run.sourceProcessingRunId)) .map(item => item.run.id)); const aggregationRunIds = new Set(next.policySnapshotAggregations .filter(item => item.run.sourceAnalysisRunIds.some(id => gpoAnalysisRunIds.has(id))) .map(item => item.run.id)); const evaluationRunIds = new Set(next.policyEvaluations .filter(item => aggregationRunIds.has(item.run.aggregationRunId)) .map(item => item.run.id)); next.assessments = next.assessments.filter(item => item.id !== assessmentId); next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id)); next.referenceRuns = next.referenceRuns.filter(item => !runIds.has(item.scanRunId)); next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id)); next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId)); next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId)); next.environmentObservations = next.environmentObservations.filter(item => !runIds.has(item.scanRunId)); next.findingOccurrences = next.findingOccurrences.filter(item => !occurrenceIds.has(item.id)); next.evidence = next.evidence.filter(item => !occurrenceIds.has(item.occurrenceId)); next.reviews = next.reviews.filter(review => review.target.kind === 'occurrence' ? !occurrenceIds.has(review.target.occurrenceId) : !removedDefinitionIds.has(review.target.definitionId)); next.diagnostics = next.diagnostics.filter(item => !fileIds.has(item.importFileId)); next.comparisons = next.comparisons.filter(item => item.assessmentId !== assessmentId); next.importArtifacts = next.importArtifacts.filter(item => !artifactIds.has(item.id)); next.processingRuns = next.processingRuns.filter(item => !processingRunIds.has(item.id)); next.ingestionJobs = next.ingestionJobs.filter(item => !item.artifactId || !artifactIds.has(item.artifactId)); next.gpoBackupInstances = next.gpoBackupInstances.filter(item => !artifactIds.has(item.artifactId) && !processingRunIds.has(item.processingRunId)); next.gpoPolicyAnalyses = next.gpoPolicyAnalyses.filter(item => !gpoAnalysisRunIds.has(item.run.id)); next.policySnapshotAggregations = next.policySnapshotAggregations.filter(item => !aggregationRunIds.has(item.run.id)); next.policyEvaluations = next.policyEvaluations.filter(item => !evaluationRunIds.has(item.run.id)); next.policyFindingRuns = next.policyFindingRuns.filter(item => !evaluationRunIds.has(item.run.evaluationRunId)); next.artifactDeletionRequests = next.artifactDeletionRequests.filter(item => !artifactIds.has(item.artifactId)); const remainingDefinitionIds = new Set([ ...next.findingOccurrences.map(item => item.definitionId), ...next.policyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId)) ]); next.findingDefinitions = next.findingDefinitions.filter(item => !removedDefinitionIds.has(item.id) || remainingDefinitionIds.has(item.id)); const remainingHashes = new Set(next.importFiles.map(item => item.sha256).filter(Boolean) as string[]); next.importFingerprints = next.importFingerprints.filter(item => !removedHashes.has(item.sha256) || remainingHashes.has(item.sha256)); this.replaceState(next); return true; } async deleteProject(projectId: string, confirmation: ControlledDeleteConfirmation): Promise { if (confirmation.projectId !== projectId || confirmation.cascade !== true) { throw new RepositoryValidationError('Projektlöschung wurde nicht für das exakte Ziel bestätigt.'); } if (!this.state.projects.some(project => project.id === projectId)) return false; const next = clone(this.state); const assessmentIds = new Set(next.assessments.filter(item => item.projectId === projectId).map(item => item.id)); const runIds = new Set(next.scanRuns.filter(item => assessmentIds.has(item.assessmentId)).map(item => item.id)); const fileIds = new Set(next.importFiles.filter(item => runIds.has(item.scanRunId)).map(item => item.id)); const occurrenceIds = new Set(next.findingOccurrences.filter(item => runIds.has(item.scanRunId)).map(item => item.id)); const removedPolicyFindingRuns = next.policyFindingRuns.filter(item => item.run.projectId === projectId); const removedDefinitionIds = new Set([ ...next.findingOccurrences.filter(item => occurrenceIds.has(item.id)).map(item => item.definitionId), ...removedPolicyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId)) ]); next.projects = next.projects.filter(item => item.id !== projectId); next.assessments = next.assessments.filter(item => !assessmentIds.has(item.id)); next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id)); next.referenceRuns = next.referenceRuns.filter(item => !runIds.has(item.scanRunId)); next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id)); next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId)); next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId)); next.environmentObservations = next.environmentObservations.filter(item => !runIds.has(item.scanRunId)); next.findingOccurrences = next.findingOccurrences.filter(item => !occurrenceIds.has(item.id)); next.policyFindingRuns = next.policyFindingRuns.filter(item => item.run.projectId !== projectId); const remainingDefinitionIds = new Set([ ...next.findingOccurrences.map(item => item.definitionId), ...next.policyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId)) ]); next.findingDefinitions = next.findingDefinitions.filter(item => !removedDefinitionIds.has(item.id) || remainingDefinitionIds.has(item.id)); next.evidence = next.evidence.filter(item => !occurrenceIds.has(item.occurrenceId)); next.reviews = next.reviews.filter(review => review.target.kind === 'occurrence' ? !occurrenceIds.has(review.target.occurrenceId) : !removedDefinitionIds.has(review.target.definitionId) || remainingDefinitionIds.has(review.target.definitionId)); next.diagnostics = next.diagnostics.filter(item => !fileIds.has(item.importFileId)); next.comparisons = next.comparisons.filter(item => item.projectId !== projectId); next.localComparisonRules = next.localComparisonRules.filter(item => item.projectId !== projectId); next.importArtifacts = next.importArtifacts.filter(item => item.projectId !== projectId); next.processingRuns = next.processingRuns.filter(item => item.projectId !== projectId); next.ingestionJobs = next.ingestionJobs.filter(item => item.projectId !== projectId); next.gpoBackupInstances = next.gpoBackupInstances.filter(item => item.projectId !== projectId); next.gpoPolicyAnalyses = next.gpoPolicyAnalyses.filter(item => item.run.projectId !== projectId); next.environmentProfiles = next.environmentProfiles.filter(item => item.projectId !== projectId); next.policySnapshotAggregations = next.policySnapshotAggregations.filter(item => item.run.projectId !== projectId); next.policyEvaluations = next.policyEvaluations.filter(item => item.run.projectId !== projectId); this.replaceState(next); return true; } async getDatabaseStructure(): Promise { return { generatedAt: new Date().toISOString(), persistence: 'memory', tables: [] }; } }