292 lines
16 KiB
TypeScript
292 lines
16 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import type { GpoBackupInstance } from '../../src/domain/gpo-ingestion';
|
|
import { gpoBackupInstanceValidationErrors } from '../../src/domain/gpo-ingestion';
|
|
import type { GpoBackupMetadataDocument } from '../../src/domain/gpo-metadata';
|
|
import { aggregateGpoBackupMetadata } from '../../src/domain/gpo-metadata';
|
|
import { isGpoSysvolPath } from '../../src/gpo/artifact-classifier';
|
|
import type { ImportResourcePolicy } from '../../src/importers/resource-policy';
|
|
import { inventoryGpoContainer, type GpoContainerInventoryResult } from './gpo-container-inventory';
|
|
import { parseAdvancedAuditCsv, reduceAdvancedAuditPolicy, type AdvancedAuditDocument } from './advanced-audit-parser';
|
|
import { parseGpoBackupMetadataXml } from './gpo-metadata-parser';
|
|
import { parseGroupPolicyPreferenceXml, reduceGroupPolicyPreferences, type GroupPolicyPreferenceDocument } from './group-policy-preference-parser';
|
|
import { parseRegistryPolicyFile, reduceRegistryPolicy, type RegistryPolicyDocument } from './registry-policy-parser';
|
|
import { parseSecurityTemplateInf, reduceSecurityTemplate, type SecurityTemplateDocument } from './security-template-parser';
|
|
import { readVerifiedZipFiles, type VerifiedZipFile } from './zip-inventory';
|
|
|
|
export interface GpoBackupIngestionOptions {
|
|
projectId: string;
|
|
sourceArtifactId: string;
|
|
discoveredAt: string;
|
|
resourcePolicy?: Partial<ImportResourcePolicy>;
|
|
maximumMetadataBytes?: number;
|
|
maximumSelectedArtifactBytes?: number;
|
|
retainIntermediateDocuments?: boolean;
|
|
}
|
|
|
|
export interface GpoBackupIngestionResult {
|
|
version: 1;
|
|
container: GpoContainerInventoryResult;
|
|
metadataDocuments: GpoBackupMetadataDocument[];
|
|
registryPolicyDocuments: RegistryPolicyDocument[];
|
|
securityTemplateDocuments: SecurityTemplateDocument[];
|
|
advancedAuditDocuments: AdvancedAuditDocument[];
|
|
preferenceDocuments: GroupPolicyPreferenceDocument[];
|
|
backupInstances: GpoBackupInstance[];
|
|
}
|
|
|
|
function parentPath(path: string): string {
|
|
const separator = path.lastIndexOf('/');
|
|
return separator < 0 ? '' : path.slice(0, separator);
|
|
}
|
|
|
|
function belongsToRoot(path: string, root: string): boolean {
|
|
return !root || path === root || path.startsWith(`${root}/`);
|
|
}
|
|
|
|
function instanceId(sourceArtifactId: string, root: string, backupId?: string): string {
|
|
return `gpo-backup-${createHash('sha256').update(`${sourceArtifactId}\0${root}\0${backupId ?? 'unresolved'}`).digest('hex').slice(0, 32)}`;
|
|
}
|
|
|
|
export function ingestGpoBackupContainer(bytes: Uint8Array, options: GpoBackupIngestionOptions): GpoBackupIngestionResult {
|
|
if (!options.projectId.trim() || !options.sourceArtifactId.trim() || !Number.isFinite(Date.parse(options.discoveredAt))) {
|
|
throw new Error('GPO-Ingestion benötigt Projekt-, Quellartefakt-ID und einen gültigen Erkennungszeitpunkt.');
|
|
}
|
|
const container = inventoryGpoContainer(bytes, options.resourcePolicy);
|
|
if (container.recognition.status !== 'gpo-backup-candidate') {
|
|
return { version: 1, container, metadataDocuments: [], registryPolicyDocuments: [], securityTemplateDocuments: [], advancedAuditDocuments: [], preferenceDocuments: [], backupInstances: [] };
|
|
}
|
|
const metadataPaths = container.inventory.entries
|
|
.filter(entry => entry.role === 'backup-metadata')
|
|
.map(entry => entry.relativePath)
|
|
.filter(path => {
|
|
const root = parentPath(path);
|
|
return container.archive.entries.some(entry => entry.kind === 'file'
|
|
&& belongsToRoot(entry.relativePath, root) && isGpoSysvolPath(entry.relativePath));
|
|
});
|
|
const metadataRoots = metadataPaths.map(parentPath);
|
|
const registryPolicyEntries = container.inventory.entries.filter(entry =>
|
|
entry.role === 'registry-policy' && metadataRoots.some(root => belongsToRoot(entry.relativePath, root))
|
|
);
|
|
const securityTemplateEntries = container.inventory.entries.filter(entry =>
|
|
entry.role === 'security-template' && metadataRoots.some(root => belongsToRoot(entry.relativePath, root))
|
|
);
|
|
const advancedAuditEntries = container.inventory.entries.filter(entry =>
|
|
entry.role === 'advanced-audit-policy' && metadataRoots.some(root => belongsToRoot(entry.relativePath, root))
|
|
);
|
|
const preferenceEntries = container.inventory.entries.filter(entry =>
|
|
entry.role === 'group-policy-preference' && metadataRoots.some(root => belongsToRoot(entry.relativePath, root))
|
|
);
|
|
const selectedPaths = [...new Set([
|
|
...metadataPaths,
|
|
...registryPolicyEntries.map(entry => entry.relativePath),
|
|
...securityTemplateEntries.map(entry => entry.relativePath),
|
|
...advancedAuditEntries.map(entry => entry.relativePath),
|
|
...preferenceEntries.map(entry => entry.relativePath)
|
|
])];
|
|
const verifiedFiles = readVerifiedZipFiles(
|
|
bytes,
|
|
selectedPaths,
|
|
options.resourcePolicy,
|
|
options.maximumSelectedArtifactBytes ?? options.maximumMetadataBytes ?? 16 * 1024 * 1024
|
|
);
|
|
const retainIntermediateDocuments = options.retainIntermediateDocuments ?? true;
|
|
const metadataPathSet = new Set(metadataPaths);
|
|
const metadataDocuments = verifiedFiles
|
|
.filter(file => metadataPathSet.has(file.entry.relativePath))
|
|
.map(file => parseGpoBackupMetadataXml(file, options.resourcePolicy));
|
|
const registryEntryByPath = new Map(registryPolicyEntries.map(entry => [entry.relativePath, entry]));
|
|
const securityEntryByPath = new Map(securityTemplateEntries.map(entry => [entry.relativePath, entry]));
|
|
const advancedAuditEntryByPath = new Map(advancedAuditEntries.map(entry => [entry.relativePath, entry]));
|
|
const preferenceEntryByPath = new Map(preferenceEntries.map(entry => [entry.relativePath, entry]));
|
|
const registryPolicyDocuments: RegistryPolicyDocument[] = [];
|
|
const securityTemplateDocuments: SecurityTemplateDocument[] = [];
|
|
const advancedAuditDocuments: AdvancedAuditDocument[] = [];
|
|
const preferenceDocuments: GroupPolicyPreferenceDocument[] = [];
|
|
const documentsByRoot = new Map<string, GpoBackupMetadataDocument[]>();
|
|
for (const document of metadataDocuments) {
|
|
const root = parentPath(document.sourcePath);
|
|
documentsByRoot.set(root, [...(documentsByRoot.get(root) ?? []), document]);
|
|
}
|
|
const rootsBySpecificity = [...documentsByRoot.keys()]
|
|
.sort((left, right) => right.length - left.length || left.localeCompare(right));
|
|
const groupFilesByRoot = (paths: Set<string>): Map<string, VerifiedZipFile[]> => {
|
|
const grouped = new Map<string, VerifiedZipFile[]>();
|
|
for (const file of verifiedFiles) {
|
|
if (!paths.has(file.entry.relativePath)) continue;
|
|
const root = rootsBySpecificity.find(candidate => belongsToRoot(file.entry.relativePath, candidate));
|
|
if (root === undefined) continue;
|
|
grouped.set(root, [...(grouped.get(root) ?? []), file]);
|
|
}
|
|
return grouped;
|
|
};
|
|
const registryFilesByRoot = groupFilesByRoot(new Set(registryEntryByPath.keys()));
|
|
const securityFilesByRoot = groupFilesByRoot(new Set(securityEntryByPath.keys()));
|
|
const auditFilesByRoot = groupFilesByRoot(new Set(advancedAuditEntryByPath.keys()));
|
|
const preferenceFilesByRoot = groupFilesByRoot(new Set(preferenceEntryByPath.keys()));
|
|
const backupInstances = [...documentsByRoot]
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([root, documents]) => {
|
|
const aggregate = aggregateGpoBackupMetadata(documents);
|
|
const id = instanceId(options.sourceArtifactId, root, aggregate.status === 'invalid' ? undefined : aggregate.backupId);
|
|
const policyObservations: GpoBackupInstance['policyObservations'] = [];
|
|
const artifactDiagnostics = new Set(aggregate.diagnosticCodes);
|
|
const registryByPath = new Map<string, { status: RegistryPolicyDocument['status']; scope: RegistryPolicyDocument['scope'] }>();
|
|
const templateByPath = new Map<string, { status: SecurityTemplateDocument['status']; scope: SecurityTemplateDocument['scope'] }>();
|
|
const auditByPath = new Map<string, { status: AdvancedAuditDocument['status']; scope: AdvancedAuditDocument['scope'] }>();
|
|
const preferenceByPath = new Map<string, {
|
|
status: GroupPolicyPreferenceDocument['status']; scope: GroupPolicyPreferenceDocument['scope'];
|
|
secretIndicatorStatus: GroupPolicyPreferenceDocument['secretIndicatorStatus'];
|
|
}>();
|
|
let blocked = aggregate.status === 'invalid';
|
|
|
|
for (const file of registryFilesByRoot.get(root) ?? []) {
|
|
const artifact = registryEntryByPath.get(file.entry.relativePath)!;
|
|
const document = parseRegistryPolicyFile(file, artifact.scope, options.resourcePolicy);
|
|
if (retainIntermediateDocuments) registryPolicyDocuments.push(document);
|
|
registryByPath.set(document.sourcePath, { status: document.status, scope: document.scope });
|
|
document.diagnosticCodes.forEach(code => artifactDiagnostics.add(code));
|
|
blocked ||= document.status === 'invalid';
|
|
policyObservations.push(...reduceRegistryPolicy(document, {
|
|
backupInstanceId: id, sourceArtifactId: options.sourceArtifactId, artifactId: artifact.artifactId
|
|
}));
|
|
}
|
|
for (const file of securityFilesByRoot.get(root) ?? []) {
|
|
const artifact = securityEntryByPath.get(file.entry.relativePath)!;
|
|
const document = parseSecurityTemplateInf(file, artifact.scope, options.resourcePolicy);
|
|
if (retainIntermediateDocuments) securityTemplateDocuments.push(document);
|
|
templateByPath.set(document.sourcePath, { status: document.status, scope: document.scope });
|
|
document.diagnosticCodes.forEach(code => artifactDiagnostics.add(code));
|
|
blocked ||= document.status === 'invalid';
|
|
policyObservations.push(...reduceSecurityTemplate(document, {
|
|
backupInstanceId: id, sourceArtifactId: options.sourceArtifactId, artifactId: artifact.artifactId
|
|
}));
|
|
}
|
|
for (const file of auditFilesByRoot.get(root) ?? []) {
|
|
const artifact = advancedAuditEntryByPath.get(file.entry.relativePath)!;
|
|
const document = parseAdvancedAuditCsv(file, artifact.scope, options.resourcePolicy);
|
|
if (retainIntermediateDocuments) advancedAuditDocuments.push(document);
|
|
auditByPath.set(document.sourcePath, { status: document.status, scope: document.scope });
|
|
document.diagnosticCodes.forEach(code => artifactDiagnostics.add(code));
|
|
blocked ||= document.status === 'invalid';
|
|
policyObservations.push(...reduceAdvancedAuditPolicy(document, {
|
|
backupInstanceId: id, sourceArtifactId: options.sourceArtifactId, artifactId: artifact.artifactId
|
|
}));
|
|
}
|
|
for (const file of preferenceFilesByRoot.get(root) ?? []) {
|
|
const artifact = preferenceEntryByPath.get(file.entry.relativePath)!;
|
|
const document = parseGroupPolicyPreferenceXml(file, artifact.scope, options.resourcePolicy);
|
|
if (retainIntermediateDocuments) preferenceDocuments.push(document);
|
|
preferenceByPath.set(document.sourcePath, {
|
|
status: document.status, scope: document.scope, secretIndicatorStatus: document.secretIndicatorStatus
|
|
});
|
|
document.diagnosticCodes.forEach(code => artifactDiagnostics.add(code));
|
|
blocked ||= document.status === 'invalid';
|
|
policyObservations.push(...reduceGroupPolicyPreferences(document, {
|
|
backupInstanceId: id, sourceArtifactId: options.sourceArtifactId, artifactId: artifact.artifactId
|
|
}));
|
|
}
|
|
policyObservations.forEach(observation => observation.diagnosticCodes.forEach(code => artifactDiagnostics.add(code)));
|
|
const secretByPath = new Map(documents.map(document => [document.sourcePath, document.secretIndicatorStatus]));
|
|
const entries = container.inventory.entries
|
|
.filter(entry => belongsToRoot(entry.relativePath, root))
|
|
.map(entry => {
|
|
if (entry.role === 'backup-metadata') return {
|
|
...entry,
|
|
disposition: aggregate.status === 'invalid' ? 'blocked' as const : 'supported' as const,
|
|
secretIndicatorStatus: secretByPath.get(entry.relativePath) ?? 'not-inspected' as const
|
|
};
|
|
if (entry.role === 'registry-policy') {
|
|
const document = registryByPath.get(entry.relativePath);
|
|
return {
|
|
...entry,
|
|
disposition: document?.status === 'invalid' ? 'blocked' as const
|
|
: document?.status === 'complete' && ['computer', 'user'].includes(document.scope) ? 'supported' as const
|
|
: 'inventory-only' as const,
|
|
secretIndicatorStatus: 'not-inspected' as const
|
|
};
|
|
}
|
|
if (entry.role === 'security-template') {
|
|
const document = templateByPath.get(entry.relativePath);
|
|
return {
|
|
...entry,
|
|
disposition: document?.status === 'invalid' ? 'blocked' as const
|
|
: document?.status === 'complete' && ['computer', 'user'].includes(document.scope) ? 'supported' as const
|
|
: 'inventory-only' as const,
|
|
secretIndicatorStatus: 'not-inspected' as const
|
|
};
|
|
}
|
|
if (entry.role === 'advanced-audit-policy') {
|
|
const document = auditByPath.get(entry.relativePath);
|
|
return {
|
|
...entry,
|
|
disposition: document?.status === 'invalid' ? 'blocked' as const
|
|
: document?.status === 'complete' && document.scope === 'computer' ? 'supported' as const
|
|
: 'inventory-only' as const,
|
|
secretIndicatorStatus: 'not-inspected' as const
|
|
};
|
|
}
|
|
if (entry.role === 'group-policy-preference') {
|
|
const document = preferenceByPath.get(entry.relativePath);
|
|
return {
|
|
...entry,
|
|
disposition: document?.status === 'invalid' ? 'blocked' as const
|
|
: document?.status === 'complete' && ['computer', 'user'].includes(document.scope) ? 'supported' as const
|
|
: 'inventory-only' as const,
|
|
secretIndicatorStatus: document?.secretIndicatorStatus ?? 'not-inspected' as const
|
|
};
|
|
}
|
|
return entry;
|
|
});
|
|
const instance: GpoBackupInstance = {
|
|
version: 1,
|
|
id,
|
|
projectId: options.projectId,
|
|
sourceArtifactId: options.sourceArtifactId,
|
|
identity: aggregate.status !== 'invalid' && aggregate.gpoId && aggregate.backupId
|
|
? { status: 'resolved', gpoId: aggregate.gpoId, backupId: aggregate.backupId, evidence: 'backup-metadata' }
|
|
: { status: 'unresolved', reasonCode: aggregate.status === 'invalid' ? 'metadata-conflict' : 'metadata-incomplete' },
|
|
domain: aggregate.status !== 'invalid' && aggregate.domainKey
|
|
? { status: 'resolved', key: aggregate.domainKey, evidence: 'backup-metadata' }
|
|
: { status: 'unknown' },
|
|
collection: { status: 'observed', collectedAt: options.discoveredAt, evidence: 'backend-receipt' },
|
|
backupSet: { id: 'gpo-backup-set:' + options.sourceArtifactId, scope: 'selected-gpos', evidence: 'user-selection' },
|
|
backupCreatedAt: aggregate.status === 'invalid' ? undefined : aggregate.backupCreatedAt,
|
|
metadataStatus: aggregate.status === 'invalid' ? 'unreadable' : aggregate.status,
|
|
sections: {
|
|
computer: entries.some(entry => entry.scope === 'computer' && entry.role !== 'backup-metadata' && entry.disposition !== 'blocked')
|
|
? 'present'
|
|
: entries.some(entry => entry.scope === 'computer' && entry.role !== 'backup-metadata') ? 'unreadable' : 'unknown',
|
|
user: entries.some(entry => entry.scope === 'user' && entry.role !== 'backup-metadata' && entry.disposition !== 'blocked')
|
|
? 'present'
|
|
: entries.some(entry => entry.scope === 'user' && entry.role !== 'backup-metadata') ? 'unreadable' : 'unknown'
|
|
},
|
|
inventory: {
|
|
version: 1,
|
|
status: blocked ? 'blocked' : 'complete',
|
|
entries,
|
|
diagnosticCodes: [...new Set([...container.inventory.diagnosticCodes, ...artifactDiagnostics])].sort()
|
|
},
|
|
policyObservations,
|
|
discoveredAt: options.discoveredAt
|
|
};
|
|
const errors = gpoBackupInstanceValidationErrors(instance);
|
|
if (errors.length) throw new Error(`Intern ungültige GPO-Backupinstanz: ${errors.join(' ')}`);
|
|
return instance;
|
|
});
|
|
return {
|
|
version: 1,
|
|
container,
|
|
metadataDocuments: retainIntermediateDocuments ? metadataDocuments : [],
|
|
registryPolicyDocuments,
|
|
securityTemplateDocuments,
|
|
advancedAuditDocuments,
|
|
preferenceDocuments,
|
|
backupInstances
|
|
};
|
|
}
|
|
|
|
|
|
|
|
|
|
|