import type { CanonicalAssessmentModel, CanonicalFieldRole, EvidenceRecord, FindingDefinition, FindingOccurrence, JsonValue, ScanRun } from '../domain/canonical-model'; import type { ComparisonMatch, ComparisonMatchMethod, ComparisonOptions, ComparisonSummary, FindingChange, FindingComparison, LocalComparisonRule, ScanRunComparison } from '../domain/comparison'; import { jaccardSimilarity, normalizeComparisonText, similarityTokens, stableJson } from './comparison-normalization'; import { localRuleConnects, validateLocalComparisonRules } from './local-mapping-rules'; import { definitionHasDurableIdentity } from '../domain/finding-identity'; import { resolvedCompatibilityKey } from '../domain/processing-compatibility'; interface ComparisonContext { definitions: Map; evidence: Map; options: ComparisonOptions; } interface MatchedPair { baseline: FindingOccurrence; target: FindingOccurrence; match: ComparisonMatch; } interface SuggestedPair extends MatchedPair { score: number; } type KeyFactory = (occurrence: FindingOccurrence, context: ComparisonContext) => string[]; const lifecycleOrder: Record = { persistent: 0, reoccurred: 1, new: 2, resolved: 3, 'possibly-identical': 4, 'not-comparable': 5 }; function normalizedRuleId(occurrence: FindingOccurrence): string { return normalizeComparisonText(occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId); } function normalizedToolId(occurrence: FindingOccurrence): string { return normalizeComparisonText(occurrence.sourceIdentity.tool.id); } function mappedValue(occurrence: FindingOccurrence, role: CanonicalFieldRole): JsonValue | undefined { return occurrence.fieldMappings.find(mapping => mapping.targetRole === role)?.normalizedValue; } function normalizedRoleValue(occurrence: FindingOccurrence, role: CanonicalFieldRole, context: ComparisonContext): string { if (role === 'rule-id') return normalizedRuleId(occurrence); if (role === 'title') return normalizeComparisonText( mappedValue(occurrence, role) ?? context.definitions.get(occurrence.definitionId)?.title ?? occurrence.sourceIdentity.originalTitle ); if (role === 'asset') return normalizeComparisonText(occurrence.affectedAsset?.normalizedValue ?? mappedValue(occurrence, role)); return normalizeComparisonText(mappedValue(occurrence, role)); } function canonicalDefinitionKeys(occurrence: FindingOccurrence, context: ComparisonContext): string[] { const definition = context.definitions.get(occurrence.definitionId); if (!definitionHasDurableIdentity(definition, occurrence)) return []; const canonicalKey = normalizeComparisonText(definition?.canonicalKey); return [ occurrence.definitionId ? `definition:${occurrence.definitionId}` : '', canonicalKey ? `canonical:${canonicalKey}` : '' ].filter(Boolean); } function toolRuleKeys(occurrence: FindingOccurrence, context?: ComparisonContext): string[] { const definition = context?.definitions.get(occurrence.definitionId); if (definition?.identity?.strategy === 'unresolved') return []; const tool = normalizedToolId(occurrence); const rule = normalizedRuleId(occurrence); return tool && rule ? [`tool-rule:${tool}|${rule}`] : []; } function frameworkKeys(occurrence: FindingOccurrence, context: ComparisonContext): string[] { const definition = context.definitions.get(occurrence.definitionId); if (!definitionHasDurableIdentity(definition, occurrence)) return []; return (definition?.frameworkReferences ?? []) .map(reference => { const framework = normalizeComparisonText(reference.framework); const control = normalizeComparisonText(reference.controlId); return framework && control ? `framework:${framework}|${control}` : ''; }) .filter(Boolean) .sort(); } function groupsByKey(occurrences: FindingOccurrence[], keyFactory: KeyFactory, context: ComparisonContext): Map { const groups = new Map(); occurrences.forEach(occurrence => { [...new Set(keyFactory(occurrence, context))].forEach(key => { if (!key) return; groups.set(key, [...(groups.get(key) ?? []), occurrence]); }); }); return groups; } function match(method: ComparisonMatchMethod, confidence: number, reason: string, decision: ComparisonMatch['decision'] = 'automatic'): ComparisonMatch { return { method, confidence, reasons: [reason], decision }; } function uniqueStageMatches( baseline: FindingOccurrence[], target: FindingOccurrence[], keyFactory: KeyFactory, context: ComparisonContext, method: ComparisonMatchMethod, confidence: number, reason: string ): MatchedPair[] { const leftGroups = groupsByKey(baseline, keyFactory, context); const rightGroups = groupsByKey(target, keyFactory, context); const candidatePairs = [...leftGroups.keys()].sort().flatMap(key => { const left = leftGroups.get(key) ?? []; const right = rightGroups.get(key) ?? []; return left.length === 1 && right.length === 1 ? [{ baseline: left[0], target: right[0], key }] : []; }); const deduplicated = new Map(); candidatePairs.forEach(pair => deduplicated.set(`${pair.baseline.id}\u0000${pair.target.id}`, pair)); const candidates = [...deduplicated.values()]; const baselineCounts = new Map(); const targetCounts = new Map(); candidates.forEach(pair => { baselineCounts.set(pair.baseline.id, (baselineCounts.get(pair.baseline.id) ?? 0) + 1); targetCounts.set(pair.target.id, (targetCounts.get(pair.target.id) ?? 0) + 1); }); return candidates .filter(pair => baselineCounts.get(pair.baseline.id) === 1 && targetCounts.get(pair.target.id) === 1) .sort((left, right) => left.baseline.id.localeCompare(right.baseline.id) || left.target.id.localeCompare(right.target.id)) .map(pair => ({ baseline: pair.baseline, target: pair.target, match: match(method, confidence, reason) })); } function localRuleMatches( baseline: FindingOccurrence[], target: FindingOccurrence[], rules: LocalComparisonRule[], context: ComparisonContext ): MatchedPair[] { const candidates = rules.slice().sort((left, right) => left.id.localeCompare(right.id)).flatMap(rule => { const pairs = baseline.flatMap(left => target .filter(right => localRuleConnects(rule, left, right, context.definitions)) .map(right => ({ baseline: left, target: right }))); return pairs.length === 1 ? [{ ...pairs[0], rule }] : []; }); const baselineCounts = new Map(); const targetCounts = new Map(); candidates.forEach(pair => { baselineCounts.set(pair.baseline.id, (baselineCounts.get(pair.baseline.id) ?? 0) + 1); targetCounts.set(pair.target.id, (targetCounts.get(pair.target.id) ?? 0) + 1); }); return candidates .filter(pair => baselineCounts.get(pair.baseline.id) === 1 && targetCounts.get(pair.target.id) === 1) .map(pair => ({ baseline: pair.baseline, target: pair.target, match: match('local-rule', 1, `Lokal bestätigte Mappingregel ${pair.rule.id}.`, 'user-confirmed') })); } function evidenceValue(records: EvidenceRecord[]): JsonValue { return records .map(record => ({ kind: record.kind, title: record.title ?? '', value: record.value })) .sort((left, right) => stableJson(left).localeCompare(stableJson(right))); } function comparisonChanges(left: FindingOccurrence, right: FindingOccurrence, context: ComparisonContext): FindingChange[] { const candidates: Array<{ kind: FindingChange['kind']; before: JsonValue; after: JsonValue }> = [ { kind: 'severity', before: left.severity.normalizedValue, after: right.severity.normalizedValue }, { kind: 'status', before: left.status.normalizedValue, after: right.status.normalizedValue }, { kind: 'score', before: left.score?.normalizedValue ?? null, after: right.score?.normalizedValue ?? null }, { kind: 'asset', before: left.affectedAsset?.normalizedValue ?? null, after: right.affectedAsset?.normalizedValue ?? null }, { kind: 'evidence', before: evidenceValue(context.evidence.get(left.id) ?? []), after: evidenceValue(context.evidence.get(right.id) ?? []) } ]; return candidates.filter(candidate => stableJson(candidate.before) !== stableJson(candidate.after)); } function similarityScore(left: FindingOccurrence, right: FindingOccurrence, context: ComparisonContext): number { const titleScore = jaccardSimilarity( similarityTokens(normalizedRoleValue(left, 'title', context)), similarityTokens(normalizedRoleValue(right, 'title', context)) ); if (!titleScore) return 0; const leftAsset = normalizedRoleValue(left, 'asset', context); const rightAsset = normalizedRoleValue(right, 'asset', context); const leftCategory = normalizedRoleValue(left, 'category', context); const rightCategory = normalizedRoleValue(right, 'category', context); const sameAsset = leftAsset && leftAsset === rightAsset ? 0.1 : 0; const sameCategory = leftCategory && leftCategory === rightCategory ? 0.08 : 0; const sameTool = normalizedToolId(left) === normalizedToolId(right) ? 0.07 : 0; return Math.min(0.95, titleScore * 0.75 + sameAsset + sameCategory + sameTool); } function similaritySuggestions( baseline: FindingOccurrence[], target: FindingOccurrence[], context: ComparisonContext ): SuggestedPair[] { const threshold = context.options.similarityThreshold ?? 0.72; const candidates = baseline.flatMap(left => target.map(right => ({ baseline: left, target: right, score: similarityScore(left, right, context) }))).filter(candidate => candidate.score >= threshold) .sort((left, right) => right.score - left.score || left.baseline.id.localeCompare(right.baseline.id) || left.target.id.localeCompare(right.target.id)); const usedBaseline = new Set(); const usedTarget = new Set(); return candidates.flatMap(candidate => { if (usedBaseline.has(candidate.baseline.id) || usedTarget.has(candidate.target.id)) return []; usedBaseline.add(candidate.baseline.id); usedTarget.add(candidate.target.id); return [{ ...candidate, match: match( 'similarity-suggestion', Number(candidate.score.toFixed(4)), 'Titel und optionale Merkmale sind ähnlich; eine Benutzerbestätigung ist erforderlich.', 'suggested' ) }]; }); } function intersects(left: string[], right: string[]): boolean { const rightKeys = new Set(right); return left.some(key => rightKeys.has(key)); } function exactHistoricalMatch( historical: FindingOccurrence[], current: FindingOccurrence, context: ComparisonContext, rules: LocalComparisonRule[], runs: Map ): FindingOccurrence | undefined { return historical.filter(previous => rules.some(rule => localRuleConnects(rule, previous, current, context.definitions)) || intersects(canonicalDefinitionKeys(previous, context), canonicalDefinitionKeys(current, context)) || intersects(toolRuleKeys(previous, context), toolRuleKeys(current, context)) || intersects(frameworkKeys(previous, context), frameworkKeys(current, context)) ).sort((left, right) => (runs.get(right.scanRunId)?.sequence ?? 0) - (runs.get(left.scanRunId)?.sequence ?? 0) || left.id.localeCompare(right.id) )[0]; } function occurrenceHasIdentity(occurrence: FindingOccurrence, context: ComparisonContext): boolean { return Boolean( canonicalDefinitionKeys(occurrence, context).length || toolRuleKeys(occurrence, context).length || frameworkKeys(occurrence, context).length ); } function emptyMatch(reason: string): ComparisonMatch { return { method: 'none', decision: 'unmatched', confidence: 0, reasons: [reason] }; } function buildSummary(results: FindingComparison[]): ComparisonSummary { return { total: results.length, new: results.filter(result => result.lifecycle === 'new').length, persistent: results.filter(result => result.lifecycle === 'persistent').length, resolved: results.filter(result => result.lifecycle === 'resolved').length, reoccurred: results.filter(result => result.lifecycle === 'reoccurred').length, possiblyIdentical: results.filter(result => result.lifecycle === 'possibly-identical').length, notComparable: results.filter(result => result.lifecycle === 'not-comparable').length, changedSeverity: results.filter(result => result.changes.some(change => change.kind === 'severity')).length, changedStatus: results.filter(result => result.changes.some(change => change.kind === 'status')).length, changedEvidence: results.filter(result => result.changes.some(change => change.kind === 'evidence')).length }; } export function compareScanRuns( model: CanonicalAssessmentModel, baselineRunId: string, targetRunId: string, options: ComparisonOptions = {} ): ScanRunComparison { if (baselineRunId === targetRunId) throw new Error('Ein Scan-Lauf kann nicht mit sich selbst verglichen werden.'); const runs = new Map(model.scanRuns.map(run => [run.id, run])); const baselineRun = runs.get(baselineRunId); const targetRun = runs.get(targetRunId); if (!baselineRun || !targetRun) throw new Error('Mindestens einer der gewählten Scan-Läufe ist nicht vorhanden.'); const definitions = new Map(model.findingDefinitions.map(definition => [definition.id, definition])); const evidence = new Map(); model.evidence.forEach(record => evidence.set(record.occurrenceId, [...(evidence.get(record.occurrenceId) ?? []), record])); const context: ComparisonContext = { definitions, evidence, options }; const rules = options.localRules ?? []; const ruleIssues = validateLocalComparisonRules(rules); if (ruleIssues.length) throw new Error(`Ungültige lokale Mappingregeln: ${ruleIssues.join(' ')}`); const baseline = model.findingOccurrences.filter(occurrence => occurrence.scanRunId === baselineRunId).sort((left, right) => left.id.localeCompare(right.id)); const target = model.findingOccurrences.filter(occurrence => occurrence.scanRunId === targetRunId).sort((left, right) => left.id.localeCompare(right.id)); const diagnostics: string[] = []; const sameAssessment = baselineRun.assessmentId === targetRun.assessmentId; if (!sameAssessment) diagnostics.push('Die Scan-Läufe gehören zu unterschiedlichen Assessments; nicht zugeordnete Findings werden als nicht vergleichbar ausgewiesen.'); const sameTool = normalizeComparisonText(baselineRun.tool.id) === normalizeComparisonText(targetRun.tool.id); if (!sameTool) diagnostics.push('Die Scan-Läufe stammen aus unterschiedlichen Werkzeugen; nur werkzeugübergreifend stabile Identitäten werden automatisch zugeordnet.'); // Persistierte Läufe desselben Werkzeugs dürfen nur verglichen werden, wenn // ihr kanonischer Verarbeitungsvertrag bekannt und kompatibel ist. Das // verhindert fachlich falsche Lifecycle-Aussagen nach semantischen Updates. const baselineFiles = model.importFiles.filter(file => file.scanRunId === baselineRunId); const targetFiles = model.importFiles.filter(file => file.scanRunId === targetRunId); if (sameTool && baselineFiles.length && targetFiles.length) { const keysFor = (files: typeof model.importFiles) => [...new Set(files.map(file => resolvedCompatibilityKey(file.detectedTool.id || file.processedWith.profileId, file.processedWith) ))]; const baselineKeys = keysFor(baselineFiles); const targetKeys = keysFor(targetFiles); const compatible = baselineKeys.length === 1 && targetKeys.length === 1 && baselineKeys[0] !== undefined && baselineKeys[0] === targetKeys[0]; if (!compatible) { const reason = baselineKeys.includes(undefined) || targetKeys.includes(undefined) ? 'Der Verarbeitungsvertrag mindestens eines Laufs ist unbekannt; der Vergleich wurde sicherheitshalber gesperrt.' : 'Die Läufe wurden mit semantisch inkompatiblen Verarbeitungsverträgen erzeugt; ein Reimport in getrennte oder einheitlich aktuelle Timeslots ist erforderlich.'; diagnostics.push(reason); const results: FindingComparison[] = [ ...baseline.map(occurrence => ({ baselineOccurrenceId: occurrence.id, lifecycle: 'not-comparable' as const, match: emptyMatch(reason), changes: [] })), ...target.map(occurrence => ({ targetOccurrenceId: occurrence.id, lifecycle: 'not-comparable' as const, match: emptyMatch(reason), changes: [] })) ]; return { version: 1, baselineRunId, targetRunId, results, summary: buildSummary(results), diagnostics }; } } const unmatchedBaseline = new Map(baseline.map(occurrence => [occurrence.id, occurrence])); const unmatchedTarget = new Map(target.map(occurrence => [occurrence.id, occurrence])); const pairs: MatchedPair[] = []; const applyPairs = (found: MatchedPair[]) => found.forEach(pair => { if (!unmatchedBaseline.has(pair.baseline.id) || !unmatchedTarget.has(pair.target.id)) return; unmatchedBaseline.delete(pair.baseline.id); unmatchedTarget.delete(pair.target.id); pairs.push(pair); }); const remainingBaseline = () => [...unmatchedBaseline.values()]; const remainingTarget = () => [...unmatchedTarget.values()]; applyPairs(localRuleMatches(remainingBaseline(), remainingTarget(), rules, context)); applyPairs(uniqueStageMatches(remainingBaseline(), remainingTarget(), canonicalDefinitionKeys, context, 'canonical-definition', 1, 'Gemeinsame bestätigte Finding-Definition oder kanonischer Schlüssel.')); applyPairs(uniqueStageMatches(remainingBaseline(), remainingTarget(), toolRuleKeys, context, 'tool-rule-id', 0.99, 'Werkzeug und Regel-ID stimmen eindeutig überein.')); applyPairs(uniqueStageMatches(remainingBaseline(), remainingTarget(), frameworkKeys, context, 'framework-control', 0.96, 'Framework und Control-ID stimmen eindeutig überein.')); const suggestions = sameAssessment ? similaritySuggestions(remainingBaseline(), remainingTarget(), context) : []; suggestions.forEach(suggestion => { unmatchedBaseline.delete(suggestion.baseline.id); unmatchedTarget.delete(suggestion.target.id); }); const historical = model.findingOccurrences.filter(occurrence => { const run = runs.get(occurrence.scanRunId); return run && run.assessmentId === targetRun.assessmentId && run.sequence < baselineRun.sequence && occurrence.scanRunId !== baselineRunId; }); const results: FindingComparison[] = ([ ...pairs.map(pair => ({ baselineOccurrenceId: pair.baseline.id, targetOccurrenceId: pair.target.id, lifecycle: 'persistent' as const, match: pair.match, changes: comparisonChanges(pair.baseline, pair.target, context) })), ...suggestions.map(pair => ({ baselineOccurrenceId: pair.baseline.id, targetOccurrenceId: pair.target.id, lifecycle: 'possibly-identical' as const, match: pair.match, changes: comparisonChanges(pair.baseline, pair.target, context) })), ...remainingTarget().map(occurrence => { const previous = sameAssessment ? exactHistoricalMatch(historical, occurrence, context, rules, runs) : undefined; const comparable = sameAssessment && (sameTool || previous) && occurrenceHasIdentity(occurrence, context); return { targetOccurrenceId: occurrence.id, ...(previous ? { historicalOccurrenceId: previous.id } : {}), lifecycle: previous ? 'reoccurred' as const : comparable ? 'new' as const : 'not-comparable' as const, match: previous ? match('canonical-definition', 0.98, 'Das Finding war im direkten Basislauf abwesend, wurde aber in einem früheren Lauf eindeutig gefunden.') : emptyMatch(comparable ? 'Im Basislauf wurde kein entsprechendes Finding gefunden.' : 'Es fehlen ausreichend stabile Vergleichsmerkmale.'), changes: [] }; }), ...remainingBaseline().map(occurrence => { const comparable = sameAssessment && sameTool && occurrenceHasIdentity(occurrence, context); return { baselineOccurrenceId: occurrence.id, lifecycle: comparable ? 'resolved' as const : 'not-comparable' as const, match: emptyMatch(comparable ? 'Im Ziellauf wurde kein entsprechendes Finding gefunden.' : 'Es fehlen ausreichend stabile Vergleichsmerkmale.'), changes: [] }; }) ] as FindingComparison[]).sort((left, right) => lifecycleOrder[left.lifecycle] - lifecycleOrder[right.lifecycle] || (left.baselineOccurrenceId ?? '').localeCompare(right.baselineOccurrenceId ?? '') || (left.targetOccurrenceId ?? '').localeCompare(right.targetOccurrenceId ?? '') ); return { version: 1, baselineRunId, targetRunId, results, summary: buildSummary(results), diagnostics }; }