import { SaxesParser } from 'saxes'; import type { GpoBackupMetadataDocument } from '../../src/domain/gpo-metadata'; import type { ImportResourcePolicy } from '../../src/importers/resource-policy'; import { resolveImportResourcePolicy, validateDecodedTextResources } from '../../src/importers/resource-policy'; import { ImportError } from '../../src/shared/errors'; import type { VerifiedZipFile } from './zip-inventory'; // 1.4.0 retains GPReport.xml link counts (user decision 2026-09-25). The template-name // variant used by the Microsoft baseline analyzer keeps its own 1.3.0 contract. const parser = { id: 'gpo-backup-metadata-xml', version: '1.4.0' } as const; const maximumMetadataBytes = 64 * 1024 * 1024; 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 dnsPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; 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 > maximumMetadataBytes) parserError('gpo-metadata-size-limit', 'Eine GPO-Metadatendatei ü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-metadata-encoding-rejected', 'GPO-Metadaten sind nicht eindeutig als UTF-8 oder BOM-markiertes UTF-16 dekodierbar.'); } } function canonicalGuid(value: string): string | undefined { return guidPattern.exec(value.trim())?.[1].toLocaleLowerCase('en-US'); } function canonicalDomain(values: Map, diagnostics: Set): string | undefined { const rawDomainGuids = values.get('gpodomainguid') ?? []; const domainGuids = rawDomainGuids.map(canonicalGuid).filter((value): value is string => Boolean(value)); const hasInvalidDomainGuid = rawDomainGuids.length !== domainGuids.length; if (hasInvalidDomainGuid) diagnostics.add('gpo-metadata-domain-invalid'); const uniqueDomainGuids = [...new Set(domainGuids)]; if (uniqueDomainGuids.length > 1) diagnostics.add('gpo-metadata-domain-conflict'); if (hasInvalidDomainGuid || uniqueDomainGuids.length > 1) return undefined; if (uniqueDomainGuids.length === 1) return `domain-guid:${uniqueDomainGuids[0]}`; const rawDnsValues = [...(values.get('gpodomain') ?? []), ...(values.get('domainname') ?? [])]; const dnsValues = rawDnsValues .map(value => value.trim().replace(/\.$/, '').toLocaleLowerCase('en-US')) .filter(value => dnsPattern.test(value)); const hasInvalidDnsValue = rawDnsValues.length !== dnsValues.length; if (hasInvalidDnsValue) diagnostics.add('gpo-metadata-domain-invalid'); const uniqueDnsValues = [...new Set(dnsValues)]; if (uniqueDnsValues.length > 1) diagnostics.add('gpo-metadata-domain-conflict'); return !hasInvalidDnsValue && uniqueDnsValues.length === 1 ? `dns:${uniqueDnsValues[0]}` : undefined; } function uniqueGuid(values: string[], diagnostics: Set, field: string): string | undefined { const candidates = values.map(canonicalGuid).filter((value): value is string => Boolean(value)); const hasInvalidValue = values.length !== candidates.length; if (hasInvalidValue) diagnostics.add(`gpo-metadata-${field}-invalid`); const unique = [...new Set(candidates)]; if (unique.length > 1) diagnostics.add(`gpo-metadata-${field}-conflict`); return !hasInvalidValue && unique.length === 1 ? unique[0] : undefined; } function uniqueTimestamp(values: string[], diagnostics: Set): string | undefined { const isoTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i; const localTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/; const trimmed = values.map(value => value.trim()); const absoluteValues = trimmed.filter(value => isoTimestamp.test(value)); const localValues = trimmed.filter(value => localTimestamp.test(value)); const hasInvalidValue = trimmed.some(value => !isoTimestamp.test(value) && !localTimestamp.test(value)); if (hasInvalidValue) diagnostics.add('gpo-metadata-backup-time-invalid'); if (localValues.length) diagnostics.add('gpo-metadata-backup-time-zone-missing'); if (absoluteValues.length && localValues.length) { diagnostics.add('gpo-metadata-backup-time-conflict'); return undefined; } if (hasInvalidValue || localValues.length) return undefined; const parsed = absoluteValues .map(value => Date.parse(value.trim())).filter(Number.isFinite).map(value => new Date(value).toISOString()); if (absoluteValues.length !== parsed.length) diagnostics.add('gpo-metadata-backup-time-invalid'); const unique = [...new Set(parsed)]; if (unique.length > 1) diagnostics.add('gpo-metadata-backup-time-conflict'); return absoluteValues.length === parsed.length && unique.length === 1 ? unique[0] : undefined; } export function parseGpoBackupMetadataXml( file: VerifiedZipFile, resourceOverrides?: Partial, includeTemplateNames = false ): GpoBackupMetadataDocument { const text = decodeXml(file.bytes); const policy = resolveImportResourcePolicy({ ...resourceOverrides, maximumDecodedCharacters: Math.min(resourceOverrides?.maximumDecodedCharacters ?? maximumMetadataBytes, maximumMetadataBytes), maximumMarkupElements: Math.min(resourceOverrides?.maximumMarkupElements ?? 500_000, 500_000) }); validateDecodedTextResources(text, 'xml', policy); if (/<\s*(?:[A-Za-z_][\w.-]*:)?include\b/i.test(text) || /\b(?:noNamespaceSchemaLocation|schemaLocation)\s*=/i.test(text) || /<\?(?!xml(?:\s|\?>))/i.test(text)) { parserError('gpo-metadata-external-reference-rejected', 'Externe Includes, Schemas und Verarbeitungsanweisungen sind in GPO-Metadaten nicht zulässig.'); } const diagnostics = new Set(); const values = new Map(); let secretIndicatorStatus: GpoBackupMetadataDocument['secretIndicatorStatus'] = 'not-detected'; // Retain only bounded identity fields, never the full report tree or secret values. const stack: Array<{ name: string; field?: string; selected: boolean; nested: boolean; text: string }> = []; // Link facts: counts only, never the linked SOM path. Read only from a GPReport root. let rootName: string | undefined; const links: Array<{ enabled: boolean }> = []; let linkEnabledText: string | undefined; const xml = new SaxesParser({ xmlns: true }); xml.on('error', () => parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.')); xml.on('doctype', () => parserError('gpo-metadata-external-reference-rejected', 'DTD ist in GPO-Metadaten nicht zulässig.')); xml.on('opentag', element => { if (stack.length >= 128) parserError('gpo-metadata-depth-limit', 'GPO-Metadaten sind zu tief verschachtelt.'); const parent = stack[stack.length - 1]; if (parent) parent.nested = true; const name = element.local.toLocaleLowerCase('en-US'); if (name === 'cpassword' || Object.values(element.attributes).some(attribute => attribute.local.toLocaleLowerCase('en-US') === 'cpassword')) { secretIndicatorStatus = 'present'; diagnostics.add('gpo-secret-indicator-present'); } const path = [...stack.map(item => item.name), name].join('/'); rootName ??= name; if (!includeTemplateNames && path === 'gpo/linksto') links.push({ enabled: true }); if (!includeTemplateNames && path === 'gpo/linksto/enabled') linkEnabledText = ''; const core = 'grouppolicybackupscheme/grouppolicyobject/grouppolicycoresettings/'; const templateName = includeTemplateNames && [core + 'displayname', 'gpo/name', 'backupinst/gpodisplayname', 'backupinst/gponame', 'backupinst/displayname'].includes(path); const field = templateName ? 'templatename' : path === core + 'id' || path === 'gpo/identifier/identifier' ? 'gpoid' : path === core + 'domain' || path === 'gpo/identifier/domain' ? 'gpodomain' : name === 'id' && parent?.name === 'backupinst' ? 'backupid' : ['gpoid', 'gpoguid', 'backupid', 'gpodomain', 'gpodomainguid', 'domainname', 'backuptime'].includes(name) ? name : undefined; stack.push({ name, field, selected: field !== undefined, nested: false, text: '' }); }); const onText = (value: string) => { if (linkEnabledText !== undefined) linkEnabledText = (linkEnabledText + value).slice(0, 16); const current = stack[stack.length - 1]; if (current?.selected) current.text = (current.text + value).slice(0, 2049); }; xml.on('text', onText); xml.on('cdata', onText); xml.on('closetag', () => { const current = stack.pop()!; if (current.name === 'enabled' && linkEnabledText !== undefined) { // Only an explicit false disables a link; an unreadable value keeps the link counted as enabled. if (links.length && linkEnabledText.trim().toLocaleLowerCase('en-US') === 'false') links[links.length - 1].enabled = false; linkEnabledText = undefined; } if (!current.selected) return; const value = current.text.trim(); if (!value || current.text.length > 2048 || current.nested) { diagnostics.add('gpo-metadata-field-shape-invalid'); return; } const entries = values.get(current.field!) ?? []; entries.push(value); values.set(current.field!, entries); }); for (let offset = 0; offset < text.length; offset += 64 * 1024) xml.write(text.slice(offset, offset + 64 * 1024)); xml.close(); const gpoId = uniqueGuid([...(values.get('gpoid') ?? []), ...(values.get('gpoguid') ?? [])], diagnostics, 'gpo-id'); const backupId = uniqueGuid([...(values.get('backupid') ?? []), ...(values.get('id') ?? [])], diagnostics, 'backup-id'); const domainKey = canonicalDomain(values, diagnostics); const backupCreatedAt = uniqueTimestamp(values.get('backuptime') ?? [], diagnostics); if (!gpoId) diagnostics.add('gpo-metadata-gpo-id-missing'); if (!backupId) diagnostics.add('gpo-metadata-backup-id-missing'); if (!domainKey) diagnostics.add('gpo-metadata-domain-missing'); if (!backupCreatedAt) diagnostics.add('gpo-metadata-backup-time-missing'); const invalid = [...diagnostics].some(code => code.endsWith('-invalid') || code.endsWith('-conflict')); return { version: 1, parser: includeTemplateNames ? { ...parser, version: '1.3.0' } : parser, ...(includeTemplateNames ? { templateNames: [...new Set(values.get('templatename') ?? [])].sort() } : {}), ...(!includeTemplateNames && rootName === 'gpo' ? { links: { status: links.some(link => link.enabled) ? 'linked' as const : 'not-linked' as const, linkCount: links.length, enabledLinkCount: links.filter(link => link.enabled).length } } : {}), sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256, status: invalid ? 'invalid' : gpoId && backupId && domainKey && backupCreatedAt ? 'complete' : 'partial', gpoId, backupId, domainKey, backupCreatedAt, secretIndicatorStatus, diagnosticCodes: [...diagnostics].sort() }; }