Assessor_2.0/src/persistence/local-assessor-repository.ts

1758 lines
110 KiB
TypeScript

import { appendRiskAcceptanceReview, assertRiskAcceptanceUnchanged, type RiskAcceptanceCommand } from '../domain/risk-acceptance';
import {
appendScopedRiskAcceptance,
scopedRiskTargetFromOccurrences,
withCapturedScopeStatus,
type ScopedRiskAcceptance,
type ScopedRiskAcceptanceTarget,
type ScopedRiskAcceptanceWrite
} from '../domain/scoped-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 { buildResultDbIdentityGuards, type ResultDbIdentityGuard } from '../domain/result-db-identity-guard';
import { isResultDbCurrent, materializeResultDb, type MaterializedResultDb, type ResultDbMaterializationStatus } from '../domain/result-db-materialization';
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[];
scopedRiskAcceptances: ScopedRiskAcceptance[];
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: [],
scopedRiskAcceptances: [],
diagnostics: [],
profileRegistrations: [],
comparisons: [],
localComparisonRules: [],
importFingerprints: [],
importArtifacts: [],
processingRuns: [],
ingestionJobs: [],
gpoBackupInstances: [],
microsoftSecurityBaselines: [],
gpoPolicyAnalyses: [],
microsoftSecurityBaselineClassifications: [],
environmentProfiles: [],
policySnapshotAggregations: [],
policyEvaluations: [],
policyFindingRuns: [],
microsoftSecurityBaselineAnalyses: [],
artifactDeletionRequests: []
});
function clone<T>(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<T extends { id: string }>(items: T[]): T[] {
return items.slice().sort((left, right) => left.id.localeCompare(right.id));
}
function appendUnique<T extends { id: string }>(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<string>): Set<string> {
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<ImportStagingData> = {
importFiles: [],
sourceTables: [],
reportSections: [],
environmentObservations: [],
findingDefinitions: [],
findingOccurrences: [],
evidence: [],
diagnostics: []
};
constructor(
readonly id: string,
private readonly onCommit: (staged: Required<ImportStagingData>) => Promise<ImportCommitResult>,
private readonly onRollback: () => Promise<void>
) {}
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<void> {
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<ImportCommitResult> {
this.ensurePending();
const result = await this.onCommit(clone(this.staged));
this.currentStatus = 'committed';
return result;
}
async rollback(): Promise<void> {
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 resultDb?: MaterializedResultDb;
private readonly activeImportSessions = new Set<string>();
get revision(): number {
return this.stateRevision;
}
private replaceState(next: LocalRepositoryState): void {
this.state = clone(next);
this.stateRevision += 1;
}
async listProjects(): Promise<AssessmentProject[]> {
return byId(this.state.projects).map(clone);
}
async getProject(projectId: string): Promise<AssessmentProject | null> {
const project = this.state.projects.find(item => item.id === projectId);
return project ? clone(project) : null;
}
async saveProject(project: AssessmentProject): Promise<void> {
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<Assessment[]> {
return byId(this.state.assessments.filter(assessment => assessment.projectId === projectId)).map(clone);
}
async listProjectScanRuns(projectId: string): Promise<ScanRun[]> {
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<ImportFileRecord[]> {
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<void> {
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<CanonicalAssessmentModel | null> {
return assessmentModel(this.state, assessmentId);
}
async saveReferenceRun(run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[]): Promise<void> {
const next = clone(this.state);
if (appendReferenceRun(next, run, catalogs)) this.replaceState(next);
}
async listReferenceRuns(projectId: string): Promise<ReferenceRecognitionRun[]> {
return clone(this.state.referenceRuns.filter(run => run.projectId === projectId));
}
async listReferenceCatalogs(projectId: string): Promise<ReferenceCatalogSnapshot[]> {
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<ImportSession> {
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<ImportCommitResult> {
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));
// Superseded runs keep their sequence but are hidden from the model the caller derived the next
// sequence from; a taken sequence therefore moves behind every stored run of the timeslot.
const timeslotSequences = next.scanRuns.filter(run => run.assessmentId === model.assessment.id).map(run => run.sequence);
model.scanRuns.forEach(run => {
if (timeslotSequences.includes(run.sequence)) run.sequence = Math.max(...timeslotSequences) + 1;
});
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<ImportFingerprint[]> {
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<ImportFingerprint[]> {
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<ImportStagingData>, expectedRevision: number): Promise<ImportCommitResult> {
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<FindingOccurrenceQueryResult> {
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];
if (audience === 'internal-analysis') return results;
return this.materializedResultDb(results, currentPolicyObservations).rows;
}
/**
* In-memory counterpart of the stored Result DB: rebuilt once per state revision and served
* unchanged to every Result DB read until the state changes again.
*/
private materializedResultDb(
sourceResults: UnifiedResultProjection[],
currentPolicyObservations: UnifiedResultProjection[]
): MaterializedResultDb {
if (this.resultDb && isResultDbCurrent(this.resultDb.status, this.stateRevision)) return this.resultDb;
const guards = this.identityGuards([...sourceResults, ...currentPolicyObservations]);
this.resultDb = materializeResultDb(sourceResults, guards, this.stateRevision, new Date().toISOString());
return this.resultDb;
}
async getResultDbStatus(): Promise<ResultDbMaterializationStatus> {
const status = this.resultDb?.status;
if (!status) {
return { state: 'not-built', repositoryRevision: this.stateRevision, rowCount: 0, redactionCount: 0, guardWithheldRowCount: 0 };
}
return clone({ ...status, repositoryRevision: this.stateRevision,
state: isResultDbCurrent(status, this.stateRevision) ? 'current' : 'outdated' });
}
/** Client identity guards from the complete, unfiltered internal state (see result-db-identity-guard). */
private identityGuards(internalResults: UnifiedResultProjection[]): Map<string, ResultDbIdentityGuard> {
// Built on every read: the in-memory state is small, and a stale guard would fail open.
const environmentObservations = this.state.environmentObservations.flatMap(observation => {
const scanRun = this.state.scanRuns.find(item => item.id === observation.scanRunId);
const assessment = scanRun ? this.state.assessments.find(item => item.id === scanRun.assessmentId) : undefined;
return assessment ? [{ projectId: assessment.projectId, observation }] : [];
});
return buildResultDbIdentityGuards({ projects: this.state.projects, environmentObservations, internalResults });
}
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
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<UnifiedResultFacetResult> {
return clone(deriveUnifiedResultFacets(this.unifiedResults(query.audience), query));
}
async getUnifiedResultById(resultProjectionId: string, projectId?: string): Promise<UnifiedResultProjection | null> {
const result = this.unifiedResults().find(item => item.resultProjectionId === resultProjectionId && (projectId === undefined || item.projectId === projectId));
return result ? clone(removeResultDbScopeIdentifiers(result)) : null;
}
async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> {
return clone(listUnifiedResultCategories(filterUnifiedResults(this.unifiedResults(query.audience), query)));
}
async adoptScanRunReplacement(projectId: string, replacedScanRunId: string, replacementScanRunId: string, supersededAt: string): Promise<ReimportReviewTransferSummary> {
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<EnvironmentSnapshot> {
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));
if (query.assessmentId && !assessmentIds.has(query.assessmentId)) {
throw new RepositoryValidationError('Environment Snapshot verweist auf ein Assessment außerhalb des Projekts.');
}
const allowedRuns = this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId) && (query.includeSuperseded || !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<void> {
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<ImportArtifact[]> {
return byId(this.state.importArtifacts.filter(item => item.projectId === projectId)).map(clone);
}
async saveProcessingRun(run: ImportProcessingRun): Promise<void> {
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<ImportProcessingRun[]> {
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<ImportProcessingRun[]> {
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<void> {
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<StoredGpoBackupInstance[]> {
return byId(this.state.gpoBackupInstances.filter(item => item.projectId === projectId)).map(clone);
}
async listGpoPolicyAnalyses(projectId: string): Promise<StoredGpoPolicyAnalysis[]> {
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<void> {
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<EnvironmentProfile[]> {
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<void> {
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<StoredPolicySnapshotAggregation[]> {
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<void> {
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<StoredPolicyEvaluation[]> {
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<void> {
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<StoredPolicyFindingRun[]> {
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<void> {
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<MicrosoftSecurityBaselineRecord[]> {
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<MicrosoftSecurityBaselineRecord | null> {
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<void> {
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<MicrosoftSecurityBaselineClassification[]> {
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<void> {
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<StoredMicrosoftSecurityBaselineAnalysis[]> {
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<IngestionJob> {
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<IngestionJob | null> {
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<IngestionJob> {
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<IngestionJob> {
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<IngestionJob[]> {
return byId(this.state.ingestionJobs.filter(item => item.projectId === projectId)).map(clone);
}
async retryIngestionJob(projectId: string, jobId: string, retriedAt: string): Promise<IngestionJob> {
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<ArtifactDeletionRequest[]> {
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<void> {
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<void> {
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<void> {
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<FindingReview> {
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 recordScopedRiskAcceptance(projectId: string, write: ScopedRiskAcceptanceWrite): Promise<ScopedRiskAcceptance> {
if (!this.state.projects.some(item => item.id === projectId)) throw new RepositoryNotFoundError('Project is not available.');
const next = clone(this.state);
const current = write.acceptanceId
? next.scopedRiskAcceptances.find(item => item.id === write.acceptanceId && item.projectId === projectId)
: undefined;
if (write.acceptanceId && !current) throw new RepositoryNotFoundError('Scoped risk acceptance is not available.');
if (!Number.isSafeInteger(write.expectedRevision) || write.expectedRevision !== (current?.revision ?? 0)) {
throw new RepositoryConflictError('The scoped risk acceptance changed. Reload it before recording a decision.');
}
let target: ScopedRiskAcceptanceTarget;
if (current) {
if (write.target) throw new RepositoryValidationError('An existing scoped risk acceptance cannot be retargeted.');
target = current.target;
} else {
if (!write.target) throw new RepositoryValidationError('A scoped risk target is required.');
const requestedTarget = write.target;
const assessmentIds = new Set(next.assessments.filter(item => item.projectId === projectId).map(item => item.id));
const projectRunIds = new Set(next.scanRuns.filter(item => assessmentIds.has(item.assessmentId)).map(item => item.id));
const selectedOccurrence = requestedTarget.kind === 'device-finding'
? next.findingOccurrences.find(item => item.id === requestedTarget.occurrenceId && projectRunIds.has(item.scanRunId))
: undefined;
const occurrences = requestedTarget.kind === 'device-finding'
? next.findingOccurrences.filter(item => item.scanRunId === selectedOccurrence?.scanRunId && projectRunIds.has(item.scanRunId))
: next.findingOccurrences.filter(item => requestedTarget.scanRunIds.includes(item.scanRunId) && projectRunIds.has(item.scanRunId));
if (requestedTarget.kind === 'device-snapshot' && (!requestedTarget.scanRunIds.length || requestedTarget.scanRunIds.some(id => !projectRunIds.has(id)))) {
throw new RepositoryValidationError('The selected scan-run scope is not valid for this project.');
}
const now = new Date().toISOString();
try { target = scopedRiskTargetFromOccurrences(requestedTarget.kind, requestedTarget.deviceKey, occurrences, now,
selectedOccurrence ? new Set([selectedOccurrence.id]) : undefined); }
catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Invalid scoped risk target.'); }
}
const now = new Date().toISOString();
const id = current?.id ?? `scoped-risk-${globalThis.crypto.randomUUID()}`;
let saved: ScopedRiskAcceptance;
try { saved = appendScopedRiskAcceptance(current, projectId, id, target, write.command, now); }
catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Invalid scoped risk acceptance.'); }
const index = next.scopedRiskAcceptances.findIndex(item => item.id === saved.id);
if (index >= 0) next.scopedRiskAcceptances[index] = saved; else next.scopedRiskAcceptances.push(saved);
this.replaceState(next);
return this.scopedWithStatus(projectId, [clone(saved)])[0];
}
async listScopedRiskAcceptances(projectId: string): Promise<ScopedRiskAcceptance[]> {
if (!this.state.projects.some(item => item.id === projectId)) throw new RepositoryNotFoundError('Project is not available.');
return this.scopedWithStatus(projectId, this.state.scopedRiskAcceptances.filter(item => item.projectId === projectId).map(clone));
}
private scopedWithStatus(projectId: string, values: ScopedRiskAcceptance[]): ScopedRiskAcceptance[] {
const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === projectId).map(item => item.id));
const current = new Set(this.state.scanRuns
.filter(item => assessmentIds.has(item.assessmentId) && !item.supersededByScanRunId).map(item => item.id));
return values.map(value => withCapturedScopeStatus(value, current));
}
async listReviews(projectId: string): Promise<FindingReview[]> {
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<void> {
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<ProfileRegistration[]> {
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<void> {
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<LocalComparisonRule[]> {
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<void> {
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<StoredComparison | null> {
const comparison = this.state.comparisons.find(item => item.id === comparisonId);
return comparison ? clone(comparison) : null;
}
async listComparisons(assessmentId: string): Promise<StoredComparison[]> {
return byId(this.state.comparisons.filter(item => item.assessmentId === assessmentId)).map(clone);
}
async exportProject(projectId: string): Promise<ProjectDataExport> {
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),
scopedRiskAcceptances: await this.listScopedRiskAcceptances(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<boolean> {
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<boolean> {
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<boolean> {
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);
next.scopedRiskAcceptances = next.scopedRiskAcceptances.filter(item => item.projectId !== projectId);
this.replaceState(next);
return true;
}
async getDatabaseStructure(): Promise<DbStructureResponse> {
return { generatedAt: new Date().toISOString(), persistence: 'memory', tables: [] };
}
}