298 lines
12 KiB
TypeScript
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
|
|
};
|
|
}
|