import { createHash } from 'node:crypto'; import { JSDOM } from 'jsdom'; import type { GpoPolicyObservation, GpoPolicyValue, GpoPreferenceContext, GpoRegistryPreferenceValue } 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 { resolveImportResourcePolicy, validateDecodedTextResources } from '../../src/importers/resource-policy'; import { ImportError } from '../../src/shared/errors'; import { parseLocalUsersGroupsPreference, reduceLocalUsersGroupsPreferences, type LocalUsersGroupsPreferenceDocument } from './gpp-local-users-groups'; import { parseDriveMapsPreference, reduceDriveMapsPreferences, type DriveMapsPreferenceDocument } from './gpp-drive-maps'; import { parseServicesPreference, reduceServicesPreferences, type ServicesPreferenceDocument } from './gpp-services'; import type { VerifiedZipFile } from './zip-inventory'; import { xmlElementStartLines } from './xml-source-lines'; import { tryParseRegistryPreferenceWithSax } from './gpp-registry-sax-parser'; export const GPP_PARSER = { id: 'gpo-preferences-xml', version: '1.0.0' } as const; export const GPP_REGISTRY_REDUCER = { id: 'gpo-preferences-registry-reducer', version: '1.0.0' } as const; const maximumFileBytes = 4 * 1024 * 1024; const maximumItems = 20_000; const maximumAttributeCharacters = 32 * 1024; const registrySettingsClsid = 'a3ccfc41-dfdb-43a5-8d26-0fe8b954da51'; const registryItemClsid = '9cd4b2f4-923d-47f5-a062-e897dd1dad50'; const guidPattern = /^\{?([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})\}?$/i; const allowedHives = new Set(['HKEY_CLASSES_ROOT', 'HKEY_CURRENT_USER', 'HKEY_LOCAL_MACHINE', 'HKEY_USERS', 'HKEY_CURRENT_CONFIG']); const allowedTypes = new Set(['REG_SZ', 'REG_DWORD', 'REG_EXPAND_SZ', 'REG_MULTI_SZ']); type PreferenceOperation = GpoPreferenceContext['operation']; export interface GppRegistryInstruction { version: 1; uid: string; sequence: number; line: number; operation: PreferenceOperation; disabled: boolean; removePolicy: boolean; applyOnce: boolean; hive: GpoRegistryPreferenceValue['hive']; key: string; target: 'key' | 'value'; valueName?: string; valueType?: 'REG_SZ' | 'REG_DWORD' | 'REG_EXPAND_SZ' | 'REG_MULTI_SZ'; rawData?: string; targeting: GpoPreferenceContext['targeting']; } export interface RegistryOrUnsupportedPreferenceDocument { version: 1; parser: typeof GPP_PARSER; sourcePath: string; sourceSha256: string; scope: GpoPolicyScope; preferenceType: 'registry' | 'unsupported'; status: 'complete' | 'partial' | 'invalid'; secretIndicatorStatus: 'not-detected' | 'present'; instructions: GppRegistryInstruction[]; diagnosticCodes: string[]; } export type GroupPolicyPreferenceDocument = | RegistryOrUnsupportedPreferenceDocument | LocalUsersGroupsPreferenceDocument | ServicesPreferenceDocument | DriveMapsPreferenceDocument; function parserError(code: string, message: string, details: Record = {}): never { throw new ImportError(message, [{ severity: 'error', code, message, stage: 'format-decoding', details }]); } function decodeXml(bytes: Uint8Array): string { if (bytes.byteLength > maximumFileBytes) parserError('gpo-preference-size-limit', 'Eine Group-Policy-Preference überschreitet das Größenlimit.', { sizeBytes: bytes.byteLength }); let encoding = 'utf-8'; let content = bytes; if (bytes[0] === 0xff && bytes[1] === 0xfe) { encoding = 'utf-16le'; content = bytes.subarray(2); } else if (bytes[0] === 0xfe && bytes[1] === 0xff) { encoding = 'utf-16be'; content = bytes.subarray(2); } else if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) content = bytes.subarray(3); try { return new TextDecoder(encoding, { fatal: true }).decode(content); } catch { parserError('gpo-preference-encoding-rejected', 'Die Group-Policy-Preference ist nicht eindeutig als UTF-8 oder BOM-markiertes UTF-16 dekodierbar.'); } } function canonicalGuid(value: string | null): string | undefined { return value ? guidPattern.exec(value.trim())?.[1].toLocaleLowerCase('en-US') : undefined; } function booleanAttribute(element: Element, name: string, diagnostics: Set): boolean { const value = element.getAttribute(name); if (value === null || value === '0') return false; if (value === '1') return true; diagnostics.add('gpo-preference-boolean-invalid'); return false; } function operation(value: string | null): PreferenceOperation | undefined { const code = (value ?? 'U').toLocaleUpperCase('en-US'); return ({ C: 'create', D: 'delete', R: 'replace', U: 'update' } as const)[code as 'C' | 'D' | 'R' | 'U']; } function registryLines(text: string): number[] { return xmlElementStartLines(text, /<(?:[A-Za-z_][\w.-]*:)?Registry(?=[\s/>])/g); } function targeting(item: Element, diagnostics: Set): GpoPreferenceContext['targeting'] | undefined { const filters = [...item.children].filter(child => child.localName.toLocaleLowerCase('en-US') === 'filters'); if (!filters.length) return { status: 'none', filterTypes: [] }; if (filters.length !== 1) { diagnostics.add('gpo-preference-targeting-invalid'); return undefined; } const nodes = [...filters[0].getElementsByTagName('*')]; if (!nodes.length) { diagnostics.add('gpo-preference-targeting-invalid'); return undefined; } const canonical: Array<{ type: string; bool: string; not: string; attributes: Array<[string, string]> }> = []; for (const node of nodes) { const type = node.localName.toLocaleLowerCase('en-US'); const bool = node.getAttribute('bool')?.toLocaleUpperCase('en-US'); const not = node.getAttribute('not'); if (!type.startsWith('filter') || !['AND', 'OR'].includes(bool ?? '') || !['0', '1'].includes(not ?? '')) { diagnostics.add('gpo-preference-targeting-invalid'); return undefined; } const attributes = [...node.attributes] .filter(attribute => !['bool', 'not', 'cpassword'].includes(attribute.localName.toLocaleLowerCase('en-US'))) .map(attribute => [attribute.localName.toLocaleLowerCase('en-US'), attribute.value] as [string, string]) .sort(([left], [right]) => left.localeCompare(right)); canonical.push({ type, bool: bool!, not: not!, attributes }); } return { status: 'present', filterTypes: [...new Set(canonical.map(node => node.type))].sort(), expressionSha256: createHash('sha256').update(JSON.stringify(canonical)).digest('hex'), evaluability: 'unknown' }; } export function parseGroupPolicyPreferenceXml( file: VerifiedZipFile, scope: GpoPolicyScope, resourceOverrides?: Partial ): GroupPolicyPreferenceDocument { const text = decodeXml(file.bytes); const policy = resolveImportResourcePolicy({ ...resourceOverrides, maximumDecodedCharacters: Math.min(resourceOverrides?.maximumDecodedCharacters ?? maximumFileBytes, maximumFileBytes), maximumMarkupElements: Math.min(resourceOverrides?.maximumMarkupElements ?? maximumItems, maximumItems) }); validateDecodedTextResources(text, 'xml', policy); if (/))/i.test(text)) { parserError('gpo-preference-external-reference-rejected', 'Externe Entitäten, Includes, Schemas und Verarbeitungsanweisungen sind in Group Policy Preferences nicht zulässig.'); } const registryDocument = tryParseRegistryPreferenceWithSax({ text, scope, parser: GPP_PARSER, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256 }); if (registryDocument) return registryDocument; let dom: JSDOM; try { dom = new JSDOM(text, { contentType: 'text/xml' }); } catch { parserError('gpo-preference-xml-invalid', 'Die Group-Policy-Preference ist kein wohlgeformtes XML.'); } const xml = dom.window.document; try { if (xml.getElementsByTagName('parsererror').length) parserError('gpo-preference-xml-invalid', 'Die Group-Policy-Preference ist kein wohlgeformtes XML.'); const diagnostics = new Set(); let secretIndicatorStatus: GroupPolicyPreferenceDocument['secretIndicatorStatus'] = 'not-detected'; for (const element of [...xml.getElementsByTagName('*')]) { for (const attribute of [...element.attributes]) { if (attribute.value.length > maximumAttributeCharacters) diagnostics.add('gpo-preference-attribute-limit'); if (attribute.localName.toLocaleLowerCase('en-US') === 'cpassword') secretIndicatorStatus = 'present'; } } if (secretIndicatorStatus === 'present') diagnostics.add('gpo-secret-indicator-present'); const root = xml.documentElement; const rootName = root.localName.toLocaleLowerCase('en-US'); if (rootName === 'groups') { return parseLocalUsersGroupsPreference({ root, text, scope, parser: GPP_PARSER, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256, secretIndicatorStatus, diagnostics, summarizeTargeting: targeting }); } if (rootName === 'ntservices') { return parseServicesPreference({ root, text, scope, parser: GPP_PARSER, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256, secretIndicatorStatus, diagnostics, summarizeTargeting: targeting }); } if (rootName === 'drives') { return parseDriveMapsPreference({ root, text, scope, parser: GPP_PARSER, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256, secretIndicatorStatus, diagnostics, summarizeTargeting: targeting }); } if (rootName !== 'registrysettings') { diagnostics.add('gpo-preference-type-unsupported'); return { version: 1, parser: GPP_PARSER, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256, scope, preferenceType: 'unsupported', status: diagnostics.has('gpo-preference-attribute-limit') ? 'invalid' : 'partial', secretIndicatorStatus, instructions: [], diagnosticCodes: [...diagnostics].sort() }; } if (canonicalGuid(root.getAttribute('clsid')) !== registrySettingsClsid) diagnostics.add('gpo-preference-registry-root-invalid'); const rootDisabled = booleanAttribute(root, 'disabled', diagnostics); const items = [...root.getElementsByTagName('*')].filter(element => element.localName.toLocaleLowerCase('en-US') === 'registry'); if (items.length > maximumItems) parserError('gpo-preference-item-limit', 'Die Group-Policy-Preference überschreitet das Item-Limit.', { items: items.length }); const lines = registryLines(text); if (lines.length !== items.length) diagnostics.add('gpo-preference-line-provenance-invalid'); const instructions: GppRegistryInstruction[] = []; let invalid = diagnostics.has('gpo-preference-attribute-limit') || diagnostics.has('gpo-preference-registry-root-invalid') || diagnostics.has('gpo-preference-boolean-invalid') || diagnostics.has('gpo-preference-line-provenance-invalid'); let partial = false; items.forEach((item, sequence) => { const before = diagnostics.size; const uid = canonicalGuid(item.getAttribute('uid')); if (!uid || canonicalGuid(item.getAttribute('clsid')) !== registryItemClsid) diagnostics.add('gpo-preference-registry-item-invalid'); const properties = [...item.children].filter(child => child.localName.toLocaleLowerCase('en-US') === 'properties'); if (properties.length !== 1) diagnostics.add('gpo-preference-registry-item-invalid'); if (properties.length === 1 && properties[0].children.length) { diagnostics.add('gpo-preference-registry-item-unsupported'); partial = true; return; } const props = properties[0]; const itemOperation = props ? operation(props.getAttribute('action')) : undefined; if (!itemOperation) diagnostics.add('gpo-preference-action-invalid'); const hive = props?.getAttribute('hive')?.toLocaleUpperCase('en-US'); const key = props?.getAttribute('key') ?? ''; if (!hive || !allowedHives.has(hive) || !key.trim() || key.startsWith('\\') || key.endsWith('\\')) diagnostics.add('gpo-preference-registry-target-invalid'); const isDefault = props ? booleanAttribute(props, 'default', diagnostics) : false; const name = props?.getAttribute('name') ?? ''; const target = isDefault || name ? 'value' as const : 'key' as const; if (isDefault && name) diagnostics.add('gpo-preference-registry-target-invalid'); const valueType = props?.getAttribute('type')?.toLocaleUpperCase('en-US') || undefined; const rawData = props?.getAttribute('value') ?? undefined; if (target === 'key' && (valueType || rawData)) diagnostics.add('gpo-preference-registry-target-invalid'); if (target === 'value' && itemOperation !== 'delete' && (!valueType || !allowedTypes.has(valueType) || rawData === undefined)) diagnostics.add('gpo-preference-registry-value-invalid'); if (target === 'value' && valueType && !allowedTypes.has(valueType)) diagnostics.add('gpo-preference-registry-value-invalid'); const removePolicy = booleanAttribute(item, 'removePolicy', diagnostics); const applyOnce = booleanAttribute(item, 'applyOnce', diagnostics); const itemTargeting = targeting(item, diagnostics); if (itemOperation === 'delete' && removePolicy) diagnostics.add('gpo-preference-remove-policy-invalid'); if (diagnostics.size !== before || !uid || !itemOperation || !hive || !allowedHives.has(hive) || !itemTargeting) { invalid = true; return; } instructions.push({ version: 1, uid, sequence, line: lines[sequence] ?? 1, operation: itemOperation, disabled: rootDisabled || booleanAttribute(item, 'disabled', diagnostics), removePolicy, applyOnce, hive: hive as GppRegistryInstruction['hive'], key, target, valueName: target === 'value' ? (isDefault ? '' : name) : undefined, valueType: target === 'value' && valueType ? valueType as GppRegistryInstruction['valueType'] : undefined, rawData: target === 'value' ? rawData : undefined, targeting: itemTargeting }); }); if (diagnostics.has('gpo-preference-boolean-invalid')) invalid = true; return { version: 1, parser: GPP_PARSER, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256, scope, preferenceType: 'registry', status: invalid ? 'invalid' : partial ? 'partial' : 'complete', secretIndicatorStatus, instructions: invalid ? [] : instructions, diagnosticCodes: [...diagnostics].sort() }; } finally { dom.window.close(); } } export function reduceGroupPolicyPreferences( document: GroupPolicyPreferenceDocument, context: { backupInstanceId: string; sourceArtifactId: string; artifactId: string } ): GpoPolicyObservation[] { if (document.preferenceType === 'local-users-groups') { return reduceLocalUsersGroupsPreferences(document, context); } if (document.preferenceType === 'services') { return reduceServicesPreferences(document, context); } if (document.preferenceType === 'drive-maps') { return reduceDriveMapsPreferences(document, context); } if (document.status === 'invalid' || document.preferenceType !== 'registry' || !['computer', 'user'].includes(document.scope)) return []; return document.instructions.map(instruction => { const value: GpoPolicyValue = { kind: 'registry-preference', hive: instruction.hive, key: instruction.key, target: instruction.target, valueName: instruction.valueName, valueType: instruction.valueType, rawData: instruction.rawData }; const identityKey = [instruction.hive, instruction.key, instruction.target === 'key' ? '' : instruction.valueName ?? ''].join('\\'); const id = `gpo-policy-${createHash('sha256').update([ context.backupInstanceId, context.artifactId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_REGISTRY_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: 'group-policy-preference', parser: document.parser, reducer: GPP_REGISTRY_REDUCER, identity: { status: 'unresolved', namespace: 'group-policy-preference', section: 'registry', key: identityKey }, state: 'instruction', values: [value], preference: { cse: 'registry', itemUid: instruction.uid, operation: instruction.operation, sequence: instruction.sequence, disabled: instruction.disabled, removePolicy: instruction.removePolicy, applyOnce: instruction.applyOnce, targeting: instruction.targeting }, sourceLines: [instruction.line], effectiveState: 'unknown', diagnosticCodes: instruction.targeting.status === 'present' ? ['gpo-preference-targeting-unevaluated'] : [] }; const errors = gpoPolicyObservationValidationErrors(observation); if (errors.length) throw new Error(`Intern ungültige Group-Policy-Preference-Beobachtung: ${errors.join(' ')}`); return observation; }); }