Assessor_2.0/src/importers/canonical-import.ts

490 lines
24 KiB
TypeScript

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<CanonicalAssessmentModel['reportSections']> = [];
const environmentObservations: NonNullable<CanonicalAssessmentModel['environmentObservations']> = [];
const definitions = new Map<string, FindingDefinition>();
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<number, string>();
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 }))
};
}