Assessor_2.0/server/gpo/gpo-backup-ingestion.ts

294 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
?? Math.min(options.resourcePolicy?.maximumZipUncompressedBytes ?? 512 * 1024 * 1024, 128 * 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,
...(aggregate.links ? { links: { ...aggregate.links, evidence: 'gp-report' as const } } : {}),
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
};
}