import type { RemediationEvidenceState } from '../domain/timeslot-comparison'; import type { PolicyWorkResult, PolicyWorkStatus } from '../evaluation/policy-work-results'; import { policyWorkVerdictPriority } from '../evaluation/policy-work-results'; import { canonicalJson } from '../shared/canonical-json'; export const GPO_TIMESLOT_COMPARISON_VERSION = '1.0.0' as const; /** One timeslot's rolled-up GPO settings exactly as Work reads them, plus its backup observation time. */ export interface GpoTimeslotInput { results: PolicyWorkResult[]; /** Latest backup creation time of the timeslot's GPO backups; never the import time. */ observedAt?: string; } type SettingVerdict = Exclude; interface TimeslotSetting { key: string; scope: string; identity: string; verdict?: SettingVerdict; baselineIds: string; actualValues: string; /** The retained configured values as Work displays them; empty when none is retained. */ displayValues: string[]; resultIds: string[]; ambiguous: boolean; facts: GpoSideFacts; } /** What Work shows for a setting in one timeslot, for the Compare detail; read as retained, never re-derived. */ export interface GpoSideFacts { verdict?: SettingVerdict; /** Retained configured values as Work displays them. */ values: string[]; /** Expected baseline values of the decisive comparisons. */ expected: string[]; /** Distinct GPO backups contributing to the setting. */ backups: number; conflict: boolean; duplicate: boolean; } export interface GpoSettingDecision { /** Cross-timeslot setting identity: scope and technical identity only, never a run or assessment. */ settingKey: string; scope: string; identity: string; baselineVerdict: SettingVerdict; targetVerdict?: SettingVerdict; state: RemediationEvidenceState; /** Retained configured value differs while the setting still deviates. Neutral, never better or worse. */ valueChanged: boolean; reasons: string[]; baselineResultIds: string[]; targetResultIds: string[]; /** Retained configured values of both timeslots as Work displays them, for a side-by-side reading. */ baselineValues: string[]; targetValues: string[]; baseline: GpoSideFacts; target?: GpoSideFacts; } export interface GpoTargetChange { settingKey: string; baselineVerdict?: SettingVerdict; targetResultIds: string[]; baseline?: GpoSideFacts; target: GpoSideFacts; } export interface GpoTimeslotComparison { version: typeof GPO_TIMESLOT_COMPARISON_VERSION; state: 'compared' | 'not-comparable'; reasons: string[]; decisions: GpoSettingDecision[]; newDeviations: GpoTargetChange[]; regressedDeviations: GpoTargetChange[]; /** Pass rate over settings decided (Pass or Fail) in both timeslots against the same baselines. */ sharedPassRate?: { settings: number; baseline: number; target: number }; } const settingKey = (scope: string, identity: string) => JSON.stringify([scope, identity]); /** * Rolls each timeslot up like Work: one verdict per technical setting, chosen by the shared * verdict priority. Conflict and duplicate entries are diagnostics and never a verdict. Several * aggregation runs holding the same setting in one timeslot make it ambiguous. */ function rollUp(results: PolicyWorkResult[]): Map { const byIdentity = new Map(); for (const result of results) { const key = settingKey(result.scope, result.identity); byIdentity.set(key, [...(byIdentity.get(key) ?? []), result]); } const settings = new Map(); for (const [key, group] of byIdentity) { const verdicts = group.filter((item): item is PolicyWorkResult & { status: SettingVerdict } => item.status !== 'Conflicts' && item.status !== 'Duplicates'); const verdict = verdicts.reduce((best, item) => !best || policyWorkVerdictPriority[item.status] > policyWorkVerdictPriority[best] ? item.status : best, undefined); const decisive = verdicts.filter(item => item.status === verdict); const shown = (value: unknown) => typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value); const displayValues = [...new Set(decisive.flatMap(item => { const actual = item.comparison?.comparator.actual; return actual === undefined ? [] : [shown(actual)]; }))].sort(); settings.set(key, { key, scope: group[0].scope, identity: group[0].identity, verdict, baselineIds: canonicalJson([...new Set(verdicts.flatMap(item => item.comparison?.baselineId ?? []))].sort()), actualValues: canonicalJson([...new Set(decisive.flatMap(item => item.comparison?.comparator.actual === undefined ? [] : [canonicalJson(item.comparison.comparator.actual)]))].sort()), displayValues, resultIds: group.map(item => item.id).sort(), ambiguous: new Set(group.flatMap(item => item.settingKey ?? [])).size > 1, facts: { ...(verdict ? { verdict } : {}), values: displayValues, expected: [...new Set(decisive.flatMap(item => { const expected = item.comparison?.comparator.baseline; return expected === undefined ? [] : [shown(expected)]; }))].sort(), backups: new Set(group.flatMap(item => item.sources.flatMap(source => source.policyBackupInstanceId ?? []))).size, conflict: group.some(item => item.conflict || item.status === 'Conflicts'), duplicate: group.some(item => item.duplicate || item.status === 'Duplicates') } }); } return settings; } /** The timeslot order is the time basis (user decision 2026-10-13); stated backup times only block when B's is not newer. */ const notNewer = (baseline?: string, target?: string) => Boolean(baseline && target && Number.isFinite(Date.parse(baseline)) && Number.isFinite(Date.parse(target)) && Date.parse(target) <= Date.parse(baseline)); /** * Conservative A/B comparison of rolled-up GPO settings. The authoritative verdicts are read as * they are and never re-derived. Remediation needs the same setting with an explicit Pass against the * same baselines in timeslot B (whose stated backup time, if any, must be newer); an absent or undecided setting stays unverified, because a * GPO backup set is no proof of a removed configuration. */ export function compareGpoTimeslots(baseline: GpoTimeslotInput, target: GpoTimeslotInput): GpoTimeslotComparison { const version = GPO_TIMESLOT_COMPARISON_VERSION; const baselineSettings = rollUp(baseline.results); const targetSettings = rollUp(target.results); const baselineFails = [...baselineSettings.values()].filter(item => item.verdict === 'Fail') .sort((left, right) => left.key.localeCompare(right.key)); const sourceReasons = [ ...(baselineSettings.size ? [] : ['baseline-source-missing']), ...(targetSettings.size ? [] : ['target-source-missing']) ]; const decision = (item: TimeslotSetting, state: RemediationEvidenceState, reasons: string[], other?: TimeslotSetting, valueChanged = false): GpoSettingDecision => ({ settingKey: item.key, scope: item.scope, identity: item.identity, baselineVerdict: 'Fail', ...(other?.verdict ? { targetVerdict: other.verdict } : {}), state, valueChanged, reasons, baselineResultIds: item.resultIds, targetResultIds: other?.resultIds ?? [], baselineValues: item.displayValues, targetValues: other?.displayValues ?? [], baseline: item.facts, ...(other ? { target: other.facts } : {}) }); if (sourceReasons.length) return { version, state: 'not-comparable', reasons: sourceReasons, decisions: baselineFails.map(item => decision(item, 'not-comparable', sourceReasons)), newDeviations: [], regressedDeviations: [] }; const timeVerified = !notNewer(baseline.observedAt, target.observedAt); const decisions = baselineFails.map(item => { if (item.ambiguous) return decision(item, 'not-comparable', ['gpo-setting-ambiguous']); const other = targetSettings.get(item.key); if (!timeVerified) return decision(item, 'not-comparable', ['target-observation-not-newer'], other); if (!other) return decision(item, 'unverified-absent', ['target-setting-absent']); if (other.ambiguous) return decision(item, 'unverified-absent', ['gpo-setting-ambiguous'], other); if (other.baselineIds !== item.baselineIds) return decision(item, 'not-comparable', ['baseline-set-changed'], other); if (other.verdict === 'Fail') return decision(item, 'confirmed-open', ['same-setting-current-deviation'], other, other.actualValues !== item.actualValues); if (other.verdict === 'Pass') return decision(item, 'verified-remediated', ['same-setting-explicit-pass'], other); return decision(item, 'unverified-absent', [`target-verdict-not-decisive:${other.verdict ?? 'none'}`], other); }); const newDeviations: GpoTargetChange[] = []; const regressedDeviations: GpoTargetChange[] = []; if (timeVerified) { for (const item of [...targetSettings.values()].sort((left, right) => left.key.localeCompare(right.key))) { if (item.verdict !== 'Fail' || item.ambiguous) continue; const before = baselineSettings.get(item.key); if (before?.ambiguous || before?.verdict === 'Fail') continue; const change = { settingKey: item.key, ...(before?.verdict ? { baselineVerdict: before.verdict } : {}), targetResultIds: item.resultIds, ...(before ? { baseline: before.facts } : {}), target: item.facts }; // A regression needs an explicit Pass against the same baselines; undecided history claims nothing. if (before?.verdict === 'Pass') { if (before.baselineIds === item.baselineIds) regressedDeviations.push(change); } else if (!before?.verdict || before.verdict === 'Not configured' || before.verdict === 'No baseline rule') newDeviations.push(change); } } const shared = [...baselineSettings.values()].flatMap(item => { const other = targetSettings.get(item.key); return other && !item.ambiguous && !other.ambiguous && item.baselineIds === other.baselineIds && (item.verdict === 'Pass' || item.verdict === 'Fail') && (other.verdict === 'Pass' || other.verdict === 'Fail') ? [{ before: item.verdict, after: other.verdict }] : []; }); const rate = (values: string[]) => values.filter(value => value === 'Pass').length / values.length * 100; return { version, state: timeVerified ? 'compared' : 'not-comparable', reasons: timeVerified ? [] : ['target-observation-not-newer'], decisions, newDeviations, regressedDeviations, ...(timeVerified && shared.length ? { sharedPassRate: { settings: shared.length, baseline: rate(shared.map(item => item.before)), target: rate(shared.map(item => item.after)) } } : {}) }; }