172 lines
10 KiB
TypeScript
172 lines
10 KiB
TypeScript
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';
|
|
|
|
const parser = { id: 'gpo-backup-metadata-xml', version: '1.2.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<string, string | number> = {}): 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<string, string[]>, diagnostics: Set<string>): 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<string>, 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>): 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<ImportResourcePolicy>,
|
|
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<string>();
|
|
const values = new Map<string, string[]>();
|
|
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 }> = [];
|
|
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('/');
|
|
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) => {
|
|
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.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() } : {}),
|
|
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()
|
|
};
|
|
}
|