490 lines
24 KiB
TypeScript
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 }))
|
|
};
|
|
}
|