208 lines
11 KiB
TypeScript
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))
|
|
} } : {})
|
|
};
|
|
}
|