131 lines
8.1 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|