Assessor_2.0/tests/domain/result-db-identity-guard.te...

131 lines
8.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { AssessmentProject } from '../../src/domain/canonical-model';
import type { EnvironmentObservation } from '../../src/domain/environment-context';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import {
buildResultDbIdentityGuards,
collectResultDbIdentityLiterals,
ResultDbIdentityGuard
} from '../../src/domain/result-db-identity-guard';
import { discloseResultDbProjection, discloseResultDbProjectionWithTrace } from '../../src/domain/result-db-disclosure';
const project: AssessmentProject = {
id: 'synthetic-project', name: 'Westeros Holding', createdAt: '2026-09-28T00:00:00Z', updatedAt: '2026-09-28T00:00:00Z',
attributes: { shortName: 'WSTR' }
};
function observation(property: string, value: string, identity?: string): EnvironmentObservation {
return {
version: 1, id: `obs-${property}`, scanRunId: 'run', importFileId: 'file',
tool: { id: 'pingcastle', name: 'PingCastle' }, subject: { type: 'domain-information', identity },
property, label: property, originalValue: value, normalizedValue: value, valueType: 'string', state: 'observed',
source: { objectPath: 'synthetic' }, mappingVersion: 'synthetic', attributes: {}
};
}
function scanner(overrides: Partial<UnifiedResultProjection> = {}): UnifiedResultProjection {
return {
resultProjectionId: 'scanner-finding:occ-1', sourceKind: 'scanner-finding', sourceResultId: 'occ-1',
projectId: project.id, assessmentId: 'timeslot-7f3a', collectionRunId: 'scan-run-7f3a', scanRunId: 'scan-run-7f3a', sourceTool: 'pingcastle',
sourceRuleId: 'A-DC-Spooler', title: 'Spooler is running on DC01', description: 'Host DC01 in sevenkingdoms.local exposes it.',
recommendation: 'Disable the spooler on WINTERFELL and ask Jon Snow.', affectedAsset: 'DC01',
severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail',
technicalExecutionState: 'completed', resultType: 'exposure_finding', isIoe: true, isDeviation: true,
lifecycleState: 'unknown', mappingConfidence: 1, reviewOwner: 'Jon Snow', importedAt: '2026-09-28T00:00:00Z',
createdAt: '2026-09-28T00:00:00Z', frameworkReferences: [], categories: [],
...overrides
};
}
describe('Result DB client identity guard', () => {
it('collects the identities a client names in its own data, without generic or public words', () => {
const literals = collectResultDbIdentityLiterals({
projects: [project],
environmentObservations: [observation('domain.fqdn', 'sevenkingdoms.local'), observation('host.name', 'WINTERFELL', 'WINTERFELL'),
observation('domain.functional_level', 'Windows Server 2016')],
affectedAssets: ['DC01', 'Domain Admins', 'krbtgt'],
reviewOwners: ['Jon Snow'],
principals: ['SEVENKINGDOMS\\arya.stark', 'BUILTIN\\Administrators', 'ned@sevenkingdoms.local']
});
// Case-insensitive: the NetBIOS name SEVENKINGDOMS and the domain label sevenkingdoms are one literal.
expect(literals.map(value => value.toLowerCase())).toEqual(expect.arrayContaining(['westeros holding', 'wstr',
'sevenkingdoms.local', 'sevenkingdoms', 'winterfell', 'dc01', 'jon snow', 'arya.stark', 'ned']));
expect(literals.map(value => value.toLowerCase())).not.toEqual(expect.arrayContaining(['domain admins', 'krbtgt', 'builtin',
'administrators', 'windows server 2016']));
expect(literals.indexOf('sevenkingdoms.local')).toBeLessThan(literals.indexOf('sevenkingdoms'));
});
it('removes names that no pattern recognizes, such as DC01 or a person, from every disclosed field', () => {
const guards = buildResultDbIdentityGuards({
projects: [project],
environmentObservations: [{ projectId: project.id, observation: observation('host.name', 'WINTERFELL') }],
internalResults: [scanner()]
});
const disclosed = discloseResultDbProjection(scanner(), guards.get(project.id));
const serialized = JSON.stringify(disclosed);
for (const identity of ['DC01', 'WINTERFELL', 'Jon Snow', 'sevenkingdoms']) expect(serialized).not.toContain(identity);
expect(disclosed.title).toBe('Spooler is running on [redacted]');
});
it('takes only account forms from GPO values, never registry keys or paths', () => {
const guards = buildResultDbIdentityGuards({
projects: [project], environmentObservations: [],
internalResults: [scanner({ policyValues: [{ key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers', valueName: 'Machine\\Test',
principals: ['SEVENKINGDOMS\\arya.stark', 'HKLM\\Software', 'BUILTIN\\Administrators'] }] })]
});
const guard = guards.get(project.id)!;
expect(guard.text('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators'))
.toBe('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators');
expect(guard.text('Granted to arya.stark in SEVENKINGDOMS')).toBe('Granted to [redacted] in [redacted]');
});
it('withholds a whole field when an identity survives in another spelling', () => {
const guard = new ResultDbIdentityGuard(['sevenkingdoms.local']);
expect(guard.text('Forest seven-kingdoms . local is affected')).toBe('[withheld by identity guard]');
expect(guard.text('Forest sevenkingdoms.local is affected')).toBe('Forest [redacted] is affected');
expect(guard.text('Nothing identifying here')).toBe('Nothing identifying here');
});
it('records a content-free trace for every stored row', () => {
const guards = buildResultDbIdentityGuards({
projects: [project], environmentObservations: [{ projectId: project.id, observation: observation('host.name', 'WINTERFELL') }],
internalResults: [scanner()]
});
const row = discloseResultDbProjectionWithTrace(scanner({
description: 'Host DC01 has 15 accounts, see https://tenant.sharepoint.com/x.', reviewStatus: 'open'
}), guards.get(project.id));
expect(row.disclosureTrace).toMatchObject({ version: 1, guardWithheldFields: [] });
// DC01 is this finding's own affected asset (private-value); Jon Snow is caught by the client guard.
expect(row.disclosureTrace?.redactions).toEqual(expect.arrayContaining([
{ field: 'title', rule: 'private-value', count: 1 },
{ field: 'description', rule: 'count', count: 1 },
{ field: 'description', rule: 'url', count: 1 },
{ field: 'description', rule: 'private-value', count: 1 },
{ field: 'recommendation', rule: 'private-value', count: 1 },
{ field: 'recommendation', rule: 'identity-guard', count: 1 }
]));
expect(row.disclosureTrace?.withheldFields).toContain('affectedAsset');
// Client-owned review state is withheld without a trace entry, so its presence is not revealed.
expect(JSON.stringify(row.disclosureTrace)).not.toMatch(/review/i);
expect(JSON.stringify(row.disclosureTrace)).not.toMatch(/DC01|WINTERFELL|Jon Snow|15|sharepoint/);
expect(row.recommendation).toBe('Disable the spooler on [redacted] and ask [redacted].');
});
it('is idempotent: disclosing a stored row again changes nothing, so UI, API and export show the same content', () => {
const guards = buildResultDbIdentityGuards({ projects: [project], environmentObservations: [], internalResults: [scanner()] });
const fixtures = [
scanner(),
scanner({ sourceTool: 'maester', sourceRuleId: 'MT.1074', title: 'No more than 100 mails via the .onmicrosoft.com domain',
description: '1. Open [Entra](https://entra.microsoft.com). 2. Select Users. 3. Save. Found 4 accounts.',
recommendation: 'Set at least 8 characters; see https://learn.microsoft.com/en-us/entra/ and Directory.ReadWrite.All.' }),
scanner({ sourceTool: 'cis-cat', sourceRuleId: '18.9.20.1.1',
title: "18.9.20.1.1 (L2) Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled'",
recommendation: 'Computer Configuration\\Policies\\Administrative Templates\\System\\Internet Communication Management' })
];
for (const fixture of fixtures) {
const stored = discloseResultDbProjectionWithTrace(fixture, guards.get(project.id));
expect(discloseResultDbProjection(stored)).toEqual(stored);
}
});
});