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

208 lines
11 KiB
TypeScript

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<PolicyWorkStatus, 'Conflicts' | 'Duplicates'>;
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<string, TimeslotSetting> {
const byIdentity = new Map<string, PolicyWorkResult[]>();
for (const result of results) {
const key = settingKey(result.scope, result.identity);
byIdentity.set(key, [...(byIdentity.get(key) ?? []), result]);
}
const settings = new Map<string, TimeslotSetting>();
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<SettingVerdict | undefined>((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))
} } : {})
};
}