362 lines
16 KiB
TypeScript
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;
|
|
});
|
|
}
|