152 lines
8.5 KiB
TypeScript
152 lines
8.5 KiB
TypeScript
import { JSDOM } from 'jsdom';
|
|
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.0.0' } as const;
|
|
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 > 2 * 1024 * 1024) 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>
|
|
): GpoBackupMetadataDocument {
|
|
const text = decodeXml(file.bytes);
|
|
const policy = resolveImportResourcePolicy({
|
|
...resourceOverrides,
|
|
maximumDecodedCharacters: Math.min(resourceOverrides?.maximumDecodedCharacters ?? 2 * 1024 * 1024, 2 * 1024 * 1024),
|
|
maximumMarkupElements: Math.min(resourceOverrides?.maximumMarkupElements ?? 20_000, 20_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.');
|
|
}
|
|
let dom: JSDOM;
|
|
try {
|
|
dom = new JSDOM(text, { contentType: 'text/xml' });
|
|
} catch {
|
|
parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.');
|
|
}
|
|
const document = dom.window.document;
|
|
try {
|
|
if (document.getElementsByTagName('parsererror').length) parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.');
|
|
const diagnostics = new Set<string>();
|
|
const values = new Map<string, string[]>();
|
|
let secretIndicatorStatus: GpoBackupMetadataDocument['secretIndicatorStatus'] = 'not-detected';
|
|
for (const element of [...document.getElementsByTagName('*')]) {
|
|
const name = element.localName.toLocaleLowerCase('en-US');
|
|
if (name === 'cpassword' || [...element.attributes].some(attribute => attribute.localName.toLocaleLowerCase('en-US') === 'cpassword')) {
|
|
secretIndicatorStatus = 'present';
|
|
diagnostics.add('gpo-secret-indicator-present');
|
|
}
|
|
if (!['gpoid', 'gpoguid', 'backupid', 'id', 'gpodomain', 'gpodomainguid', 'domainname', 'backuptime'].includes(name)) continue;
|
|
if (name === 'id' && element.parentElement?.localName.toLocaleLowerCase('en-US') !== 'backupinst') continue;
|
|
const value = element.textContent?.trim() ?? '';
|
|
if (!value || value.length > 2048 || element.children.length) {
|
|
diagnostics.add('gpo-metadata-field-shape-invalid');
|
|
continue;
|
|
}
|
|
values.set(name, [...(values.get(name) ?? []), value]);
|
|
}
|
|
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,
|
|
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()
|
|
};
|
|
} finally {
|
|
dom.window.close();
|
|
}
|
|
}
|