Assessor_2.0/src/features/project-import/datahub-scan-analysis.ts

298 lines
12 KiB
TypeScript

import { compareScanRuns } from '../../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<T extends { id: string }>(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<string, FindingReview>,
reviewsByDefinition: Map<string, FindingReview>
): 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)),
cveEnrichments: [...new Map(models.flatMap(model => model.cveEnrichments ?? [])
.map(record => [record.cveId, record])).values()],
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
};
}