Assessor_2.0/server/gpo/group-policy-preference-par...

344 lines
17 KiB
TypeScript

import { createHash } from 'node:crypto';
import { JSDOM } from 'jsdom';
import type { GpoPolicyObservation, GpoPolicyValue, GpoPreferenceContext, GpoRegistryPreferenceValue } from '../../src/domain/gpo-policy';
import { gpoPolicyObservationValidationErrors } from '../../src/domain/gpo-policy';
import type { GpoPolicyScope } from '../../src/domain/gpo-ingestion';
import type { ImportResourcePolicy } from '../../src/importers/resource-policy';
import { resolveImportResourcePolicy, validateDecodedTextResources } from '../../src/importers/resource-policy';
import { ImportError } from '../../src/shared/errors';
import {
parseLocalUsersGroupsPreference,
reduceLocalUsersGroupsPreferences,
type LocalUsersGroupsPreferenceDocument
} from './gpp-local-users-groups';
import {
parseDriveMapsPreference,
reduceDriveMapsPreferences,
type DriveMapsPreferenceDocument
} from './gpp-drive-maps';
import {
parseServicesPreference,
reduceServicesPreferences,
type ServicesPreferenceDocument
} from './gpp-services';
import type { VerifiedZipFile } from './zip-inventory';
import { xmlElementStartLines } from './xml-source-lines';
import { tryParseRegistryPreferenceWithSax } from './gpp-registry-sax-parser';
export const GPP_PARSER = { id: 'gpo-preferences-xml', version: '1.0.0' } as const;
export const GPP_REGISTRY_REDUCER = { id: 'gpo-preferences-registry-reducer', version: '1.0.0' } as const;
const maximumFileBytes = 4 * 1024 * 1024;
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'];
export interface GppRegistryInstruction {
version: 1;
uid: string;
sequence: number;
line: number;
operation: PreferenceOperation;
disabled: boolean;
removePolicy: boolean;
applyOnce: boolean;
hive: GpoRegistryPreferenceValue['hive'];
key: string;
target: 'key' | 'value';
valueName?: string;
valueType?: 'REG_SZ' | 'REG_DWORD' | 'REG_EXPAND_SZ' | 'REG_MULTI_SZ';
rawData?: string;
targeting: GpoPreferenceContext['targeting'];
}
export interface RegistryOrUnsupportedPreferenceDocument {
version: 1;
parser: typeof GPP_PARSER;
sourcePath: string;
sourceSha256: string;
scope: GpoPolicyScope;
preferenceType: 'registry' | 'unsupported';
status: 'complete' | 'partial' | 'invalid';
secretIndicatorStatus: 'not-detected' | 'present';
instructions: GppRegistryInstruction[];
diagnosticCodes: string[];
}
export type GroupPolicyPreferenceDocument =
| RegistryOrUnsupportedPreferenceDocument
| LocalUsersGroupsPreferenceDocument
| ServicesPreferenceDocument
| DriveMapsPreferenceDocument;
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 > maximumFileBytes) parserError('gpo-preference-size-limit', 'Eine Group-Policy-Preference ü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-preference-encoding-rejected', 'Die Group-Policy-Preference ist nicht eindeutig als UTF-8 oder BOM-markiertes UTF-16 dekodierbar.');
}
}
function canonicalGuid(value: string | null): string | undefined {
return value ? guidPattern.exec(value.trim())?.[1].toLocaleLowerCase('en-US') : undefined;
}
function booleanAttribute(element: Element, name: string, diagnostics: Set<string>): boolean {
const value = element.getAttribute(name);
if (value === null || value === '0') return false;
if (value === '1') return true;
diagnostics.add('gpo-preference-boolean-invalid');
return false;
}
function operation(value: string | null): 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 registryLines(text: string): number[] {
return xmlElementStartLines(text, /<(?:[A-Za-z_][\w.-]*:)?Registry(?=[\s/>])/g);
}
function targeting(item: Element, diagnostics: Set<string>): GpoPreferenceContext['targeting'] | undefined {
const filters = [...item.children].filter(child => child.localName.toLocaleLowerCase('en-US') === 'filters');
if (!filters.length) return { status: 'none', filterTypes: [] };
if (filters.length !== 1) { diagnostics.add('gpo-preference-targeting-invalid'); return undefined; }
const nodes = [...filters[0].getElementsByTagName('*')];
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.toLocaleLowerCase('en-US');
const bool = node.getAttribute('bool')?.toLocaleUpperCase('en-US');
const not = node.getAttribute('not');
if (!type.startsWith('filter') || !['AND', 'OR'].includes(bool ?? '') || !['0', '1'].includes(not ?? '')) {
diagnostics.add('gpo-preference-targeting-invalid');
return undefined;
}
const attributes = [...node.attributes]
.filter(attribute => !['bool', 'not', 'cpassword'].includes(attribute.localName.toLocaleLowerCase('en-US')))
.map(attribute => [attribute.localName.toLocaleLowerCase('en-US'), attribute.value] as [string, string])
.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'
};
}
export function parseGroupPolicyPreferenceXml(
file: VerifiedZipFile,
scope: GpoPolicyScope,
resourceOverrides?: Partial<ImportResourcePolicy>
): GroupPolicyPreferenceDocument {
const text = decodeXml(file.bytes);
const policy = resolveImportResourcePolicy({
...resourceOverrides,
maximumDecodedCharacters: Math.min(resourceOverrides?.maximumDecodedCharacters ?? maximumFileBytes, maximumFileBytes),
maximumMarkupElements: Math.min(resourceOverrides?.maximumMarkupElements ?? maximumItems, maximumItems)
});
validateDecodedTextResources(text, 'xml', policy);
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('gpo-preference-external-reference-rejected', 'Externe Entitäten, Includes, Schemas und Verarbeitungsanweisungen sind in Group Policy Preferences nicht zulässig.');
}
const registryDocument = tryParseRegistryPreferenceWithSax({
text,
scope,
parser: GPP_PARSER,
sourcePath: file.entry.relativePath,
sourceSha256: file.entry.sha256
});
if (registryDocument) return registryDocument;
let dom: JSDOM;
try {
dom = new JSDOM(text, { contentType: 'text/xml' });
} catch {
parserError('gpo-preference-xml-invalid', 'Die Group-Policy-Preference ist kein wohlgeformtes XML.');
}
const xml = dom.window.document;
try {
if (xml.getElementsByTagName('parsererror').length) parserError('gpo-preference-xml-invalid', 'Die Group-Policy-Preference ist kein wohlgeformtes XML.');
const diagnostics = new Set<string>();
let secretIndicatorStatus: GroupPolicyPreferenceDocument['secretIndicatorStatus'] = 'not-detected';
for (const element of [...xml.getElementsByTagName('*')]) {
for (const attribute of [...element.attributes]) {
if (attribute.value.length > maximumAttributeCharacters) diagnostics.add('gpo-preference-attribute-limit');
if (attribute.localName.toLocaleLowerCase('en-US') === 'cpassword') secretIndicatorStatus = 'present';
}
}
if (secretIndicatorStatus === 'present') diagnostics.add('gpo-secret-indicator-present');
const root = xml.documentElement;
const rootName = root.localName.toLocaleLowerCase('en-US');
if (rootName === 'groups') {
return parseLocalUsersGroupsPreference({
root,
text,
scope,
parser: GPP_PARSER,
sourcePath: file.entry.relativePath,
sourceSha256: file.entry.sha256,
secretIndicatorStatus,
diagnostics,
summarizeTargeting: targeting
});
}
if (rootName === 'ntservices') {
return parseServicesPreference({
root,
text,
scope,
parser: GPP_PARSER,
sourcePath: file.entry.relativePath,
sourceSha256: file.entry.sha256,
secretIndicatorStatus,
diagnostics,
summarizeTargeting: targeting
});
}
if (rootName === 'drives') {
return parseDriveMapsPreference({
root,
text,
scope,
parser: GPP_PARSER,
sourcePath: file.entry.relativePath,
sourceSha256: file.entry.sha256,
secretIndicatorStatus,
diagnostics,
summarizeTargeting: targeting
});
}
if (rootName !== 'registrysettings') {
diagnostics.add('gpo-preference-type-unsupported');
return {
version: 1, parser: GPP_PARSER, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256,
scope, preferenceType: 'unsupported', status: diagnostics.has('gpo-preference-attribute-limit') ? 'invalid' : 'partial',
secretIndicatorStatus, instructions: [], diagnosticCodes: [...diagnostics].sort()
};
}
if (canonicalGuid(root.getAttribute('clsid')) !== registrySettingsClsid) diagnostics.add('gpo-preference-registry-root-invalid');
const rootDisabled = booleanAttribute(root, 'disabled', diagnostics);
const items = [...root.getElementsByTagName('*')].filter(element => element.localName.toLocaleLowerCase('en-US') === 'registry');
if (items.length > maximumItems) parserError('gpo-preference-item-limit', 'Die Group-Policy-Preference überschreitet das Item-Limit.', { items: items.length });
const lines = registryLines(text);
if (lines.length !== items.length) diagnostics.add('gpo-preference-line-provenance-invalid');
const instructions: GppRegistryInstruction[] = [];
let 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');
let partial = false;
items.forEach((item, sequence) => {
const before = diagnostics.size;
const uid = canonicalGuid(item.getAttribute('uid'));
if (!uid || canonicalGuid(item.getAttribute('clsid')) !== registryItemClsid) diagnostics.add('gpo-preference-registry-item-invalid');
const properties = [...item.children].filter(child => child.localName.toLocaleLowerCase('en-US') === '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');
partial = true;
return;
}
const props = properties[0];
const itemOperation = props ? operation(props.getAttribute('action')) : undefined;
if (!itemOperation) diagnostics.add('gpo-preference-action-invalid');
const hive = props?.getAttribute('hive')?.toLocaleUpperCase('en-US');
const key = props?.getAttribute('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?.getAttribute('name') ?? '';
const target = isDefault || name ? 'value' as const : 'key' as const;
if (isDefault && name) diagnostics.add('gpo-preference-registry-target-invalid');
const valueType = props?.getAttribute('type')?.toLocaleUpperCase('en-US') || undefined;
const rawData = props?.getAttribute('value') ?? undefined;
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) { invalid = true; return; }
instructions.push({
version: 1, uid, sequence, line: lines[sequence] ?? 1, operation: itemOperation,
disabled: rootDisabled || booleanAttribute(item, 'disabled', diagnostics), removePolicy, applyOnce,
hive: hive as GppRegistryInstruction['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
});
});
if (diagnostics.has('gpo-preference-boolean-invalid')) invalid = true;
return {
version: 1, parser: GPP_PARSER, sourcePath: file.entry.relativePath, sourceSha256: file.entry.sha256,
scope, preferenceType: 'registry', status: invalid ? 'invalid' : partial ? 'partial' : 'complete', secretIndicatorStatus,
instructions: invalid ? [] : instructions, diagnosticCodes: [...diagnostics].sort()
};
} finally {
dom.window.close();
}
}
export function reduceGroupPolicyPreferences(
document: GroupPolicyPreferenceDocument,
context: { backupInstanceId: string; sourceArtifactId: string; artifactId: string }
): GpoPolicyObservation[] {
if (document.preferenceType === 'local-users-groups') {
return reduceLocalUsersGroupsPreferences(document, context);
}
if (document.preferenceType === 'services') {
return reduceServicesPreferences(document, context);
}
if (document.preferenceType === 'drive-maps') {
return reduceDriveMapsPreferences(document, context);
}
if (document.status === 'invalid' || document.preferenceType !== 'registry' || !['computer', 'user'].includes(document.scope)) return [];
return document.instructions.map(instruction => {
const value: GpoPolicyValue = {
kind: 'registry-preference', hive: instruction.hive, key: instruction.key, target: instruction.target,
valueName: instruction.valueName, valueType: instruction.valueType, rawData: instruction.rawData
};
const identityKey = [instruction.hive, instruction.key, instruction.target === 'key' ? '<key>' : instruction.valueName ?? ''].join('\\');
const id = `gpo-policy-${createHash('sha256').update([
context.backupInstanceId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_REGISTRY_REDUCER.version
].join('\0')).digest('hex').slice(0, 32)}`;
const observation: GpoPolicyObservation = {
version: 1, id, backupInstanceId: context.backupInstanceId, sourceArtifactId: context.sourceArtifactId,
artifactId: context.artifactId, sourcePath: document.sourcePath, sourceSha256: document.sourceSha256,
scope: document.scope as 'computer' | 'user', deliveryMechanism: 'group-policy-preference',
parser: document.parser, reducer: GPP_REGISTRY_REDUCER,
identity: { status: 'unresolved', namespace: 'group-policy-preference', section: 'registry', key: identityKey },
state: 'instruction', values: [value],
preference: {
cse: 'registry', itemUid: instruction.uid, operation: instruction.operation, sequence: instruction.sequence,
disabled: instruction.disabled, removePolicy: instruction.removePolicy, applyOnce: instruction.applyOnce, targeting: instruction.targeting
},
sourceLines: [instruction.line], effectiveState: 'unknown',
diagnosticCodes: instruction.targeting.status === 'present' ? ['gpo-preference-targeting-unevaluated'] : []
};
const errors = gpoPolicyObservationValidationErrors(observation);
if (errors.length) throw new Error(`Intern ungültige Group-Policy-Preference-Beobachtung: ${errors.join(' ')}`);
return observation;
});
}