1758 lines
110 KiB
TypeScript
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: [] };
|
|
}
|
|
}
|