130 lines
7.2 KiB
TypeScript
130 lines
7.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { PolicyWorkResult, PolicyWorkStatus } from '../../src/evaluation/policy-work-results';
|
|
import { compareGpoTimeslots } from '../../src/comparison/gpo-timeslot-comparison';
|
|
|
|
// Synthetic rolled-up GPO Work results. Only the fields the comparison reads are populated.
|
|
const observedA = '2026-01-01T10:00:00.000Z';
|
|
const observedB = '2026-02-01T10:00:00.000Z';
|
|
|
|
function setting(id: string, identity: string, status: PolicyWorkStatus, options: {
|
|
actual?: unknown; baselineId?: string; aggregationRun?: string; scope?: string;
|
|
} = {}): PolicyWorkResult {
|
|
const scope = options.scope ?? 'computer';
|
|
return {
|
|
id, status, title: identity, scope, identity, importedAt: observedA, sources: [],
|
|
settingKey: JSON.stringify(['dummy-project', options.aggregationRun ?? 'aggregation-1', scope, identity]),
|
|
comparison: {
|
|
baselineId: options.baselineId ?? 'baseline-server',
|
|
comparator: { actual: options.actual === undefined ? undefined : { kind: 'number', value: options.actual } }
|
|
} as unknown as PolicyWorkResult['comparison']
|
|
};
|
|
}
|
|
|
|
describe('GPO timeslot comparison', () => {
|
|
it('pairs rolled-up settings by scope and identity and keeps a value change neutral', () => {
|
|
const result = compareGpoTimeslots(
|
|
{ observedAt: observedA, results: [
|
|
setting('a-1', 'MinimumPasswordLength', 'Fail', { actual: 7 }),
|
|
setting('a-2', 'LockoutBadCount', 'Fail', { actual: 0 }),
|
|
setting('a-3', 'NoLMHash', 'Fail', { actual: 0 }),
|
|
setting('a-4', 'PasswordComplexity', 'Fail', { actual: 0 })
|
|
] },
|
|
{ observedAt: observedB, results: [
|
|
setting('b-1', 'MinimumPasswordLength', 'Fail', { actual: 10 }),
|
|
setting('b-2', 'LockoutBadCount', 'Fail', { actual: 0 }),
|
|
setting('b-3', 'NoLMHash', 'Pass', { actual: 1 }),
|
|
setting('b-4', 'PasswordComplexity', 'Not configured')
|
|
] }
|
|
);
|
|
const byIdentity = Object.fromEntries(result.decisions.map(item => [item.identity, item]));
|
|
expect(byIdentity.MinimumPasswordLength).toMatchObject({ state: 'confirmed-open', valueChanged: true, targetResultIds: ['b-1'] });
|
|
// Both retained values travel as Work displays them, for the Finding Reader's side-by-side reading.
|
|
expect(byIdentity.MinimumPasswordLength).toMatchObject({
|
|
baselineValues: ['{"kind":"number","value":7}'], targetValues: ['{"kind":"number","value":10}'] });
|
|
expect(byIdentity.PasswordComplexity.targetValues).toEqual([]);
|
|
expect(byIdentity.LockoutBadCount).toMatchObject({ state: 'confirmed-open', valueChanged: false });
|
|
expect(byIdentity.NoLMHash).toMatchObject({ state: 'verified-remediated', reasons: ['same-setting-explicit-pass'] });
|
|
// A setting missing from the newer backup set is not proof of a removed configuration.
|
|
expect(byIdentity.PasswordComplexity).toMatchObject({ state: 'unverified-absent', reasons: ['target-verdict-not-decisive:Not configured'] });
|
|
});
|
|
|
|
it('rolls several baseline releases of one setting up with the Work verdict order', () => {
|
|
const result = compareGpoTimeslots(
|
|
{ observedAt: observedA, results: [
|
|
setting('a-1', 'SeBackupPrivilege', 'Pass', { baselineId: 'baseline-dc' }),
|
|
setting('a-2', 'SeBackupPrivilege', 'Fail', { baselineId: 'baseline-server' })
|
|
] },
|
|
{ observedAt: observedB, results: [
|
|
setting('b-1', 'SeBackupPrivilege', 'Pass', { baselineId: 'baseline-dc' }),
|
|
setting('b-2', 'SeBackupPrivilege', 'Pass', { baselineId: 'baseline-server' })
|
|
] }
|
|
);
|
|
expect(result.decisions).toHaveLength(1);
|
|
expect(result.decisions[0]).toMatchObject({ state: 'verified-remediated', baselineResultIds: ['a-1', 'a-2'] });
|
|
});
|
|
|
|
it('separates new and regressed settings and claims nothing from undecided history', () => {
|
|
const result = compareGpoTimeslots(
|
|
{ observedAt: observedA, results: [
|
|
setting('a-1', 'SeEnableDelegationPrivilege', 'Not configured'),
|
|
setting('a-2', 'SeRestorePrivilege', 'Pass'),
|
|
setting('a-3', 'SeAuditPrivilege', 'Review required'),
|
|
setting('a-4', 'SeLoadDriverPrivilege', 'Pass', { baselineId: 'baseline-old' })
|
|
] },
|
|
{ observedAt: observedB, results: [
|
|
setting('b-1', 'SeEnableDelegationPrivilege', 'Fail'),
|
|
setting('b-2', 'SeRestorePrivilege', 'Fail'),
|
|
setting('b-3', 'SeAuditPrivilege', 'Fail'),
|
|
setting('b-4', 'SeLoadDriverPrivilege', 'Fail'),
|
|
setting('b-5', 'DnsPolicyConfig', 'Fail')
|
|
] }
|
|
);
|
|
expect(result.newDeviations.map(item => item.targetResultIds[0]).sort()).toEqual(['b-1', 'b-5']);
|
|
expect(result.regressedDeviations.map(item => item.targetResultIds[0])).toEqual(['b-2']);
|
|
});
|
|
|
|
it('requires a newer backup set, the same baselines and a GPO source in both timeslots', () => {
|
|
const fail = { observedAt: observedA, results: [setting('a-1', 'NoLMHash', 'Fail')] };
|
|
const sameTime = compareGpoTimeslots(fail, { observedAt: observedA, results: [setting('b-1', 'NoLMHash', 'Pass')] });
|
|
expect(sameTime.decisions[0]).toMatchObject({ state: 'not-comparable', reasons: ['target-observation-not-newer'] });
|
|
expect(sameTime.sharedPassRate).toBeUndefined();
|
|
// Without stated backup times (real backups state no time zone) the timeslot order decides.
|
|
const untimed = compareGpoTimeslots({ results: fail.results }, { results: [setting('b-1', 'NoLMHash', 'Pass')] });
|
|
expect(untimed.decisions[0]).toMatchObject({ state: 'verified-remediated' });
|
|
|
|
const changedBaseline = compareGpoTimeslots(fail, { observedAt: observedB, results: [setting('b-1', 'NoLMHash', 'Pass', { baselineId: 'baseline-new' })] });
|
|
expect(changedBaseline.decisions[0]).toMatchObject({ state: 'not-comparable', reasons: ['baseline-set-changed'] });
|
|
|
|
const missing = compareGpoTimeslots(fail, { observedAt: observedB, results: [] });
|
|
expect(missing).toMatchObject({ state: 'not-comparable', reasons: ['target-source-missing'] });
|
|
expect(missing.decisions[0].state).toBe('not-comparable');
|
|
});
|
|
|
|
it('marks a setting held by several aggregation runs of one timeslot as ambiguous', () => {
|
|
const result = compareGpoTimeslots(
|
|
{ observedAt: observedA, results: [
|
|
setting('a-1', 'NoLMHash', 'Fail', { aggregationRun: 'aggregation-1' }),
|
|
setting('a-2', 'NoLMHash', 'Fail', { aggregationRun: 'aggregation-2' })
|
|
] },
|
|
{ observedAt: observedB, results: [setting('b-1', 'NoLMHash', 'Pass')] }
|
|
);
|
|
expect(result.decisions[0]).toMatchObject({ state: 'not-comparable', reasons: ['gpo-setting-ambiguous'] });
|
|
});
|
|
|
|
it('computes the shared pass rate only over settings decided in both timeslots', () => {
|
|
const result = compareGpoTimeslots(
|
|
{ observedAt: observedA, results: [
|
|
setting('a-1', 'One', 'Fail'), setting('a-2', 'Two', 'Fail'), setting('a-3', 'Three', 'Pass'),
|
|
setting('a-4', 'OnlyInA', 'Fail')
|
|
] },
|
|
{ observedAt: observedB, results: [
|
|
setting('b-1', 'One', 'Pass'), setting('b-2', 'Two', 'Fail'), setting('b-3', 'Three', 'Pass'),
|
|
setting('b-5', 'OnlyInB', 'Pass')
|
|
] }
|
|
);
|
|
expect(result.sharedPassRate?.settings).toBe(3);
|
|
expect(result.sharedPassRate?.baseline).toBeCloseTo(100 / 3);
|
|
expect(result.sharedPassRate?.target).toBeCloseTo(200 / 3);
|
|
});
|
|
});
|