1051 lines
67 KiB
TypeScript
1051 lines
67 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
import { readFile, stat } from 'node:fs/promises';
|
|
import { extname, resolve, sep } from 'node:path';
|
|
import type {
|
|
AssessorRepository,
|
|
FindingOccurrenceQuery,
|
|
ImportSession,
|
|
ImportSessionStart,
|
|
ImportStagingData,
|
|
ProfileRegistration,
|
|
StoredComparison
|
|
} from '../src/persistence/assessor-repository';
|
|
import type { UnifiedResultQuery } from '../src/domain/unified-result';
|
|
import type { EnvironmentSnapshotQuery } from '../src/domain/environment-context';
|
|
import type { Assessment, AssessmentProject, FindingReview, ImportFileRecord } from '../src/domain/canonical-model';
|
|
import type { LocalComparisonRule } from '../src/domain/comparison';
|
|
import { ImportError } from '../src/shared/errors';
|
|
import { defaultImportResourcePolicy } from '../src/importers/resource-policy';
|
|
import type { PersistenceKind } from './repository-factory';
|
|
import type { GpoBackupSubmissionService } from './gpo/gpo-backup-submission-service';
|
|
import type { GpoBackupManagementService } from './gpo/gpo-backup-management-service';
|
|
import type { MicrosoftSecurityBaselineService } from './gpo/microsoft-security-baseline-service';
|
|
import type { GpoPolicySnapshotService } from './gpo/gpo-policy-snapshot-service';
|
|
import type { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service';
|
|
import type { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service';
|
|
import type { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
|
|
import type { CveEnrichmentService } from './cve/cve-enrichment-service';
|
|
import { NvdCveClientError } from './cve/nvd-cve-client';
|
|
import type { AiOverviewService } from './ai/ai-overview-service';
|
|
import { AiOverviewServiceError } from './ai/ai-overview-service';
|
|
import type { AiOverviewCreateRequest, AiOverviewScope } from '../src/domain/ai-overview';
|
|
import type { RegulatoryMappingCreateRequest, RegulatoryMappingDecisionCreateRequest } from '../src/domain/regulatory-mapping';
|
|
import type { RegulatoryMappingService } from './regulatory/regulatory-mapping-service';
|
|
import { RegulatoryMappingServiceError } from './regulatory/regulatory-mapping-service';
|
|
import type { 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 { directImportExecutor, type ImportExecutor } from './import-executor';
|
|
import { createCanonicalImportBatch } from '../src/importers/canonical-import';
|
|
import { processingScopeIssues } from '../src/domain/processing-compatibility';
|
|
import { ASSESSOR_PRODUCT_VERSION } from '../src/domain/assessor-release';
|
|
import { compareReimportSourceHashes } from '../src/domain/reimport-source';
|
|
import {
|
|
findPossibleDuplicateImportFile,
|
|
findSameScanReportFile,
|
|
possibleDuplicateLabel,
|
|
sameScanReportLabel
|
|
} from '../src/domain/import-duplicates';
|
|
import { failedPreviewEntry, importRecordCount, previewEntryFromDataset } from '../src/features/import-workflow/import-workflow';
|
|
import { applyRecognitionToDataset, recognitionOverrideErrors } from '../src/recognition/recognition-engine';
|
|
import { importProfiles } from '../src/profiles/profile-catalog';
|
|
import {
|
|
ImportSessionStateError,
|
|
RepositoryConflictError,
|
|
RepositoryNotFoundError,
|
|
RepositoryValidationError
|
|
} from '../src/persistence/repository-errors';
|
|
import {
|
|
ASSESSOR_API_PREFIX,
|
|
ASSESSOR_MUTATION_HEADER,
|
|
type AssessorApiErrorBody,
|
|
type AssessorHealthResponse,
|
|
type ImportBatchCompleteRequest,
|
|
type ImportBatchCompleteResponse,
|
|
type ImportBatchPreviewRequest,
|
|
type ImportBatchPreviewResponse,
|
|
type ImportSourceParseRequest,
|
|
type ImportSourceValidationRequest,
|
|
type GpoBackupSubmissionRequest,
|
|
type GpoBackupSubmissionResponse,
|
|
type EnvironmentProfileCreateRequest,
|
|
type PolicySnapshotAggregationCreateRequest,
|
|
type PolicyEvaluationCreateRequest,
|
|
type PolicyFindingRunCreateRequest,
|
|
type ImportSourceValidationResponse
|
|
} from '../src/api/assessor-api-contract';
|
|
|
|
const DEFAULT_MAXIMUM_BODY_BYTES = 140 * 1024 * 1024;
|
|
const MAXIMUM_IMPORT_SOURCE_BYTES = defaultImportResourcePolicy.maximumFileBytes;
|
|
const MAXIMUM_PREVIEW_CACHE_SOURCE_BYTES = 256 * 1024 * 1024;
|
|
const MAXIMUM_PREVIEW_CACHE_BATCHES = 10;
|
|
const DEFAULT_PREVIEW_TTL_MS = 15 * 60 * 1000;
|
|
const CONTENT_SECURITY_POLICY = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'";
|
|
|
|
const mediaTypes: Record<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 {
|
|
repository: AssessorRepository;
|
|
staticRoot: string;
|
|
releaseId?: string;
|
|
developmentInstanceId?: string;
|
|
maximumBodyBytes?: number;
|
|
now?: () => Date;
|
|
persistence?: PersistenceKind;
|
|
importExecutor?: ImportExecutor;
|
|
previewTtlMs?: number;
|
|
gpoBackupSubmissionService?: GpoBackupSubmissionService;
|
|
gpoBackupManagementService?: GpoBackupManagementService;
|
|
gpoPolicySnapshotService?: GpoPolicySnapshotService;
|
|
gpoPolicyEvaluationService?: GpoPolicyEvaluationService;
|
|
gpoPolicyFindingService?: GpoPolicyFindingService;
|
|
gpoPolicyAnalysisOverviewService?: GpoPolicyAnalysisOverviewService;
|
|
automaticGpoAnalysis?: GpoAutomaticAnalysisService;
|
|
microsoftSecurityBaselineService?: MicrosoftSecurityBaselineService;
|
|
cveEnrichmentService?: CveEnrichmentService;
|
|
aiOverviewService?: AiOverviewService;
|
|
regulatoryMappingService?: RegulatoryMappingService;
|
|
}
|
|
|
|
class HttpError extends Error {
|
|
constructor(readonly status: number, readonly code: string, message: string) {
|
|
super(message);
|
|
this.name = 'HttpError';
|
|
}
|
|
}
|
|
|
|
function setSecurityHeaders(response: ServerResponse): void {
|
|
response.setHeader('Content-Security-Policy', CONTENT_SECURITY_POLICY);
|
|
response.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
response.setHeader('X-Frame-Options', 'DENY');
|
|
}
|
|
|
|
function sendJson(response: ServerResponse, status: number, value: unknown, requestId: string): void {
|
|
const body = JSON.stringify(value);
|
|
response.statusCode = status;
|
|
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
response.setHeader('Cache-Control', 'no-store');
|
|
response.setHeader('X-Assessor-Request-Id', requestId);
|
|
response.end(body);
|
|
}
|
|
|
|
async function readJson<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 routeSegments(pathname: string): string[] {
|
|
return pathname.slice(ASSESSOR_API_PREFIX.length).split('/').filter(Boolean).map(segment => decodeURIComponent(segment));
|
|
}
|
|
|
|
function queryFrom(url: URL): FindingOccurrenceQuery {
|
|
const numberValue = (name: string): number | undefined => {
|
|
const value = url.searchParams.get(name);
|
|
return value === null ? undefined : Number(value);
|
|
};
|
|
return {
|
|
projectId: url.searchParams.get('projectId') ?? undefined,
|
|
assessmentId: url.searchParams.get('assessmentId') ?? undefined,
|
|
scanRunId: url.searchParams.get('scanRunId') ?? undefined,
|
|
definitionId: url.searchParams.get('definitionId') ?? undefined,
|
|
toolId: url.searchParams.get('toolId') ?? undefined,
|
|
severity: (url.searchParams.get('severity') ?? undefined) as FindingOccurrenceQuery['severity'],
|
|
status: (url.searchParams.get('status') ?? undefined) as FindingOccurrenceQuery['status'],
|
|
search: url.searchParams.get('search') ?? undefined,
|
|
offset: numberValue('offset'),
|
|
limit: numberValue('limit')
|
|
};
|
|
}
|
|
|
|
function unifiedQueryFrom(url: URL): UnifiedResultQuery {
|
|
const many = (name: string): string[] | undefined => {
|
|
const values = url.searchParams.getAll(name)
|
|
.flatMap(value => value.split(','))
|
|
.map(value => value.trim())
|
|
.filter(Boolean);
|
|
return values.length ? [...new Set(values)] : undefined;
|
|
};
|
|
const numberValue = (name: string): number | undefined => {
|
|
const value = url.searchParams.get(name);
|
|
return value === null ? undefined : Number(value);
|
|
};
|
|
const booleanValue = (name: string): boolean | undefined => {
|
|
const value = url.searchParams.get(name);
|
|
if (value === 'true') return true;
|
|
if (value === 'false') return false;
|
|
return undefined;
|
|
};
|
|
return {
|
|
audience: url.searchParams.get('audience') === 'internal-analysis' ? 'internal-analysis' : 'result-db',
|
|
projectId: url.searchParams.get('projectId') ?? undefined,
|
|
assessmentId: url.searchParams.get('assessmentId') ?? undefined,
|
|
assessmentIds: many('assessmentIds'),
|
|
scanRunId: url.searchParams.get('scanRunId') ?? undefined,
|
|
scanRunIds: many('scanRunIds'),
|
|
evaluationRunId: url.searchParams.get('evaluationRunId') ?? undefined,
|
|
sourceKind: many('sourceKind') as UnifiedResultQuery['sourceKind'],
|
|
sourceTool: many('sourceTool'),
|
|
severity: many('severity') as UnifiedResultQuery['severity'],
|
|
outcome: many('outcome') as UnifiedResultQuery['outcome'],
|
|
technicalExecutionState: many('technicalExecutionState') as UnifiedResultQuery['technicalExecutionState'],
|
|
resultType: many('resultType') as UnifiedResultQuery['resultType'],
|
|
policyClassification: many('policyClassification') as UnifiedResultQuery['policyClassification'],
|
|
isIoe: booleanValue('isIoe'),
|
|
deviation: booleanValue('deviation'),
|
|
mappingRuleVersion: many('mappingRuleVersion'),
|
|
sourceProfileVersion: many('sourceProfileVersion'),
|
|
processingStatus: many('processingStatus') as UnifiedResultQuery['processingStatus'],
|
|
reviewDisposition: many('reviewDisposition') as UnifiedResultQuery['reviewDisposition'],
|
|
lifecycleState: many('lifecycleState') as UnifiedResultQuery['lifecycleState'],
|
|
categoryNamespace: many('categoryNamespace'),
|
|
categoryValue: many('categoryValue'),
|
|
affectedAsset: url.searchParams.get('affectedAsset') ?? undefined,
|
|
environmentState: many('environmentState') as UnifiedResultQuery['environmentState'],
|
|
environmentProperty: many('environmentProperty'),
|
|
environmentSubjectType: many('environmentSubjectType'),
|
|
search: url.searchParams.get('search') ?? undefined,
|
|
observedFrom: url.searchParams.get('observedFrom') ?? undefined,
|
|
observedTo: url.searchParams.get('observedTo') ?? undefined,
|
|
offset: numberValue('offset'),
|
|
limit: numberValue('limit')
|
|
};
|
|
}
|
|
|
|
function validateImportSource(value: ImportSourceValidationRequest): ImportSourceValidationResponse {
|
|
if (!value || typeof value.fileName !== 'string' || !value.fileName.trim()) {
|
|
throw new HttpError(400, 'invalid_file_metadata', 'Ein Dateiname ist erforderlich.');
|
|
}
|
|
if (!Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0) {
|
|
throw new HttpError(400, 'invalid_file_metadata', 'Die Dateigröße muss eine nichtnegative Ganzzahl sein.');
|
|
}
|
|
if (value.sizeBytes > MAXIMUM_IMPORT_SOURCE_BYTES) {
|
|
throw new HttpError(413, 'import_source_too_large', `Die Datei überschreitet das Limit von ${MAXIMUM_IMPORT_SOURCE_BYTES} Bytes.`);
|
|
}
|
|
const format = supportedImportFormats.get(extname(value.fileName).toLowerCase());
|
|
if (!format) throw new HttpError(415, 'unsupported_import_format', 'Unterstützt werden CSV, JSON, XML, HTML, XLSX und XLS.');
|
|
return { accepted: true, format, maximumSizeBytes: MAXIMUM_IMPORT_SOURCE_BYTES };
|
|
}
|
|
|
|
export function isValidImportBase64(value: unknown): value is string {
|
|
if (typeof value !== 'string' || value.length % 4 !== 0) return false;
|
|
let padding = 0;
|
|
if (value.endsWith('=')) padding = value.endsWith('==') ? 2 : 1;
|
|
const dataEnd = value.length - padding;
|
|
for (let index = 0; index < dataEnd; index += 1) {
|
|
const code = value.charCodeAt(index);
|
|
const accepted = code >= 65 && code <= 90
|
|
|| code >= 97 && code <= 122
|
|
|| code >= 48 && code <= 57
|
|
|| code === 43
|
|
|| code === 47;
|
|
if (!accepted) return false;
|
|
}
|
|
for (let index = dataEnd; index < value.length; index += 1) {
|
|
if (value.charCodeAt(index) !== 61) return false;
|
|
}
|
|
return true;
|
|
}
|
|
function decodeImportBytes(body: ImportSourceParseRequest): Buffer {
|
|
validateImportSource({ fileName: body.fileName, sizeBytes: body.sizeBytes });
|
|
if (!isValidImportBase64(body.bytesBase64)) {
|
|
throw new HttpError(400, 'invalid_file_encoding', 'Der Dateiinhalt ist nicht gültig base64-kodiert.');
|
|
}
|
|
const bytes = Buffer.from(body.bytesBase64, 'base64');
|
|
if (bytes.length !== body.sizeBytes) throw new HttpError(400, 'file_size_mismatch', 'Metadaten und tatsächliche Dateigröße stimmen nicht überein.');
|
|
return bytes;
|
|
}
|
|
|
|
async function parseImportSourceRequest(body: ImportSourceParseRequest, executor: ImportExecutor) {
|
|
const bytes = decodeImportBytes(body);
|
|
return executor.execute({
|
|
source: {
|
|
metadata: {
|
|
name: body.fileName,
|
|
sizeBytes: body.sizeBytes,
|
|
lastModified: body.lastModified,
|
|
mediaType: body.mediaType
|
|
},
|
|
bytes
|
|
},
|
|
delimiter: body.delimiter
|
|
});
|
|
}
|
|
function importFailureMessage(error: unknown): string {
|
|
if (error instanceof ImportError && error.diagnostics.length) {
|
|
return error.diagnostics.map(item => `${item.code}: ${item.message}`).join(' · ');
|
|
}
|
|
return error instanceof Error ? error.message : 'Unbekannter Parserfehler';
|
|
}
|
|
function statusForError(error: unknown): { status: number; code: string; message: string } {
|
|
if (error instanceof HttpError) return error;
|
|
if (error instanceof ImportError) return { status: 422, code: error.diagnostics[0]?.code ?? 'import_rejected', message: error.message };
|
|
if (error instanceof RepositoryNotFoundError) return { status: 404, code: 'repository_not_found', message: error.message };
|
|
if (error instanceof RepositoryConflictError || error instanceof ImportSessionStateError) return { status: 409, code: 'repository_conflict', message: error.message };
|
|
if (error instanceof RepositoryValidationError) return { status: 400, code: 'repository_validation', message: error.message };
|
|
if (error instanceof NvdCveClientError) {
|
|
const status = error.code === 'invalid_cve_id' ? 400 : error.code === 'nvd_cve_not_found' ? 404 : 502;
|
|
return { status, code: error.code, message: error.message };
|
|
}
|
|
if (error instanceof AiOverviewServiceError) return { status: error.status, code: error.code, message: error.message };
|
|
if (error instanceof RegulatoryMappingServiceError) return { status: error.status, code: error.code, message: error.message };
|
|
return { status: 500, code: 'internal_error', message: 'Die Anfrage konnte nicht verarbeitet werden.' };
|
|
}
|
|
|
|
async function serveStatic(request: IncomingMessage, response: ServerResponse, staticRoot: string, pathname: string): Promise<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 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 auditEvents: AuditEvent[] = [];
|
|
let requestSequence = 0;
|
|
|
|
const server = createServer(async (request, response) => {
|
|
setSecurityHeaders(response);
|
|
const requestId = `request-${++requestSequence}`;
|
|
const startedAt = Date.now();
|
|
const method = request.method ?? 'GET';
|
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
|
let status = 500;
|
|
let failureCode: string | undefined;
|
|
try {
|
|
if (!url.pathname.startsWith(ASSESSOR_API_PREFIX)) {
|
|
status = await serveStatic(request, response, options.staticRoot, decodeURIComponent(url.pathname));
|
|
return;
|
|
}
|
|
if (method !== 'GET' && method !== 'HEAD') verifyMutationRequest(request);
|
|
const segments = routeSegments(url.pathname);
|
|
|
|
if (method === 'GET' && segments.length === 1 && segments[0] === 'health') {
|
|
const result: AssessorHealthResponse = {
|
|
status: 'ok', application: 'ASSESSOR', productVersion: ASSESSOR_PRODUCT_VERSION, apiVersion: 1,
|
|
releaseId: options.releaseId,
|
|
developmentInstanceId: options.developmentInstanceId,
|
|
repositoryRevision: repository.revision, persistence: options.persistence ?? 'memory'
|
|
};
|
|
status = 200; sendJson(response, status, result, requestId); return;
|
|
}
|
|
if (method === 'GET' && segments.length === 1 && segments[0] === 'audit-events') {
|
|
status = 200; sendJson(response, status, auditEvents.slice().reverse(), requestId); return;
|
|
}
|
|
if (method === 'GET' && segments.length === 1 && segments[0] === 'db-structure') {
|
|
status = 200; sendJson(response, status, await repository.getDatabaseStructure(), requestId); return;
|
|
}
|
|
if (method === 'POST' && segments.join('/') === 'import-sources/validate') {
|
|
const body = await readJson<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 committed = await repository.commitCanonicalImport({ ...canonical, replacesScanRunId: body.replacesScanRunId });
|
|
const fingerprints = await repository.findImportFingerprints(body.entries.map(entry => entry.sha256));
|
|
importBatchPreviews.delete(body.batchId);
|
|
const result: ImportBatchCompleteResponse = {
|
|
batchId: body.batchId,
|
|
fingerprints,
|
|
repositoryRevision: committed.repositoryRevision,
|
|
projectId: canonical.model.project.id,
|
|
assessmentId: canonical.model.assessment.id,
|
|
scanRunId: committed.scanRunId,
|
|
replacedScanRunId: body.replacesScanRunId,
|
|
importedFiles: committed.importedFiles,
|
|
importedOccurrences: committed.importedOccurrences,
|
|
reimportReviewTransfer: committed.reimportReviewTransfer
|
|
};
|
|
status = 200; sendJson(response, status, result, requestId); return;
|
|
}
|
|
if (method === 'POST' && segments.join('/') === 'import-sources/parse') {
|
|
const body = await readJson<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/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());
|
|
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.');
|
|
}
|
|
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, fileName: body.fileName, bytes, receivedAt: now().toISOString()
|
|
});
|
|
const evaluation = result.deduplicated && 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);
|
|
status = 200; sendJson(response, status, { deleted, repositoryRevision: repository.revision }, requestId); return;
|
|
}
|
|
if (segments[0] === 'projects' && segments[2] === 'assessment-history' && segments.length === 3 && method === 'GET') {
|
|
if (!await repository.getProject(segments[1])) throw new RepositoryNotFoundError(`Projekt ${segments[1]} ist nicht vorhanden.`);
|
|
status = 200; sendJson(response, status, {
|
|
version: 1,
|
|
projectId: segments[1],
|
|
assessments: await repository.listAssessments(segments[1]),
|
|
scanRuns: await repository.listProjectScanRuns(segments[1])
|
|
}, requestId); return;
|
|
}
|
|
if (segments[0] === 'projects' && segments[2] === 'environment-snapshot' && segments.length === 3 && method === 'GET') {
|
|
const snapshotQuery: EnvironmentSnapshotQuery = {
|
|
projectId: segments[1],
|
|
assessmentId: url.searchParams.get('assessmentId') ?? undefined,
|
|
scanRunIds: url.searchParams.getAll('scanRunIds').filter(Boolean)
|
|
};
|
|
status = 200; sendJson(response, status, await repository.getEnvironmentSnapshot(snapshotQuery), requestId); return;
|
|
}
|
|
if (segments[0] === 'projects' && segments[2] === 'import-files' && segments.length === 3 && method === 'GET') {
|
|
const project = await repository.getProject(segments[1]);
|
|
if (!project) throw new RepositoryNotFoundError(`Projekt ${segments[1]} ist nicht vorhanden.`);
|
|
status = 200; sendJson(response, status, await repository.listProjectImportFiles(segments[1]), requestId); return;
|
|
}
|
|
if (segments[0] === 'projects' && segments[2] === 'assessments' && segments.length === 3 && method === 'GET') {
|
|
status = 200; sendJson(response, status, await repository.listAssessments(segments[1]), requestId); return;
|
|
}
|
|
if (segments[0] === 'projects' && segments[2] === 'reviews' && segments.length === 3 && method === 'GET') {
|
|
status = 200; sendJson(response, status, await repository.listReviews(segments[1]), requestId); return;
|
|
}
|
|
if (segments[0] === 'projects' && segments[2] === 'mapping-rules' && segments.length === 3 && method === 'GET') {
|
|
status = 200; sendJson(response, status, await repository.listLocalComparisonRules(segments[1]), requestId); return;
|
|
}
|
|
if (segments[0] === 'projects' && segments[2] === 'mapping-rules' && segments.length === 3 && method === 'POST') {
|
|
const rule = await readJson<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') {
|
|
status = 200; sendJson(response, status, await repository.exportProject(segments[1]), requestId); return;
|
|
}
|
|
if (segments[0] === 'projects' && segments[2] === 'ai-overview' && segments.length === 3 && method === 'GET') {
|
|
const scope: AiOverviewScope = {
|
|
assessmentIds: url.searchParams.getAll('assessmentIds').flatMap(value => value.split(',')).filter(Boolean),
|
|
scanRunIds: url.searchParams.getAll('scanRunIds').flatMap(value => value.split(',')).filter(Boolean)
|
|
};
|
|
if (!options.aiOverviewService) {
|
|
status = 200; sendJson(response, status, { version: 1, projectId: segments[1], scope, generationEnabled: false, state: 'empty', stale: false }, requestId); return;
|
|
}
|
|
status = 200; sendJson(response, status, await options.aiOverviewService.get(segments[1], scope), requestId); return;
|
|
}
|
|
if (segments[0] === 'projects' && segments[2] === 'ai-overview' && segments.length === 3 && method === 'POST') {
|
|
if (!options.aiOverviewService) throw new HttpError(503, 'ai_generation_disabled', 'Work LiteLLM is not configured.');
|
|
const body = await readJson<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]), 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.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] === 'facets' && method === 'GET') {
|
|
status = 200; sendJson(response, status, await repository.queryUnifiedResultFacets(unifiedQueryFrom(url)), requestId); return;
|
|
}
|
|
if (segments.length === 2 && segments[0] === 'results' && segments[1] === 'categories' && method === 'GET') {
|
|
status = 200; sendJson(response, status, await repository.listResultCategories(unifiedQueryFrom(url)), requestId); return;
|
|
}
|
|
if (segments.length === 2 && segments[0] === 'results' && method === 'GET') {
|
|
const result = await repository.getUnifiedResultById(segments[1]);
|
|
if (!result) throw new RepositoryNotFoundError(`Result ${segments[1]} ist nicht vorhanden.`);
|
|
status = 200; sendJson(response, status, result, requestId); return;
|
|
}
|
|
if (segments.length === 2 && segments[0] === 'cves' && method === 'GET') {
|
|
const record = await repository.getCveEnrichment(segments[1]);
|
|
if (!record) throw new RepositoryNotFoundError(`CVE enrichment ${segments[1]} is not available.`);
|
|
status = 200; sendJson(response, status, { version: 1, record }, requestId); return;
|
|
}
|
|
if (segments.length === 3 && segments[0] === 'cves' && segments[2] === 'enrich' && method === 'POST') {
|
|
if (!options.cveEnrichmentService) throw new HttpError(503, 'cve_enrichment_unavailable', 'CVE enrichment is not configured.');
|
|
await readJson<Record<string, never>>(request, maximumBodyBytes);
|
|
try {
|
|
const record = await options.cveEnrichmentService.enrich(segments[1]);
|
|
status = 200; sendJson(response, status, { version: 1, record }, requestId); return;
|
|
} catch (error) {
|
|
if (error instanceof NvdCveClientError) throw error;
|
|
throw new HttpError(400, 'cve_enrichment_rejected', error instanceof Error ? error.message : 'CVE enrichment was rejected.');
|
|
}
|
|
}
|
|
if (segments.length === 1 && segments[0] === 'findings' && method === 'GET') {
|
|
status = 200; sendJson(response, status, await repository.findOccurrences(queryFrom(url)), requestId); return;
|
|
}
|
|
if (segments.length === 1 && segments[0] === 'reviews' && method === 'POST') {
|
|
const body = await readJson<{ review: FindingReview; expectedRevision?: number }>(request, maximumBodyBytes);
|
|
await repository.saveReview(body.review, body.expectedRevision);
|
|
status = 200; sendJson(response, status, { review: body.review, repositoryRevision: repository.revision }, requestId); return;
|
|
}
|
|
if (segments.length === 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;
|
|
}
|