Assessor_2.0/server/gpo/registry-policy-parser.ts

362 lines
16 KiB
TypeScript

import { createHash } from 'node:crypto';
import type {
GpoPolicyObservation,
GpoRegistryPolicyData,
GpoRegistryPolicyValue
} from '../../src/domain/gpo-policy';
import { gpoPolicyObservationValidationErrors } from '../../src/domain/gpo-policy';
import type { GpoPolicyScope } from '../../src/domain/gpo-ingestion';
import type { ImportResourcePolicy } from '../../src/importers/resource-policy';
import { ImportError } from '../../src/shared/errors';
import type { VerifiedZipFile } from './zip-inventory';
export const REGISTRY_POLICY_PARSER = { id: 'gpo-registry-policy-binary', version: '1.0.0' } as const;
export const REGISTRY_POLICY_REDUCER = { id: 'gpo-registry-policy-instruction-reducer', version: '1.0.0' } as const;
const maximumFileBytes = 16 * 1024 * 1024;
const maximumRecords = 50_000;
const maximumDataBytes = 65_535;
const supportedTypes = new Set([0, 1, 2, 3, 4, 5, 7, 11]);
type RegistryTypeCode = 0 | 1 | 2 | 3 | 4 | 5 | 7 | 11;
export interface RegistryPolicyInstruction {
version: 1;
sequence: number;
byteOffset: number;
key: string;
valueName: string;
typeCode: RegistryTypeCode;
data: GpoRegistryPolicyData;
}
export interface RegistryPolicyDocument {
version: 1;
parser: typeof REGISTRY_POLICY_PARSER;
sourcePath: string;
sourceSha256: string;
scope: GpoPolicyScope;
status: 'complete' | 'partial' | 'invalid';
instructions: RegistryPolicyInstruction[];
diagnosticCodes: string[];
}
class RegistryPolicyParseFailure extends Error {
constructor(readonly code: string, message: string) {
super(message);
}
}
function parserError(code: string, message: string, details: Record<string, string | number> = {}): never {
throw new ImportError(message, [{ severity: 'error', code, message, stage: 'format-decoding', details }]);
}
function fail(code: string, message: string): never {
throw new RegistryPolicyParseFailure(code, message);
}
function uint32(bytes: Uint8Array, offset: number, bigEndian = false): number {
if (offset < 0 || offset + 4 > bytes.byteLength) fail('gpo-registry-policy-truncated', 'Die Registry-Policy-Datei ist abgeschnitten.');
const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 4);
return view.getUint32(0, !bigEndian);
}
function utf16Character(bytes: Uint8Array, offset: number, expected: number, diagnosticCode: string): number {
if (offset + 2 > bytes.byteLength || bytes[offset] !== expected || bytes[offset + 1] !== 0) {
fail(diagnosticCode, 'Die Registry-Policy-Datensatzstruktur ist ungültig.');
}
return offset + 2;
}
function decodeUtf16(bytes: Uint8Array, code: string): string {
if (bytes.byteLength % 2 !== 0) fail(code, 'Ein UTF-16LE-Feld besitzt eine ungerade Bytezahl.');
try {
return new TextDecoder('utf-16le', { fatal: true }).decode(bytes);
} catch {
fail(code, 'Ein Registry-Policy-Feld ist nicht als UTF-16LE dekodierbar.');
}
}
function readIdentifier(bytes: Uint8Array, offset: number, code: string): { value: string; nextOffset: number } {
for (let cursor = offset; cursor + 3 < bytes.byteLength; cursor += 2) {
if (bytes[cursor] === 0 && bytes[cursor + 1] === 0 && bytes[cursor + 2] === 0x3b && bytes[cursor + 3] === 0) {
return { value: decodeUtf16(bytes.subarray(offset, cursor), code), nextOffset: cursor + 4 };
}
}
fail(code, 'Ein Registry-Policy-Feld besitzt keinen gültigen Abschluss.');
}
function validKey(key: string): boolean {
if (!key || key.length > 32_767 || key.startsWith('\\') || key.endsWith('\\')
|| /^(?:HKEY_|HKLM\\|HKCU\\)/i.test(key)) return false;
return key.split('\\').every(segment => segment.length > 0 && [...segment].every(character => {
const code = character.charCodeAt(0);
return (code >= 0x20 && code <= 0x5b) || (code >= 0x5d && code <= 0x7e);
}));
}
function validValueName(valueName: string): boolean {
return valueName.length <= 259 && [...valueName].every(character => {
const code = character.charCodeAt(0);
return code >= 0x20 && code <= 0x7e;
});
}
function decodeString(bytes: Uint8Array, diagnosticCode: string): string {
if (bytes.byteLength < 2 || bytes.byteLength % 2 !== 0
|| bytes[bytes.byteLength - 2] !== 0 || bytes[bytes.byteLength - 1] !== 0) {
fail(diagnosticCode, 'Ein Registry-Policy-String ist nicht nullterminiert.');
}
const value = decodeUtf16(bytes.subarray(0, bytes.byteLength - 2), diagnosticCode);
if (value.includes('\u0000')) fail(diagnosticCode, 'Ein Registry-Policy-String enthält ein unerwartetes Nullzeichen.');
return value;
}
function decodeData(typeCode: RegistryTypeCode, bytes: Uint8Array): GpoRegistryPolicyData {
if (typeCode === 0) {
if (bytes.byteLength !== 0) fail('gpo-registry-policy-none-size-invalid', 'Eine reine Registry-Schlüsselanlage darf keine Daten enthalten.');
return { kind: 'none' };
}
if (typeCode === 1 || typeCode === 2) {
return { kind: 'string', value: decodeString(bytes, 'gpo-registry-policy-string-invalid') };
}
if (typeCode === 3) return { kind: 'binary', hexadecimal: Buffer.from(bytes).toString('hex') };
if (typeCode === 4 || typeCode === 5) {
if (bytes.byteLength !== 4) fail('gpo-registry-policy-integer-size-invalid', 'Ein Registry-Policy-DWORD benötigt exakt vier Bytes.');
return { kind: 'uint32', value: uint32(bytes, 0, typeCode === 5) };
}
if (typeCode === 7) {
if (bytes.byteLength < 4 || bytes.byteLength % 2 !== 0
|| bytes.slice(-4).some(byte => byte !== 0)) {
fail('gpo-registry-policy-multi-string-invalid', 'Ein Registry-Policy-MULTI_SZ ist nicht doppelt nullterminiert.');
}
const decoded = decodeUtf16(bytes, 'gpo-registry-policy-multi-string-invalid');
const values = decoded.slice(0, -2).split('\u0000');
if (values.length === 1 && values[0] === '') return { kind: 'multi-string', values: [] };
if (values.some(value => !value || value.includes('\u0000'))) {
fail('gpo-registry-policy-multi-string-invalid', 'Ein Registry-Policy-MULTI_SZ enthält einen leeren Zwischeneintrag.');
}
return { kind: 'multi-string', values };
}
if (bytes.byteLength !== 8) fail('gpo-registry-policy-integer-size-invalid', 'Ein Registry-Policy-QWORD benötigt exakt acht Bytes.');
const view = new DataView(bytes.buffer, bytes.byteOffset, 8);
return { kind: 'uint64', value: view.getBigUint64(0, true).toString() };
}
function targetList(data: GpoRegistryPolicyData, allowSubkeys: boolean): string[] | undefined {
if (data.kind !== 'string') return undefined;
const targets = data.value.split(';');
if (!targets.length || targets.some(target => !target || !validValueName(target)
|| (allowSubkeys ? target.includes('\\') : false))) return undefined;
return targets;
}
function instructionValue(instruction: RegistryPolicyInstruction): { value: GpoRegistryPolicyValue; diagnostics: string[] } {
const lowerName = instruction.valueName.toLocaleLowerCase('en-US');
const common = { kind: 'registry-policy' as const, key: instruction.key };
if (instruction.typeCode === 0) {
if (instruction.valueName !== '' || instruction.data.kind !== 'none') {
fail('gpo-registry-policy-none-invalid', 'REG_NONE ist nur als leere Schlüsselanlage zulässig.');
}
return {
value: { ...common, operation: 'create-key', typeCode: 0, data: instruction.data },
diagnostics: []
};
}
if (lowerName === '**deletevalues') {
const targets = instruction.typeCode === 1 ? targetList(instruction.data, false) : undefined;
if (!targets) fail('gpo-registry-policy-special-value-invalid', '**DeleteValues besitzt ungültige Typ- oder Zieldaten.');
return { value: { ...common, operation: 'delete-values', targets }, diagnostics: [] };
}
if (lowerName.startsWith('**del.') && instruction.valueName.length > 6) {
if (instruction.typeCode !== 1 || instruction.data.kind !== 'string' || instruction.data.value !== ' ') {
fail('gpo-registry-policy-special-value-invalid', '**Del.<Wert> besitzt ungültige Typ- oder Platzhalterdaten.');
}
return { value: { ...common, operation: 'delete-value', valueName: instruction.valueName.slice(6) }, diagnostics: [] };
}
if (lowerName === '**delvals.') {
if (instruction.typeCode !== 1 || instruction.data.kind !== 'string' || instruction.data.value !== ' ') {
fail('gpo-registry-policy-special-value-invalid', '**DelVals. besitzt ungültige Typ- oder Platzhalterdaten.');
}
return { value: { ...common, operation: 'delete-all-values' }, diagnostics: [] };
}
if (lowerName === '**deletekeys') {
const targets = instruction.typeCode === 1 ? targetList(instruction.data, true) : undefined;
if (!targets) fail('gpo-registry-policy-special-value-invalid', '**DeleteKeys besitzt ungültige Typ- oder Zieldaten.');
return { value: { ...common, operation: 'delete-keys', targets }, diagnostics: [] };
}
if (lowerName === '**securekey') {
if (instruction.typeCode !== 4 || instruction.data.kind !== 'uint32') {
fail('gpo-registry-policy-special-value-invalid', '**SecureKey besitzt ungültige Typdaten.');
}
return { value: { ...common, operation: 'secure-key', secure: instruction.data.value === 1 }, diagnostics: [] };
}
if (lowerName.startsWith('**soft.') && instruction.valueName.length > 7) {
return {
value: {
...common,
operation: 'set-value-if-absent',
valueName: instruction.valueName.slice(7),
typeCode: instruction.typeCode,
data: instruction.data
},
diagnostics: []
};
}
if (lowerName.startsWith('**')) {
return {
value: {
...common,
operation: 'unsupported-special',
valueName: instruction.valueName,
typeCode: instruction.typeCode,
data: instruction.data
},
diagnostics: ['gpo-registry-policy-special-value-unsupported']
};
}
return {
value: {
...common,
operation: 'set-value',
valueName: instruction.valueName,
typeCode: instruction.typeCode,
data: instruction.data
},
diagnostics: []
};
}
export function parseRegistryPolicyFile(
file: VerifiedZipFile,
scope: GpoPolicyScope,
_resourceOverrides?: Partial<ImportResourcePolicy>
): RegistryPolicyDocument {
if (file.bytes.byteLength > maximumFileBytes) {
parserError('gpo-registry-policy-size-limit', 'Eine Registry-Policy-Datei überschreitet das Größenlimit.', { sizeBytes: file.bytes.byteLength });
}
const diagnostics = new Set<string>();
const instructions: RegistryPolicyInstruction[] = [];
let partial = !['computer', 'user'].includes(scope);
if (partial) diagnostics.add('gpo-registry-policy-scope-unsupported');
try {
const bytes = file.bytes;
if (bytes.byteLength < 8 || bytes[0] !== 0x50 || bytes[1] !== 0x52 || bytes[2] !== 0x65 || bytes[3] !== 0x67) {
fail('gpo-registry-policy-signature-invalid', 'Eine Registry-Policy-Datei besitzt keine gültige PReg-Signatur.');
}
if (uint32(bytes, 4) !== 1) fail('gpo-registry-policy-version-unsupported', 'Die Registry-Policy-Version wird nicht unterstützt.');
let offset = 8;
while (offset < bytes.byteLength) {
if (instructions.length >= maximumRecords) {
parserError('gpo-registry-policy-record-limit', 'Eine Registry-Policy-Datei überschreitet das Datensatzlimit.', { records: instructions.length });
}
const byteOffset = offset;
offset = utf16Character(bytes, offset, 0x5b, 'gpo-registry-policy-record-open-invalid');
const keyField = readIdentifier(bytes, offset, 'gpo-registry-policy-key-invalid');
offset = keyField.nextOffset;
const valueField = readIdentifier(bytes, offset, 'gpo-registry-policy-value-name-invalid');
offset = valueField.nextOffset;
if (!validKey(keyField.value)) fail('gpo-registry-policy-key-invalid', 'Ein Registry-Policy-Schlüssel ist ungültig.');
if (!validValueName(valueField.value)) fail('gpo-registry-policy-value-name-invalid', 'Ein Registry-Policy-Wertname ist ungültig.');
const typeCode = uint32(bytes, offset);
if (!supportedTypes.has(typeCode)) fail('gpo-registry-policy-type-unsupported', 'Ein Registry-Policy-Datentyp wird nicht unterstützt.');
offset += 4;
offset = utf16Character(bytes, offset, 0x3b, 'gpo-registry-policy-type-delimiter-invalid');
const size = uint32(bytes, offset);
if (size > maximumDataBytes) fail('gpo-registry-policy-data-size-invalid', 'Ein Registry-Policy-Datensatz überschreitet das Datenlimit.');
offset += 4;
offset = utf16Character(bytes, offset, 0x3b, 'gpo-registry-policy-size-delimiter-invalid');
if (offset + size > bytes.byteLength) fail('gpo-registry-policy-truncated', 'Die Registry-Policy-Daten sind abgeschnitten.');
const data = decodeData(typeCode as RegistryTypeCode, bytes.subarray(offset, offset + size));
offset += size;
offset = utf16Character(bytes, offset, 0x5d, 'gpo-registry-policy-record-close-invalid');
instructions.push({
version: 1,
sequence: instructions.length,
byteOffset,
key: keyField.value,
valueName: valueField.value,
typeCode: typeCode as RegistryTypeCode,
data
});
}
for (const instruction of instructions) {
const interpreted = instructionValue(instruction);
interpreted.diagnostics.forEach(code => {
diagnostics.add(code);
partial = true;
});
}
} catch (error) {
if (error instanceof ImportError) throw error;
if (!(error instanceof RegistryPolicyParseFailure)) throw error;
diagnostics.add(error.code);
return {
version: 1,
parser: REGISTRY_POLICY_PARSER,
sourcePath: file.entry.relativePath,
sourceSha256: file.entry.sha256,
scope,
status: 'invalid',
instructions: [],
diagnosticCodes: [...diagnostics]
};
}
return {
version: 1,
parser: REGISTRY_POLICY_PARSER,
sourcePath: file.entry.relativePath,
sourceSha256: file.entry.sha256,
scope,
status: partial ? 'partial' : 'complete',
instructions,
diagnosticCodes: [...diagnostics].sort()
};
}
export function reduceRegistryPolicy(
document: RegistryPolicyDocument,
context: { backupInstanceId: string; sourceArtifactId: string; artifactId: string }
): GpoPolicyObservation[] {
if (document.status === 'invalid' || !['computer', 'user'].includes(document.scope)) return [];
return document.instructions.map(instruction => {
const interpreted = instructionValue(instruction);
const target = interpreted.value.valueName ?? interpreted.value.targets?.join(';') ?? interpreted.value.operation;
const id = 'gpo-policy-' + createHash('sha256').update([
context.backupInstanceId,
context.artifactId,
document.sourceSha256,
instruction.sequence.toString(),
REGISTRY_POLICY_REDUCER.version
].join('\0')).digest('hex').slice(0, 32);
const observation: GpoPolicyObservation = {
version: 1,
id,
backupInstanceId: context.backupInstanceId,
sourceArtifactId: context.sourceArtifactId,
artifactId: context.artifactId,
sourcePath: document.sourcePath,
sourceSha256: document.sourceSha256,
scope: document.scope as 'computer' | 'user',
deliveryMechanism: 'registry-policy',
parser: document.parser,
reducer: REGISTRY_POLICY_REDUCER,
identity: {
status: 'unresolved',
namespace: 'registry-policy',
section: document.scope === 'computer' ? 'HKEY_LOCAL_MACHINE' : 'HKEY_CURRENT_USER',
key: instruction.key + '\\' + target
},
state: 'instruction',
values: [interpreted.value],
registryPolicy: { sequence: instruction.sequence, operation: interpreted.value.operation },
sourceRecords: [{ index: instruction.sequence, byteOffset: instruction.byteOffset }],
effectiveState: 'unknown',
diagnosticCodes: interpreted.diagnostics
};
const errors = gpoPolicyObservationValidationErrors(observation);
if (errors.length) throw new Error('Intern ungültige Registry-Policy-Beobachtung: ' + errors.join(' '));
return observation;
});
}