Assessor_2.0/server/assessor-http-server.ts

1444 lines
100 KiB
TypeScript

import { RiskAcceptanceValidationError, type RiskAcceptanceCommand } from '../src/domain/risk-acceptance';
import type { ScopedRiskAcceptanceWrite } from '../src/domain/scoped-risk-acceptance';
import { createHash, timingSafeEqual } 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 { ClientInformationQuery } from '../src/domain/client-information';
import { clientInformationReadPortFrom } from '../src/persistence/client-information-read-port';
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 { AiOverviewService } from './compliance/ai-overview-service';
import { AiOverviewServiceError } from './compliance/ai-overview-service';
import type { AiOverviewCreateRequest, AiOverviewScope } from '../src/compliance/ai-overview';
import type {
RegulatoryMappingBatchCreateRequest,
RegulatoryMappingBatchScope,
RegulatoryMappingCreateRequest,
RegulatoryMappingDecisionCreateRequest,
RegulatoryFramework
} from '../src/compliance/regulatory-mapping';
import type { RegulatoryMappingService } from './compliance/regulatory-mapping-service';
import type { ComplianceStore } from '../src/compliance/compliance-store';
import { RegulatoryMappingServiceError } from './compliance/regulatory-mapping-service';
import { RegulatoryCorpusUploadError, type RegulatoryCorpusUploadService } from './compliance/regulatory-corpus-upload-service';
import { BsiCatalogError, type BsiCatalogService } from './compliance/bsi-catalog-service';
import type { BsiCorpus } from './compliance/bsi-corpus';
import type { RegulatoryConsistencyReviewCreateRequest } from '../src/compliance/bsi-consistency';
import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-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 { MitreCatalogError, type MitreCatalogService } from './reference-data/mitre-catalog-service';
import { ReferenceRecognitionService, deriveReferenceRun } from './reference-data/reference-recognition-service';
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 { assembleConsistentReportInput, createReportRun, ReportStateChangedError } from '../src/reporting/report-run';
import { reportReadPortFrom, type ReportReadPort } from '../src/reporting/report-read-port';
import type { FileSystemReportArchive } from './reporting/report-archive';
import { ContextStateChangedError, createAssessmentContext } from '../src/assessment-context/context-run';
import { publishContextReport } from '../src/assessment-context/context-report-publication';
import { ReportContextRequiredError } from '../src/reporting/report-context';
import type { AssessmentContext } from '../src/assessment-context/context-engine';
import { ASSESSMENT_CONTEXT_ENGINE } from '../src/assessment-context/context-engine';
import { ASSESSMENT_CONTEXT_CATALOG } from '../src/assessment-context/context-catalog';
import { buildWorkPackages, policyFindingKey, scannerFindingKey, REMEDIATION_ENGINE_VERSION, REMEDIATION_CATALOG_VERSION, type RemediationFinding } from '../src/remediation/remediation-engine';
import { remediationReadPortFrom, type RemediationReadPort } from '../src/remediation/remediation-read-port';
import { MemoryRemediationStore, type RemediationStore } from './remediation/remediation-store';
import { loadCanonicalProjectWorkspace } from '../src/features/project-import/load-canonical-project-workspace';
import { summarizeWorkspaceResults, type DatahubTimeslotResultMetrics } from '../src/features/project-import/datahub-scan-analysis';
import { profileForSheet } from '../src/features/project-import/workspace-profile';
import { buildReportProjection } from '../src/reporting/report-projection';
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,
type ReportRunRequest,
type ReportRunResponse
} 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<string, string> = {
'.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<string, ImportSourceValidationResponse['format']>([
['.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 {
remediationStore?: RemediationStore | null;
remediationPort?: RemediationReadPort | null;
artifactStore?: BackendArtifactStore;
mitreCatalogService?: MitreCatalogService;
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;
/**
* Read-only port of the separate Report Engine. `undefined` narrows `repository` to its read methods
* (in-memory operation); `null` means the Report Engine is unavailable and ASSESSOR keeps running without it.
*/
reportPort?: ReportReadPort | null;
/** Write-once report archive outside the database; report runs are refused without it. */
reportArchive?: FileSystemReportArchive;
aiOverviewService?: AiOverviewService;
regulatoryMappingService?: RegulatoryMappingService;
regulatoryCorpusUploadService?: RegulatoryCorpusUploadService;
complianceExport?: Pick<ComplianceStore, 'exportProject'>;
/** Compliance Engine: active BSI Kernel catalog and ISO mapping with Update from BSI. */
bsiCatalogService?: Pick<BsiCatalogService, 'current' | 'refresh' | 'reset'>;
/** Compliance Engine: the BSI Kernel search index the Data Hub BSI tile builds. */
bsiSearchIndex?: Pick<BsiCorpus, 'status' | 'startBuild'>;
isoUploadToken?: string;
}
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<T>(request: IncomingMessage, maximumBodyBytes: number): Promise<T> {
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 verifyIsoUploadToken(request: IncomingMessage, expected: string | undefined): void {
if (!expected || expected.length < 32) throw new HttpError(503, 'iso_upload_token_unconfigured', 'An ISO upload token of at least 32 characters must be configured.');
const supplied = request.headers['x-assessor-iso-upload-token'];
const candidate = typeof supplied === 'string' ? supplied : '';
const expectedHash = createHash('sha256').update(expected).digest();
const candidateHash = createHash('sha256').update(candidate).digest();
if (!candidate || !timingSafeEqual(expectedHash, candidateHash)) throw new HttpError(403, 'invalid_iso_upload_token', 'The ISO upload token is invalid.');
}
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')
};
}
/** The Compliance Engine framework of a mapping request; the service validates the value and defaults to ISO. */
function regulatoryFrameworkFrom(url: URL): RegulatoryFramework | undefined {
return (url.searchParams.get('framework') ?? undefined) as RegulatoryFramework | undefined;
}
function regulatoryMappingBatchScopeFrom(url: URL): RegulatoryMappingBatchScope {
const query = unifiedQueryFrom(url);
const { audience: _audience, projectId: _projectId, assessmentId: _assessmentId,
assessmentIds = [], scanRunId: _scanRunId, scanRunIds = [], offset: _offset, limit: _limit,
...filters } = query;
return { assessmentIds, scanRunIds, filters };
}
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 MitreCatalogError) return { status: 502, code: error.code, message: error.message };
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 ReportStateChangedError) return { status: 409, code: 'report_state_changed', message: error.message };
if (error instanceof ReportContextRequiredError) return { status: 409, code: 'report_context_required', message: error.message };
if (error instanceof ContextStateChangedError) return { status: 409, code: 'context_state_changed', 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 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 };
if (error instanceof RegulatoryCorpusUploadError) return { status: error.status, code: error.code, message: error.message };
if (error instanceof BsiCatalogError) return { status: 502, 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<number> {
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 remediationStore = options.remediationStore === undefined ? new MemoryRemediationStore() : options.remediationStore;
const remediationPort = options.remediationPort === undefined ? remediationReadPortFrom(repository, async () => repository.revision) : options.remediationPort;
const clientInformation = clientInformationReadPortFrom(repository,
() => repository.getCommittedRevision?.() ?? Promise.resolve(repository.revision));
const reportPort = options.reportPort === undefined
? reportReadPortFrom(repository, async () => repository.revision) : options.reportPort;
const requireReportPort = async (projectId: string): Promise<ReportReadPort> => {
if (!reportPort) throw new HttpError(503, 'report_engine_unavailable', 'Die Report Engine ist nicht verfügbar.');
if (!await reportPort.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
return reportPort;
};
const referenceRecognition = new ReferenceRecognitionService(repository, options.mitreCatalogService
? () => options.mitreCatalogService!.recognitionCatalogs() : undefined);
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<string, ImportSession>();
const importBatchPreviews = new Map<string, CachedImportBatchPreview>();
const timeslotMetricsCache = new Map<string, Promise<DatahubTimeslotResultMetrics | null>>();
// Context results per committed revision and exact scope; bounded, and a new revision never reuses an entry.
const assessmentContextCache = new Map<string, AssessmentContext>();
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 (segments.join('/') === 'reference-data/mitre-attack' && method === 'GET') {
if (!options.mitreCatalogService) throw new HttpError(503, 'mitre_catalog_unavailable', 'Der MITRE-Katalog ist nicht konfiguriert.');
status = 200; sendJson(response, status, await options.mitreCatalogService.current(), requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'references' && segments.length === 3 && method === 'GET') {
status = 200; sendJson(response, status, await referenceRecognition.status(segments[1]), requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'references' && segments[3] === 'recognize' && segments.length === 4 && method === 'POST') {
await readJson(request, maximumBodyBytes);
status = 200; sendJson(response, status, await referenceRecognition.reprocess(segments[1]), requestId); return;
}
if (segments.join('/') === 'reference-data/mitre-attack/refresh' && method === 'POST') {
if (!options.mitreCatalogService) throw new HttpError(503, 'mitre_catalog_unavailable', 'Der MITRE-Katalog ist nicht konfiguriert.');
await readJson<Record<string, never>>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.mitreCatalogService.refresh(), requestId); return;
}
if (segments.join('/') === 'reference-data/mitre-attack/source' && method === 'GET') {
if (!options.mitreCatalogService) throw new HttpError(503, 'mitre_catalog_unavailable', 'Der MITRE-Katalog ist nicht konfiguriert.');
const source = await options.mitreCatalogService.officialSource();
status = 200;
response.statusCode = status;
response.setHeader('Content-Type', 'application/json; charset=utf-8');
response.setHeader('Content-Disposition', `attachment; filename="enterprise-attack-v${source.catalog.version}.json"`);
response.setHeader('Content-Length', source.bytes.byteLength);
response.setHeader('Cache-Control', 'no-store');
response.setHeader('X-Assessor-Request-Id', requestId);
response.end(source.bytes);
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<ImportSourceValidationRequest>(request, maximumBodyBytes);
status = 200; sendJson(response, status, validateImportSource(body), requestId); return;
}
if (method === 'POST' && segments.join('/') === 'import-batches/preview') {
const body = await readJson<ImportBatchPreviewRequest>(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<string, string>();
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<ImportBatchCompleteRequest>(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 referenceCatalogs = await referenceRecognition.catalogs();
canonical.model.referenceRuns = canonical.model.scanRuns.map(scan => deriveReferenceRun(canonical.model, scan.id, referenceCatalogs, importedAt));
const committed = await repository.commitCanonicalImport({ ...canonical, referenceCatalogs, 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<ImportSourceParseRequest>(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<Record<string, never>>(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/iso-corpus' && method === 'GET') {
if (!options.regulatoryCorpusUploadService) throw new HttpError(503, 'iso_corpus_unavailable', 'ISO corpus management is not configured.');
const corpusStatus = await options.regulatoryCorpusUploadService.liveStatus();
const tokenConfigured = Boolean(options.isoUploadToken && options.isoUploadToken.length >= 32);
status = 200; sendJson(response, status, tokenConfigured ? corpusStatus : {
...corpusStatus, configured: false, disabledReason: 'An ISO upload token of at least 32 characters must be configured.'
}, requestId); return;
}
if (segments.join('/') === 'reference-data/bsi-grundschutz' && method === 'GET') {
if (!options.bsiCatalogService) throw new HttpError(503, 'bsi_catalog_unavailable', 'The BSI catalog is not configured.');
status = 200; sendJson(response, status, await options.bsiCatalogService.current(), requestId); return;
}
if (segments.join('/') === 'reference-data/bsi-grundschutz/refresh' && method === 'POST') {
if (!options.bsiCatalogService) throw new HttpError(503, 'bsi_catalog_unavailable', 'The BSI catalog is not configured.');
await readJson<Record<string, never>>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.bsiCatalogService.refresh(), requestId); return;
}
if (segments.join('/') === 'reference-data/bsi-grundschutz/search-index' && method === 'GET') {
if (!options.bsiSearchIndex) throw new HttpError(503, 'bsi_search_index_unavailable', 'The BSI search index is not configured.');
status = 200; sendJson(response, status, await options.bsiSearchIndex.status(), requestId); return;
}
if (segments.join('/') === 'reference-data/bsi-grundschutz/search-index/build' && method === 'POST') {
if (!options.bsiSearchIndex) throw new HttpError(503, 'bsi_search_index_unavailable', 'The BSI search index is not configured.');
const body = await readJson<{ force?: boolean }>(request, maximumBodyBytes);
status = 202; sendJson(response, status, await options.bsiSearchIndex.startBuild(body?.force === true), requestId); return;
}
if (segments.join('/') === 'reference-data/bsi-grundschutz/reset' && method === 'POST') {
if (!options.bsiCatalogService) throw new HttpError(503, 'bsi_catalog_unavailable', 'The BSI catalog is not configured.');
await readJson<Record<string, never>>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.bsiCatalogService.reset(), requestId); return;
}
if (segments.join('/') === 'reference-data/iso-corpus/auto-mapping' && method === 'PUT') {
if (!options.regulatoryCorpusUploadService) throw new HttpError(503, 'iso_corpus_unavailable', 'ISO corpus management is not configured.');
const body = await readJson<{ enabled: boolean }>(request, maximumBodyBytes);
if (typeof body?.enabled !== 'boolean') throw new HttpError(400, 'invalid_auto_mapping', 'enabled must be true or false.');
status = 200; sendJson(response, status, await options.regulatoryCorpusUploadService.setAutoMapping(body.enabled), requestId); return;
}
if (segments.join('/') === 'reference-data/iso-corpus' && method === 'POST') {
if (!options.regulatoryCorpusUploadService) throw new HttpError(503, 'iso_corpus_unavailable', 'ISO corpus management is not configured.');
verifyIsoUploadToken(request, options.isoUploadToken);
const body = await readJson<{ iso27001: { sizeBytes: number; bytesBase64: string }; iso27002: { sizeBytes: number; bytesBase64: string } }>(request, maximumBodyBytes);
const decode = (file: { sizeBytes: number; bytesBase64: string } | undefined): Buffer => {
if (!file || !Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < 1 || file.sizeBytes > 32 * 1024 * 1024 || !isValidImportBase64(file.bytesBase64)) {
throw new HttpError(400, 'invalid_iso_pdf', 'Both ISO PDFs require a valid size and base64 payload of at most 32 MiB each.');
}
const bytes = Buffer.from(file.bytesBase64, 'base64');
if (bytes.length !== file.sizeBytes) throw new HttpError(400, 'file_size_mismatch', 'ISO PDF metadata and actual file size do not match.');
return bytes;
};
const result = await options.regulatoryCorpusUploadService.submit(decode(body?.iso27001), decode(body?.iso27002));
status = 202; sendJson(response, status, result, 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<MicrosoftSecurityBaselineSelectionContext>(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<EnvironmentProfileCreateRequest>(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<PolicySnapshotAggregationCreateRequest>(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<PolicyEvaluationCreateRequest>(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<PolicyFindingRunCreateRequest>(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<Record<string, never>>(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<Record<string, never>>(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<GpoBackupSubmissionRequest>(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<AssessmentProject>(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);
// Archived reports of a client are removed together with the client (idempotent on retry).
await options.reportArchive?.deleteProject(segments[1]);
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] === 'client-information' && segments.length === 3 && method === 'GET') {
const params = url.searchParams;
const allowed = new Set(['assessmentId', 'scanRunIds', 'includeSuperseded', 'areas', 'properties', 'subjectTypes', 'subjectIds', 'toolIds', 'states', 'search', 'offset', 'limit', 'sectionOffset', 'sectionLimit']);
if ([...params.keys()].some(key => !allowed.has(key))) throw new RepositoryValidationError('Unknown Client Information query parameter.');
const scalarKeys = ['assessmentId', 'includeSuperseded', 'search', 'offset', 'limit', 'sectionOffset', 'sectionLimit'];
if (scalarKeys.some(key => params.getAll(key).length > 1)) throw new RepositoryValidationError('Repeated scalar Client Information parameter.');
const history = params.get('includeSuperseded');
if (history !== null && history !== 'true' && history !== 'false') throw new RepositoryValidationError('Invalid history selector.');
const numeric = (key: string) => {
const value = params.get(key);
if (value !== null && !/^\d+$/.test(value)) throw new RepositoryValidationError('Invalid Client Information pagination.');
return value === null ? undefined : Number(value);
};
const query: ClientInformationQuery = {
projectId: segments[1], assessmentId: params.get('assessmentId') ?? undefined,
scanRunIds: params.getAll('scanRunIds'), includeSuperseded: history === 'true',
areas: params.getAll('areas') as ClientInformationQuery['areas'], properties: params.getAll('properties'),
subjectTypes: params.getAll('subjectTypes'), subjectIds: params.getAll('subjectIds'), toolIds: params.getAll('toolIds'),
states: params.getAll('states') as ClientInformationQuery['states'], search: params.get('search') ?? undefined,
offset: numeric('offset'), limit: numeric('limit'), sectionOffset: numeric('sectionOffset'), sectionLimit: numeric('sectionLimit')
};
status = 200; sendJson(response, status, await clientInformation.queryClientInformation(query), 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<LocalComparisonRule>(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') {
// The Compliance Engine delivers its own project data beside the Core export.
const coreExport = await repository.exportProject(segments[1]);
const complianceExport = options.complianceExport ? await options.complianceExport.exportProject(segments[1]) : {};
status = 200; sendJson(response, status, { ...coreExport, ...complianceExport }, requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'report-projection' && segments.length === 3 && method === 'GET') {
const assessmentIds = url.searchParams.getAll('assessmentIds');
const scanRunIds = url.searchParams.getAll('scanRunIds');
const { input } = await assembleConsistentReportInput(await requireReportPort(segments[1]), {
projectId: segments[1], generatedAt: now().toISOString(),
assessmentIds: assessmentIds.length ? assessmentIds : undefined,
scanRunIds: scanRunIds.length ? scanRunIds : undefined
});
status = 200; sendJson(response, status, buildReportProjection(input), requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'work-packages' && segments.length === 3 && method === 'GET') {
if (!remediationStore || !remediationPort) throw new HttpError(503, 'remediation_unavailable', 'Remediation Engine is not available.');
const projectId = segments[1];
if (!await remediationPort.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
const assessmentIds = [...new Set(url.searchParams.getAll('assessmentIds'))].sort();
const scanRunIds = [...new Set(url.searchParams.getAll('scanRunIds'))].sort();
if (!assessmentIds.length) throw new HttpError(400, 'missing_assessment_scope', 'At least one assessment ID is required.');
const revision = await remediationPort.currentRevision();
const contextKey = JSON.stringify([revision, projectId, assessmentIds, scanRunIds]);
const context = assessmentContextCache.get(contextKey);
const key = JSON.stringify([contextKey, REMEDIATION_ENGINE_VERSION, REMEDIATION_CATALOG_VERSION,
ASSESSMENT_CONTEXT_ENGINE.version, ASSESSMENT_CONTEXT_CATALOG.version]);
const stored = await remediationStore.read(key);
if (stored) { status = 200; sendJson(response, status, { status: 'ready', result: stored }, requestId); return; }
if (!context) { status = 200; sendJson(response, status, { status: 'context-required' }, requestId); return; }
// Core-owned adapter: the engine receives only Work records through its declared read port.
const workspace = await loadCanonicalProjectWorkspace({
listProjectAssessments: id => remediationPort.listAssessments(id),
loadAssessmentModel: async id => {
const model = await remediationPort.loadAssessmentModel(id);
if (!model || model.project.id !== projectId) throw new RepositoryNotFoundError(`Assessment ${id} is not in this project.`);
return model;
},
queryUnifiedResults: query => remediationPort.queryUnifiedResults(query),
listPolicyEvaluations: async id => ({ records: await remediationPort.listPolicyEvaluations(id) }),
...(remediationPort.listCurrentPolicyEvaluationsForMetrics ? { listCurrentPolicyEvaluationsForMetrics: async (id: string) => ({ records: await remediationPort.listCurrentPolicyEvaluationsForMetrics!(id) }) } : {})
}, projectId, scanRunIds.length ? scanRunIds : undefined, assessmentIds, { includeHistoricalScores: false });
const findings: RemediationFinding[] = [];
const seen = new Set<string>();
for (const sheet of workspace.dataset?.sheets ?? []) {
const profile = profileForSheet(workspace.dataset!, sheet);
for (const record of sheet.analysis.records) {
if (!profile.matchesStatus(record, 'deviation')) continue;
const field = (label: string) => record.fields.find(f => f.label.trim().toLowerCase() === label)?.value.trim() ?? '';
const identityKey = field('technical identity');
const scope = field('scope');
const key = record.findingOccurrenceId ? scannerFindingKey(record.findingOccurrenceId) : identityKey && scope ? policyFindingKey(scope, identityKey) : '';
if (!key || seen.has(key)) continue;
seen.add(key);
const canonical = workspace.models.flatMap(m => m.findingOccurrences).find(o => o.id === record.findingOccurrenceId);
findings.push({ key, occurrenceId: record.findingOccurrenceId, identityKey, scope,
tool: canonical?.sourceIdentity.tool.id ?? sheet.toolId ?? '',
ruleId: canonical?.sourceIdentity.originalRuleId ?? field('source rule id'), title: record.title,
outcome: canonical?.outcome?.normalizedValue ?? 'deviation',
// Native severity as normalized by the Core; GPO settings carry none and stay without.
...(canonical?.severity?.normalizedValue ? { severity: canonical.severity.normalizedValue } : {}),
remediation: canonical ? workspace.models.flatMap(m => m.findingDefinitions).find(d => d.id === canonical.definitionId)?.recommendation
: record.policyBaselineComparisons?.map(c => c.remediation).filter(Boolean).join('\n') });
}
}
if (await remediationPort.currentRevision() !== revision) throw new HttpError(409, 'remediation_state_changed', 'The data changed while preparing work packages. Reload the scope.');
const result = buildWorkPackages({ projectId, assessmentIds, scanRunIds, findings, context });
await remediationStore.save(key, result);
status = 200; sendJson(response, status, { status: 'ready', result }, requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'assessment-context' && segments.length === 3 && method === 'GET') {
// Context engine (Phase 13E): read-only second reading through the same read-only port as reports.
const assessmentIds = [...new Set(url.searchParams.getAll('assessmentIds'))].sort();
const scanRunIds = [...new Set(url.searchParams.getAll('scanRunIds'))].sort();
if (!assessmentIds.length) throw new HttpError(400, 'missing_assessment_scope', 'At least one assessment ID is required.');
const port = await requireReportPort(segments[1]);
const cacheKey = JSON.stringify([await port.currentRevision(), segments[1], assessmentIds, scanRunIds]);
let context = assessmentContextCache.get(cacheKey);
if (!context) {
context = await createAssessmentContext(port, { projectId: segments[1], assessmentIds, scanRunIds, generatedAt: now().toISOString() });
assessmentContextCache.set(JSON.stringify([context.repositoryRevision, segments[1], assessmentIds, scanRunIds]), context);
while (assessmentContextCache.size > 8) assessmentContextCache.delete(assessmentContextCache.keys().next().value!);
}
status = 200; sendJson(response, status, context, requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'report-runs' && segments.length === 3 && method === 'POST') {
const body = await readJson<ReportRunRequest>(request, maximumBodyBytes);
if (body.kind !== 'management' && body.kind !== 'technical') {
throw new HttpError(400, 'invalid_report_kind', 'kind muss management oder technical sein.');
}
const ids = (value: unknown) => Array.isArray(value) && value.every(item => typeof item === 'string') && value.length
? value as string[] : undefined;
if (!options.reportArchive) throw new HttpError(503, 'report_archive_unavailable', 'Das Report-Archiv ist nicht konfiguriert.');
const startedAt = now();
const port = await requireReportPort(segments[1]);
const run = await createReportRun(port, {
projectId: segments[1], generatedAt: startedAt.toISOString(),
assessmentIds: ids(body.assessmentIds), scanRunIds: ids(body.scanRunIds)
}, {
// Both reports read the same published Context (user decision 2026-10-08): the Management Report
// requires it, the Technical Report renders its problem fields when it is available.
requireContext: body.kind === 'management',
readContext: async scope => {
const assessmentIds = [...new Set(scope.assessmentIds)].sort();
const scanRunIds = [...new Set(scope.scanRunIds ?? [])].sort();
const key = JSON.stringify([await port.currentRevision(), scope.projectId, assessmentIds, scanRunIds]);
let context = assessmentContextCache.get(key);
if (!context) {
context = await createAssessmentContext(port, { ...scope, assessmentIds, scanRunIds });
assessmentContextCache.set(JSON.stringify([context.repositoryRevision, scope.projectId, assessmentIds, scanRunIds]), context);
while (assessmentContextCache.size > 8) assessmentContextCache.delete(assessmentContextCache.keys().next().value!);
}
return publishContextReport(context);
}
});
const manifest = await options.reportArchive.write(run, startedAt);
const result: ReportRunResponse = {
runId: manifest.runId, generatedAt: run.projection.generatedAt, repositoryRevision: run.revision, kind: body.kind,
fileName: `assessor-${body.kind}-report-${run.projection.generatedAt.slice(0, 10)}.html`,
html: body.kind === 'management' ? run.files.management : run.files.technical
};
status = 200; sendJson(response, status, result, requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'timeslot-result-metrics' && segments.length === 3 && method === 'GET') {
if (!await repository.getProject(segments[1])) throw new RepositoryNotFoundError(`Projekt ${segments[1]} ist nicht vorhanden.`);
const assessmentIds = url.searchParams.getAll('assessmentIds');
const scanRunIds = url.searchParams.getAll('scanRunIds');
if (!assessmentIds.length) throw new HttpError(400, 'missing_assessment_scope', 'At least one assessment ID is required.');
const cacheKey = JSON.stringify([repository.revision, segments[1], [...assessmentIds].sort(), [...scanRunIds].sort()]);
let metrics = timeslotMetricsCache.get(cacheKey);
if (!metrics) {
metrics = (async () => {
const { dataset } = await loadCanonicalProjectWorkspace({
listProjectAssessments: projectId => repository.listAssessments(projectId),
loadAssessmentModel: async assessmentId => {
const model = await repository.loadAssessmentModel(assessmentId);
if (!model) throw new RepositoryNotFoundError(`Assessment ${assessmentId} ist nicht vorhanden.`);
return model;
},
queryUnifiedResults: query => repository.queryUnifiedResults(query),
listPolicyEvaluations: async projectId => ({ records: await repository.listPolicyEvaluations(projectId) }),
listCurrentPolicyEvaluationsForMetrics: repository.listCurrentPolicyEvaluationsForMetrics
? async projectId => ({ records: await repository.listCurrentPolicyEvaluationsForMetrics!(projectId) }) : undefined,
getPolicyAnalysisOverview: options.gpoPolicyAnalysisOverviewService
? projectId => options.gpoPolicyAnalysisOverviewService!.get(projectId) : undefined
}, segments[1], scanRunIds.length ? scanRunIds : undefined, assessmentIds,
{ includeHistoricalScores: false, includeBaselineGuidance: false, summaryOnly: true });
const sheet = dataset?.sheets.find(candidate => candidate.isProjectSheet) ?? dataset?.sheets[0];
return sheet ? summarizeWorkspaceResults(sheet.analysis.records, profileForSheet(dataset, sheet)) : null;
})();
if (timeslotMetricsCache.size >= 24) timeslotMetricsCache.clear();
timeslotMetricsCache.set(cacheKey, metrics);
void metrics.catch(() => timeslotMetricsCache.delete(cacheKey));
}
status = 200; sendJson(response, status, await metrics, 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<AiOverviewCreateRequest>(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], regulatoryFrameworkFrom(url)), 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<RegulatoryMappingCreateRequest>(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<RegulatoryMappingDecisionCreateRequest>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.regulatoryMappingService.decide(segments[1], body), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-control-assignments' && segments.length === 3 && 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.controlAssignments(segments[1], regulatoryMappingBatchScopeFrom(url)), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-control-overview' && segments.length === 3 && 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.controlOverview(segments[1], regulatoryMappingBatchScopeFrom(url)), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-batches' && segments.length === 3 && 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.getBatch(segments[1], regulatoryMappingBatchScopeFrom(url), regulatoryFrameworkFrom(url)), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-consistency' && segments.length === 3 && 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.consistency(segments[1], regulatoryMappingBatchScopeFrom(url)), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-consistency-reviews' && segments.length === 3 && method === 'POST') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
const body = await readJson<RegulatoryConsistencyReviewCreateRequest>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.regulatoryMappingService.markConsistencyReviewed(segments[1], body), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-batches' && segments.length === 3 && method === 'POST') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
const body = await readJson<RegulatoryMappingBatchCreateRequest>(request, maximumBodyBytes);
status = 202; sendJson(response, status, await options.regulatoryMappingService.startBatch(segments[1], body), requestId); return;
}
if (segments.length === 1 && segments[0] === 'assessments' && method === 'POST') {
const assessment = await readJson<Assessment>(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] === 'status' && method === 'GET') {
status = 200; sendJson(response, status, await repository.getResultDbStatus(), 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 === 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 === 4 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && segments[3] === 'scoped' && method === 'GET') {
status = 200; sendJson(response, status, await repository.listScopedRiskAcceptances(segments[1]), requestId); return;
}
if (segments.length === 4 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && segments[3] === 'scoped' && method === 'POST') {
const body = await readJson<ScopedRiskAcceptanceWrite>(request, maximumBodyBytes);
const target = body?.target;
const invalidTarget = target && (typeof target.deviceKey !== 'string' || !target.deviceKey.trim()
|| (target.kind === 'device-finding' ? typeof target.occurrenceId !== 'string' || !target.occurrenceId.trim()
: target.kind === 'device-snapshot' ? !Array.isArray(target.scanRunIds) || !target.scanRunIds.length || target.scanRunIds.some(id => typeof id !== 'string' || !id.trim())
: true));
if (!body || !Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 || !body.command
|| (body.acceptanceId !== undefined && (typeof body.acceptanceId !== 'string' || !body.acceptanceId.trim())) || invalidTarget) {
throw new HttpError(400, 'invalid_scoped_risk_acceptance', 'A scoped risk command and expected revision are required.');
}
const acceptance = await repository.recordScopedRiskAcceptance(segments[1], body);
status = 200; sendJson(response, status, { acceptance, 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<ProfileRegistration>(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<StoredComparison>(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<ImportSessionStart>(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<ImportStagingData>(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<Record<string, never>>(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<Record<string, never>>(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;
}