Assessor_2.0/server/gpo/admx-policy-catalog.ts

363 lines
18 KiB
TypeScript

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<string, string>;
children: XmlNode[];
text: string;
}
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 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 (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(text)
|| /<\s*(?:[A-Za-z_][\w.-]*:)?include\b/i.test(text)
|| /\b(?:noNamespaceSchemaLocation|schemaLocation)\s*=/i.test(text)
|| /<\?(?!xml(?:\s|\?>))/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<string, AdmxPolicyRegistryTarget>();
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<string>();
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<string, string> = {};
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<string>();
if (!admxFiles.length) diagnostics.add('admx-catalog-missing');
const namespaces = new Set<string>();
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<string>();
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 };
}