359 lines
20 KiB
TypeScript
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)
|
|
}
|
|
};
|
|
}
|