363 lines
18 KiB
TypeScript
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 };
|
|
}
|