Assessor_2.0/server/gpo/gpp-local-users-groups.ts

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