import { RiskAcceptanceValidationError, type RiskAcceptanceCommand } from '../src/domain/risk-acceptance'; import { createHash } from 'node:crypto'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { readFile, stat } from 'node:fs/promises'; import { extname, resolve, sep } from 'node:path'; import type { AssessorRepository, FindingOccurrenceQuery, ImportSession, ImportSessionStart, ImportStagingData, ProfileRegistration, StoredComparison } from '../src/persistence/assessor-repository'; import type { UnifiedResultQuery } from '../src/domain/unified-result'; import type { EnvironmentSnapshotQuery } from '../src/domain/environment-context'; import type { Assessment, AssessmentProject, FindingReview, ImportFileRecord } from '../src/domain/canonical-model'; import type { LocalComparisonRule } from '../src/domain/comparison'; import { ImportError } from '../src/shared/errors'; import { defaultImportResourcePolicy } from '../src/importers/resource-policy'; import type { PersistenceKind } from './repository-factory'; import type { GpoBackupSubmissionService } from './gpo/gpo-backup-submission-service'; import type { GpoBackupManagementService } from './gpo/gpo-backup-management-service'; import type { MicrosoftSecurityBaselineService } from './gpo/microsoft-security-baseline-service'; import type { GpoPolicySnapshotService } from './gpo/gpo-policy-snapshot-service'; import type { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service'; import type { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service'; import type { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service'; import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-service'; import type { CveEnrichmentService } from './cve/cve-enrichment-service'; import { NvdCveClientError } from './cve/nvd-cve-client'; import type { AiOverviewService } from './ai/ai-overview-service'; import { AiOverviewServiceError } from './ai/ai-overview-service'; import type { AiOverviewCreateRequest, AiOverviewScope } from '../src/domain/ai-overview'; import type { RegulatoryMappingCreateRequest, RegulatoryMappingDecisionCreateRequest } from '../src/domain/regulatory-mapping'; import type { RegulatoryMappingService } from './regulatory/regulatory-mapping-service'; import { RegulatoryMappingServiceError } from './regulatory/regulatory-mapping-service'; import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline'; import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service'; import type { BackendArtifactStore } from './artifacts/backend-artifact-store'; import { directImportExecutor, type ImportExecutor } from './import-executor'; import { createCanonicalImportBatch } from '../src/importers/canonical-import'; import { processingScopeIssues } from '../src/domain/processing-compatibility'; import { ASSESSOR_PRODUCT_VERSION } from '../src/domain/assessor-release'; import { compareReimportSourceHashes } from '../src/domain/reimport-source'; import { findPossibleDuplicateImportFile, findSameScanReportFile, possibleDuplicateLabel, sameScanReportLabel } from '../src/domain/import-duplicates'; import { failedPreviewEntry, importRecordCount, previewEntryFromDataset } from '../src/features/import-workflow/import-workflow'; import { applyRecognitionToDataset, recognitionOverrideErrors } from '../src/recognition/recognition-engine'; import { importProfiles } from '../src/profiles/profile-catalog'; import { ImportSessionStateError, RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../src/persistence/repository-errors'; import { ASSESSOR_API_PREFIX, ASSESSOR_MUTATION_HEADER, type AssessorApiErrorBody, type AssessorHealthResponse, type ImportBatchCompleteRequest, type ImportBatchCompleteResponse, type ImportBatchPreviewRequest, type ImportBatchPreviewResponse, type ImportSourceParseRequest, type ImportSourceValidationRequest, type GpoBackupSubmissionRequest, type GpoBackupSubmissionResponse, type EnvironmentProfileCreateRequest, type PolicySnapshotAggregationCreateRequest, type PolicyEvaluationCreateRequest, type PolicyFindingRunCreateRequest, type ImportSourceValidationResponse } from '../src/api/assessor-api-contract'; const DEFAULT_MAXIMUM_BODY_BYTES = 140 * 1024 * 1024; const MAXIMUM_IMPORT_SOURCE_BYTES = defaultImportResourcePolicy.maximumFileBytes; const MAXIMUM_PREVIEW_CACHE_SOURCE_BYTES = 256 * 1024 * 1024; const MAXIMUM_PREVIEW_CACHE_BATCHES = 10; const DEFAULT_PREVIEW_TTL_MS = 15 * 60 * 1000; const CONTENT_SECURITY_POLICY = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'"; const mediaTypes: Record = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.ico': 'image/x-icon', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp' }; const supportedImportFormats = new Map([ ['.csv', 'CSV'], ['.json', 'JSON'], ['.xml', 'XML'], ['.html', 'HTML'], ['.htm', 'HTML'], ['.xlsx', 'XLSX'], ['.xls', 'XLS'] ]); interface CachedImportBatchPreview { preview: ImportBatchPreviewResponse; expiresAt: number; sourceBytes: number; targetProjectId?: string; targetAssessmentId?: string; replacesScanRunId?: string; } export interface AuditEvent { id: string; requestId: string; timestamp: string; method: string; path: string; status: number; durationMs: number; errorCode?: string; } export interface AssessorHttpServerOptions { artifactStore?: BackendArtifactStore; repository: AssessorRepository; staticRoot: string; releaseId?: string; developmentInstanceId?: string; maximumBodyBytes?: number; now?: () => Date; persistence?: PersistenceKind; importExecutor?: ImportExecutor; previewTtlMs?: number; gpoBackupSubmissionService?: GpoBackupSubmissionService; gpoBackupManagementService?: GpoBackupManagementService; gpoPolicySnapshotService?: GpoPolicySnapshotService; gpoPolicyEvaluationService?: GpoPolicyEvaluationService; gpoPolicyFindingService?: GpoPolicyFindingService; gpoPolicyAnalysisOverviewService?: GpoPolicyAnalysisOverviewService; automaticGpoAnalysis?: GpoAutomaticAnalysisService; microsoftSecurityBaselineService?: MicrosoftSecurityBaselineService; cveEnrichmentService?: CveEnrichmentService; aiOverviewService?: AiOverviewService; regulatoryMappingService?: RegulatoryMappingService; } class HttpError extends Error { constructor(readonly status: number, readonly code: string, message: string) { super(message); this.name = 'HttpError'; } } function setSecurityHeaders(response: ServerResponse): void { response.setHeader('Content-Security-Policy', CONTENT_SECURITY_POLICY); response.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); response.setHeader('Referrer-Policy', 'no-referrer'); response.setHeader('X-Content-Type-Options', 'nosniff'); response.setHeader('X-Frame-Options', 'DENY'); } function sendJson(response: ServerResponse, status: number, value: unknown, requestId: string): void { const body = JSON.stringify(value); response.statusCode = status; response.setHeader('Content-Type', 'application/json; charset=utf-8'); response.setHeader('Cache-Control', 'no-store'); response.setHeader('X-Assessor-Request-Id', requestId); response.end(body); } async function readJson(request: IncomingMessage, maximumBodyBytes: number): Promise { const contentType = String(request.headers['content-type'] ?? '').split(';', 1)[0].trim().toLowerCase(); if (contentType !== 'application/json') throw new HttpError(415, 'unsupported_media_type', 'Für diese Anfrage ist application/json erforderlich.'); const declaredLength = Number(request.headers['content-length'] ?? 0); if (declaredLength > maximumBodyBytes) throw new HttpError(413, 'request_too_large', 'Der Anfrageinhalt überschreitet das zulässige Größenlimit.'); const chunks: Buffer[] = []; let size = 0; for await (const chunk of request) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); size += buffer.length; if (size > maximumBodyBytes) throw new HttpError(413, 'request_too_large', 'Der Anfrageinhalt überschreitet das zulässige Größenlimit.'); chunks.push(buffer); } if (!chunks.length) throw new HttpError(400, 'empty_json_body', 'Ein JSON-Anfrageinhalt ist erforderlich.'); try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T; } catch { throw new HttpError(400, 'invalid_json', 'Der Anfrageinhalt ist kein gültiges JSON.'); } } function verifyMutationRequest(request: IncomingMessage): void { if (request.headers[ASSESSOR_MUTATION_HEADER.toLowerCase()] !== '1') { throw new HttpError(403, 'missing_mutation_header', `Die Sicherheitskopfzeile ${ASSESSOR_MUTATION_HEADER} fehlt.`); } const origin = request.headers.origin; const host = request.headers.host; if (origin && (!host || origin !== `http://${host}` && origin !== `https://${host}`)) { throw new HttpError(403, 'cross_origin_rejected', 'Schreibzugriffe sind nur vom gleichen Ursprung zulässig.'); } } function routeSegments(pathname: string): string[] { return pathname.slice(ASSESSOR_API_PREFIX.length).split('/').filter(Boolean).map(segment => decodeURIComponent(segment)); } function queryFrom(url: URL): FindingOccurrenceQuery { const numberValue = (name: string): number | undefined => { const value = url.searchParams.get(name); return value === null ? undefined : Number(value); }; return { projectId: url.searchParams.get('projectId') ?? undefined, assessmentId: url.searchParams.get('assessmentId') ?? undefined, scanRunId: url.searchParams.get('scanRunId') ?? undefined, definitionId: url.searchParams.get('definitionId') ?? undefined, toolId: url.searchParams.get('toolId') ?? undefined, severity: (url.searchParams.get('severity') ?? undefined) as FindingOccurrenceQuery['severity'], status: (url.searchParams.get('status') ?? undefined) as FindingOccurrenceQuery['status'], search: url.searchParams.get('search') ?? undefined, offset: numberValue('offset'), limit: numberValue('limit') }; } function unifiedQueryFrom(url: URL): UnifiedResultQuery { const many = (name: string): string[] | undefined => { const values = url.searchParams.getAll(name) .flatMap(value => value.split(',')) .map(value => value.trim()) .filter(Boolean); return values.length ? [...new Set(values)] : undefined; }; const numberValue = (name: string): number | undefined => { const value = url.searchParams.get(name); return value === null ? undefined : Number(value); }; const booleanValue = (name: string): boolean | undefined => { const value = url.searchParams.get(name); if (value === 'true') return true; if (value === 'false') return false; return undefined; }; return { audience: url.searchParams.get('audience') === 'internal-analysis' ? 'internal-analysis' : 'result-db', projectId: url.searchParams.get('projectId') ?? undefined, assessmentId: url.searchParams.get('assessmentId') ?? undefined, assessmentIds: many('assessmentIds'), scanRunId: url.searchParams.get('scanRunId') ?? undefined, scanRunIds: many('scanRunIds'), evaluationRunId: url.searchParams.get('evaluationRunId') ?? undefined, sourceKind: many('sourceKind') as UnifiedResultQuery['sourceKind'], sourceTool: many('sourceTool'), severity: many('severity') as UnifiedResultQuery['severity'], outcome: many('outcome') as UnifiedResultQuery['outcome'], technicalExecutionState: many('technicalExecutionState') as UnifiedResultQuery['technicalExecutionState'], resultType: many('resultType') as UnifiedResultQuery['resultType'], policyClassification: many('policyClassification') as UnifiedResultQuery['policyClassification'], isIoe: booleanValue('isIoe'), deviation: booleanValue('deviation'), mappingRuleVersion: many('mappingRuleVersion'), sourceProfileVersion: many('sourceProfileVersion'), processingStatus: many('processingStatus') as UnifiedResultQuery['processingStatus'], reviewDisposition: many('reviewDisposition') as UnifiedResultQuery['reviewDisposition'], lifecycleState: many('lifecycleState') as UnifiedResultQuery['lifecycleState'], categoryNamespace: many('categoryNamespace'), categoryValue: many('categoryValue'), affectedAsset: url.searchParams.get('affectedAsset') ?? undefined, environmentState: many('environmentState') as UnifiedResultQuery['environmentState'], environmentProperty: many('environmentProperty'), environmentSubjectType: many('environmentSubjectType'), search: url.searchParams.get('search') ?? undefined, observedFrom: url.searchParams.get('observedFrom') ?? undefined, observedTo: url.searchParams.get('observedTo') ?? undefined, offset: numberValue('offset'), limit: numberValue('limit') }; } function validateImportSource(value: ImportSourceValidationRequest): ImportSourceValidationResponse { if (!value || typeof value.fileName !== 'string' || !value.fileName.trim()) { throw new HttpError(400, 'invalid_file_metadata', 'Ein Dateiname ist erforderlich.'); } if (!Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0) { throw new HttpError(400, 'invalid_file_metadata', 'Die Dateigröße muss eine nichtnegative Ganzzahl sein.'); } if (value.sizeBytes > MAXIMUM_IMPORT_SOURCE_BYTES) { throw new HttpError(413, 'import_source_too_large', `Die Datei überschreitet das Limit von ${MAXIMUM_IMPORT_SOURCE_BYTES} Bytes.`); } const format = supportedImportFormats.get(extname(value.fileName).toLowerCase()); if (!format) throw new HttpError(415, 'unsupported_import_format', 'Unterstützt werden CSV, JSON, XML, HTML, XLSX und XLS.'); return { accepted: true, format, maximumSizeBytes: MAXIMUM_IMPORT_SOURCE_BYTES }; } export function isValidImportBase64(value: unknown): value is string { if (typeof value !== 'string' || value.length % 4 !== 0) return false; let padding = 0; if (value.endsWith('=')) padding = value.endsWith('==') ? 2 : 1; const dataEnd = value.length - padding; for (let index = 0; index < dataEnd; index += 1) { const code = value.charCodeAt(index); const accepted = code >= 65 && code <= 90 || code >= 97 && code <= 122 || code >= 48 && code <= 57 || code === 43 || code === 47; if (!accepted) return false; } for (let index = dataEnd; index < value.length; index += 1) { if (value.charCodeAt(index) !== 61) return false; } return true; } function decodeImportBytes(body: ImportSourceParseRequest): Buffer { validateImportSource({ fileName: body.fileName, sizeBytes: body.sizeBytes }); if (!isValidImportBase64(body.bytesBase64)) { throw new HttpError(400, 'invalid_file_encoding', 'Der Dateiinhalt ist nicht gültig base64-kodiert.'); } const bytes = Buffer.from(body.bytesBase64, 'base64'); if (bytes.length !== body.sizeBytes) throw new HttpError(400, 'file_size_mismatch', 'Metadaten und tatsächliche Dateigröße stimmen nicht überein.'); return bytes; } async function parseImportSourceRequest(body: ImportSourceParseRequest, executor: ImportExecutor) { const bytes = decodeImportBytes(body); return executor.execute({ source: { metadata: { name: body.fileName, sizeBytes: body.sizeBytes, lastModified: body.lastModified, mediaType: body.mediaType }, bytes }, delimiter: body.delimiter }); } function importFailureMessage(error: unknown): string { if (error instanceof ImportError && error.diagnostics.length) { return error.diagnostics.map(item => `${item.code}: ${item.message}`).join(' · '); } return error instanceof Error ? error.message : 'Unbekannter Parserfehler'; } function statusForError(error: unknown): { status: number; code: string; message: string } { if (error instanceof HttpError) return error; if (error instanceof ImportError) return { status: 422, code: error.diagnostics[0]?.code ?? 'import_rejected', message: error.message }; if (error instanceof RepositoryNotFoundError) return { status: 404, code: 'repository_not_found', message: error.message }; if (error instanceof RepositoryConflictError || error instanceof ImportSessionStateError) return { status: 409, code: 'repository_conflict', message: error.message }; if (error instanceof RepositoryValidationError) return { status: 400, code: 'repository_validation', message: error.message }; if (error instanceof RiskAcceptanceValidationError) return { status: 400, code: 'risk_acceptance_validation', message: error.message }; if (error instanceof NvdCveClientError) { const status = error.code === 'invalid_cve_id' ? 400 : error.code === 'nvd_cve_not_found' ? 404 : 502; return { status, code: error.code, message: error.message }; } if (error instanceof AiOverviewServiceError) return { status: error.status, code: error.code, message: error.message }; if (error instanceof RegulatoryMappingServiceError) return { status: error.status, code: error.code, message: error.message }; return { status: 500, code: 'internal_error', message: 'Die Anfrage konnte nicht verarbeitet werden.' }; } async function serveStatic(request: IncomingMessage, response: ServerResponse, staticRoot: string, pathname: string): Promise { if (request.method !== 'GET' && request.method !== 'HEAD') throw new HttpError(405, 'method_not_allowed', 'Diese HTTP-Methode ist nicht zulässig.'); let requested = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, ''); let filePath = resolve(staticRoot, requested); const rootPrefix = resolve(staticRoot) + sep; if (filePath !== resolve(staticRoot) && !filePath.startsWith(rootPrefix)) throw new HttpError(404, 'not_found', 'Ressource nicht gefunden.'); try { const info = await stat(filePath); if (!info.isFile()) throw new Error('not a file'); } catch { if (extname(requested)) throw new HttpError(404, 'not_found', 'Ressource nicht gefunden.'); requested = 'index.html'; filePath = resolve(staticRoot, requested); } const body = await readFile(filePath); response.statusCode = 200; response.setHeader('Content-Type', mediaTypes[extname(filePath).toLowerCase()] ?? 'application/octet-stream'); response.setHeader('Cache-Control', filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable'); if (request.method === 'HEAD') response.end(); else response.end(body); return 200; } export function createAssessorHttpServer(options: AssessorHttpServerOptions): Server { const repository = options.repository; const maximumBodyBytes = options.maximumBodyBytes ?? DEFAULT_MAXIMUM_BODY_BYTES; const now = options.now ?? (() => new Date()); const importExecutor = options.importExecutor ?? directImportExecutor; const previewTtlMs = options.previewTtlMs ?? DEFAULT_PREVIEW_TTL_MS; const importSessions = new Map(); const importBatchPreviews = new Map(); const auditEvents: AuditEvent[] = []; let requestSequence = 0; const server = createServer(async (request, response) => { setSecurityHeaders(response); const requestId = `request-${++requestSequence}`; const startedAt = Date.now(); const method = request.method ?? 'GET'; const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`); let status = 500; let failureCode: string | undefined; try { if (!url.pathname.startsWith(ASSESSOR_API_PREFIX)) { status = await serveStatic(request, response, options.staticRoot, decodeURIComponent(url.pathname)); return; } if (method !== 'GET' && method !== 'HEAD') verifyMutationRequest(request); const segments = routeSegments(url.pathname); if (method === 'GET' && segments.length === 1 && segments[0] === 'health') { const result: AssessorHealthResponse = { status: 'ok', application: 'ASSESSOR', productVersion: ASSESSOR_PRODUCT_VERSION, apiVersion: 1, releaseId: options.releaseId, developmentInstanceId: options.developmentInstanceId, repositoryRevision: repository.revision, persistence: options.persistence ?? 'memory' }; status = 200; sendJson(response, status, result, requestId); return; } if (method === 'GET' && segments.length === 1 && segments[0] === 'audit-events') { status = 200; sendJson(response, status, auditEvents.slice().reverse(), requestId); return; } if (method === 'GET' && segments.length === 1 && segments[0] === 'db-structure') { status = 200; sendJson(response, status, await repository.getDatabaseStructure(), requestId); return; } if (method === 'POST' && segments.join('/') === 'import-sources/validate') { const body = await readJson(request, maximumBodyBytes); status = 200; sendJson(response, status, validateImportSource(body), requestId); return; } if (method === 'POST' && segments.join('/') === 'import-batches/preview') { const body = await readJson(request, maximumBodyBytes); if (!body || !Array.isArray(body.files) || !body.files.length || body.files.length > 50) { throw new HttpError(400, 'invalid_import_batch', 'Ein Importstapel muss zwischen 1 und 50 Dateien enthalten.'); } const batchId = `batch-${now().getTime()}-${requestSequence}`; const hashes = body.files.map(file => { const bytes = typeof file.bytesBase64 === 'string' ? Buffer.from(file.bytesBase64, 'base64') : Buffer.from(''); return createHash('sha256').update(bytes).digest('hex'); }); const existingProjectFiles = body.targetProjectId ? await repository.listProjectImportFiles(body.targetProjectId) : []; if (body.targetProjectId && !await repository.getProject(body.targetProjectId)) { throw new RepositoryNotFoundError(`Projekt ${body.targetProjectId} ist nicht vorhanden.`); } if (body.targetAssessmentId) { if (!body.targetProjectId) throw new HttpError(400, 'timeslot_project_required', 'Ein Timeslot kann nur zusammen mit seinem Mandanten gewählt werden.'); const targetAssessment = (await repository.listAssessments(body.targetProjectId)).find(item => item.id === body.targetAssessmentId); if (!targetAssessment) throw new RepositoryNotFoundError('Der gewählte Timeslot ist für diesen Mandanten nicht vorhanden.'); } if (body.replacesScanRunId) { if (!body.targetProjectId || !body.targetAssessmentId) { throw new HttpError(400, 'reimport_target_required', 'Ein Reimport benötigt Mandant, Timeslot und zu ersetzenden Scanbericht.'); } const targetRun = (await repository.listProjectScanRuns(body.targetProjectId)).find(run => run.id === body.replacesScanRunId); if (!targetRun || targetRun.assessmentId !== body.targetAssessmentId) { throw new RepositoryNotFoundError('Der zu ersetzende Scanbericht gehört nicht zum gewählten Timeslot.'); } const sourceMatch = compareReimportSourceHashes( existingProjectFiles.filter(file => file.scanRunId === body.replacesScanRunId).map(file => file.sha256), hashes ); if (sourceMatch === 'reference-unavailable') { throw new HttpError(409, 'reimport_source_unverifiable', 'Der ursprüngliche Scan besitzt keinen vollständig gespeicherten SHA-256-Nachweis. Ein sicherer Reimport ist deshalb nicht möglich.'); } if (sourceMatch !== 'match') { throw new HttpError(409, 'reimport_source_mismatch', 'Die ausgewählten Dateien sind nicht exakt derselbe Scanbericht wie der Bericht im gewählten Timeslot. Der Reimport wurde verweigert.'); } } const known = body.targetProjectId ? new Map(existingProjectFiles.filter(file => file.sha256).map(file => [file.sha256!, file])) : new Map((await repository.findImportFingerprints(hashes)).map(item => [item.sha256, item])); const firstInBatch = new Map(); const entries = []; for (let index = 0; index < body.files.length; index += 1) { const file = body.files[index]; const sha256 = hashes[index]; const fingerprint = { sha256, fileName: file.fileName, sizeBytes: file.sizeBytes, lastModified: file.lastModified }; const duplicateOf = firstInBatch.get(sha256) ?? known.get(sha256)?.fileName; try { const dataset = await parseImportSourceRequest(file, importExecutor); const detectedTool = dataset.recognition?.source.selected ? { id: dataset.recognition.source.selected.toolId, name: dataset.recognition.source.selected.toolName } : { id: dataset.profileId, name: dataset.scanTool }; // Derselbe Bericht in einem anderen Ausgabeformat traegt denselben // Namensstamm samt eingebettetem Erhebungszeitpunkt, hat aber andere // Bytes - der SHA-256-Abgleich erkennt ihn deshalb nicht. const sameReport = duplicateOf ? undefined : findSameScanReportFile(existingProjectFiles, file.fileName); const possibleDuplicate = duplicateOf || sameReport ? undefined : findPossibleDuplicateImportFile(existingProjectFiles, detectedTool, file.lastModified); const entry = previewEntryFromDataset( dataset, fingerprint, duplicateOf, sameReport ? sameScanReportLabel(sameReport) : possibleDuplicate ? possibleDuplicateLabel(possibleDuplicate) : undefined ); entries.push({ ...entry, id: `${batchId}:${index}` }); if (!firstInBatch.has(sha256)) firstInBatch.set(sha256, file.fileName); } catch (error) { const message = importFailureMessage(error); entries.push({ ...failedPreviewEntry(fingerprint, message), id: `${batchId}:${index}` }); } } const previewCreatedAt = now(); const result: ImportBatchPreviewResponse = { version: 1, batchId, createdAt: previewCreatedAt.toISOString(), replacesScanRunId: body.replacesScanRunId, entries }; for (const [cachedId, cached] of importBatchPreviews) { if (cached.expiresAt <= previewCreatedAt.getTime()) importBatchPreviews.delete(cachedId); } importBatchPreviews.set(batchId, { preview: result, expiresAt: previewCreatedAt.getTime() + previewTtlMs, sourceBytes: body.files.reduce((sum, file) => sum + file.sizeBytes, 0), targetProjectId: body.targetProjectId, targetAssessmentId: body.targetAssessmentId, replacesScanRunId: body.replacesScanRunId }); const cachedSourceBytes = () => [...importBatchPreviews.values()].reduce((sum, item) => sum + item.sourceBytes, 0); while (importBatchPreviews.size > MAXIMUM_PREVIEW_CACHE_BATCHES || cachedSourceBytes() > MAXIMUM_PREVIEW_CACHE_SOURCE_BYTES) { importBatchPreviews.delete(importBatchPreviews.keys().next().value!); } status = 200; sendJson(response, status, result, requestId); return; } if (method === 'POST' && segments.join('/') === 'import-batches/complete') { const body = await readJson(request, maximumBodyBytes); if (!body || typeof body.batchId !== 'string' || !body.batchId.trim() || !Array.isArray(body.entries) || !body.entries.length || body.entries.length > 50) { throw new HttpError(400, 'invalid_import_batch_completion', 'Batch-ID und mindestens eine importierte Datei sind erforderlich.'); } const cachedPreview = importBatchPreviews.get(body.batchId); if (!cachedPreview || cachedPreview.expiresAt <= now().getTime()) { importBatchPreviews.delete(body.batchId); throw new HttpError(409, 'import_batch_expired', 'Die Importvorschau ist nicht mehr verfügbar und muss neu erstellt werden.'); } const preview = cachedPreview.preview; if (cachedPreview.targetProjectId !== body.targetProjectId || cachedPreview.targetAssessmentId !== body.targetAssessmentId || cachedPreview.replacesScanRunId !== body.replacesScanRunId) { throw new HttpError(409, 'import_batch_target_mismatch', 'Das Importziel stimmt nicht mit der geprüften Vorschau überein.'); } const requested = new Map(body.entries.map(entry => [entry.entryId, entry])); const selected = preview.entries.filter(entry => requested.has(entry.id)); const selectionMatches = selected.every(entry => { const requestEntry = requested.get(entry.id)!; return Boolean(entry.dataset) && entry.sha256 === requestEntry.sha256 && entry.fileName === requestEntry.fileName && entry.sizeBytes === requestEntry.sizeBytes && entry.lastModified === requestEntry.lastModified; }); if (selected.length !== body.entries.length || !selectionMatches) throw new HttpError(400, 'import_batch_selection_mismatch', 'Die Abschlussauswahl stimmt nicht mit der geprüften Vorschau überein.'); const confirmedSelected = selected.map(entry => { const requestEntry = requested.get(entry.id)!; if (entry.mappingsToConfirm > 0 && !requestEntry.recognitionOverrides) { throw new HttpError(409, 'import_mapping_confirmation_required', 'Offene Quellen-, Tabellen- oder Feldzuordnungen müssen vor dem Import bestätigt werden.'); } if (!requestEntry.recognitionOverrides || !entry.dataset?.recognition) return entry; const validationErrors = recognitionOverrideErrors(entry.dataset.recognition, requestEntry.recognitionOverrides, importProfiles, entry.dataset.mapping); if (validationErrors.length) throw new HttpError(400, 'invalid_recognition_overrides', validationErrors.join(' ')); const dataset = applyRecognitionToDataset(entry.dataset, requestEntry.recognitionOverrides, importProfiles); return { ...entry, dataset, mappingsToConfirm: 0, toolId: dataset.recognition?.source.selected.toolId ?? dataset.profileId, toolName: dataset.recognition?.source.selected.toolName ?? dataset.scanTool, confidence: dataset.recognition?.source.selected.confidence.level, confidenceScore: dataset.recognition?.source.selected.confidence.score, recordCount: importRecordCount(dataset) }; }); const importedAt = now().toISOString(); const canonical = createCanonicalImportBatch(body.batchId, confirmedSelected, importedAt, body.targetProjectId, body.targetAssessmentId); let existingImportFiles: ImportFileRecord[] = []; if (body.targetProjectId) { const targetProject = await repository.getProject(body.targetProjectId); if (!targetProject) throw new RepositoryNotFoundError(`Projekt ${body.targetProjectId} ist nicht vorhanden.`); canonical.model.project = { ...targetProject, updatedAt: importedAt }; } if (body.targetAssessmentId) { if (!body.targetProjectId) throw new HttpError(400, 'timeslot_project_required', 'Ein Timeslot kann nur zusammen mit seinem Mandanten gewählt werden.'); const targetAssessment = (await repository.listAssessments(body.targetProjectId)).find(item => item.id === body.targetAssessmentId); if (!targetAssessment) throw new RepositoryNotFoundError('Der gewählte Timeslot ist für diesen Mandanten nicht vorhanden.'); const existingModel = await repository.loadAssessmentModel(targetAssessment.id); existingImportFiles = existingModel?.importFiles ?? []; const replacedRun = body.replacesScanRunId ? existingModel?.scanRuns.find(run => run.id === body.replacesScanRunId) : undefined; if (body.replacesScanRunId && !replacedRun) throw new RepositoryNotFoundError('Der zu ersetzende Scanbericht ist nicht mehr vorhanden.'); if (replacedRun) { const sourceMatch = compareReimportSourceHashes( existingImportFiles.filter(file => file.scanRunId === replacedRun.id).map(file => file.sha256), confirmedSelected.map(entry => entry.sha256) ); if (sourceMatch !== 'match') { throw new HttpError(409, sourceMatch === 'reference-unavailable' ? 'reimport_source_unverifiable' : 'reimport_source_mismatch', sourceMatch === 'reference-unavailable' ? 'Der ursprüngliche Scan besitzt keinen vollständig gespeicherten SHA-256-Nachweis. Ein sicherer Reimport ist deshalb nicht möglich.' : 'Die Abschlussauswahl ist nicht exakt derselbe Scanbericht wie der Bericht im gewählten Timeslot. Der Reimport wurde verweigert.'); } } if (replacedRun && replacedRun.tool.id !== canonical.model.scanRuns[0].tool.id) { throw new HttpError(409, 'reimport_tool_mismatch', `Die ausgewählten Dateien wurden als ${canonical.model.scanRuns[0].tool.name} erkannt, erwartet wird ${replacedRun.tool.name}.`); } const nextSequence = Math.max(0, ...(existingModel?.scanRuns.map(run => run.sequence) ?? [])) + 1; canonical.model.assessment = { ...targetAssessment, updatedAt: importedAt }; canonical.model.scanRuns = canonical.model.scanRuns.map(run => ({ ...run, assessmentId: targetAssessment.id, sequence: nextSequence })); } const processingFiles = body.replacesScanRunId ? existingImportFiles.filter(file => file.scanRunId !== body.replacesScanRunId) : existingImportFiles; if (!body.replacesScanRunId && processingScopeIssues([...processingFiles, ...canonical.model.importFiles]).length) { throw new HttpError(409, 'incompatible_processing_versions', 'Der gewählte Timeslot enthält unbekannte oder semantisch inkompatible Verarbeitungsstände. Bitte alle betroffenen Dateien mit dem aktuellen Profil in einen neuen Timeslot reimportieren.'); } const committed = await repository.commitCanonicalImport({ ...canonical, replacesScanRunId: body.replacesScanRunId }); const fingerprints = await repository.findImportFingerprints(body.entries.map(entry => entry.sha256)); importBatchPreviews.delete(body.batchId); const result: ImportBatchCompleteResponse = { batchId: body.batchId, fingerprints, repositoryRevision: committed.repositoryRevision, projectId: canonical.model.project.id, assessmentId: canonical.model.assessment.id, scanRunId: committed.scanRunId, replacedScanRunId: body.replacesScanRunId, importedFiles: committed.importedFiles, importedOccurrences: committed.importedOccurrences, reimportReviewTransfer: committed.reimportReviewTransfer }; status = 200; sendJson(response, status, result, requestId); return; } if (method === 'POST' && segments.join('/') === 'import-sources/parse') { const body = await readJson(request, maximumBodyBytes); const dataset = await parseImportSourceRequest(body, importExecutor); status = 200; sendJson(response, status, dataset, requestId); return; } if (segments.join('/') === 'reference-data/policy-evaluation-rule-catalog' && method === 'GET') { const policyEvaluationService = options.gpoPolicyEvaluationService; if (!policyEvaluationService) throw new HttpError(503, 'policy_evaluation_service_unavailable', 'Die Policy-Evaluation ist nicht konfiguriert.'); status = 200; sendJson(response, status, { version: 1, catalog: policyEvaluationService.catalog() }, requestId); return; } if (segments[0] === 'reference-data' && segments[1] === 'microsoft-security-baselines' && segments.length === 4 && segments[3] === 'analysis' && method === 'GET') { if (!options.microsoftSecurityBaselineService) throw new HttpError(503, 'baseline_catalog_unavailable', 'The Microsoft baseline catalog is not configured.'); status = 200; sendJson(response, status, { version: 1, records: await options.microsoftSecurityBaselineService.listAnalyses(segments[2]) }, requestId); return; } if (segments[0] === 'reference-data' && segments[1] === 'microsoft-security-baselines' && segments.length === 3 && method === 'DELETE') { if (!options.microsoftSecurityBaselineService) throw new HttpError(503, 'baseline_catalog_unavailable', 'The Microsoft baseline catalog is not configured.'); const body = await readJson<{ confirmDelete?: boolean }>(request, maximumBodyBytes); if (body?.confirmDelete !== true) throw new HttpError(400, 'baseline_delete_confirmation_required', 'Explicit baseline deletion confirmation is required.'); const record = await options.microsoftSecurityBaselineService.delete(segments[2]); status = 200; sendJson(response, status, { version: 1, record }, requestId); return; } if (segments[0] === 'reference-data' && segments[1] === 'microsoft-security-baselines' && segments.length === 4 && segments[3] === 'reprocess' && method === 'POST') { if (!options.microsoftSecurityBaselineService) throw new HttpError(503, 'baseline_catalog_unavailable', 'The Microsoft baseline catalog is not configured.'); await readJson>(request, maximumBodyBytes); const record = await options.microsoftSecurityBaselineService.reprocess(segments[2]); status = 200; sendJson(response, status, { version: 1, record }, requestId); return; } if (segments.join('/') === 'reference-data/microsoft-security-baselines' && method === 'GET') { if (!options.microsoftSecurityBaselineService) throw new HttpError(503, 'baseline_catalog_unavailable', 'The Microsoft baseline catalog is not configured.'); status = 200; sendJson(response, status, { version: 1, records: await options.microsoftSecurityBaselineService.list() }, requestId); return; } if (segments.join('/') === 'reference-data/microsoft-security-baselines' && method === 'POST') { if (!options.microsoftSecurityBaselineService) throw new HttpError(503, 'baseline_catalog_unavailable', 'The Microsoft baseline catalog is not configured.'); const body = await readJson<{ fileName: string; sizeBytes: number; bytesBase64: string }>(request, maximumBodyBytes); if (!body || typeof body.fileName !== 'string' || !Number.isSafeInteger(body.sizeBytes) || body.sizeBytes < 1 || !isValidImportBase64(body.bytesBase64)) { throw new HttpError(400, 'invalid_baseline_upload', 'A ZIP file name, size, and valid base64 payload are required.'); } const bytes = Buffer.from(body.bytesBase64, 'base64'); if (bytes.byteLength !== body.sizeBytes) throw new HttpError(400, 'file_size_mismatch', 'Baseline metadata and actual file size do not match.'); const record = await options.microsoftSecurityBaselineService.submit({ fileName: body.fileName, bytes, receivedAt: now().toISOString() }); status = 201; sendJson(response, status, { version: 1, record }, requestId); return; } if (segments.join('/') === 'reference-data/microsoft-security-baselines/select' && method === 'POST') { if (!options.microsoftSecurityBaselineService) throw new HttpError(503, 'baseline_catalog_unavailable', 'The Microsoft baseline catalog is not configured.'); const body = await readJson(request, maximumBodyBytes); if (!body || typeof body.productFamily !== 'string' || !body.productFamily.trim() || typeof body.productVersion !== 'string' || !body.productVersion.trim()) { throw new HttpError(400, 'baseline_context_invalid', 'Product family and product version are required for baseline selection.'); } status = 200; sendJson(response, status, await options.microsoftSecurityBaselineService.select(body), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'policy-analyses' && segments.length === 3 && method === 'GET') { if (!await repository.getProject(segments[1])) throw new RepositoryNotFoundError(`Projekt ${segments[1]} ist nicht vorhanden.`); status = 200; sendJson(response, status, { version: 1, projectId: segments[1], records: await repository.listGpoPolicyAnalyses(segments[1]) }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'policy-analysis-overview' && segments.length === 3 && method === 'GET') { const overviewService = options.gpoPolicyAnalysisOverviewService; if (!overviewService) throw new HttpError(503, 'policy_analysis_overview_unavailable', 'Die Policy-Analyseansicht ist nicht konfiguriert.'); status = 200; sendJson(response, status, await overviewService.get(segments[1]), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'environment-profiles' && segments.length === 3 && method === 'GET') { const policySnapshotService = options.gpoPolicySnapshotService; if (!policySnapshotService) throw new HttpError(503, 'policy_snapshot_service_unavailable', 'Die Policy-Snapshot-Verarbeitung ist nicht konfiguriert.'); status = 200; sendJson(response, status, { version: 1, projectId: segments[1], records: await policySnapshotService.listEnvironmentProfiles(segments[1]) }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'environment-profiles' && segments.length === 3 && method === 'POST') { const policySnapshotService = options.gpoPolicySnapshotService; if (!policySnapshotService) throw new HttpError(503, 'policy_snapshot_service_unavailable', 'Die Policy-Snapshot-Verarbeitung ist nicht konfiguriert.'); const body = await readJson(request, maximumBodyBytes); const record = await policySnapshotService.createEnvironmentProfile(segments[1], body); status = 200; sendJson(response, status, { version: 1, record }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'policy-snapshot-aggregations' && segments.length === 3 && method === 'GET') { const policySnapshotService = options.gpoPolicySnapshotService; if (!policySnapshotService) throw new HttpError(503, 'policy_snapshot_service_unavailable', 'Die Policy-Snapshot-Verarbeitung ist nicht konfiguriert.'); status = 200; sendJson(response, status, { version: 1, projectId: segments[1], records: await policySnapshotService.listAggregations(segments[1]) }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'policy-snapshot-aggregations' && segments.length === 3 && method === 'POST') { const policySnapshotService = options.gpoPolicySnapshotService; if (!policySnapshotService) throw new HttpError(503, 'policy_snapshot_service_unavailable', 'Die Policy-Snapshot-Verarbeitung ist nicht konfiguriert.'); const body = await readJson(request, maximumBodyBytes); const record = await policySnapshotService.aggregate(segments[1], body); status = 200; sendJson(response, status, { version: 1, record }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'policy-evaluations' && segments.length === 3 && method === 'GET') { const policyEvaluationService = options.gpoPolicyEvaluationService; if (!policyEvaluationService) throw new HttpError(503, 'policy_evaluation_service_unavailable', 'Die Policy-Evaluation ist nicht konfiguriert.'); status = 200; sendJson(response, status, { version: 1, projectId: segments[1], records: await policyEvaluationService.list(segments[1]) }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'policy-evaluations' && segments.length === 3 && method === 'POST') { const policyEvaluationService = options.gpoPolicyEvaluationService; if (!policyEvaluationService) throw new HttpError(503, 'policy_evaluation_service_unavailable', 'Die Policy-Evaluation ist nicht konfiguriert.'); const body = await readJson(request, maximumBodyBytes); const record = await policyEvaluationService.evaluate(segments[1], body); status = 200; sendJson(response, status, { version: 1, record }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'policy-finding-runs' && segments.length === 3 && method === 'GET') { const policyFindingService = options.gpoPolicyFindingService; if (!policyFindingService) throw new HttpError(503, 'policy_finding_service_unavailable', 'Die Policy-Finding-Factory ist nicht konfiguriert.'); status = 200; sendJson(response, status, { version: 1, projectId: segments[1], records: await policyFindingService.list(segments[1]) }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'policy-finding-runs' && segments.length === 3 && method === 'POST') { const policyFindingService = options.gpoPolicyFindingService; if (!policyFindingService) throw new HttpError(503, 'policy_finding_service_unavailable', 'Die Policy-Finding-Factory ist nicht konfiguriert.'); const body = await readJson(request, maximumBodyBytes); const record = await policyFindingService.create(segments[1], body); status = 200; sendJson(response, status, { version: 1, record }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 5 && segments[4] === 'evaluate' && method === 'POST') { if (!options.automaticGpoAnalysis) throw new HttpError(503, 'gpo_evaluation_unavailable', 'GPO evaluation unavailable.'); await readJson>(request, maximumBodyBytes); const artifact = (await repository.listImportArtifacts(segments[1])).find(item => item.id === segments[3]); if (!artifact) throw new RepositoryNotFoundError('GPO source missing.'); const runs = (await repository.listProcessingRuns(artifact.id)).filter(item => item.projectId === segments[1]) .sort((a, b) => b.completedAt.localeCompare(a.completedAt) || b.id.localeCompare(a.id)); if (!runs[0]) throw new HttpError(409, 'gpo_processing_required', 'Import processing required.'); status = 200; sendJson(response, status, await options.automaticGpoAnalysis.run(segments[1], runs[0].id), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 5 && segments[4] === 'reprocess' && method === 'POST') { if (!options.gpoBackupManagementService) throw new HttpError(503, 'gpo_management_unavailable', 'Die GPO-Verwaltung ist nicht konfiguriert.'); const body = await readJson<{ expectedLatestProcessingRunId: string }>(request, maximumBodyBytes); if (!body || typeof body.expectedLatestProcessingRunId !== 'string' || !body.expectedLatestProcessingRunId.trim()) { throw new HttpError(400, 'gpo_reprocess_predecessor_required', 'Der erwartete letzte Processing Run ist erforderlich.'); } const result = await options.gpoBackupManagementService.reprocess(segments[1], segments[3], body.expectedLatestProcessingRunId); status = 202; sendJson(response, status, result, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 5 && segments[4] === 'retry' && method === 'POST') { if (!options.gpoBackupManagementService) throw new HttpError(503, 'gpo_management_unavailable', 'Die GPO-Verwaltung ist nicht konfiguriert.'); await readJson>(request, maximumBodyBytes); const result = await options.gpoBackupManagementService.retry(segments[1], segments[3]); status = 200; sendJson(response, status, result, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 4 && method === 'DELETE') { if (!options.gpoBackupManagementService) throw new HttpError(503, 'gpo_management_unavailable', 'Die GPO-Verwaltung ist nicht konfiguriert.'); const body = await readJson<{ artifactId: string; confirmDelete: true }>(request, maximumBodyBytes); if (!body || body.confirmDelete !== true || body.artifactId !== segments[3]) throw new HttpError(400, 'gpo_delete_confirmation_invalid', 'Die Löschung wurde nicht für das exakte GPO-Artefakt bestätigt.'); const result = await options.gpoBackupManagementService.delete(segments[1], segments[3]); status = 200; sendJson(response, status, result, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 4 && segments[3] === 'status' && method === 'GET') { const result = await listProjectGpoBackupStatus(repository, segments[1], now().toISOString(), options.artifactStore); status = 200; sendJson(response, status, result, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'gpo-backups' && segments.length === 3 && method === 'POST') { if (!options.gpoBackupSubmissionService) throw new HttpError(503, 'gpo_ingestion_unavailable', 'Die GPO-Ingestion ist nicht konfiguriert.'); const body = await readJson(request, maximumBodyBytes); if (!body || typeof body.fileName !== 'string' || !Number.isSafeInteger(body.sizeBytes) || body.sizeBytes < 1) { throw new HttpError(400, 'invalid_gpo_upload', 'Dateiname und positive Dateigröße sind erforderlich.'); } if (!isValidImportBase64(body.bytesBase64)) { throw new HttpError(400, 'invalid_file_encoding', 'Der GPO-Container ist nicht gültig base64-kodiert.'); } if (body.restoreArtifactId !== undefined && (typeof body.restoreArtifactId !== 'string' || !body.restoreArtifactId.trim())) { throw new HttpError(400, 'invalid_gpo_restore', 'A valid GPO source identity is required.'); } const bytes = Buffer.from(body.bytesBase64, 'base64'); if (bytes.byteLength !== body.sizeBytes) throw new HttpError(400, 'file_size_mismatch', 'Metadaten und tatsächliche GPO-Dateigröße stimmen nicht überein.'); const result: GpoBackupSubmissionResponse = await options.gpoBackupSubmissionService.submit({ projectId: segments[1], assessmentId: body.assessmentId, restoreArtifactId: body.restoreArtifactId, fileName: body.fileName, bytes, receivedAt: now().toISOString() }); const evaluation = result.deduplicated && !result.sourceRestored && !body.restoreArtifactId && options.automaticGpoAnalysis ? await (async () => { const runs = (await repository.listProcessingRuns(result.artifactId)) .sort((left, right) => Date.parse(right.completedAt) - Date.parse(left.completedAt) || right.id.localeCompare(left.id)); return runs[0] ? options.automaticGpoAnalysis!.run(segments[1], runs[0].id) : undefined; })() : undefined; status = 202; sendJson(response, status, { ...result, evaluation }, requestId); return; } if (segments.length === 1 && segments[0] === 'projects' && method === 'GET') { status = 200; sendJson(response, status, await repository.listProjects(), requestId); return; } if (segments.length === 1 && segments[0] === 'projects' && method === 'POST') { const project = await readJson(request, maximumBodyBytes); await repository.saveProject(project); status = 200; sendJson(response, status, { project, repositoryRevision: repository.revision }, requestId); return; } if (segments[0] === 'projects' && segments.length === 2 && method === 'GET') { const project = await repository.getProject(segments[1]); if (!project) throw new RepositoryNotFoundError(`Projekt ${segments[1]} ist nicht vorhanden.`); status = 200; sendJson(response, status, project, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'scan-runs' && segments.length === 5 && segments[4] === 'adopt-replacement' && method === 'POST') { const body = await readJson<{ replacementScanRunId?: string }>(request, maximumBodyBytes); if (!body || typeof body.replacementScanRunId !== 'string' || !body.replacementScanRunId.trim()) { throw new HttpError(400, 'replacement_scan_required', 'Der neu importierte Ersatzscan ist erforderlich.'); } const reimportReviewTransfer = await repository.adoptScanRunReplacement(segments[1], segments[3], body.replacementScanRunId, now().toISOString()); status = 200; sendJson(response, status, { repositoryRevision: repository.revision, reimportReviewTransfer }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'scan-runs' && segments.length === 4 && method === 'DELETE') { const confirmation = await readJson<{ projectId: string; scanRunId: string; cascade: true }>(request, maximumBodyBytes); const deleted = await repository.deleteScanRun(segments[1], segments[3], confirmation); status = 200; sendJson(response, status, { deleted, repositoryRevision: repository.revision }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'assessments' && segments.length === 4 && method === 'DELETE') { const confirmation = await readJson<{ projectId: string; assessmentId: string; cascade: true }>(request, maximumBodyBytes); const deleted = await repository.deleteAssessment(segments[1], segments[3], confirmation); status = 200; sendJson(response, status, { deleted, repositoryRevision: repository.revision }, requestId); return; } if (segments[0] === 'projects' && segments.length === 2 && method === 'DELETE') { const confirmation = await readJson<{ projectId: string; cascade: true }>(request, maximumBodyBytes); const deleted = await repository.deleteProject(segments[1], confirmation); status = 200; sendJson(response, status, { deleted, repositoryRevision: repository.revision }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'assessment-history' && segments.length === 3 && method === 'GET') { if (!await repository.getProject(segments[1])) throw new RepositoryNotFoundError(`Projekt ${segments[1]} ist nicht vorhanden.`); status = 200; sendJson(response, status, { version: 1, projectId: segments[1], assessments: await repository.listAssessments(segments[1]), scanRuns: await repository.listProjectScanRuns(segments[1]) }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'environment-snapshot' && segments.length === 3 && method === 'GET') { const snapshotQuery: EnvironmentSnapshotQuery = { projectId: segments[1], assessmentId: url.searchParams.get('assessmentId') ?? undefined, scanRunIds: url.searchParams.getAll('scanRunIds').filter(Boolean) }; status = 200; sendJson(response, status, await repository.getEnvironmentSnapshot(snapshotQuery), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'import-files' && segments.length === 3 && method === 'GET') { const project = await repository.getProject(segments[1]); if (!project) throw new RepositoryNotFoundError(`Projekt ${segments[1]} ist nicht vorhanden.`); status = 200; sendJson(response, status, await repository.listProjectImportFiles(segments[1]), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'assessments' && segments.length === 3 && method === 'GET') { status = 200; sendJson(response, status, await repository.listAssessments(segments[1]), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'reviews' && segments.length === 3 && method === 'GET') { status = 200; sendJson(response, status, await repository.listReviews(segments[1]), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'mapping-rules' && segments.length === 3 && method === 'GET') { status = 200; sendJson(response, status, await repository.listLocalComparisonRules(segments[1]), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'mapping-rules' && segments.length === 3 && method === 'POST') { const rule = await readJson(request, maximumBodyBytes); await repository.saveLocalComparisonRule(segments[1], rule); status = 200; sendJson(response, status, { rule, repositoryRevision: repository.revision }, requestId); return; } if (segments[0] === 'projects' && segments[2] === 'export' && segments.length === 3 && method === 'GET') { status = 200; sendJson(response, status, await repository.exportProject(segments[1]), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'ai-overview' && segments.length === 3 && method === 'GET') { const scope: AiOverviewScope = { assessmentIds: url.searchParams.getAll('assessmentIds').flatMap(value => value.split(',')).filter(Boolean), scanRunIds: url.searchParams.getAll('scanRunIds').flatMap(value => value.split(',')).filter(Boolean) }; if (!options.aiOverviewService) { status = 200; sendJson(response, status, { version: 1, projectId: segments[1], scope, generationEnabled: false, state: 'empty', stale: false }, requestId); return; } status = 200; sendJson(response, status, await options.aiOverviewService.get(segments[1], scope), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'ai-overview' && segments.length === 3 && method === 'POST') { if (!options.aiOverviewService) throw new HttpError(503, 'ai_generation_disabled', 'Work LiteLLM is not configured.'); const body = await readJson(request, maximumBodyBytes); status = 202; sendJson(response, status, await options.aiOverviewService.start(segments[1], body), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'regulatory-mappings' && segments.length === 4 && method === 'GET') { if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.'); status = 200; sendJson(response, status, await options.regulatoryMappingService.get(segments[1], segments[3]), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-runs' && segments.length === 3 && method === 'POST') { if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.'); const body = await readJson(request, maximumBodyBytes); status = 202; sendJson(response, status, await options.regulatoryMappingService.start(segments[1], body), requestId); return; } if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-decisions' && segments.length === 3 && method === 'POST') { if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.'); const body = await readJson(request, maximumBodyBytes); status = 200; sendJson(response, status, await options.regulatoryMappingService.decide(segments[1], body), requestId); return; } if (segments.length === 1 && segments[0] === 'assessments' && method === 'POST') { const assessment = await readJson(request, maximumBodyBytes); await repository.saveAssessment(assessment); status = 200; sendJson(response, status, { assessment, repositoryRevision: repository.revision }, requestId); return; } if (segments[0] === 'assessments' && segments[2] === 'model' && segments.length === 3 && method === 'GET') { const model = await repository.loadAssessmentModel(segments[1]); if (!model) throw new RepositoryNotFoundError(`Assessment ${segments[1]} ist nicht vorhanden.`); status = 200; sendJson(response, status, model, requestId); return; } if (segments.length === 1 && segments[0] === 'results' && method === 'GET') { status = 200; sendJson(response, status, await repository.queryUnifiedResults(unifiedQueryFrom(url)), requestId); return; } if (segments.length === 2 && segments[0] === 'results' && segments[1] === 'facets' && method === 'GET') { status = 200; sendJson(response, status, await repository.queryUnifiedResultFacets(unifiedQueryFrom(url)), requestId); return; } if (segments.length === 2 && segments[0] === 'results' && segments[1] === 'categories' && method === 'GET') { status = 200; sendJson(response, status, await repository.listResultCategories(unifiedQueryFrom(url)), requestId); return; } if (segments.length === 2 && segments[0] === 'results' && method === 'GET') { const result = await repository.getUnifiedResultById(segments[1]); if (!result) throw new RepositoryNotFoundError(`Result ${segments[1]} ist nicht vorhanden.`); status = 200; sendJson(response, status, result, requestId); return; } if (segments.length === 2 && segments[0] === 'cves' && method === 'GET') { const record = await repository.getCveEnrichment(segments[1]); if (!record) throw new RepositoryNotFoundError(`CVE enrichment ${segments[1]} is not available.`); status = 200; sendJson(response, status, { version: 1, record }, requestId); return; } if (segments.length === 3 && segments[0] === 'cves' && segments[2] === 'enrich' && method === 'POST') { if (!options.cveEnrichmentService) throw new HttpError(503, 'cve_enrichment_unavailable', 'CVE enrichment is not configured.'); await readJson>(request, maximumBodyBytes); try { const record = await options.cveEnrichmentService.enrich(segments[1]); status = 200; sendJson(response, status, { version: 1, record }, requestId); return; } catch (error) { if (error instanceof NvdCveClientError) throw error; throw new HttpError(400, 'cve_enrichment_rejected', error instanceof Error ? error.message : 'CVE enrichment was rejected.'); } } if (segments.length === 1 && segments[0] === 'findings' && method === 'GET') { status = 200; sendJson(response, status, await repository.findOccurrences(queryFrom(url)), requestId); return; } if (segments.length === 1 && segments[0] === 'reviews' && method === 'POST') { const body = await readJson<{ review: FindingReview; expectedRevision?: number }>(request, maximumBodyBytes); await repository.saveReview(body.review, body.expectedRevision); status = 200; sendJson(response, status, { review: body.review, repositoryRevision: repository.revision }, requestId); return; } if (segments.length === 3 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && method === 'POST') { const body = await readJson<{ occurrenceId: string; command: RiskAcceptanceCommand; expectedRevision: number }>(request, maximumBodyBytes); if (typeof body.occurrenceId !== 'string' || !body.occurrenceId || !Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) throw new HttpError(400, 'invalid_risk_acceptance', 'Finding and expected review revision are required.'); const review = await repository.recordRiskAcceptance(segments[1], body.occurrenceId, body.command, body.expectedRevision); status = 200; sendJson(response, status, { review, repositoryRevision: repository.revision }, requestId); return; } if (segments.length === 1 && segments[0] === 'profiles' && method === 'GET') { status = 200; sendJson(response, status, await repository.listProfileRegistrations(), requestId); return; } if (segments.length === 1 && segments[0] === 'profiles' && method === 'POST') { const profile = await readJson(request, maximumBodyBytes); await repository.saveProfileRegistration(profile); status = 200; sendJson(response, status, { profile, repositoryRevision: repository.revision }, requestId); return; } if (segments.length === 1 && segments[0] === 'comparisons' && method === 'GET') { const assessmentId = url.searchParams.get('assessmentId'); if (!assessmentId) throw new HttpError(400, 'missing_query', 'assessmentId ist erforderlich.'); status = 200; sendJson(response, status, await repository.listComparisons(assessmentId), requestId); return; } if (segments.length === 1 && segments[0] === 'comparisons' && method === 'POST') { const comparison = await readJson(request, maximumBodyBytes); await repository.saveComparison(comparison); status = 200; sendJson(response, status, { comparison, repositoryRevision: repository.revision }, requestId); return; } if (segments[0] === 'comparisons' && segments.length === 2 && method === 'GET') { const comparison = await repository.loadComparison(segments[1]); if (!comparison) throw new RepositoryNotFoundError(`Vergleich ${segments[1]} ist nicht vorhanden.`); status = 200; sendJson(response, status, comparison, requestId); return; } if (segments.length === 1 && segments[0] === 'import-sessions' && method === 'POST') { const start = await readJson(request, maximumBodyBytes); const session = await repository.beginImport(start); importSessions.set(session.id, session); status = 201; sendJson(response, status, { id: session.id, status: session.status }, requestId); return; } if (segments[0] === 'import-sessions' && segments.length >= 2) { const session = importSessions.get(segments[1]); if (!session) throw new RepositoryNotFoundError(`Importsitzung ${segments[1]} ist nicht aktiv.`); if (segments.length === 3 && segments[2] === 'stage' && method === 'POST') { await session.stage(await readJson(request, maximumBodyBytes)); status = 200; sendJson(response, status, { id: session.id, status: session.status }, requestId); return; } if (segments.length === 3 && segments[2] === 'commit' && method === 'POST') { await readJson>(request, maximumBodyBytes); const result = await session.commit(); importSessions.delete(session.id); status = 200; sendJson(response, status, result, requestId); return; } if (segments.length === 2 && method === 'DELETE') { await readJson>(request, maximumBodyBytes); await session.rollback(); importSessions.delete(session.id); status = 200; sendJson(response, status, { id: session.id, status: session.status }, requestId); return; } } throw new HttpError(404, 'api_route_not_found', 'API-Endpunkt nicht gefunden.'); } catch (error) { const failure = statusForError(error); status = failure.status; failureCode = failure.code; const body: AssessorApiErrorBody = { error: { code: failure.code, message: failure.message, requestId } }; sendJson(response, status, body, requestId); } finally { if (url.pathname.startsWith(ASSESSOR_API_PREFIX)) { auditEvents.push({ id: `audit-${requestSequence}`, requestId, timestamp: now().toISOString(), method, path: url.pathname, status, durationMs: Date.now() - startedAt, errorCode: failureCode }); if (auditEvents.length > 1000) auditEvents.shift(); } } }); server.on('close', () => { void options.importExecutor?.close?.(); void options.gpoBackupSubmissionService?.close(); void options.aiOverviewService?.close(); }); return server; }