207 lines
8.9 KiB
TypeScript
207 lines
8.9 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
parseRegistryPolicyFile,
|
|
reduceRegistryPolicy
|
|
} from '../../server/gpo/registry-policy-parser';
|
|
import type { VerifiedZipFile } from '../../server/gpo/zip-inventory';
|
|
|
|
type RecordInput = { key: string; valueName: string; typeCode: number; data: Uint8Array };
|
|
|
|
function utf16(value: string): Buffer {
|
|
return Buffer.from(value, 'utf16le');
|
|
}
|
|
|
|
function stringData(value: string): Buffer {
|
|
return Buffer.concat([utf16(value), Buffer.alloc(2)]);
|
|
}
|
|
|
|
function multiStringData(values: string[]): Buffer {
|
|
return Buffer.concat([utf16(values.join('\0')), Buffer.alloc(4)]);
|
|
}
|
|
|
|
function dword(value: number, bigEndian = false): Buffer {
|
|
const bytes = Buffer.alloc(4);
|
|
if (bigEndian) bytes.writeUInt32BE(value);
|
|
else bytes.writeUInt32LE(value);
|
|
return bytes;
|
|
}
|
|
|
|
function qword(value: bigint): Buffer {
|
|
const bytes = Buffer.alloc(8);
|
|
bytes.writeBigUInt64LE(value);
|
|
return bytes;
|
|
}
|
|
|
|
function policyFile(records: RecordInput[], version = 1): Uint8Array {
|
|
const header = Buffer.alloc(8);
|
|
header.write('PReg', 0, 'ascii');
|
|
header.writeUInt32LE(version, 4);
|
|
const encoded = records.map(record => {
|
|
const type = Buffer.alloc(4);
|
|
type.writeUInt32LE(record.typeCode);
|
|
const size = Buffer.alloc(4);
|
|
size.writeUInt32LE(record.data.byteLength);
|
|
return Buffer.concat([
|
|
utf16('['),
|
|
utf16(record.key), Buffer.alloc(2), utf16(';'),
|
|
utf16(record.valueName), Buffer.alloc(2), utf16(';'),
|
|
type, utf16(';'),
|
|
size, utf16(';'),
|
|
record.data,
|
|
utf16(']')
|
|
]);
|
|
});
|
|
return Buffer.concat([header, ...encoded]);
|
|
}
|
|
|
|
function verifiedFile(bytes: Uint8Array): VerifiedZipFile {
|
|
return {
|
|
entry: {
|
|
index: 1,
|
|
relativePath: 'synthetic/DomainSysvol/GPO/Machine/Registry.pol',
|
|
kind: 'file',
|
|
compressionMethod: 8,
|
|
compressedBytes: bytes.byteLength,
|
|
uncompressedBytes: bytes.byteLength,
|
|
crc32: '00000000',
|
|
sha256: createHash('sha256').update(bytes).digest('hex')
|
|
},
|
|
bytes
|
|
};
|
|
}
|
|
|
|
function context() {
|
|
return { backupInstanceId: 'backup-synthetic', sourceArtifactId: 'source-synthetic', artifactId: 'entry-synthetic' };
|
|
}
|
|
|
|
describe('Registry.pol parser and instruction reducer', () => {
|
|
it('accepts REG_NONE only as an empty key-creation instruction', () => {
|
|
const document = parseRegistryPolicyFile(verifiedFile(policyFile([{
|
|
key: 'Software\\Policies\\Synthetic\\Child',
|
|
valueName: '',
|
|
typeCode: 0,
|
|
data: Buffer.alloc(0)
|
|
}])), 'computer');
|
|
|
|
expect(document).toMatchObject({ status: 'complete' });
|
|
expect(reduceRegistryPolicy(document, context())).toMatchObject([{
|
|
registryPolicy: { sequence: 0, operation: 'create-key' },
|
|
values: [{
|
|
kind: 'registry-policy',
|
|
operation: 'create-key',
|
|
key: 'Software\\Policies\\Synthetic\\Child',
|
|
typeCode: 0,
|
|
data: { kind: 'none' }
|
|
}]
|
|
}]);
|
|
|
|
expect(parseRegistryPolicyFile(verifiedFile(policyFile([{
|
|
key: 'Software\\Policies\\Synthetic\\Child',
|
|
valueName: 'unexpected',
|
|
typeCode: 0,
|
|
data: Buffer.alloc(0)
|
|
}])), 'computer')).toMatchObject({
|
|
status: 'invalid',
|
|
instructions: [],
|
|
diagnosticCodes: ['gpo-registry-policy-none-invalid']
|
|
});
|
|
});
|
|
it('decodes the documented Registry.pol types without losing record order', () => {
|
|
const document = parseRegistryPolicyFile(verifiedFile(policyFile([
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: 'Text', typeCode: 1, data: stringData('strict') },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: 'Expanded', typeCode: 2, data: stringData('%SYSTEMROOT%') },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: 'Bytes', typeCode: 3, data: Buffer.from([0, 127, 255]) },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: 'Little', typeCode: 4, data: dword(42) },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: 'Big', typeCode: 5, data: dword(43, true) },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: 'List', typeCode: 7, data: multiStringData(['one', 'two']) },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: 'Wide', typeCode: 11, data: qword(9_007_199_254_740_993n) }
|
|
])), 'computer');
|
|
|
|
expect(document).toMatchObject({
|
|
status: 'complete',
|
|
instructions: [
|
|
{ sequence: 0, data: { kind: 'string', value: 'strict' } },
|
|
{ sequence: 1, data: { kind: 'string', value: '%SYSTEMROOT%' } },
|
|
{ sequence: 2, data: { kind: 'binary', hexadecimal: '007fff' } },
|
|
{ sequence: 3, data: { kind: 'uint32', value: 42 } },
|
|
{ sequence: 4, data: { kind: 'uint32', value: 43 } },
|
|
{ sequence: 5, data: { kind: 'multi-string', values: ['one', 'two'] } },
|
|
{ sequence: 6, data: { kind: 'uint64', value: '9007199254740993' } }
|
|
]
|
|
});
|
|
});
|
|
|
|
it('preserves documented delete, secure and soft operations as ordered instructions', () => {
|
|
const document = parseRegistryPolicyFile(verifiedFile(policyFile([
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: '**DeleteValues', typeCode: 1, data: stringData('One;Two') },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: '**Del.Three', typeCode: 1, data: stringData(' ') },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: '**DelVals.', typeCode: 1, data: stringData(' ') },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: '**DeleteKeys', typeCode: 1, data: stringData('ChildA;ChildB') },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: '**SecureKey', typeCode: 4, data: dword(1) },
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: '**soft.Mode', typeCode: 4, data: dword(1) }
|
|
])), 'computer');
|
|
const observations = reduceRegistryPolicy(document, context());
|
|
|
|
expect(observations.map(observation => observation.registryPolicy)).toEqual([
|
|
{ sequence: 0, operation: 'delete-values' },
|
|
{ sequence: 1, operation: 'delete-value' },
|
|
{ sequence: 2, operation: 'delete-all-values' },
|
|
{ sequence: 3, operation: 'delete-keys' },
|
|
{ sequence: 4, operation: 'secure-key' },
|
|
{ sequence: 5, operation: 'set-value-if-absent' }
|
|
]);
|
|
expect(observations[0]).toMatchObject({
|
|
state: 'instruction',
|
|
values: [{ kind: 'registry-policy', operation: 'delete-values', targets: ['One', 'Two'] }],
|
|
sourceRecords: [{ index: 0, byteOffset: 8 }],
|
|
effectiveState: 'unknown'
|
|
});
|
|
expect(observations[4]).toMatchObject({ values: [{ operation: 'secure-key', secure: true }] });
|
|
});
|
|
|
|
it('keeps unknown reserved operations partial and does not silently reinterpret them', () => {
|
|
const document = parseRegistryPolicyFile(verifiedFile(policyFile([
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: '**FutureOperation', typeCode: 1, data: stringData('opaque') }
|
|
])), 'computer');
|
|
|
|
expect(document).toMatchObject({
|
|
status: 'partial',
|
|
diagnosticCodes: ['gpo-registry-policy-special-value-unsupported']
|
|
});
|
|
expect(reduceRegistryPolicy(document, context())).toMatchObject([{
|
|
values: [{ operation: 'unsupported-special' }],
|
|
diagnosticCodes: ['gpo-registry-policy-special-value-unsupported']
|
|
}]);
|
|
});
|
|
|
|
it('fails closed on an invalid header, version, truncation or special-operation shape', () => {
|
|
const validRecord = { key: 'Software\\Policies\\Synthetic', valueName: 'Mode', typeCode: 4, data: dword(1) };
|
|
const invalidSignature = policyFile([validRecord]);
|
|
invalidSignature[0] = 0;
|
|
expect(parseRegistryPolicyFile(verifiedFile(invalidSignature), 'computer')).toMatchObject({
|
|
status: 'invalid', instructions: [], diagnosticCodes: ['gpo-registry-policy-signature-invalid']
|
|
});
|
|
expect(parseRegistryPolicyFile(verifiedFile(policyFile([validRecord], 2)), 'computer')).toMatchObject({
|
|
status: 'invalid', instructions: [], diagnosticCodes: ['gpo-registry-policy-version-unsupported']
|
|
});
|
|
const truncated = policyFile([validRecord]).slice(0, -1);
|
|
expect(parseRegistryPolicyFile(verifiedFile(truncated), 'computer')).toMatchObject({
|
|
status: 'invalid', instructions: [], diagnosticCodes: ['gpo-registry-policy-record-close-invalid']
|
|
});
|
|
const invalidSpecial = parseRegistryPolicyFile(verifiedFile(policyFile([
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: '**SecureKey', typeCode: 1, data: stringData('1') }
|
|
])), 'computer');
|
|
expect(invalidSpecial).toMatchObject({
|
|
status: 'invalid', instructions: [], diagnosticCodes: ['gpo-registry-policy-special-value-invalid']
|
|
});
|
|
});
|
|
|
|
it('does not reduce a Registry.pol file whose scope is not technically known', () => {
|
|
const document = parseRegistryPolicyFile(verifiedFile(policyFile([
|
|
{ key: 'Software\\Policies\\Synthetic', valueName: 'Mode', typeCode: 4, data: dword(1) }
|
|
])), 'unknown');
|
|
expect(document).toMatchObject({ status: 'partial', diagnosticCodes: ['gpo-registry-policy-scope-unsupported'] });
|
|
expect(reduceRegistryPolicy(document, context())).toEqual([]);
|
|
});
|
|
}); |