Assessor_2.0/server/gpo/gpo-metadata-parser.ts

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()
};
}