241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { SaxesParser, type SaxesTagPlain } from 'saxes';
|
|
import type { GpoPreferenceContext, GpoRegistryPreferenceValue } from '../../src/domain/gpo-policy';
|
|
import type { GpoPolicyScope } from '../../src/domain/gpo-ingestion';
|
|
import { ImportError } from '../../src/shared/errors';
|
|
import type { GppRegistryInstruction, RegistryOrUnsupportedPreferenceDocument } from './group-policy-preference-parser';
|
|
import { xmlElementStartLines } from './xml-source-lines';
|
|
|
|
const maximumItems = 20_000;
|
|
const maximumAttributeCharacters = 32 * 1024;
|
|
const registrySettingsClsid = 'a3ccfc41-dfdb-43a5-8d26-0fe8b954da51';
|
|
const registryItemClsid = '9cd4b2f4-923d-47f5-a062-e897dd1dad50';
|
|
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 allowedHives = new Set(['HKEY_CLASSES_ROOT', 'HKEY_CURRENT_USER', 'HKEY_LOCAL_MACHINE', 'HKEY_USERS', 'HKEY_CURRENT_CONFIG']);
|
|
const allowedTypes = new Set(['REG_SZ', 'REG_DWORD', 'REG_EXPAND_SZ', 'REG_MULTI_SZ']);
|
|
|
|
type PreferenceOperation = GpoPreferenceContext['operation'];
|
|
|
|
interface SaxNode {
|
|
localName: string;
|
|
attributes: Record<string, string>;
|
|
children: SaxNode[];
|
|
}
|
|
|
|
export interface RegistrySaxParseContext {
|
|
text: string;
|
|
scope: GpoPolicyScope;
|
|
parser: { readonly id: 'gpo-preferences-xml'; readonly version: '1.0.0' };
|
|
sourcePath: string;
|
|
sourceSha256: 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 nodeFromTag(tag: SaxesTagPlain): SaxNode {
|
|
return {
|
|
localName: localName(tag.name),
|
|
attributes: Object.fromEntries(Object.entries(tag.attributes).map(([name, value]) => [localName(name), value])),
|
|
children: []
|
|
};
|
|
}
|
|
|
|
function canonicalGuid(value: string | undefined): string | undefined {
|
|
return value ? guidPattern.exec(value.trim())?.[1].toLocaleLowerCase('en-US') : undefined;
|
|
}
|
|
|
|
function booleanAttribute(node: SaxNode, name: string, diagnostics: Set<string>): boolean {
|
|
const value = node.attributes[name.toLocaleLowerCase('en-US')];
|
|
if (value === undefined || value === '0') return false;
|
|
if (value === '1') return true;
|
|
diagnostics.add('gpo-preference-boolean-invalid');
|
|
return false;
|
|
}
|
|
|
|
function operation(value: string | undefined): PreferenceOperation | undefined {
|
|
const code = (value ?? 'U').toLocaleUpperCase('en-US');
|
|
return ({ C: 'create', D: 'delete', R: 'replace', U: 'update' } as const)[code as 'C' | 'D' | 'R' | 'U'];
|
|
}
|
|
|
|
function directChildren(node: SaxNode, name: string): SaxNode[] {
|
|
const normalized = name.toLocaleLowerCase('en-US');
|
|
return node.children.filter(child => child.localName === normalized);
|
|
}
|
|
|
|
function targeting(item: SaxNode, diagnostics: Set<string>): GpoPreferenceContext['targeting'] | undefined {
|
|
const filters = directChildren(item, 'filters');
|
|
if (!filters.length) return { status: 'none', filterTypes: [] };
|
|
if (filters.length !== 1) { diagnostics.add('gpo-preference-targeting-invalid'); return undefined; }
|
|
const nodes: SaxNode[] = [];
|
|
const pending = [...filters[0].children].reverse();
|
|
while (pending.length) {
|
|
const node = pending.pop()!;
|
|
nodes.push(node);
|
|
for (let index = node.children.length - 1; index >= 0; index -= 1) pending.push(node.children[index]);
|
|
}
|
|
if (!nodes.length) { diagnostics.add('gpo-preference-targeting-invalid'); return undefined; }
|
|
const canonical: Array<{ type: string; bool: string; not: string; attributes: Array<[string, string]> }> = [];
|
|
for (const node of nodes) {
|
|
const type = node.localName;
|
|
const bool = node.attributes.bool?.toLocaleUpperCase('en-US');
|
|
const not = node.attributes.not;
|
|
if (!type.startsWith('filter') || !['AND', 'OR'].includes(bool ?? '') || !['0', '1'].includes(not ?? '')) {
|
|
diagnostics.add('gpo-preference-targeting-invalid');
|
|
return undefined;
|
|
}
|
|
const attributes = Object.entries(node.attributes)
|
|
.filter(([name]) => !['bool', 'not', 'cpassword'].includes(name))
|
|
.sort(([left], [right]) => left.localeCompare(right));
|
|
canonical.push({ type, bool: bool!, not: not!, attributes });
|
|
}
|
|
return {
|
|
status: 'present',
|
|
filterTypes: [...new Set(canonical.map(node => node.type))].sort(),
|
|
expressionSha256: createHash('sha256').update(JSON.stringify(canonical)).digest('hex'),
|
|
evaluability: 'unknown'
|
|
};
|
|
}
|
|
|
|
function parseItem(
|
|
item: SaxNode,
|
|
sequence: number,
|
|
line: number,
|
|
rootDisabled: boolean,
|
|
diagnostics: Set<string>
|
|
): { instruction?: GppRegistryInstruction; partial: boolean; invalid: boolean } {
|
|
const before = diagnostics.size;
|
|
const uid = canonicalGuid(item.attributes.uid);
|
|
if (!uid || canonicalGuid(item.attributes.clsid) !== registryItemClsid) diagnostics.add('gpo-preference-registry-item-invalid');
|
|
const properties = directChildren(item, 'properties');
|
|
if (properties.length !== 1) diagnostics.add('gpo-preference-registry-item-invalid');
|
|
if (properties.length === 1 && properties[0].children.length) {
|
|
diagnostics.add('gpo-preference-registry-item-unsupported');
|
|
return { partial: true, invalid: false };
|
|
}
|
|
const props = properties[0];
|
|
const itemOperation = props ? operation(props.attributes.action) : undefined;
|
|
if (!itemOperation) diagnostics.add('gpo-preference-action-invalid');
|
|
const hive = props?.attributes.hive?.toLocaleUpperCase('en-US');
|
|
const key = props?.attributes.key ?? '';
|
|
if (!hive || !allowedHives.has(hive) || !key.trim() || key.startsWith('\\') || key.endsWith('\\')) diagnostics.add('gpo-preference-registry-target-invalid');
|
|
const isDefault = props ? booleanAttribute(props, 'default', diagnostics) : false;
|
|
const name = props?.attributes.name ?? '';
|
|
const target = isDefault || name ? 'value' as const : 'key' as const;
|
|
if (isDefault && name) diagnostics.add('gpo-preference-registry-target-invalid');
|
|
const valueType = props?.attributes.type?.toLocaleUpperCase('en-US') || undefined;
|
|
const rawData = props?.attributes.value;
|
|
if (target === 'key' && (valueType || rawData)) diagnostics.add('gpo-preference-registry-target-invalid');
|
|
if (target === 'value' && itemOperation !== 'delete' && (!valueType || !allowedTypes.has(valueType) || rawData === undefined)) diagnostics.add('gpo-preference-registry-value-invalid');
|
|
if (target === 'value' && valueType && !allowedTypes.has(valueType)) diagnostics.add('gpo-preference-registry-value-invalid');
|
|
const removePolicy = booleanAttribute(item, 'removepolicy', diagnostics);
|
|
const applyOnce = booleanAttribute(item, 'applyonce', diagnostics);
|
|
const itemTargeting = targeting(item, diagnostics);
|
|
if (itemOperation === 'delete' && removePolicy) diagnostics.add('gpo-preference-remove-policy-invalid');
|
|
if (diagnostics.size !== before || !uid || !itemOperation || !hive || !allowedHives.has(hive) || !itemTargeting) {
|
|
return { partial: false, invalid: true };
|
|
}
|
|
const instruction: GppRegistryInstruction = {
|
|
version: 1, uid, sequence, line, operation: itemOperation,
|
|
disabled: rootDisabled || booleanAttribute(item, 'disabled', diagnostics), removePolicy, applyOnce,
|
|
hive: hive as GpoRegistryPreferenceValue['hive'], key, target,
|
|
valueName: target === 'value' ? (isDefault ? '' : name) : undefined,
|
|
valueType: target === 'value' && valueType ? valueType as GppRegistryInstruction['valueType'] : undefined,
|
|
rawData: target === 'value' ? rawData : undefined,
|
|
targeting: itemTargeting
|
|
};
|
|
return { instruction, partial: false, invalid: false };
|
|
}
|
|
|
|
export function tryParseRegistryPreferenceWithSax(
|
|
context: RegistrySaxParseContext
|
|
): RegistryOrUnsupportedPreferenceDocument | undefined {
|
|
const diagnostics = new Set<string>();
|
|
const lines = xmlElementStartLines(context.text, /<(?:[A-Za-z_][\w.-]*:)?Registry(?=[\s/>])/g);
|
|
const instructions: GppRegistryInstruction[] = [];
|
|
let rootName: string | undefined;
|
|
let rootDisabled = false;
|
|
let secretDetected = false;
|
|
let currentItem: SaxNode | undefined;
|
|
let itemStack: SaxNode[] = [];
|
|
let itemCount = 0;
|
|
let depth = 0;
|
|
let invalid = false;
|
|
let partial = false;
|
|
let parseFailure: Error | undefined;
|
|
const parser = new SaxesParser({ xmlns: false, position: true });
|
|
parser.on('error', error => { parseFailure = error; });
|
|
parser.on('opentag', tag => {
|
|
const node = nodeFromTag(tag);
|
|
if (depth === 0) {
|
|
rootName = node.localName;
|
|
if (rootName === 'registrysettings') {
|
|
if (canonicalGuid(node.attributes.clsid) !== registrySettingsClsid) diagnostics.add('gpo-preference-registry-root-invalid');
|
|
rootDisabled = booleanAttribute(node, 'disabled', diagnostics);
|
|
}
|
|
}
|
|
if (rootName === 'registrysettings') {
|
|
for (const [name, value] of Object.entries(node.attributes)) {
|
|
if (value.length > maximumAttributeCharacters) diagnostics.add('gpo-preference-attribute-limit');
|
|
if (name === 'cpassword') secretDetected = true;
|
|
}
|
|
if (currentItem) {
|
|
if (node.localName === 'registry') diagnostics.add('gpo-preference-registry-item-invalid');
|
|
itemStack.at(-1)!.children.push(node);
|
|
itemStack.push(node);
|
|
} else if (depth > 0 && node.localName === 'registry') {
|
|
itemCount += 1;
|
|
if (itemCount > maximumItems) parserError('gpo-preference-item-limit', 'Die Group-Policy-Preference überschreitet das Item-Limit.', { items: itemCount });
|
|
currentItem = node;
|
|
itemStack = [node];
|
|
}
|
|
}
|
|
depth += 1;
|
|
});
|
|
parser.on('closetag', () => {
|
|
depth -= 1;
|
|
if (!currentItem) return;
|
|
if (itemStack.length > 1) {
|
|
itemStack.pop();
|
|
return;
|
|
}
|
|
const sequence = itemCount - 1;
|
|
const result = parseItem(currentItem, sequence, lines[sequence] ?? 1, rootDisabled, diagnostics);
|
|
if (result.instruction) instructions.push(result.instruction);
|
|
partial ||= result.partial;
|
|
invalid ||= result.invalid;
|
|
currentItem = undefined;
|
|
itemStack = [];
|
|
});
|
|
try {
|
|
parser.write(context.text).close();
|
|
} catch (error) {
|
|
if (error instanceof ImportError) throw error;
|
|
parseFailure = error instanceof Error ? error : new Error('XML parsing failed.');
|
|
}
|
|
if (parseFailure) parserError('gpo-preference-xml-invalid', 'Die Group-Policy-Preference ist kein wohlgeformtes XML.');
|
|
if (rootName !== 'registrysettings') return undefined;
|
|
const secretIndicatorStatus: RegistryOrUnsupportedPreferenceDocument['secretIndicatorStatus'] = secretDetected ? 'present' : 'not-detected';
|
|
if (secretDetected) diagnostics.add('gpo-secret-indicator-present');
|
|
if (lines.length !== itemCount) diagnostics.add('gpo-preference-line-provenance-invalid');
|
|
invalid ||= diagnostics.has('gpo-preference-attribute-limit') || diagnostics.has('gpo-preference-registry-root-invalid')
|
|
|| diagnostics.has('gpo-preference-boolean-invalid') || diagnostics.has('gpo-preference-line-provenance-invalid');
|
|
return {
|
|
version: 1,
|
|
parser: context.parser,
|
|
sourcePath: context.sourcePath,
|
|
sourceSha256: context.sourceSha256,
|
|
scope: context.scope,
|
|
preferenceType: 'registry',
|
|
status: invalid ? 'invalid' : partial ? 'partial' : 'complete',
|
|
secretIndicatorStatus,
|
|
instructions: invalid ? [] : instructions,
|
|
diagnosticCodes: [...diagnostics].sort()
|
|
};
|
|
}
|