import { createHash } from 'node:crypto'; import { SaxesParser, type SaxesTagPlain } from 'saxes'; import { ADMX_POLICY_CATALOG_PARSER, GPO_POLICY_IDENTITY_RESOLVER, admxPolicyCatalogValidationErrors, gpoTechnicalPolicyIdentityValidationErrors, type AdmxLanguageResource, type AdmxPolicyCatalog, type AdmxPolicyDefinition, type AdmxPolicyReference, type AdmxPolicyRegistryTarget, type GpoPolicyIdentityResolution, type GpoRegistryTechnicalTarget } from '../../src/domain/gpo-policy-identity'; import type { GpoPolicyObservation, GpoRegistryPolicyValue } from '../../src/domain/gpo-policy'; import { ImportError } from '../../src/shared/errors'; import type { VerifiedZipFile } from './zip-inventory'; const policyDefinitionsNamespace = 'http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions'; const maximumXmlBytes = 16 * 1024 * 1024; const maximumNodes = 100_000; const maximumDepth = 64; const maximumAttributeCharacters = 64 * 1024; const maximumTextCharacters = 4 * 1024 * 1024; interface XmlNode { name: string; attributes: Record; children: XmlNode[]; text: 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 decodeXml(bytes: Uint8Array): string { if (bytes.byteLength > maximumXmlBytes) parserError('admx-file-size-limit', 'Eine ADMX-/ADML-Datei überschreitet das Größenlimit.'); try { if (bytes.byteLength >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) { return new TextDecoder('utf-16le', { fatal: true }).decode(bytes.subarray(2)); } if (bytes.byteLength >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { const swapped = bytes.subarray(2).slice(); for (let index = 0; index < swapped.length; index += 2) [swapped[index], swapped[index + 1]] = [swapped[index + 1], swapped[index]]; return new TextDecoder('utf-16le', { fatal: true }).decode(swapped); } const source = bytes.byteLength >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? bytes.subarray(3) : bytes; return new TextDecoder('utf-8', { fatal: true }).decode(source); } catch { parserError('admx-encoding-invalid', 'Eine ADMX-/ADML-Datei ist nicht als UTF-8 oder BOM-markiertes UTF-16 dekodierbar.'); } } function parseXml(file: VerifiedZipFile): XmlNode { const text = decodeXml(file.bytes); if (/))/i.test(text)) { parserError('admx-external-reference-rejected', 'Externe Entitäten, Includes, Schemas und Verarbeitungsanweisungen sind in ADMX/ADML nicht zulässig.'); } let root: XmlNode | undefined; const stack: XmlNode[] = []; let nodes = 0; let textCharacters = 0; let failure: Error | undefined; const parser = new SaxesParser({ xmlns: false, position: true }); parser.on('error', error => { failure = error; }); parser.on('opentag', (tag: SaxesTagPlain) => { nodes += 1; if (nodes > maximumNodes) parserError('admx-element-limit', 'Eine ADMX-/ADML-Datei überschreitet das Elementlimit.'); if (stack.length >= maximumDepth) parserError('admx-depth-limit', 'Eine ADMX-/ADML-Datei überschreitet das Verschachtelungslimit.'); const attributes = Object.fromEntries(Object.entries(tag.attributes).map(([name, value]) => [localName(name), value])); if (Object.values(attributes).some(value => value.length > maximumAttributeCharacters)) { parserError('admx-attribute-limit', 'Ein ADMX-/ADML-Attribut überschreitet das Zeichenlimit.'); } const node: XmlNode = { name: localName(tag.name), attributes, children: [], text: '' }; if (stack.length) stack.at(-1)!.children.push(node); else if (!root) root = node; else parserError('admx-xml-invalid', 'Eine ADMX-/ADML-Datei besitzt mehrere Wurzelelemente.'); stack.push(node); }); parser.on('text', value => { textCharacters += value.length; if (textCharacters > maximumTextCharacters) parserError('admx-text-limit', 'Eine ADMX-/ADML-Datei überschreitet das Textlimit.'); if (stack.length) stack.at(-1)!.text += value; }); parser.on('closetag', () => { stack.pop(); }); try { parser.write(text).close(); } catch (error) { if (error instanceof ImportError) throw error; failure = error instanceof Error ? error : new Error('XML parsing failed.'); } if (failure || !root || stack.length) parserError('admx-xml-invalid', 'Eine ADMX-/ADML-Datei ist kein wohlgeformtes XML.'); return root; } function direct(node: XmlNode, name: string): XmlNode[] { const normalized = name.toLocaleLowerCase('en-US'); return node.children.filter(child => child.name === normalized); } function descendants(node: XmlNode): XmlNode[] { const result: XmlNode[] = []; const pending = [...node.children].reverse(); while (pending.length) { const current = pending.pop()!; result.push(current); for (let index = current.children.length - 1; index >= 0; index -= 1) pending.push(current.children[index]); } return result; } function resourceId(value: string | undefined): string | undefined { return value ? /^\$\(string\.([^)]+)\)$/.exec(value)?.[1] : undefined; } function normalizedRegistryKey(value: string): string | undefined { const key = value.trim().replaceAll('/', '\\').replace(/^\\+|\\+$/g, ''); if (!key || key.length > 32_767 || /[\u0000-\u001f]/.test(key) || key.split('\\').some(segment => !segment)) return undefined; return key; } function scopes(policyClass: AdmxPolicyDefinition['class']): Array<'computer' | 'user'> { return policyClass === 'both' ? ['computer', 'user'] : [policyClass === 'machine' ? 'computer' : 'user']; } function registryTarget( policyClass: AdmxPolicyDefinition['class'], key: string | undefined, valueName: string | undefined, role: AdmxPolicyRegistryTarget['role'], elementId?: string, keyValues = false ): AdmxPolicyRegistryTarget[] { const normalizedKey = key ? normalizedRegistryKey(key) : undefined; if (!normalizedKey || (!keyValues && valueName === undefined) || (valueName !== undefined && /[\u0000-\u001f]/.test(valueName))) return []; return scopes(policyClass).map(scope => ({ version: 1, scope, key: normalizedKey, match: keyValues ? 'key-values' : 'exact-value', ...(keyValues ? {} : { valueName }), role, ...(elementId ? { elementId } : {}) })); } function parsePolicy(file: VerifiedZipFile, namespace: string, node: XmlNode): AdmxPolicyDefinition | undefined { const name = node.attributes.name?.trim(); const rawClass = node.attributes.class?.toLocaleLowerCase('en-US'); if (!name || !['machine', 'user', 'both'].includes(rawClass ?? '')) return undefined; const policyClass = rawClass as AdmxPolicyDefinition['class']; const policyKey = node.attributes.key; const targets: AdmxPolicyRegistryTarget[] = []; targets.push(...registryTarget(policyClass, policyKey, node.attributes.valuename, 'policy-state')); const pending: Array<{ node: XmlNode; listRole?: 'enabled-list' | 'disabled-list' }> = node.children .map(child => ({ node: child })); while (pending.length) { const current = pending.pop()!; const listRole = current.node.name === 'enabledlist' ? 'enabled-list' : current.node.name === 'disabledlist' ? 'disabled-list' : current.listRole; if (['text', 'multitext', 'decimal', 'enum', 'boolean', 'list'].includes(current.node.name)) { const key = current.node.attributes.key ?? policyKey; const keyValues = current.node.name === 'list' && current.node.attributes.valuename === undefined; targets.push(...registryTarget(policyClass, key, current.node.attributes.valuename, 'element', current.node.attributes.id, keyValues)); } else if (current.node.name === 'item' && listRole) { targets.push(...registryTarget(policyClass, current.node.attributes.key ?? policyKey, current.node.attributes.valuename, listRole)); } current.node.children.forEach(child => pending.push({ node: child, listRole })); } const unique = new Map(); targets.forEach(target => unique.set(JSON.stringify(target), target)); return { version: 1, technicalId: `${namespace}#${name}`, targetNamespace: namespace, name, class: policyClass, displayNameResourceId: resourceId(node.attributes.displayname), explainTextResourceId: resourceId(node.attributes.explaintext), sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256, registryTargets: [...unique.values()].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))) }; } function parseAdmx(file: VerifiedZipFile): { namespace: string; policies: AdmxPolicyDefinition[]; diagnostics: string[] } { const root = parseXml(file); if (root.name !== 'policydefinitions' || root.attributes.xmlns !== undefined && root.attributes.xmlns !== policyDefinitionsNamespace) { parserError('admx-root-invalid', 'Eine ADMX-Datei besitzt keinen unterstützten PolicyDefinitions-Namespace.'); } const namespaceNodes = direct(root, 'policynamespaces').flatMap(node => direct(node, 'target')); const namespace = namespaceNodes.length === 1 ? namespaceNodes[0].attributes.namespace?.trim() : undefined; if (!namespace) parserError('admx-target-namespace-invalid', 'Eine ADMX-Datei benötigt genau einen technischen Zielnamespace.'); const diagnostics = new Set(); const policyNodes = direct(root, 'policies').flatMap(node => direct(node, 'policy')); const policies = policyNodes.flatMap(node => { const policy = parsePolicy(file, namespace, node); if (!policy) diagnostics.add('admx-policy-definition-invalid'); else if (!policy.registryTargets.length) diagnostics.add('admx-policy-without-registry-target'); return policy ? [policy] : []; }); return { namespace, policies, diagnostics: [...diagnostics] }; } function fileName(path: string): string { return path.slice(path.lastIndexOf('/') + 1); } function baseName(path: string): string { const name = fileName(path); return name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : name; } function parseAdml(file: VerifiedZipFile): AdmxLanguageResource { const root = parseXml(file); if (root.name !== 'policydefinitionresources' || root.attributes.xmlns !== undefined && root.attributes.xmlns !== policyDefinitionsNamespace) { parserError('adml-root-invalid', 'Eine ADML-Datei besitzt keinen unterstützten PolicyDefinitions-Namespace.'); } const strings: Record = {}; for (const node of descendants(root).filter(candidate => candidate.name === 'string')) { const id = node.attributes.id?.trim(); if (!id || Object.hasOwn(strings, id)) parserError('adml-string-id-invalid', 'Eine ADML-Datei enthält eine leere oder doppelte String-ID.'); strings[id] = node.text; } const parent = file.entry.relativePath.slice(0, Math.max(0, file.entry.relativePath.lastIndexOf('/'))); const localeCandidate = parent.slice(parent.lastIndexOf('/') + 1); return { version: 1, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256, admxFileName: `${baseName(file.entry.relativePath)}.admx`, locale: /^[a-z]{2,3}(?:-[A-Z]{2})?$/.test(localeCandidate) ? localeCandidate : 'und', strings }; } export function buildAdmxPolicyCatalog( files: readonly VerifiedZipFile[], source: AdmxPolicyCatalog['source'] ): AdmxPolicyCatalog { const admxFiles = files.filter(file => /\.admx$/i.test(file.entry.relativePath)) .slice().sort((left, right) => left.entry.relativePath.localeCompare(right.entry.relativePath)); const admlFiles = files.filter(file => /\.adml$/i.test(file.entry.relativePath)) .slice().sort((left, right) => left.entry.relativePath.localeCompare(right.entry.relativePath)); const diagnostics = new Set(); if (!admxFiles.length) diagnostics.add('admx-catalog-missing'); const namespaces = new Set(); const policies: AdmxPolicyDefinition[] = []; for (const file of admxFiles) { const parsed = parseAdmx(file); if (namespaces.has(parsed.namespace)) parserError('admx-target-namespace-duplicate', 'Mehrere ADMX-Dateien verwenden denselben technischen Zielnamespace.'); namespaces.add(parsed.namespace); parsed.diagnostics.forEach(code => diagnostics.add(code)); policies.push(...parsed.policies); } const ids = new Set(); for (const policy of policies) { if (ids.has(policy.technicalId)) parserError('admx-policy-identity-duplicate', 'Eine technische ADMX-Policy-ID ist im Katalog doppelt.'); ids.add(policy.technicalId); } const languageResources = admlFiles.map(parseAdml); const admxNames = new Set(admxFiles.map(file => fileName(file.entry.relativePath).toLocaleLowerCase('en-US'))); if (languageResources.some(resource => !admxNames.has(resource.admxFileName.toLocaleLowerCase('en-US')))) diagnostics.add('adml-without-admx'); const catalogId = createHash('sha256').update([ ADMX_POLICY_CATALOG_PARSER.version, source.baselineId, ...[...admxFiles, ...admlFiles].map(file => `${file.entry.relativePath}\0${file.entry.sha256}`).sort() ].join('\0')).digest('hex'); const catalog: AdmxPolicyCatalog = { version: 1, id: `admx-catalog-${catalogId.slice(0, 32)}`, parser: ADMX_POLICY_CATALOG_PARSER, source, status: diagnostics.size ? 'partial' : 'complete', policies: policies.sort((left, right) => left.technicalId.localeCompare(right.technicalId)), languageResources, diagnosticCodes: [...diagnostics].sort() }; const errors = admxPolicyCatalogValidationErrors(catalog); if (errors.length) throw new Error('Intern ungültiger ADMX-Katalog: ' + errors.join(' ')); return catalog; } function canonicalRegistryText(value: string): string { return value.normalize('NFC').toLocaleLowerCase('en-US'); } function observationTarget(observation: GpoPolicyObservation): GpoRegistryTechnicalTarget | undefined { if (observation.deliveryMechanism !== 'registry-policy' || observation.values.length !== 1) return undefined; const value = observation.values[0] as GpoRegistryPolicyValue; if (value.kind !== 'registry-policy' || value.operation === 'unsupported-special') return undefined; const hive = observation.scope === 'computer' ? 'HKEY_LOCAL_MACHINE' : 'HKEY_CURRENT_USER'; if (['set-value', 'set-value-if-absent', 'delete-value'].includes(value.operation) && value.valueName !== undefined) { return { kind: 'registry-value', hive, key: value.key, valueName: value.valueName }; } if (value.operation === 'delete-values' && value.targets?.length === 1) { return { kind: 'registry-value', hive, key: value.key, valueName: value.targets[0] }; } if (['create-key', 'delete-all-values', 'secure-key'].includes(value.operation)) return { kind: 'registry-key', hive, key: value.key }; if (value.operation === 'delete-keys' && value.targets?.length === 1) { return { kind: 'registry-key', hive, key: `${value.key}\\${value.targets[0]}` }; } return undefined; } function referencesForTarget( observation: GpoPolicyObservation, target: GpoRegistryTechnicalTarget, catalog: AdmxPolicyCatalog ): AdmxPolicyReference[] { const key = canonicalRegistryText(target.key); const valueName = target.kind === 'registry-value' ? canonicalRegistryText(target.valueName ?? '') : undefined; const references = catalog.policies.flatMap(policy => policy.registryTargets .filter(candidate => candidate.scope === observation.scope && canonicalRegistryText(candidate.key) === key && target.kind === 'registry-value' && (candidate.match === 'key-values' || canonicalRegistryText(candidate.valueName ?? '') === valueName)) .map(candidate => ({ technicalId: policy.technicalId, targetNamespace: policy.targetNamespace, policyName: policy.name, settingId: candidate.elementId ?? (candidate.role === 'policy-state' ? '$policy' : `${candidate.role}:${candidate.valueName ?? '*'}`), targetRole: candidate.role }))); const unique = new Map(references.map(reference => [JSON.stringify(reference), reference])); return [...unique.values()].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); } export function resolveRegistryPolicyIdentity( observation: GpoPolicyObservation, catalog?: AdmxPolicyCatalog ): GpoPolicyIdentityResolution { const target = observationTarget(observation); if (!target) return { version: 1, status: 'unresolved', reasonCode: 'registry-operation-has-no-single-technical-target' }; const canonical = [observation.scope, target.kind, target.hive, canonicalRegistryText(target.key), canonicalRegistryText(target.valueName ?? '')].join('\0'); const digest = createHash('sha256').update(`${GPO_POLICY_IDENTITY_RESOLVER.version}\0${canonical}`).digest('hex'); const references = catalog ? referencesForTarget(observation, target, catalog) : []; const admx = !catalog ? { status: 'not-found' as const } : references.length === 1 ? { status: 'matched' as const, catalogId: catalog.id, reference: references[0] } : references.length > 1 ? { status: 'ambiguous' as const, catalogId: catalog.id, candidates: references } : { status: 'not-found' as const, catalogId: catalog.id }; const identity = { version: 1 as const, id: `gpo-policy-identity-${digest.slice(0, 32)}`, resolver: GPO_POLICY_IDENTITY_RESOLVER, scope: observation.scope, target, admx }; const errors = gpoTechnicalPolicyIdentityValidationErrors(identity); if (errors.length) throw new Error('Intern ungültige technische Policy-Identität: ' + errors.join(' ')); return { version: 1, status: 'resolved', identity }; }