1416 lines
97 KiB
TypeScript
1416 lines
97 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
|
|
} 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 { 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'>;
|
|
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')
|
|
};
|
|
}
|
|
|
|
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/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]), 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)), 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;
|
|
}
|