import { compareScanRuns } from '../../evaluation/scan-run-comparison/comparison-engine'; import type { CanonicalAssessmentModel, FindingOccurrence, FindingReview, ScanRun } from '../../domain/canonical-model'; import type { ScanRunComparison } from '../../domain/comparison'; import { isDeviation } from '../../domain/deviation-predicate'; import { outcomeFromLegacyStatus } from '../../domain/unified-result'; import type { FindingRecord } from '../../domain/finding'; import type { ImportProfile } from '../../domain/import-profile'; import { dashboardResultState } from '../dashboard/dashboard-result-state'; export interface DatahubScanComparison { baselineRun: ScanRun; targetRun: ScanRun; comparison: ScanRunComparison; baselineOpen: number; targetOpen: number; resolved: number; critical: number; reviewed: number; openPercent: number; resolvedPercent: number; criticalPercent: number; reviewedPercent: number; openChangePercent: number | null; } export interface DatahubTimeslotResultMetrics { all: number; deviation: number; passed: number; notApplicable: number; notRun: number; error: number; } export interface TimeslotSourceSelection { id: string; runIds: string[]; hasGpo: boolean; } export interface ComparableTimeslotSources { runIds: string[]; toolIds: string[]; includesGpo: boolean; excludedSourceIds: string[]; } const unsafeComparisonToolIds = new Set(['generic', 'unknown', 'multiple']); /** * Keeps only source kinds represented in every selected timeslot. Scanner * runs retain their original IDs; GPO is reported separately because it is an * assessment-bound source rather than an artificial ScanRun. */ export function comparableTimeslotSources( slots: TimeslotSourceSelection[], runs: ScanRun[] ): ComparableTimeslotSources { if (!slots.length) return { runIds: [], toolIds: [], includesGpo: false, excludedSourceIds: [] }; const runById = new Map(runs.map(run => [run.id, run])); const sourceSets = slots.map(slot => { const ids = new Set(slot.runIds.map(id => runById.get(id)?.tool.id).filter((id): id is string => Boolean(id))); if (slot.hasGpo) ids.add('gpo-backup'); return ids; }); const allSourceIds = [...new Set(sourceSets.flatMap(set => [...set]))]; const commonSourceIds = slots.length === 1 ? new Set(allSourceIds) : new Set(allSourceIds.filter(id => !unsafeComparisonToolIds.has(id) && sourceSets.every(set => set.has(id)))); const toolIds = [...commonSourceIds].filter(id => id !== 'gpo-backup').sort(); const toolIdSet = new Set(toolIds); return { runIds: slots.flatMap(slot => slot.runIds.filter(id => { const toolId = runById.get(id)?.tool.id; return Boolean(toolId && toolIdSet.has(toolId)); })), toolIds, includesGpo: commonSourceIds.has('gpo-backup'), excludedSourceIds: allSourceIds.filter(id => !commonSourceIds.has(id)).sort() }; } export function summarizeTimeslotResults( models: CanonicalAssessmentModel[], runIds: string[] ): DatahubTimeslotResultMetrics { const selectedRunIds = new Set(runIds); const occurrences = models.flatMap(model => model.findingOccurrences) .filter(occurrence => selectedRunIds.has(occurrence.scanRunId)); const outcomes = occurrences.map(occurrence => ({ occurrence, outcome: occurrence.outcome?.normalizedValue ?? outcomeFromLegacyStatus(occurrence.status.normalizedValue ?? 'unknown') })); return { all: occurrences.length, deviation: outcomes.filter(({ occurrence, outcome }) => isDeviation(Boolean(occurrence.isIoe), outcome)).length, passed: outcomes.filter(({ outcome }) => outcome === 'pass').length, notApplicable: outcomes.filter(({ outcome }) => outcome === 'not_applicable').length, notRun: outcomes.filter(({ outcome }) => outcome === 'not_run').length, error: outcomes.filter(({ occurrence, outcome }) => outcome === 'execution_error' || occurrence.technicalExecutionState?.normalizedValue === 'failed').length }; } function uniqueById(items: T[]): T[] { return [...new Map(items.map(item => [item.id, item])).values()]; } function runTime(run: ScanRun): number { return Date.parse(run.completedAt ?? run.startedAt ?? run.importedAt) || 0; } function percentage(value: number, total: number): number { return total > 0 ? Math.min(100, Math.max(0, Math.round((value / total) * 100))) : 0; } function isOpen(occurrence: FindingOccurrence): boolean { return occurrence.status.normalizedValue !== 'passed' && occurrence.status.normalizedValue !== 'resolved'; } function isReviewed( occurrence: FindingOccurrence, reviewsByOccurrence: Map, reviewsByDefinition: Map ): boolean { const review = reviewsByOccurrence.get(occurrence.id) ?? reviewsByDefinition.get(occurrence.definitionId); return Boolean(review && review.workflowStatus !== 'unreviewed'); } /** * A client is the user's comparison boundary. Historic imports can still live * in separate persisted assessments, so this read-only projection gives their * scan runs one virtual assessment identity before invoking the existing * comparison engine. No source model or persisted identifier is changed. */ export function mergeProjectModelsForComparison(models: CanonicalAssessmentModel[]): CanonicalAssessmentModel { if (!models.length) throw new Error('Für den Vergleich sind Assessment-Daten erforderlich.'); const project = models[0].project; if (models.some(model => model.project.id !== project.id)) { throw new Error('Verglichen werden dürfen nur Timeslots desselben Mandanten.'); } const virtualAssessmentId = `project-history:${project.id}`; const orderedRuns = models.flatMap(model => model.scanRuns) .sort((left, right) => runTime(left) - runTime(right) || left.id.localeCompare(right.id)) .map((run, index) => ({ ...run, assessmentId: virtualAssessmentId, sequence: index + 1 })); return { schemaVersion: 1, project, assessment: { ...models[0].assessment, id: virtualAssessmentId, projectId: project.id, name: `${project.name} · Verlauf` }, scanRuns: uniqueById(orderedRuns), importFiles: uniqueById(models.flatMap(model => model.importFiles)), sourceTables: uniqueById(models.flatMap(model => model.sourceTables)), findingDefinitions: uniqueById(models.flatMap(model => model.findingDefinitions)), findingOccurrences: uniqueById(models.flatMap(model => model.findingOccurrences)), evidence: uniqueById(models.flatMap(model => model.evidence)), reviews: uniqueById(models.flatMap(model => model.reviews)), diagnostics: uniqueById(models.flatMap(model => model.diagnostics)) }; } function comparisonMetrics( model: CanonicalAssessmentModel, baselineRun: ScanRun, targetRun: ScanRun ): DatahubScanComparison { const comparison = compareScanRuns(model, baselineRun.id, targetRun.id); const baselineOccurrences = model.findingOccurrences.filter(item => item.scanRunId === baselineRun.id && isOpen(item)); const targetOccurrences = model.findingOccurrences.filter(item => item.scanRunId === targetRun.id && isOpen(item)); const reviewsByOccurrence = new Map(model.reviews.flatMap(review => review.target.kind === 'occurrence' ? [[review.target.occurrenceId, review] as const] : [] )); const reviewsByDefinition = new Map(model.reviews.flatMap(review => review.target.kind === 'definition' ? [[review.target.definitionId, review] as const] : [] )); const baselineOpen = baselineOccurrences.length; const targetOpen = targetOccurrences.length; const critical = targetOccurrences.filter(item => item.severity.normalizedValue === 'critical').length; const reviewed = targetOccurrences.filter(item => isReviewed(item, reviewsByOccurrence, reviewsByDefinition)).length; const resolved = comparison.results.filter(result => { if (result.lifecycle !== 'resolved' || !result.baselineOccurrenceId) return false; return baselineOccurrences.some(item => item.id === result.baselineOccurrenceId); }).length; return { baselineRun, targetRun, comparison, baselineOpen, targetOpen, resolved, critical, reviewed, openPercent: percentage(targetOpen, Math.max(baselineOpen, targetOpen)), resolvedPercent: percentage(resolved, baselineOpen), criticalPercent: percentage(critical, targetOpen), reviewedPercent: percentage(reviewed, targetOpen), openChangePercent: baselineOpen > 0 ? Math.round(((targetOpen - baselineOpen) / baselineOpen) * 100) : null }; } export function compareProjectScanRuns( models: CanonicalAssessmentModel[], firstRunId: string, secondRunId: string ): DatahubScanComparison { const model = mergeProjectModelsForComparison(models); const firstRun = model.scanRuns.find(run => run.id === firstRunId); const secondRun = model.scanRuns.find(run => run.id === secondRunId); if (!firstRun || !secondRun) throw new Error('Mindestens ein gewählter Scanstand ist nicht mehr vorhanden.'); const [baselineRun, targetRun] = [firstRun, secondRun].sort((left, right) => left.sequence - right.sequence || left.id.localeCompare(right.id) ) as [ScanRun, ScanRun]; return comparisonMetrics(model, baselineRun, targetRun); } export function compareProjectTimeslots( models: CanonicalAssessmentModel[], firstRunIds: string[], secondRunIds: string[], firstEffectiveAt: string, secondEffectiveAt: string ): DatahubScanComparison { if (!firstRunIds.length || !secondRunIds.length) throw new Error('Beide Timeslots benötigen mindestens einen Scannerlauf.'); const merged = mergeProjectModelsForComparison(models); const firstSet = new Set(firstRunIds); const secondSet = new Set(secondRunIds); if (firstRunIds.some(id => !merged.scanRuns.some(run => run.id === id)) || secondRunIds.some(id => !merged.scanRuns.some(run => run.id === id))) { throw new Error('Mindestens ein Scannerlauf des gewählten Timeslots ist nicht mehr vorhanden.'); } const ordered = [ { runIds: firstSet, effectiveAt: firstEffectiveAt }, { runIds: secondSet, effectiveAt: secondEffectiveAt } ].sort((left, right) => Date.parse(left.effectiveAt) - Date.parse(right.effectiveAt)); const assessmentId = merged.assessment.id; const baselineRun: ScanRun = { id: `timeslot-baseline:${ordered[0].effectiveAt}`, assessmentId, sequence: 1, label: 'Timeslot baseline', tool: { id: 'multiple', name: 'Assessment sources' }, importedAt: ordered[0].effectiveAt }; const targetRun: ScanRun = { id: `timeslot-target:${ordered[1].effectiveAt}`, assessmentId, sequence: 2, label: 'Timeslot target', tool: { id: 'multiple', name: 'Assessment sources' }, importedAt: ordered[1].effectiveAt }; const projected: CanonicalAssessmentModel = { ...merged, scanRuns: [baselineRun, targetRun], findingOccurrences: merged.findingOccurrences.flatMap(occurrence => { if (ordered[0].runIds.has(occurrence.scanRunId)) return [{ ...occurrence, scanRunId: baselineRun.id }]; if (ordered[1].runIds.has(occurrence.scanRunId)) return [{ ...occurrence, scanRunId: targetRun.id }]; return []; }) }; return comparisonMetrics(projected, baselineRun, targetRun); } /** * Uses the same projected records and authoritative status resolution as * Client Info. This is required for mixed scanner/GPO timeslots: GPO policy * rows are assessment-bound and therefore do not have scanner run IDs. */ export function summarizeWorkspaceResults( records: FindingRecord[], profile: ImportProfile ): DatahubTimeslotResultMetrics { const states = records.map(record => dashboardResultState(record, profile)); return { all: records.length, deviation: states.filter(state => state === 'deviation').length, passed: states.filter(state => state === 'passed').length, notApplicable: states.filter(state => state === 'not-applicable').length, notRun: states.filter(state => state === 'not-run').length, error: states.filter(state => state === 'error').length }; }