import type { CanonicalAssessmentModel, CanonicalFieldRole, CanonicalFindingStatus, CanonicalSeverity, FieldMapping, FindingDefinition, FindingOccurrence, ImportDiagnostic, ImportFileRecord, MappingConfidence, MappingProvenance, SourceTableRecord, ToolIdentity } from '../domain/canonical-model'; import type { ImportFingerprintInput, ImportPreviewEntry } from '../domain/import-workflow'; import type { FieldRoleRecognition } from '../domain/import-recognition'; import { clean, normalizeNumericValue } from '../shared/text'; import { sourceNativeFindingIdentity, unresolvedFindingIdentity } from '../domain/finding-identity'; import { projectMappedCandidates } from './canonical-candidate-projection'; import { cisCatResultCategory, compareCisCatSummary, describeCisCatSummaryMismatch, extractCisCatSummary } from './cis-cat-summary'; import { normalizeStatusDimensionsFor } from './status-normalization'; import { defaultSecurityScoringPolicy } from '../domain/security-scoring'; import { processingManifest } from '../domain/processing-compatibility'; export interface CanonicalImportBatch { sessionId: string; model: CanonicalAssessmentModel; fingerprints: ImportFingerprintInput[]; } function hash(value: string): string { let result = 0x811c9dc5; for (let index = 0; index < value.length; index += 1) { result ^= value.charCodeAt(index); result = Math.imul(result, 0x01000193); } return (result >>> 0).toString(16).padStart(8, '0'); } function confidence(entry: ImportPreviewEntry): MappingConfidence { return { level: entry.confidence ?? 'unknown', score: entry.confidenceScore ?? 0, reasons: entry.dataset?.recognition?.source.selected.confidence.reasons ?? [] }; } function provenance(field?: FieldRoleRecognition): MappingProvenance { return { decisionSource: field?.decisionSource === 'user-confirmed' ? 'user-confirmed' : 'automatic', confidence: field?.confidence ?? { level: 'unknown', score: 0, reasons: [] }, mappingVersion: '1.0.0' }; } function normalizeSeverity(value: string): CanonicalSeverity { const normalized = clean(value).toLocaleLowerCase('de-DE'); if (/critical|kritisch/.test(normalized)) return 'critical'; if (/\bhigh\b|\bhoch\b/.test(normalized)) return 'high'; if (/medium|mittel|moderate/.test(normalized)) return 'medium'; if (/\blow\b|niedrig/.test(normalized)) return 'low'; if (/info/.test(normalized)) return 'info'; return 'unknown'; } // Statusnormalisierung: siehe ./status-normalization.ts (gemeinsame Quelle fuer // beide Importpfade, wendet das deklarierte Profil-Mapping an). function fieldValue(fields: Array<{ role: CanonicalFieldRole; value: string }>, role: CanonicalFieldRole): string { return fields.find(field => field.role === role && clean(field.value))?.value ?? ''; } function toolFor(entry: ImportPreviewEntry): ToolIdentity { const detectedVersion = entry.dataset?.extraction?.environmentObservations ?.find(item => item.property === 'report.engine_version')?.normalizedValue; return { id: entry.toolId && entry.toolId !== 'unknown' ? entry.toolId : entry.dataset?.profileId ?? 'generic', name: entry.toolName && entry.toolName !== 'Unknown' ? entry.toolName : entry.dataset?.scanTool ?? 'Generic', ...(typeof detectedVersion === 'string' && detectedVersion.trim() ? { version: detectedVersion.trim() } : {}) }; } export function createCanonicalImportBatch( batchId: string, entries: ImportPreviewEntry[], importedAt = new Date().toISOString(), targetProjectId?: string, targetAssessmentId?: string ): CanonicalImportBatch { if (!entries.length || entries.some(entry => !entry.dataset)) throw new Error('Der kanonische Import benötigt erfolgreich vorbereitete Dateien.'); // Bestaetigungspflichtig sind nur Entscheidungen, die veraendern WAS // importiert wird: das erkannte Werkzeug (bestimmt Profil und Normalisierung) // und die Tabellenauswahl (bestimmt, welche Zeilen zu Findings werden). // Offene Feldrollen blockieren bewusst nicht: eine unbestaetigte Spalte wird // mit appliedRole 'additional' uebernommen, ihr Rohwert bleibt in // originalFields und in der Quelltabelle vollstaendig erhalten. Es entsteht // kein undefinierter Datenbankzustand, sondern nur eine Spalte ohne // semantische Rolle - kein Grund, den Import zu verweigern. const hasOpenDecisions = entries.some(entry => entry.mappingsToConfirm > 0 || entry.dataset?.recognition?.source.requiresConfirmation || entry.dataset?.recognition?.tables.some(table => table.requiresConfirmation)); if (hasOpenDecisions) throw new Error('Der kanonische Import darf keine unbestätigten Quellen- oder Tabellenentscheidungen enthalten.'); const safeBatchId = batchId.replace(/[^a-zA-Z0-9_-]/g, '-'); const projectId = targetProjectId || `project-${safeBatchId}`; const assessmentId = targetAssessmentId || `assessment-${safeBatchId}`; const scanRunId = `run-${safeBatchId}`; const sourceTools = entries.map(toolFor); const recognizedTools = new Map(sourceTools .filter(tool => tool.id !== 'generic' && tool.id !== 'unknown') .map(tool => [tool.id, tool])); const runTool = recognizedTools.size === 1 ? [...recognizedTools.values()][0] : recognizedTools.size > 1 ? { id: 'multiple', name: 'Multiple Tools' } : { id: 'generic', name: 'Tabular Data' }; const projectName = `${runTool.name} · ${entries.length} Datei${entries.length === 1 ? '' : 'en'}`; const importFiles: ImportFileRecord[] = []; const sourceTables: SourceTableRecord[] = []; const reportSections: NonNullable = []; const environmentObservations: NonNullable = []; const definitions = new Map(); const findingOccurrences: FindingOccurrence[] = []; const evidence: CanonicalAssessmentModel['evidence'] = []; const diagnostics: ImportDiagnostic[] = []; entries.forEach((entry, fileIndex) => { const dataset = entry.dataset!; const fileId = `file-${safeBatchId}-${fileIndex}`; const tool = sourceTools[fileIndex]; const mappingRuleVersion = dataset.mapping?.mappingProfile.version ?? '1.0.0'; const profileId = dataset.profileId ?? dataset.mapping?.mappingProfile.id ?? 'generic'; const profileVersion = dataset.profileVersion ?? '1.0.0'; const processing = processingManifest(profileId, profileVersion); let unresolvedIdentityCount = 0; // Vorkommen dieser Datei werden zuerst gesammelt und erst nach der // Gegenprobe uebernommen. Nur so laesst sich vor dem Ablegen entscheiden, // ob Zeilen ohne Ergebniswert verworfen werden duerfen. const fileOccurrences: FindingOccurrence[] = []; const fileEvidence: CanonicalAssessmentModel['evidence'] = []; importFiles.push({ id: fileId, scanRunId, fileName: entry.fileName, format: entry.format ?? dataset.format, sizeBytes: entry.sizeBytes, lastModifiedAt: entry.lastModified > 0 ? new Date(entry.lastModified).toISOString() : undefined, sha256: entry.sha256, importedAt, detectedTool: tool, detection: confidence(entry), processedWith: { parserId: dataset.intermediate?.processing.find(item => item.stage === 'format-decoding')?.processor.id ?? (entry.format ?? dataset.format).toLocaleLowerCase('de-DE'), parserVersion: dataset.intermediate?.processing.find(item => item.stage === 'format-decoding')?.processor.version ?? '1.0.0', profileId, profileVersion, mappingVersion: mappingRuleVersion, ...(processing ? { normalizationVersion: processing.normalizationVersion, presentationVersion: processing.presentationVersion, compatibilityKey: processing.compatibilityKey, changeClass: processing.changeClass, reimportRequired: processing.reimportRequired } : {}) }, sourceArtifact: { kind: 'not-retained', reason: 'Originaldatei wird gemäß aktueller Aufbewahrungsentscheidung nicht separat gespeichert.' } }); const sourceTableIds = new Map(); dataset.sheets.forEach((sheet, tableIndex) => { const sourceTableIndex = sheet.sourceTableIndex ?? dataset.intermediate?.tables.find(table => table.id === sheet.id)?.index ?? tableIndex; const tableId = `table-${safeBatchId}-${fileIndex}-${sourceTableIndex}`; sourceTableIds.set(sourceTableIndex, tableId); sourceTables.push({ id: tableId, importFileId: fileId, name: sheet.name, index: sourceTableIndex, hidden: sheet.hidden ? true : undefined, rowCount: sheet.analysis.records.length, columnCount: sheet.analysis.headers.length, attributes: { sourceFile: entry.fileName, sourceSheet: sheet.sourceSheet ?? sheet.name }, rows: sheet.analysis.records.map(record => ({ index: record.index, fields: record.fields.map(field => ({ key: `column-${field.index}`, label: field.label, value: field.value, columnIndex: field.index })), highlighted: record.highlighted })) }); if (dataset.mapping) return; const recognizedFields = dataset.recognition?.fields.filter(field => field.tableId === sheet.id) ?? []; sheet.analysis.records.forEach((record, recordIndex) => { const mappedFields = record.fields.map(field => ({ field, recognition: recognizedFields.find(item => item.columnIndex === field.index), role: recognizedFields.find(item => item.columnIndex === field.index)?.appliedRole ?? 'additional' as CanonicalFieldRole, value: field.value })); const valuesByRole = mappedFields.map(item => ({ role: item.role, value: item.value })); const ruleId = fieldValue(valuesByRole, 'rule-id'); const title = fieldValue(valuesByRole, 'title') || record.title; const occurrenceId = `occurrence-${safeBatchId}-${fileIndex}-${tableIndex}-${recordIndex}`; const findingIdentity = ruleId ? sourceNativeFindingIdentity(tool, ruleId) : unresolvedFindingIdentity(`${safeBatchId}:${fileIndex}:${tableIndex}:${recordIndex}`); if (!findingIdentity.identity.durable) unresolvedIdentityCount += 1; const definitionId = `definition-${hash(findingIdentity.canonicalKey)}`; if (!definitions.has(definitionId)) definitions.set(definitionId, { id: definitionId, canonicalKey: findingIdentity.canonicalKey, identity: findingIdentity.identity, title, description: fieldValue(valuesByRole, 'description') || undefined, category: fieldValue(valuesByRole, 'category') || undefined, recommendation: fieldValue(valuesByRole, 'recommendation') || undefined, frameworkReferences: [], createdAt: importedAt, updatedAt: importedAt }); const fieldMappings: FieldMapping[] = mappedFields.map(item => ({ targetRole: item.role, sourceFieldKeys: [`column-${item.field.index}`], normalizedValue: item.value, provenance: provenance(item.recognition) })); const severityValue = fieldValue(valuesByRole, 'severity'); const statusValue = fieldValue(valuesByRole, 'status'); const scoreValue = fieldValue(valuesByRole, 'score'); const assetValue = fieldValue(valuesByRole, 'asset'); const severityField = mappedFields.find(item => item.role === 'severity')?.recognition; const statusField = mappedFields.find(item => item.role === 'status')?.recognition; const statusDimensions = normalizeStatusDimensionsFor(tool.id, statusValue); const statusProvenance = provenance(statusField); const occurrence: FindingOccurrence = { id: occurrenceId, definitionId, scanRunId, importFileId: fileId, sourceTableId: tableId, sourceIdentity: { tool, originalRuleId: ruleId || undefined, canonicalRuleId: ruleId || undefined, originalTitle: title }, sourcePosition: { rowIndex: record.index }, originalFields: record.fields.map(field => ({ key: `column-${field.index}`, label: field.label, value: field.value, columnIndex: field.index })), fieldMappings, severity: { originalValue: severityValue, normalizedValue: normalizeSeverity(severityValue), provenance: provenance(severityField) }, status: { originalValue: statusValue, normalizedValue: statusDimensions.legacy, provenance: statusProvenance }, outcome: { originalValue: statusValue, normalizedValue: statusDimensions.outcome, provenance: statusProvenance }, technicalExecutionState: { originalValue: statusValue, normalizedValue: statusDimensions.technical, provenance: statusProvenance }, mappingRuleVersion }; const numericScore = normalizeNumericValue(scoreValue); if (numericScore !== null) occurrence.score = { originalValue: scoreValue, normalizedValue: numericScore, provenance: provenance(mappedFields.find(item => item.role === 'score')?.recognition) }; if (assetValue) occurrence.affectedAsset = { originalValue: assetValue, normalizedValue: clean(assetValue), provenance: provenance(mappedFields.find(item => item.role === 'asset')?.recognition) }; fileOccurrences.push(occurrence); const evidenceValue = fieldValue(valuesByRole, 'evidence'); if (evidenceValue) fileEvidence.push({ id: `evidence-${safeBatchId}-${fileIndex}-${tableIndex}-${recordIndex}`, occurrenceId, kind: 'text', value: evidenceValue, sourceFieldKeys: mappedFields.filter(item => item.role === 'evidence').map(item => `column-${item.field.index}`) }); }); }); const extractorVersion = dataset.extraction?.extractor.version ?? 'unknown'; dataset.extraction?.reportSections?.forEach((section, sectionIndex) => reportSections.push({ version: 1, id: `report-section-${safeBatchId}-${fileIndex}-${sectionIndex}`, scanRunId, importFileId: fileId, tool, sectionKey: section.sectionKey, parentSectionKey: section.parentSectionKey, label: section.label, ordinal: section.ordinal, state: section.state, prose: section.prose, tables: section.tables, source: { rowIndex: section.source.rowIndex, columnIndex: section.source.columnIndex, cellReference: section.source.cellReference, objectPath: section.source.objectPath, elementSelector: section.source.elementSelector }, extractorVersion, attributes: { ...section.attributes, ...(section.source.tableIndex === undefined ? {} : { sourceTableIndex: section.source.tableIndex }) } })); dataset.extraction?.environmentObservations?.forEach((observation, observationIndex) => environmentObservations.push({ version: 1, id: `environment-observation-${safeBatchId}-${fileIndex}-${observationIndex}`, scanRunId, importFileId: fileId, tool, subject: observation.subject, property: observation.property, label: observation.label, originalValue: observation.originalValue, normalizedValue: observation.normalizedValue, valueType: observation.valueType, state: observation.state, observedAt: observation.observedAt, sectionKey: observation.sectionKey, source: { rowIndex: observation.source.rowIndex, columnIndex: observation.source.columnIndex, cellReference: observation.source.cellReference, objectPath: observation.source.objectPath, elementSelector: observation.source.elementSelector }, mappingVersion: `${dataset.mapping?.mappingProfile.version ?? '1.0.0'}+context-1.0.0`, attributes: { ...observation.attributes, ...(observation.source.tableIndex === undefined ? {} : { sourceTableIndex: observation.source.tableIndex }) } })); if (dataset.mapping) { const selectedTableIndexes = dataset.recognition ? new Set(dataset.recognition.tables.filter(table => table.selected).map(table => table.tableIndex)) : undefined; const projected = projectMappedCandidates({ safeBatchId, fileIndex, fileId, scanRunId, tool, importedAt, mapping: dataset.mapping, sourceTableIds, selectedTableIndexes }); projected.definitions.forEach(definition => { if (!definitions.has(definition.id)) definitions.set(definition.id, definition); }); fileOccurrences.push(...projected.occurrences.map(occurrence => ({ ...occurrence, mappingRuleVersion }))); fileEvidence.push(...projected.evidence); unresolvedIdentityCount += projected.unresolvedIdentityCount; const excludedCandidateCount = dataset.mapping.candidates.length - projected.occurrences.length; if (excludedCandidateCount > 0) diagnostics.push({ id: `diagnostic-${safeBatchId}-${fileIndex}-candidate-table-selection`, importFileId: fileId, severity: 'info', code: 'candidate-table-not-selected', message: `${excludedCandidateCount} Quellkandidat(en) aus nicht ausgewählten Tabellen wurden nicht als Findings persistiert.`, details: { excludedCandidateCount } }); } // Gegenprobe gegen die Zahlen, die der Bericht selbst nennt, und - wenn sie // aufgeht - Beschraenkung auf die Zeilen, die der Bericht ueberhaupt als // Ergebnis fuehrt. // // Verworfen wird ausschliesslich, wenn die verbleibende Menge exakt der // Summary entspricht. Passt sie nicht, bleibt alles erhalten: eine // unerwartete Berichtsform darf zu einer Warnung fuehren, niemals zu // stillschweigendem Datenverlust. const persisted = { occurrences: fileOccurrences, evidence: fileEvidence }; if (tool.id === 'cis-cat') { const declared = extractCisCatSummary(dataset.intermediate?.rawText ?? dataset.rawText ?? ''); if (!declared) { diagnostics.push({ id: `diagnostic-${safeBatchId}-${fileIndex}-cis-cat-summary-absent`, importFileId: fileId, severity: 'info', code: 'cis-cat-summary-absent', message: 'Der CIS-CAT-Bericht enthält keinen auswertbaren Summary-Block; die Anzahl der übernommenen Datensätze konnte nicht gegen die Angaben des Berichts geprüft werden.' }); } else { const resultRows = fileOccurrences.filter(occurrence => cisCatResultCategory(occurrence.status.originalValue)); const reduced = compareCisCatSummary(declared, resultRows.map(occurrence => occurrence.status.originalValue)); const droppedCount = fileOccurrences.length - resultRows.length; if (reduced.matches) { if (droppedCount > 0) { const keptIds = new Set(resultRows.map(occurrence => occurrence.id)); persisted.occurrences = resultRows; persisted.evidence = fileEvidence.filter(item => keptIds.has(item.occurrenceId)); diagnostics.push({ id: `diagnostic-${safeBatchId}-${fileIndex}-cis-cat-non-result-rows-dropped`, importFileId: fileId, severity: 'info', code: 'cis-cat-non-result-rows-dropped', message: `${droppedCount} Zeilen ohne Ergebniswert wurden nicht als Findings übernommen; die verbleibenden ${resultRows.length} entsprechen exakt dem Summary des Berichts. Ihre Rohdaten bleiben in den Quelltabellen erhalten.`, details: { droppedCount, keptCount: resultRows.length } }); } diagnostics.push({ id: `diagnostic-${safeBatchId}-${fileIndex}-cis-cat-summary-verified`, importFileId: fileId, severity: 'info', code: 'cis-cat-summary-verified', message: `Die übernommenen ${reduced.importedRowCount} Datensätze stimmen mit dem Summary des Berichts überein.`, details: { declared: reduced.declared, imported: reduced.imported } }); } else { // Auch die ungefilterte Menge ausweisen: sie zeigt, ob ueberhaupt zu // viele Zeilen ankamen oder ob je Kategorie etwas fehlt. const unfiltered = compareCisCatSummary(declared, fileOccurrences.map(occurrence => occurrence.status.originalValue)); diagnostics.push({ id: `diagnostic-${safeBatchId}-${fileIndex}-cis-cat-summary-mismatch`, importFileId: fileId, severity: 'warning', code: 'cis-cat-summary-mismatch', message: `${describeCisCatSummaryMismatch(unfiltered)} Es wurden vorsorglich keine Zeilen verworfen, weil auch die auf Ergebniszeilen beschränkte Menge (${reduced.importedRowCount}) nicht zum Summary passt.`, details: { declared: unfiltered.declared, imported: unfiltered.imported, declaredRowCount: unfiltered.declaredRowCount, importedRowCount: unfiltered.importedRowCount, uncategorizedCount: unfiltered.uncategorizedCount, resultRowCount: reduced.importedRowCount } }); } } } findingOccurrences.push(...persisted.occurrences); evidence.push(...persisted.evidence); const occurrenceTableIds = new Set(persisted.occurrences .filter(occurrence => occurrence.sourceTableId) .map(occurrence => occurrence.sourceTableId)); sourceTables .filter(table => table.importFileId === fileId && occurrenceTableIds.has(table.id)) .forEach(table => { delete table.rows; }); if (unresolvedIdentityCount) diagnostics.push({ id: `diagnostic-${safeBatchId}-${fileIndex}-finding-identity`, importFileId: fileId, severity: 'warning', code: 'finding-identity-unresolved', message: `${unresolvedIdentityCount} Finding-Vorkommen besitzen keine stabile native Regel-ID und bleiben bis zu einer expliziten lokalen Bestätigung ohne dauerhafte Identität.`, details: { unresolvedIdentityCount } }); (dataset.intermediate?.diagnostics ?? []).forEach((item, index) => diagnostics.push({ id: `diagnostic-${safeBatchId}-${fileIndex}-${index}`, importFileId: fileId, severity: item.severity, code: item.code, message: item.message, details: item.details })); }); // Definitionen entstehen ausschliesslich zusammen mit einem Vorkommen. Wurde // ein Vorkommen nicht uebernommen, bliebe seine Definition sonst ohne Bezug // in der Datenbank stehen - fuer korrekte Importe ist der Filter wirkungslos. const referencedDefinitionIds = new Set(findingOccurrences.map(occurrence => occurrence.definitionId)); const model: CanonicalAssessmentModel = { schemaVersion: 1, project: { id: projectId, name: projectName, createdAt: importedAt, updatedAt: importedAt, attributes: { importBatchId: batchId } }, assessment: { id: assessmentId, projectId, name: `Assessment ${importedAt.slice(0, 10)}`, createdAt: importedAt, updatedAt: importedAt, scoringPolicy: defaultSecurityScoringPolicy() }, scanRuns: [{ id: scanRunId, assessmentId, sequence: 1, label: 'Import 1', tool: runTool, importedAt }], importFiles, sourceTables, reportSections, environmentObservations, findingDefinitions: [...definitions.values()].filter(definition => referencedDefinitionIds.has(definition.id)), findingOccurrences, evidence, reviews: [], diagnostics }; return { sessionId: `session-${safeBatchId}`, model, fingerprints: entries.map(entry => ({ sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified })) }; }