import { createHash } from 'node:crypto'; import { SaxesParser, type SaxesTagPlain } from 'saxes'; import type { GpoPreferenceContext, GpoRegistryPreferenceValue } from '../../src/domain/gpo-policy'; import type { GpoPolicyScope } from '../../src/domain/gpo-ingestion'; import { ImportError } from '../../src/shared/errors'; import type { GppRegistryInstruction, RegistryOrUnsupportedPreferenceDocument } from './group-policy-preference-parser'; import { xmlElementStartLines } from './xml-source-lines'; 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']; interface SaxNode { localName: string; attributes: Record; children: SaxNode[]; } export interface RegistrySaxParseContext { text: string; scope: GpoPolicyScope; parser: { readonly id: 'gpo-preferences-xml'; readonly version: '1.0.0' }; sourcePath: string; sourceSha256: string; } function parserError(code: string, message: string, details: Record = {}): never { throw new ImportError(message, [{ severity: 'error', code, message, stage: 'format-decoding', details }]); } function localName(name: string): string { return (name.includes(':') ? name.slice(name.lastIndexOf(':') + 1) : name).toLocaleLowerCase('en-US'); } function nodeFromTag(tag: SaxesTagPlain): SaxNode { return { localName: localName(tag.name), attributes: Object.fromEntries(Object.entries(tag.attributes).map(([name, value]) => [localName(name), value])), children: [] }; } function canonicalGuid(value: string | undefined): string | undefined { return value ? guidPattern.exec(value.trim())?.[1].toLocaleLowerCase('en-US') : undefined; } function booleanAttribute(node: SaxNode, name: string, diagnostics: Set): boolean { const value = node.attributes[name.toLocaleLowerCase('en-US')]; if (value === undefined || value === '0') return false; if (value === '1') return true; diagnostics.add('gpo-preference-boolean-invalid'); return false; } function operation(value: string | undefined): 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 directChildren(node: SaxNode, name: string): SaxNode[] { const normalized = name.toLocaleLowerCase('en-US'); return node.children.filter(child => child.localName === normalized); } function targeting(item: SaxNode, diagnostics: Set): GpoPreferenceContext['targeting'] | undefined { const filters = directChildren(item, 'filters'); if (!filters.length) return { status: 'none', filterTypes: [] }; if (filters.length !== 1) { diagnostics.add('gpo-preference-targeting-invalid'); return undefined; } const nodes: SaxNode[] = []; const pending = [...filters[0].children].reverse(); while (pending.length) { const node = pending.pop()!; nodes.push(node); for (let index = node.children.length - 1; index >= 0; index -= 1) pending.push(node.children[index]); } 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; const bool = node.attributes.bool?.toLocaleUpperCase('en-US'); const not = node.attributes.not; if (!type.startsWith('filter') || !['AND', 'OR'].includes(bool ?? '') || !['0', '1'].includes(not ?? '')) { diagnostics.add('gpo-preference-targeting-invalid'); return undefined; } const attributes = Object.entries(node.attributes) .filter(([name]) => !['bool', 'not', 'cpassword'].includes(name)) .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' }; } function parseItem( item: SaxNode, sequence: number, line: number, rootDisabled: boolean, diagnostics: Set ): { instruction?: GppRegistryInstruction; partial: boolean; invalid: boolean } { const before = diagnostics.size; const uid = canonicalGuid(item.attributes.uid); if (!uid || canonicalGuid(item.attributes.clsid) !== registryItemClsid) diagnostics.add('gpo-preference-registry-item-invalid'); const properties = directChildren(item, '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'); return { partial: true, invalid: false }; } const props = properties[0]; const itemOperation = props ? operation(props.attributes.action) : undefined; if (!itemOperation) diagnostics.add('gpo-preference-action-invalid'); const hive = props?.attributes.hive?.toLocaleUpperCase('en-US'); const key = props?.attributes.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?.attributes.name ?? ''; const target = isDefault || name ? 'value' as const : 'key' as const; if (isDefault && name) diagnostics.add('gpo-preference-registry-target-invalid'); const valueType = props?.attributes.type?.toLocaleUpperCase('en-US') || undefined; const rawData = props?.attributes.value; 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) { return { partial: false, invalid: true }; } const instruction: GppRegistryInstruction = { version: 1, uid, sequence, line, operation: itemOperation, disabled: rootDisabled || booleanAttribute(item, 'disabled', diagnostics), removePolicy, applyOnce, hive: hive as GpoRegistryPreferenceValue['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 }; return { instruction, partial: false, invalid: false }; } export function tryParseRegistryPreferenceWithSax( context: RegistrySaxParseContext ): RegistryOrUnsupportedPreferenceDocument | undefined { const diagnostics = new Set(); const lines = xmlElementStartLines(context.text, /<(?:[A-Za-z_][\w.-]*:)?Registry(?=[\s/>])/g); const instructions: GppRegistryInstruction[] = []; let rootName: string | undefined; let rootDisabled = false; let secretDetected = false; let currentItem: SaxNode | undefined; let itemStack: SaxNode[] = []; let itemCount = 0; let depth = 0; let invalid = false; let partial = false; let parseFailure: Error | undefined; const parser = new SaxesParser({ xmlns: false, position: true }); parser.on('error', error => { parseFailure = error; }); parser.on('opentag', tag => { const node = nodeFromTag(tag); if (depth === 0) { rootName = node.localName; if (rootName === 'registrysettings') { if (canonicalGuid(node.attributes.clsid) !== registrySettingsClsid) diagnostics.add('gpo-preference-registry-root-invalid'); rootDisabled = booleanAttribute(node, 'disabled', diagnostics); } } if (rootName === 'registrysettings') { for (const [name, value] of Object.entries(node.attributes)) { if (value.length > maximumAttributeCharacters) diagnostics.add('gpo-preference-attribute-limit'); if (name === 'cpassword') secretDetected = true; } if (currentItem) { if (node.localName === 'registry') diagnostics.add('gpo-preference-registry-item-invalid'); itemStack.at(-1)!.children.push(node); itemStack.push(node); } else if (depth > 0 && node.localName === 'registry') { itemCount += 1; if (itemCount > maximumItems) parserError('gpo-preference-item-limit', 'Die Group-Policy-Preference überschreitet das Item-Limit.', { items: itemCount }); currentItem = node; itemStack = [node]; } } depth += 1; }); parser.on('closetag', () => { depth -= 1; if (!currentItem) return; if (itemStack.length > 1) { itemStack.pop(); return; } const sequence = itemCount - 1; const result = parseItem(currentItem, sequence, lines[sequence] ?? 1, rootDisabled, diagnostics); if (result.instruction) instructions.push(result.instruction); partial ||= result.partial; invalid ||= result.invalid; currentItem = undefined; itemStack = []; }); try { parser.write(context.text).close(); } catch (error) { if (error instanceof ImportError) throw error; parseFailure = error instanceof Error ? error : new Error('XML parsing failed.'); } if (parseFailure) parserError('gpo-preference-xml-invalid', 'Die Group-Policy-Preference ist kein wohlgeformtes XML.'); if (rootName !== 'registrysettings') return undefined; const secretIndicatorStatus: RegistryOrUnsupportedPreferenceDocument['secretIndicatorStatus'] = secretDetected ? 'present' : 'not-detected'; if (secretDetected) diagnostics.add('gpo-secret-indicator-present'); if (lines.length !== itemCount) diagnostics.add('gpo-preference-line-provenance-invalid'); 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'); return { version: 1, parser: context.parser, sourcePath: context.sourcePath, sourceSha256: context.sourceSha256, scope: context.scope, preferenceType: 'registry', status: invalid ? 'invalid' : partial ? 'partial' : 'complete', secretIndicatorStatus, instructions: invalid ? [] : instructions, diagnosticCodes: [...diagnostics].sort() }; }