238 lines
12 KiB
TypeScript
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;
|
|
});
|
|
}
|