Assessor_2.0/server/gpo/gpp-services.ts

238 lines
12 KiB
TypeScript

import { createHash } from 'node:crypto';
import type { GpoPolicyObservation, GpoPreferenceContext, GpoServicePreferenceValue } 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_SERVICES_REDUCER = { id: 'gpo-preferences-services-reducer', version: '1.0.0' } as const;
const servicesClsid = '2cfb484a-4e96-4b5d-a0b6-093d2f91e6ae';
const serviceClsid = 'ab6f0b67-341f-4e51-92f9-005fbfba1a43';
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;
type Targeting = GpoPreferenceContext['targeting'];
export interface GppServiceInstruction {
version: 1;
cse: 'services';
uid: string;
sequence: number;
line: number;
operation: 'configure';
disabled: boolean;
removePolicy: boolean;
applyOnce: boolean;
targeting: Targeting;
value: GpoServicePreferenceValue;
}
export interface ServicesPreferenceDocument {
version: 1;
parser: { id: string; version: string };
sourcePath: string;
sourceSha256: string;
scope: GpoPolicyScope;
preferenceType: 'services';
status: 'complete' | 'partial' | 'invalid';
secretIndicatorStatus: 'not-detected' | 'present';
instructions: GppServiceInstruction[];
diagnosticCodes: string[];
}
export interface ServicesParseContext {
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;
}
const serviceActions = {
NOCHANGE: 'no-change', START: 'start', STOP: 'stop', RESTART: 'restart', RESTART_IF_REQUIRED: 'restart-if-required'
} as const;
const startupTypes = {
NOCHANGE: 'no-change', AUTOMATIC: 'automatic', BOOT: 'boot', DISABLED: 'disabled', MANUAL: 'manual', SYSTEM: 'system'
} as const;
const failureActions = {
START: 'start', STOP: 'stop', RESTART: 'restart', NOACTION: 'no-action', RESTART_IF_REQUIRED: 'restart-if-required'
} as const;
const allowedPropertyAttributes = new Set([
'serviceaction', 'startuptype', 'servicename', 'timeout', 'accountname', 'cpassword', 'interact',
'firstfailure', 'secondfailure', 'thirdfailure', 'resetfailcountdelay', 'restartservicedelay',
'restartcomputerdelay', 'restartmessage', 'program', 'args', 'append'
]);
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 undefined;
}
function integerAttribute(element: Element, name: string, diagnostics: Set<string>): number | undefined {
const value = attribute(element, name);
if (value === undefined) return undefined;
if (!/^\d+$/.test(value)) { diagnostics.add('gpo-preference-service-number-invalid'); return undefined; }
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) { diagnostics.add('gpo-preference-service-number-invalid'); return undefined; }
return parsed;
}
function enumAttribute<T extends Record<string, string>>(
element: Element,
name: string,
values: T,
diagnostics: Set<string>,
defaultKey?: keyof T
): T[keyof T] | undefined {
const raw = attribute(element, name);
const key = (raw === undefined && defaultKey ? String(defaultKey) : raw?.toLocaleUpperCase('en-US')) as keyof T | undefined;
if (key !== undefined && Object.hasOwn(values, key)) return values[key];
diagnostics.add('gpo-preference-service-enum-invalid');
return undefined;
}
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 itemLines(text: string): number[] {
return xmlElementStartLines(text, /<(?:[A-Za-z_][\w.-]*:)?NTService(?=[\s/>])/g);
}
function parseServiceValue(properties: Element, diagnostics: Set<string>): GpoServicePreferenceValue | undefined {
const serviceName = attribute(properties, 'serviceName');
if (!serviceName?.trim()) { diagnostics.add('gpo-preference-service-target-invalid'); return undefined; }
const serviceAction = enumAttribute(properties, 'serviceAction', serviceActions, diagnostics, 'NOCHANGE');
const startupType = enumAttribute(properties, 'startupType', startupTypes, diagnostics);
const firstFailure = attribute(properties, 'firstFailure') === undefined
? undefined : enumAttribute(properties, 'firstFailure', failureActions, diagnostics);
const secondFailure = attribute(properties, 'secondFailure') === undefined
? undefined : enumAttribute(properties, 'secondFailure', failureActions, diagnostics);
const thirdFailure = attribute(properties, 'thirdFailure') === undefined
? undefined : enumAttribute(properties, 'thirdFailure', failureActions, diagnostics);
const timeout = integerAttribute(properties, 'timeout', diagnostics);
const interact = booleanAttribute(properties, 'interact', diagnostics, true);
const resetFailCountDelay = integerAttribute(properties, 'resetFailCountDelay', diagnostics);
const restartServiceDelay = integerAttribute(properties, 'restartServiceDelay', diagnostics);
const restartComputerDelay = integerAttribute(properties, 'restartComputerDelay', diagnostics);
if (!serviceAction || !startupType) return undefined;
return {
kind: 'service-preference', serviceName, serviceAction, startupType, timeout,
accountName: attribute(properties, 'accountName'),
passwordAttributePresent: [...properties.attributes]
.some(candidate => candidate.localName.toLocaleLowerCase('en-US') === 'cpassword'),
interact, firstFailure, secondFailure, thirdFailure,
resetFailCountDelay, restartServiceDelay, restartComputerDelay,
restartMessage: attribute(properties, 'restartMessage'),
program: attribute(properties, 'program'),
args: attribute(properties, 'args'),
append: attribute(properties, 'append')
};
}
export function parseServicesPreference(context: ServicesParseContext): ServicesPreferenceDocument {
const { root, diagnostics } = context;
const rootDisabled = booleanAttribute(root, 'disabled', diagnostics) ?? false;
if (canonicalGuid(attribute(root, 'clsid')) !== servicesClsid) diagnostics.add('gpo-preference-services-root-invalid');
const items = directChildren(root, 'NTService');
const unsupportedScope = context.scope !== 'computer';
const unsupportedRootChild = [...root.children].some(child => child.localName.toLocaleLowerCase('en-US') !== 'ntservice');
let partial = unsupportedScope || unsupportedRootChild;
if (unsupportedScope) diagnostics.add('gpo-preference-services-scope-unsupported');
if (unsupportedRootChild) diagnostics.add('gpo-preference-services-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-services-root-invalid')
|| diagnostics.has('gpo-preference-line-provenance-invalid')
|| diagnostics.has('gpo-preference-boolean-invalid');
const instructions: GppServiceInstruction[] = [];
items.forEach((item, sequence) => {
const before = diagnostics.size;
const uid = canonicalGuid(attribute(item, 'uid'));
if (!uid || canonicalGuid(attribute(item, 'clsid')) !== serviceClsid) diagnostics.add('gpo-preference-service-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-service-item-invalid');
const props = properties[0];
const unsupportedShape = props && (props.children.length > 0 || [...props.attributes]
.some(candidate => !allowedPropertyAttributes.has(candidate.localName.toLocaleLowerCase('en-US'))));
if (unsupportedShape) {
diagnostics.add('gpo-preference-service-item-unsupported');
partial = true;
return;
}
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);
const value = props ? parseServiceValue(props, diagnostics) : undefined;
if (diagnostics.size !== before || !uid || !itemTargeting || !value) { invalid = true; return; }
instructions.push({
version: 1, cse: 'services', uid, sequence, line: lines[sequence] ?? 1, operation: 'configure',
disabled: rootDisabled || itemDisabled, removePolicy, applyOnce, targeting: itemTargeting, value
});
});
if (diagnostics.has('gpo-preference-boolean-invalid')
|| diagnostics.has('gpo-preference-service-number-invalid')
|| diagnostics.has('gpo-preference-service-enum-invalid')) invalid = true;
return {
version: 1, parser: context.parser, sourcePath: context.sourcePath, sourceSha256: context.sourceSha256,
scope: context.scope, preferenceType: 'services', status: invalid ? 'invalid' : partial ? 'partial' : 'complete',
secretIndicatorStatus: context.secretIndicatorStatus, instructions: invalid || unsupportedScope ? [] : instructions,
diagnosticCodes: [...diagnostics].sort()
};
}
export function reduceServicesPreferences(
document: ServicesPreferenceDocument,
context: { backupInstanceId: string; sourceArtifactId: string; artifactId: string }
): GpoPolicyObservation[] {
if (document.status === 'invalid' || document.scope !== 'computer') return [];
return document.instructions.map(instruction => {
const id = `gpo-policy-${createHash('sha256').update([
context.backupInstanceId, context.artifactId, document.sourceSha256, instruction.uid, String(instruction.sequence), GPP_SERVICES_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.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: 'computer', deliveryMechanism: 'group-policy-preference', parser: document.parser,
reducer: GPP_SERVICES_REDUCER,
identity: {
status: 'unresolved', namespace: 'group-policy-preference', section: 'service',
key: instruction.value.serviceName.toLocaleLowerCase('en-US')
},
state: 'instruction', values: [instruction.value],
preference: {
cse: 'services', 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 Services-Beobachtung: ${errors.join(' ')}`);
return observation;
});
}