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; interface SourceUnit { toolId: string; run: ScanRun; files: ImportFileRecord[]; fileIds: Set; occurrences: FindingOccurrence[]; observations: NonNullable; } 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, 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(); 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(); 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(); 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(); 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(); const baselinePassIds = new Map(); 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) } }; }