Assessor_2.0/src/comparison/timeslot-comparison-engine.ts

359 lines
20 KiB
TypeScript

import type { CanonicalAssessmentModel, FindingOccurrence, ImportFileRecord, ScanRun } from '../domain/canonical-model';
import type { FindingComparisonIdentityResult, RemediationEvidenceState } from '../domain/timeslot-comparison';
import {
comparisonObservationTime,
comparisonScopeIdentity,
evaluateRemediationEvidence,
findingComparisonIdentity
} from '../domain/timeslot-comparison';
import type { ToolTimeslotComparisonDefinition } from '../domain/tool-profile-definition';
import { isDeviation } from '../domain/deviation-predicate';
import { outcomeFromLegacyStatus } from '../domain/unified-result';
import { resolvedCompatibilityKey } from '../domain/processing-compatibility';
import { toolProfileDefinitions } from '../profiles/profile-definition-catalog';
export interface TimeslotOccurrenceDecision {
baselineOccurrenceId: string;
targetOccurrenceId?: string;
identityKey?: string;
state: RemediationEvidenceState;
reasons: string[];
}
export interface TimeslotSourceComparison {
toolId: string;
baselineRunId?: string;
targetRunId?: string;
/** The import files of this source in each timeslot; a run from several scanners holds several sources. */
baselineFileIds: string[];
targetFileIds: string[];
state: 'compared' | 'not-comparable';
reasons: string[];
decisions: TimeslotOccurrenceDecision[];
/** Current deviations whose identity was not a deviation and not a verified pass in A. */
newDeviationOccurrenceIds: string[];
/** Current deviations whose identity was a single verified pass in A. */
regressedDeviationOccurrenceIds: string[];
/** The passing A occurrence of each regressed deviation, in the same order. */
regressedBaselineOccurrenceIds: string[];
}
export interface ProjectTimeslotEvidenceComparison {
version: '1.2.0';
projectId: string;
baselineRunIds: string[];
targetRunIds: string[];
sources: TimeslotSourceComparison[];
summary: {
baselineDeviations: number;
verifiedRemediated: number;
confirmedOpen: number;
unverifiedAbsent: number;
notComparable: number;
notApplicable: number;
newDeviations: number;
regressedDeviations: number;
};
}
const normalizedTool = (value: string): string => value.trim().toLocaleLowerCase('en-US');
/**
* Diagnostic policy `fail-closed-by-impact`: a diagnostic blocks remediation claims unless it is known to leave
* identity, results and coverage untouched. These codes only sanitize or confirm the source; every other code,
* including unknown future ones, still blocks.
*/
const NON_IMPACTING_DIAGNOSTICS = new Set(['html-active-content-removed', 'unicode-null-escaped', 'cis-cat-summary-verified',
// PingCastle detail links affect only attached evidence tables, never a rule, its scope or its presence.
'pingcastle-linked-section-unresolved', 'pingcastle-linked-section-ambiguous', 'pingcastle-linked-section-depth-limit',
'pingcastle-linked-section-target-limit', 'pingcastle-linked-section-table-limit']);
/**
* User decision 2026-10-13: the explicit timeslot order is the time basis; timeslot A lies before B. These markers
* carry that order into the shared evidence contract. A source's own report time only blocks when both timeslots
* state one and B's is not newer (an older report or the same report again).
*/
const TIMESLOT_A = '2000-01-01T00:00:00.000Z';
const TIMESLOT_B = '2000-01-02T00:00:00.000Z';
function oneValue(observations: SourceUnit['observations'], property: string): string | undefined {
const values = [...new Set(observations.filter(item => item.state === 'observed' && item.property === property)
.map(item => String(item.normalizedValue ?? item.originalValue ?? '').trim()).filter(Boolean))];
return values.length === 1 ? values[0] : undefined;
}
type AvailableDefinition = Exclude<ToolTimeslotComparisonDefinition, { kind: 'unavailable' }>;
interface SourceUnit {
toolId: string;
run: ScanRun;
files: ImportFileRecord[];
fileIds: Set<string>;
occurrences: FindingOccurrence[];
observations: NonNullable<CanonicalAssessmentModel['environmentObservations']>;
}
function occurrenceOutcome(occurrence: FindingOccurrence) {
return occurrence.outcome?.normalizedValue
?? outcomeFromLegacyStatus(occurrence.status.normalizedValue ?? 'unknown');
}
function filesCompatibility(left: ImportFileRecord[], right: ImportFileRecord[]): { compatible: boolean; reasons: string[] } {
if (!left.length || !right.length) return { compatible: false, reasons: ['processing-provenance-missing'] };
const keys = (files: ImportFileRecord[]) => [...new Set(files.map(file =>
resolvedCompatibilityKey(file.detectedTool.id || file.processedWith.profileId, file.processedWith)
))];
const leftKeys = keys(left);
const rightKeys = keys(right);
if (leftKeys.some(key => !key) || rightKeys.some(key => !key)) {
return { compatible: false, reasons: ['processing-contract-unknown'] };
}
if (leftKeys.length !== 1 || rightKeys.length !== 1 || leftKeys[0] !== rightKeys[0]) {
return { compatible: false, reasons: ['processing-contract-incompatible'] };
}
return { compatible: true, reasons: [] };
}
function identityFor(
occurrence: FindingOccurrence,
definition: Exclude<ToolTimeslotComparisonDefinition, { kind: 'unavailable' }>,
scope: { kind: string; key: string }
): FindingComparisonIdentityResult {
return findingComparisonIdentity({
occurrence,
granularity: definition.granularity,
scope,
...(definition.granularity === 'asset' ? {
object: {
kind: definition.object.kind,
namespace: definition.object.namespace,
key: typeof occurrence.affectedAsset?.normalizedValue === 'string'
? occurrence.affectedAsset.normalizedValue : ''
}
} : {})
});
}
function unmatchedDecisions(occurrences: FindingOccurrence[], reasons: string[]): TimeslotOccurrenceDecision[] {
return occurrences.filter(item => isDeviation(Boolean(item.isIoe), occurrenceOutcome(item))).map(item => ({
baselineOccurrenceId: item.id, state: 'not-comparable', reasons
}));
}
/**
* Conservative A/B remediation comparison. Scanner runs remain separate and
* retain their file, processing, scope and observation-time provenance.
*/
export function compareProjectTimeslotEvidence(
models: CanonicalAssessmentModel[],
baselineRunIds: string[],
targetRunIds: string[]
): ProjectTimeslotEvidenceComparison {
if (!models.length) throw new Error('Timeslot comparison requires project data.');
const projectId = models[0].project.id;
if (models.some(model => model.project.id !== projectId)) throw new Error('Timeslots must belong to the same project.');
// One side may lack scanner runs; its sources are then reported as not comparable, never resolved.
if (!baselineRunIds.length && !targetRunIds.length) throw new Error('At least one timeslot requires scanner runs.');
if (baselineRunIds.some(id => targetRunIds.includes(id))) throw new Error('Baseline and target timeslots must use different runs.');
const runs = models.flatMap(model => model.scanRuns);
const runById = new Map(runs.map(run => [run.id, run]));
const missing = [...baselineRunIds, ...targetRunIds].filter(id => !runById.has(id));
if (missing.length) throw new Error('At least one selected scanner run is unavailable.');
const occurrences = models.flatMap(model => model.findingOccurrences);
const files = models.flatMap(model => model.importFiles);
const observations = models.flatMap(model => model.environmentObservations ?? []);
const diagnostics = models.flatMap(model => model.diagnostics);
// A source is the files of one run that one scanner produced. A run imported from several scanners
// (tool `multiple`) is split by each file's own scanner; sources are never merged into one contract.
const sourcesOf = (ids: string[]): SourceUnit[] => ids.flatMap(runId => {
const run = runById.get(runId)!;
const byTool = new Map<string, ImportFileRecord[]>();
for (const file of files.filter(item => item.scanRunId === runId)) {
const toolId = normalizedTool(file.detectedTool.id && file.detectedTool.id !== 'unknown' ? file.detectedTool.id : run.tool.id);
byTool.set(toolId, [...(byTool.get(toolId) ?? []), file]);
}
if (!byTool.size) byTool.set(normalizedTool(run.tool.id), []);
return [...byTool].map(([toolId, unitFiles]) => {
const fileIds = new Set(unitFiles.map(item => item.id));
return {
toolId, run, files: unitFiles, fileIds,
occurrences: occurrences.filter(item => item.scanRunId === runId && (!fileIds.size || fileIds.has(item.importFileId))),
observations: observations.filter(item => item.scanRunId === runId && fileIds.has(item.importFileId) && normalizedTool(item.tool.id) === toolId)
};
});
});
const baselineSources = sourcesOf(baselineRunIds);
const targetSources = sourcesOf(targetRunIds);
const toolIds = [...new Set([...baselineSources, ...targetSources].map(item => item.toolId))].sort();
const notComparable = (toolId: string, baseline: SourceUnit | undefined, target: SourceUnit | undefined, reasons: string[]): TimeslotSourceComparison => ({
toolId, baselineRunId: baseline?.run.id, targetRunId: target?.run.id,
baselineFileIds: baseline ? [...baseline.fileIds] : [], targetFileIds: target ? [...target.fileIds] : [],
state: 'not-comparable', reasons, decisions: unmatchedDecisions(baseline?.occurrences ?? [], reasons),
newDeviationOccurrenceIds: [], regressedDeviationOccurrenceIds: [], regressedBaselineOccurrenceIds: []
});
const sources = toolIds.flatMap((toolId): TimeslotSourceComparison[] => {
const baselineUnits = baselineSources.filter(item => item.toolId === toolId);
const targetUnits = targetSources.filter(item => item.toolId === toolId);
const profile = toolProfileDefinitions.find(item => item.id === toolId);
if (!profile || profile.timeslotComparison.kind === 'unavailable') {
const reasons = [profile ? 'comparison-profile-unavailable' : 'comparison-profile-missing'];
return [...baselineUnits.map(unit => notComparable(toolId, unit, undefined, [...reasons, ...(targetUnits.length ? [] : ['target-source-missing'])])),
...targetUnits.map(unit => notComparable(toolId, undefined, unit, [...reasons, ...(baselineUnits.length ? [] : ['baseline-source-missing'])]))];
}
const definition = profile.timeslotComparison;
// Several sources of one scanner (for example two domains) pair only by their verified scope.
const scopeOf = (unit: SourceUnit) => comparisonScopeIdentity(definition, unit.observations);
const keyOf = (unit: SourceUnit) => { const scope = scopeOf(unit); return scope.state === 'verified' ? scope.scope.key : undefined; };
const paired = new Set<SourceUnit>();
const results = baselineUnits.map((baseline): TimeslotSourceComparison => {
if (baselineUnits.length === 1 && targetUnits.length === 1) {
paired.add(targetUnits[0]);
return compareSources(toolId, definition, baseline, targetUnits[0]);
}
const scope = scopeOf(baseline);
if (scope.state !== 'verified') return notComparable(toolId, baseline, undefined, [...scope.reasons, 'baseline-source-ambiguous']);
const sameBaseline = baselineUnits.filter(unit => keyOf(unit) === scope.scope.key);
const matches = targetUnits.filter(unit => keyOf(unit) === scope.scope.key);
if (sameBaseline.length > 1 || matches.length > 1) {
matches.forEach(unit => paired.add(unit));
return notComparable(toolId, baseline, undefined, [sameBaseline.length > 1 ? 'baseline-source-ambiguous' : 'target-source-ambiguous']);
}
if (!matches.length) return notComparable(toolId, baseline, undefined, [targetUnits.length ? 'comparison-scope-changed' : 'target-source-missing']);
paired.add(matches[0]);
return compareSources(toolId, definition, baseline, matches[0]);
});
// A source only in B is never new or worse; its deviations are reported as not comparable.
for (const target of targetUnits.filter(unit => !paired.has(unit))) {
results.push(notComparable(toolId, undefined, target, [baselineUnits.length ? 'comparison-scope-changed' : 'baseline-source-missing']));
}
return results;
});
function compareSources(toolId: string, definition: AvailableDefinition, baseline: SourceUnit, target: SourceUnit): TimeslotSourceComparison {
const baselineOccurrences = baseline.occurrences;
const compatibility = filesCompatibility(baseline.files, target.files);
let baselineScope = comparisonScopeIdentity(definition, baseline.observations);
let targetScope = comparisonScopeIdentity(definition, target.observations);
// User decision 2026-10-13: both timeslots belong to one client. When each holds exactly one report of this
// scanner and neither states its scope (a Maester Excel export has no tenant), the client is the scope.
// A scope stated on one side only, or several reports per side, never pairs this way.
const single = baselineSources.filter(unit => unit.toolId === toolId).length === 1
&& targetSources.filter(unit => unit.toolId === toolId).length === 1;
const unstated = (scope: typeof baselineScope) => scope.state === 'unverified'
&& scope.reasons.every(reason => reason === 'scope-property-missing');
if (single && unstated(baselineScope) && unstated(targetScope)) {
const clientScope = { state: 'verified' as const, scope: { kind: 'client', key: JSON.stringify(['client', projectId]) } };
baselineScope = clientScope;
targetScope = clientScope;
}
const baselineTime = comparisonObservationTime(definition, baseline.observations);
const targetTime = comparisonObservationTime(definition, target.observations);
const sourceReasons = [
...compatibility.reasons,
...(baselineScope.state === 'unverified' ? baselineScope.reasons : []),
...(targetScope.state === 'unverified' ? targetScope.reasons : []),
...(baselineTime.state === 'verified' && targetTime.state === 'verified'
&& Date.parse(targetTime.observedAt) <= Date.parse(baselineTime.observedAt) ? ['target-observation-not-newer'] : [])
];
if (baselineScope.state === 'verified' && targetScope.state === 'verified'
&& baselineScope.scope.key !== targetScope.scope.key) sourceReasons.push('comparison-scope-changed');
const blockingDiagnostics = diagnostics
.filter(item => item.severity !== 'info' && !NON_IMPACTING_DIAGNOSTICS.has(item.code)
&& (baseline.fileIds.has(item.importFileId) || target.fileIds.has(item.importFileId)))
.map(item => `diagnostic:${item.code}`);
if (sourceReasons.length || baselineScope.state !== 'verified' || targetScope.state !== 'verified') {
return notComparable(toolId, baseline, target, [...new Set(sourceReasons)]);
}
// A findings-only report proves absence only under its declared negative-evidence contract: same scope
// (paired above), compatible processing, no blocking diagnostic and the same scanner engine version.
const baselineEngine = oneValue(baseline.observations, 'report.engine_version');
const absenceProven = definition.negativeEvidence === 'complete-explicit-negative' && compatibility.compatible
&& !blockingDiagnostics.length && Boolean(baselineEngine) && baselineEngine === oneValue(target.observations, 'report.engine_version');
const targetIdentities = target.occurrences.map(item => ({ item, identity: identityFor(item, definition, targetScope.scope) }));
const targetByIdentity = new Map<string, typeof targetIdentities>();
targetIdentities.forEach(entry => {
if (entry.identity.state !== 'verified') return;
const key = entry.identity.identity.key;
targetByIdentity.set(key, [...(targetByIdentity.get(key) ?? []), entry]);
});
const baselineDeviations = baselineOccurrences.filter(item => isDeviation(Boolean(item.isIoe), occurrenceOutcome(item)));
const baselineIdentities = baselineDeviations.map(item => ({ item, identity: identityFor(item, definition, baselineScope.scope) }));
const baselineCounts = new Map<string, number>();
baselineIdentities.forEach(entry => {
if (entry.identity.state === 'verified') baselineCounts.set(entry.identity.identity.key, (baselineCounts.get(entry.identity.identity.key) ?? 0) + 1);
});
const decisions = baselineIdentities.map(({ item, identity }): TimeslotOccurrenceDecision => {
if (identity.state !== 'verified') return {
baselineOccurrenceId: item.id, state: 'not-comparable', reasons: identity.reasons
};
const matches = targetByIdentity.get(identity.identity.key) ?? [];
if ((baselineCounts.get(identity.identity.key) ?? 0) !== 1 || matches.length > 1) return {
baselineOccurrenceId: item.id, identityKey: identity.identity.key,
state: 'not-comparable', reasons: ['comparison-identity-ambiguous']
};
const match = matches.length === 1 ? matches[0] : undefined;
const decision = evaluateRemediationEvidence({
baselineIdentity: identity,
targetIdentity: match?.identity,
baselineObservedAt: TIMESLOT_A,
targetObservedAt: TIMESLOT_B,
processingCompatible: compatibility.compatible,
targetOutcome: match ? occurrenceOutcome(match.item) : undefined,
targetIsIoe: match?.item.isIoe,
coverage: definition.resultEvidence === 'explicit-results' || absenceProven ? 'complete' : 'unknown',
coveredIdentityKeys: absenceProven ? [identity.identity.key] : [],
absenceSemantics: definition.negativeEvidence === 'complete-explicit-negative' ? 'explicit-negative' : 'not-reported',
blockingDiagnostics
});
return {
baselineOccurrenceId: item.id, ...(match ? { targetOccurrenceId: match.item.id } : {}),
identityKey: decision.identityKey, state: decision.state, reasons: decision.reasons
};
});
const baselineKeys = new Set(baselineIdentities.flatMap(entry => entry.identity.state === 'verified' ? [entry.identity.identity.key] : []));
// A regression needs exactly one explicit pass of the same identity in A.
const baselinePassCounts = new Map<string, number>();
const baselinePassIds = new Map<string, string>();
baselineOccurrences.filter(item => occurrenceOutcome(item) === 'pass').forEach(item => {
const identity = identityFor(item, definition, baselineScope.scope);
if (identity.state !== 'verified') return;
baselinePassCounts.set(identity.identity.key, (baselinePassCounts.get(identity.identity.key) ?? 0) + 1);
baselinePassIds.set(identity.identity.key, item.id);
});
const currentDeviations = targetIdentities.filter(entry => entry.identity.state === 'verified'
&& isDeviation(Boolean(entry.item.isIoe), occurrenceOutcome(entry.item))
&& (targetByIdentity.get(entry.identity.identity.key) ?? []).length === 1
&& !baselineKeys.has(entry.identity.identity.key));
const keyOfEntry = (entry: typeof targetIdentities[number]) => entry.identity.state === 'verified' ? entry.identity.identity.key : '';
const regressed = currentDeviations.filter(entry => baselinePassCounts.get(keyOfEntry(entry)) === 1);
// Several passes of one identity in A are ambiguous history: neither new nor regressed.
const newDeviationOccurrenceIds = currentDeviations.filter(entry => !baselinePassCounts.has(keyOfEntry(entry))).map(entry => entry.item.id);
return {
toolId, baselineRunId: baseline.run.id, targetRunId: target.run.id,
baselineFileIds: [...baseline.fileIds], targetFileIds: [...target.fileIds],
state: 'compared', reasons: [...new Set(blockingDiagnostics)], decisions, newDeviationOccurrenceIds,
regressedDeviationOccurrenceIds: regressed.map(entry => entry.item.id),
regressedBaselineOccurrenceIds: regressed.map(entry => baselinePassIds.get(keyOfEntry(entry))!)
};
}
const decisions = sources.flatMap(source => source.decisions);
return {
version: '1.2.0', projectId, baselineRunIds: [...baselineRunIds], targetRunIds: [...targetRunIds], sources,
summary: {
baselineDeviations: decisions.length,
verifiedRemediated: decisions.filter(item => item.state === 'verified-remediated').length,
confirmedOpen: decisions.filter(item => item.state === 'confirmed-open').length,
unverifiedAbsent: decisions.filter(item => item.state === 'unverified-absent').length,
notComparable: decisions.filter(item => item.state === 'not-comparable').length,
notApplicable: decisions.filter(item => item.state === 'not-applicable').length,
newDeviations: sources.reduce((sum, source) => sum + source.newDeviationOccurrenceIds.length, 0),
regressedDeviations: sources.reduce((sum, source) => sum + source.regressedDeviationOccurrenceIds.length, 0)
}
};
}