Assessor_2.0/server/gpo/gpp-registry-sax-parser.ts

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