297 lines
14 KiB
TypeScript
297 lines
14 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import type {
|
|
GpoLocalGroupPreferenceValue,
|
|
GpoLocalUserPreferenceValue,
|
|
GpoPolicyObservation,
|
|
GpoPreferenceContext
|
|
} from '../../src/domain/gpo-policy';
|
|
import { gpoPolicyObservationValidationErrors } from '../../src/domain/gpo-policy';
|
|
import type { GpoPolicyScope } from '../../src/domain/gpo-ingestion';
|
|
import { xmlElementStartLines } from './xml-source-lines';
|
|
|
|
export const GPP_LOCAL_USERS_GROUPS_REDUCER = { id: 'gpo-preferences-local-users-groups-reducer', version: '1.0.0' } as const;
|
|
|
|
const groupsClsid = '3125e937-eb16-4b4c-9934-544fc6d24d26';
|
|
const userClsid = 'df5f1855-51e5-4d24-8b1a-d9bde98ba1d1';
|
|
const groupClsid = '6d4a79e4-529c-4481-abd0-f5bd7ea93ba7';
|
|
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 sidPattern = /^S-\d(?:-\d+)+$/i;
|
|
|
|
type Operation = GpoPreferenceContext['operation'];
|
|
type Targeting = GpoPreferenceContext['targeting'];
|
|
|
|
interface CommonInstruction {
|
|
version: 1;
|
|
cse: 'local-users-groups';
|
|
uid: string;
|
|
sequence: number;
|
|
line: number;
|
|
operation: Operation;
|
|
disabled: boolean;
|
|
removePolicy: boolean;
|
|
applyOnce: boolean;
|
|
targeting: Targeting;
|
|
}
|
|
|
|
export interface GppLocalUserInstruction extends CommonInstruction {
|
|
itemType: 'user';
|
|
value: GpoLocalUserPreferenceValue;
|
|
}
|
|
|
|
export interface GppLocalGroupInstruction extends CommonInstruction {
|
|
itemType: 'group';
|
|
value: GpoLocalGroupPreferenceValue;
|
|
}
|
|
|
|
export type GppLocalUsersGroupsInstruction = GppLocalUserInstruction | GppLocalGroupInstruction;
|
|
|
|
export interface LocalUsersGroupsPreferenceDocument {
|
|
version: 1;
|
|
parser: { id: string; version: string };
|
|
sourcePath: string;
|
|
sourceSha256: string;
|
|
scope: GpoPolicyScope;
|
|
preferenceType: 'local-users-groups';
|
|
status: 'complete' | 'partial' | 'invalid';
|
|
secretIndicatorStatus: 'not-detected' | 'present';
|
|
instructions: GppLocalUsersGroupsInstruction[];
|
|
diagnosticCodes: string[];
|
|
}
|
|
|
|
export interface LocalUsersGroupsParseContext {
|
|
root: Element;
|
|
text: string;
|
|
scope: GpoPolicyScope;
|
|
parser: { id: string; version: string };
|
|
sourcePath: string;
|
|
sourceSha256: string;
|
|
secretIndicatorStatus: 'not-detected' | 'present';
|
|
diagnostics: Set<string>;
|
|
summarizeTargeting: (item: Element, diagnostics: Set<string>) => Targeting | undefined;
|
|
}
|
|
|
|
function attribute(element: Element, name: string): string | undefined {
|
|
const normalized = name.toLocaleLowerCase('en-US');
|
|
return [...element.attributes].find(candidate => candidate.localName.toLocaleLowerCase('en-US') === normalized)?.value;
|
|
}
|
|
|
|
function canonicalGuid(value: string | undefined): string | undefined {
|
|
return value ? guidPattern.exec(value.trim())?.[1].toLocaleLowerCase('en-US') : undefined;
|
|
}
|
|
|
|
function booleanAttribute(element: Element, name: string, diagnostics: Set<string>, optional = false): boolean | undefined {
|
|
const value = attribute(element, name);
|
|
if (value === undefined) return optional ? undefined : false;
|
|
if (value === '0') return false;
|
|
if (value === '1') return true;
|
|
diagnostics.add('gpo-preference-boolean-invalid');
|
|
return optional ? undefined : false;
|
|
}
|
|
|
|
function operation(value: string | undefined): Operation | 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 itemLines(text: string): number[] {
|
|
return xmlElementStartLines(text, /<(?:[A-Za-z_][\w.-]*:)?(?:User|Group)(?=[\s/>])/g);
|
|
}
|
|
|
|
function directChildren(element: Element, name: string): Element[] {
|
|
const normalized = name.toLocaleLowerCase('en-US');
|
|
return [...element.children].filter(child => child.localName.toLocaleLowerCase('en-US') === normalized);
|
|
}
|
|
|
|
function textAttribute(element: Element, name: string): string | undefined {
|
|
const value = attribute(element, name);
|
|
return value?.trim() ? value : undefined;
|
|
}
|
|
|
|
function parseUserValue(properties: Element, itemOperation: Operation, diagnostics: Set<string>): GpoLocalUserPreferenceValue | undefined {
|
|
if (properties.children.length) {
|
|
diagnostics.add('gpo-preference-local-user-item-unsupported');
|
|
return undefined;
|
|
}
|
|
const userName = textAttribute(properties, 'userName');
|
|
if (!userName) { diagnostics.add('gpo-preference-local-user-target-invalid'); return undefined; }
|
|
const expires = textAttribute(properties, 'expires');
|
|
if (expires && !/^\d{4}-\d{2}-\d{2}$/.test(expires)) { diagnostics.add('gpo-preference-local-user-expiry-invalid'); return undefined; }
|
|
const noChange = booleanAttribute(properties, 'noChange', diagnostics, true);
|
|
return {
|
|
kind: 'local-user-preference',
|
|
userName,
|
|
newName: itemOperation === 'update' ? textAttribute(properties, 'newName') : undefined,
|
|
fullName: textAttribute(properties, 'fullName'),
|
|
description: textAttribute(properties, 'description'),
|
|
passwordAttributePresent: [...properties.attributes].some(candidate => candidate.localName.toLocaleLowerCase('en-US') === 'cpassword'),
|
|
changePasswordAtLogon: booleanAttribute(properties, 'changeLogon', diagnostics, true),
|
|
accountDisabled: booleanAttribute(properties, 'acctDisabled', diagnostics, true),
|
|
passwordNeverExpires: booleanAttribute(properties, 'neverExpires', diagnostics, true),
|
|
passwordChangeAllowed: noChange === undefined ? undefined : !noChange,
|
|
expires
|
|
};
|
|
}
|
|
|
|
function parseGroupValue(properties: Element, itemOperation: Operation, diagnostics: Set<string>): GpoLocalGroupPreferenceValue | undefined {
|
|
const allowedChildren = [...properties.children].every(child => child.localName.toLocaleLowerCase('en-US') === 'members');
|
|
const membersContainers = directChildren(properties, 'members');
|
|
if (!allowedChildren || membersContainers.length > 1) {
|
|
diagnostics.add('gpo-preference-local-group-item-unsupported');
|
|
return undefined;
|
|
}
|
|
const groupName = textAttribute(properties, 'groupName');
|
|
const groupSid = textAttribute(properties, 'groupSid');
|
|
if (!groupName || (groupSid && !sidPattern.test(groupSid))) {
|
|
diagnostics.add('gpo-preference-local-group-target-invalid');
|
|
return undefined;
|
|
}
|
|
const currentUserCode = textAttribute(properties, 'userAction')?.toLocaleUpperCase('en-US');
|
|
if (currentUserCode && !['ADD', 'REMOVE'].includes(currentUserCode)) {
|
|
diagnostics.add('gpo-preference-local-group-user-action-invalid');
|
|
return undefined;
|
|
}
|
|
const members: GpoLocalGroupPreferenceValue['members'] = [];
|
|
for (const member of membersContainers.flatMap(container => [...container.children])) {
|
|
if (member.localName.toLocaleLowerCase('en-US') !== 'member' || member.children.length) {
|
|
diagnostics.add('gpo-preference-local-group-member-invalid');
|
|
return undefined;
|
|
}
|
|
const memberOperation = textAttribute(member, 'action')?.toLocaleUpperCase('en-US');
|
|
const sid = textAttribute(member, 'sid');
|
|
const name = textAttribute(member, 'name');
|
|
if (!['ADD', 'REMOVE'].includes(memberOperation ?? '') || (sid ? !sidPattern.test(sid) : !name)) {
|
|
diagnostics.add('gpo-preference-local-group-member-invalid');
|
|
return undefined;
|
|
}
|
|
members.push({
|
|
sequence: members.length,
|
|
operation: memberOperation === 'ADD' ? 'add' : 'remove',
|
|
selector: sid ? { kind: 'sid', value: sid } : { kind: 'name', value: name! }
|
|
});
|
|
}
|
|
return {
|
|
kind: 'local-group-preference',
|
|
groupName,
|
|
groupSid,
|
|
newName: itemOperation === 'update' ? textAttribute(properties, 'newName') : undefined,
|
|
description: textAttribute(properties, 'description'),
|
|
currentUserOperation: currentUserCode ? currentUserCode === 'ADD' ? 'add' : 'remove' : undefined,
|
|
suppressCurrentUserOperation: booleanAttribute(properties, 'removeAccounts', diagnostics) ?? false,
|
|
deleteAllUsers: booleanAttribute(properties, 'deleteAllUsers', diagnostics) ?? false,
|
|
deleteAllGroups: booleanAttribute(properties, 'deleteAllGroups', diagnostics) ?? false,
|
|
members
|
|
};
|
|
}
|
|
|
|
export function parseLocalUsersGroupsPreference(context: LocalUsersGroupsParseContext): LocalUsersGroupsPreferenceDocument {
|
|
const { root, diagnostics } = context;
|
|
const rootDisabled = booleanAttribute(root, 'disabled', diagnostics) ?? false;
|
|
if (canonicalGuid(attribute(root, 'clsid')) !== groupsClsid) diagnostics.add('gpo-preference-local-users-groups-root-invalid');
|
|
const items = [...root.children].filter(child => ['user', 'group'].includes(child.localName.toLocaleLowerCase('en-US')));
|
|
const unknownRootChildren = [...root.children].some(child => !['user', 'group'].includes(child.localName.toLocaleLowerCase('en-US')));
|
|
let partial = unknownRootChildren;
|
|
if (unknownRootChildren) diagnostics.add('gpo-preference-local-users-groups-item-unsupported');
|
|
const lines = itemLines(context.text);
|
|
if (lines.length !== items.length) diagnostics.add('gpo-preference-line-provenance-invalid');
|
|
let invalid = diagnostics.has('gpo-preference-attribute-limit')
|
|
|| diagnostics.has('gpo-preference-local-users-groups-root-invalid')
|
|
|| diagnostics.has('gpo-preference-line-provenance-invalid')
|
|
|| diagnostics.has('gpo-preference-boolean-invalid');
|
|
const instructions: GppLocalUsersGroupsInstruction[] = [];
|
|
items.forEach((item, sequence) => {
|
|
const itemType = item.localName.toLocaleLowerCase('en-US') as 'user' | 'group';
|
|
const before = diagnostics.size;
|
|
const uid = canonicalGuid(attribute(item, 'uid'));
|
|
const expectedClsid = itemType === 'user' ? userClsid : groupClsid;
|
|
if (!uid || canonicalGuid(attribute(item, 'clsid')) !== expectedClsid) diagnostics.add('gpo-preference-local-users-groups-item-invalid');
|
|
const properties = directChildren(item, 'properties');
|
|
const allowedItemChildren = [...item.children].every(child => ['properties', 'filters'].includes(child.localName.toLocaleLowerCase('en-US')));
|
|
if (properties.length !== 1 || !allowedItemChildren) diagnostics.add('gpo-preference-local-users-groups-item-invalid');
|
|
const props = properties[0];
|
|
const itemOperation = props ? operation(attribute(props, 'action')) : undefined;
|
|
if (!itemOperation) diagnostics.add('gpo-preference-action-invalid');
|
|
const removePolicy = booleanAttribute(item, 'removePolicy', diagnostics) ?? false;
|
|
const applyOnce = booleanAttribute(item, 'applyOnce', diagnostics) ?? false;
|
|
const itemDisabled = booleanAttribute(item, 'disabled', diagnostics) ?? false;
|
|
const itemTargeting = context.summarizeTargeting(item, diagnostics);
|
|
if (itemOperation === 'delete' && removePolicy) diagnostics.add('gpo-preference-remove-policy-invalid');
|
|
const unsupportedShape = props && (itemType === 'user'
|
|
? props.children.length > 0
|
|
: ![...props.children].every(child => child.localName.toLocaleLowerCase('en-US') === 'members')
|
|
|| directChildren(props, 'members').length > 1);
|
|
const value = props && itemOperation
|
|
? itemType === 'user' ? parseUserValue(props, itemOperation, diagnostics) : parseGroupValue(props, itemOperation, diagnostics)
|
|
: undefined;
|
|
if (unsupportedShape) { partial = true; return; }
|
|
if (diagnostics.size !== before || !uid || !itemOperation || !itemTargeting || !value) { invalid = true; return; }
|
|
const common: CommonInstruction = {
|
|
version: 1, cse: 'local-users-groups', uid, sequence, line: lines[sequence] ?? 1,
|
|
operation: itemOperation, disabled: rootDisabled || itemDisabled,
|
|
removePolicy, applyOnce, targeting: itemTargeting
|
|
};
|
|
instructions.push(itemType === 'user'
|
|
? { ...common, itemType: 'user', value: value as GpoLocalUserPreferenceValue }
|
|
: { ...common, itemType: 'group', value: value as GpoLocalGroupPreferenceValue });
|
|
});
|
|
if (diagnostics.has('gpo-preference-boolean-invalid')) invalid = true;
|
|
return {
|
|
version: 1,
|
|
parser: context.parser,
|
|
sourcePath: context.sourcePath,
|
|
sourceSha256: context.sourceSha256,
|
|
scope: context.scope,
|
|
preferenceType: 'local-users-groups',
|
|
status: invalid ? 'invalid' : partial ? 'partial' : 'complete',
|
|
secretIndicatorStatus: context.secretIndicatorStatus,
|
|
instructions: invalid ? [] : instructions,
|
|
diagnosticCodes: [...diagnostics].sort()
|
|
};
|
|
}
|
|
|
|
export function reduceLocalUsersGroupsPreferences(
|
|
document: LocalUsersGroupsPreferenceDocument,
|
|
context: { backupInstanceId: string; sourceArtifactId: string; artifactId: string }
|
|
): GpoPolicyObservation[] {
|
|
if (document.status === 'invalid' || !['computer', 'user'].includes(document.scope)) return [];
|
|
return document.instructions.map(instruction => {
|
|
const target = instruction.itemType === 'user'
|
|
? instruction.value.userName.toLocaleLowerCase('en-US')
|
|
: instruction.value.groupSid?.toLocaleUpperCase('en-US') ?? instruction.value.groupName.toLocaleLowerCase('en-US');
|
|
const id = `gpo-policy-${createHash('sha256').update([
|
|
context.backupInstanceId, context.artifactId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_LOCAL_USERS_GROUPS_REDUCER.version
|
|
].join('\0')).digest('hex').slice(0, 32)}`;
|
|
const diagnostics = new Set<string>();
|
|
if (instruction.targeting.status === 'present') diagnostics.add('gpo-preference-targeting-unevaluated');
|
|
if (instruction.itemType === 'user' && instruction.value.passwordAttributePresent) diagnostics.add('gpo-secret-indicator-present');
|
|
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_LOCAL_USERS_GROUPS_REDUCER,
|
|
identity: {
|
|
status: 'unresolved', namespace: 'group-policy-preference',
|
|
section: instruction.itemType === 'user' ? 'local user' : 'local group', key: target
|
|
},
|
|
state: 'instruction',
|
|
values: [instruction.value],
|
|
preference: {
|
|
cse: 'local-users-groups', 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: [...diagnostics].sort()
|
|
};
|
|
const errors = gpoPolicyObservationValidationErrors(observation);
|
|
if (errors.length) throw new Error(`Intern ungültige Local-Users-and-Groups-Beobachtung: ${errors.join(' ')}`);
|
|
return observation;
|
|
});
|
|
}
|