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

339 lines
17 KiB
TypeScript

import { deflateRawSync } from 'node:zlib';
import { describe, expect, it } from 'vitest';
import { ingestGpoBackupContainer } from '../../server/gpo/gpo-backup-ingestion';
import { gpoBackupCompleteness } from '../../src/domain/gpo-ingestion';
import { buildGpoAnalysisSnapshots } from '../../src/gpo/snapshot-builder';
import { ImportError } from '../../src/shared/errors';
import { createGpoFolderTransport } from '../../src/gpo/gpo-folder-transport';
interface Entry {
path: string;
content: string | Uint8Array;
}
function crc32(bytes: Uint8Array): number {
let value = 0xffffffff;
for (const byte of bytes) {
value ^= byte;
for (let bit = 0; bit < 8; bit += 1) value = (value >>> 1) ^ (0xedb88320 & -(value & 1));
}
return (value ^ 0xffffffff) >>> 0;
}
function zip(entries: Entry[]): Uint8Array {
const localParts: Buffer[] = [];
const centralParts: Buffer[] = [];
let localOffset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.path);
const content = typeof entry.content === 'string' ? Buffer.from(entry.content) : Buffer.from(entry.content);
const compressed = deflateRawSync(content);
const checksum = crc32(content);
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(8, 8);
local.writeUInt32LE(checksum, 14); local.writeUInt32LE(compressed.length, 18); local.writeUInt32LE(content.length, 22);
local.writeUInt16LE(name.length, 26); name.copy(local, 30);
localParts.push(local, compressed);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8); central.writeUInt16LE(8, 10); central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(compressed.length, 20); central.writeUInt32LE(content.length, 24); central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(localOffset, 42); name.copy(central, 46);
centralParts.push(central);
localOffset += local.length + compressed.length;
}
const directory = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(localOffset, 16);
return Buffer.concat([...localParts, directory, end]);
}
const discoveredAt = '2026-07-20T12:00:00.000Z';
const gpoId = '11111111-1111-4111-8111-111111111111';
const backupId = '22222222-2222-4222-8222-222222222222';
function metadata(gpo = gpoId, backup = backupId, extra = ''): string {
return `<?xml version="1.0"?><BackupInst><ID>{${backup}}</ID><GPOGuid>{${gpo}}</GPOGuid><GPODomain>synthetic.example</GPODomain><BackupTime>2026-07-19T10:30:00Z</BackupTime>${extra}</BackupInst>`;
}
function ingest(bytes: Uint8Array) {
return ingestGpoBackupContainer(bytes, {
projectId: 'project-synthetic', sourceArtifactId: 'artifact-synthetic', discoveredAt
});
}
describe('GPO backup metadata ingestion', () => {
it('processes a 2300-GPO folder and ZIP including metadata larger than 2 MiB', async () => {
let seed = 31;
const noise = Array.from({ length: 8192 }, () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return String.fromCharCode(65 + (seed >>> 16) % 26);
}).join('');
const entries: Entry[] = [];
for (let index = 1; index <= 2300; index++) {
const suffix = index.toString(16).padStart(12, '0');
entries.push({ path: `backup-${index}/Backup.xml`, content: metadata(
`00000000-0000-4000-8000-${suffix}`, `11111111-1111-4111-8111-${suffix}`,
`<Details>${index === 1 ? noise.repeat(270) : noise}</Details>`
) });
entries.push({ path: `backup-${index}/DomainSysvol/GPO/Machine/Registry.pol`, content: new Uint8Array([80, 82, 101, 103, 1, 0, 0, 0]) });
}
// Folder transport preserves the same bytes and goes through the ZIP ingestion boundary.
const sources = entries.map(entry => {
const bytes = typeof entry.content === 'string' ? new TextEncoder().encode(entry.content) : entry.content;
return { relativePath: entry.path, sizeBytes: bytes.length, readBytes: async () => bytes };
});
const folder = await createGpoFolderTransport(sources);
expect(folder.sourceBytes).toBeGreaterThan(16 * 1024 * 1024);
for (const bytes of [folder.bytes, zip(entries)]) {
const result = ingestGpoBackupContainer(bytes, {
projectId: 'synthetic-load', sourceArtifactId: 'synthetic-load-artifact', discoveredAt,
retainIntermediateDocuments: false
});
expect(result.backupInstances).toHaveLength(2300);
expect(result.backupInstances.every(instance => instance.identity.status === 'resolved')).toBe(true);
}
}, 60_000);
it('derives technical identity from XML content and keeps set/effectiveness unknown', () => {
const root = 'folder-name-is-not-an-identity';
const result = ingest(zip([
{ path: `${root}/Backup.xml`, content: metadata() },
{ path: `${root}/DomainSysvol/GPO/Machine/Registry.pol`, content: new Uint8Array([80, 82, 101, 103, 1, 0, 0, 0]) }
]));
expect(result.backupInstances).toHaveLength(1);
expect(result.metadataDocuments[0]).toMatchObject({ status: 'complete', secretIndicatorStatus: 'not-detected' });
expect(result.backupInstances[0]).toMatchObject({
identity: { status: 'resolved', gpoId, backupId, evidence: 'backup-metadata' },
domain: { status: 'resolved', key: 'dns:synthetic.example', evidence: 'backup-metadata' },
collection: { status: 'observed', collectedAt: discoveredAt, evidence: 'backend-receipt' },
backupSet: { id: 'gpo-backup-set:artifact-synthetic', scope: 'selected-gpos', evidence: 'user-selection' },
backupCreatedAt: '2026-07-19T10:30:00.000Z',
metadataStatus: 'complete',
sections: { computer: 'present', user: 'unknown' }
});
expect(gpoBackupCompleteness(result.backupInstances[0])).toBe('unknown');
});
it('keeps GPMC local backup times unresolved without discarding technical identity', () => {
const localTimeMetadata = metadata().replace('2026-07-19T10:30:00Z', '2026-07-19T10:30:00');
const result = ingest(zip([
{ path: 'one/bkupInfo.xml', content: localTimeMetadata },
{ path: 'one/DomainSysvol/GPO/Machine/Registry.pol', content: new Uint8Array([80, 82, 101, 103, 1, 0, 0, 0]) }
]));
expect(result.metadataDocuments[0]).toMatchObject({
status: 'partial',
gpoId,
backupId,
domainKey: 'dns:synthetic.example',
backupCreatedAt: undefined
});
expect(result.metadataDocuments[0].diagnosticCodes).toContain('gpo-metadata-backup-time-zone-missing');
expect(result.backupInstances[0]).toMatchObject({
identity: { status: 'resolved', gpoId, backupId },
domain: { status: 'resolved', key: 'dns:synthetic.example' },
backupCreatedAt: undefined,
metadataStatus: 'partial',
inventory: { status: 'complete' }
});
});
it('parses Security Templates into versioned observations without claiming effective application', () => {
const path = 'one/DomainSysvol/GPO/Machine/Microsoft/Windows NT/SecEdit/GptTmpl.inf';
const result = ingest(zip([
{ path: 'one/Backup.xml', content: metadata() },
{ path, content: '[Unicode]\r\nUnicode=yes\r\n[System Access]\r\nMinimumPasswordLength=14\r\n[Version]\r\nsignature="$CHICAGO$"' }
]));
expect(result.securityTemplateDocuments).toMatchObject([{ status: 'complete', sourcePath: path }]);
expect(result.backupInstances[0].inventory.entries).toEqual(expect.arrayContaining([
expect.objectContaining({ relativePath: path, role: 'security-template', disposition: 'supported', secretIndicatorStatus: 'not-inspected' })
]));
expect(result.backupInstances[0].policyObservations).toEqual([
expect.objectContaining({
deliveryMechanism: 'security-template',
state: 'configured',
identity: expect.objectContaining({ section: 'System Access', key: 'MinimumPasswordLength' }),
values: [{ kind: 'scalar', raw: '14' }],
effectiveState: 'unknown'
})
]);
});
it('keeps identical policy files at different paths as distinct observations', () => {
const content = '[System Access]\r\nMinimumPasswordLength=14';
const result = ingest(zip([
{ path: 'one/Backup.xml', content: metadata() },
{ path: 'one/DomainSysvol/GPO/Machine/Microsoft/Windows NT/SecEdit/GptTmpl.inf', content },
{ path: 'one/DomainSysvol/GPO/User/Microsoft/Windows NT/SecEdit/GptTmpl.inf', content }
]));
const observations = result.backupInstances[0].policyObservations ?? [];
expect(observations).toHaveLength(2);
expect(new Set(observations.map(item => item.id))).toHaveLength(2);
expect(new Set(observations.map(item => item.artifactId))).toHaveLength(2);
});
it('releases parser intermediates on the persistence worker path', () => {
const path = 'one/DomainSysvol/GPO/Machine/Microsoft/Windows NT/SecEdit/GptTmpl.inf';
const result = ingestGpoBackupContainer(zip([
{ path: 'one/Backup.xml', content: metadata() },
{ path, content: '[System Access]\r\nMinimumPasswordLength=14' }
]), {
projectId: 'project-synthetic', sourceArtifactId: 'artifact-synthetic', discoveredAt,
retainIntermediateDocuments: false
});
expect(result).toMatchObject({
metadataDocuments: [], securityTemplateDocuments: [], advancedAuditDocuments: [], preferenceDocuments: []
});
expect(result.backupInstances[0].policyObservations).toEqual([
expect.objectContaining({ deliveryMechanism: 'security-template', sourcePath: path })
]);
});
it('retains valid Security Template observations with visible partial inventory diagnostics', () => {
const path = 'one/DomainSysvol/GPO/Machine/Microsoft/Windows NT/SecEdit/GptTmpl.inf';
const result = ingest(zip([
{ path: 'one/Backup.xml', content: metadata() },
{ path, content: '[System Access]\r\nMinimumPasswordLength=14\r\n[Registry Values]\r\nMACHINE\\Synthetic=not-a-type' }
]));
expect(result.securityTemplateDocuments).toMatchObject([{
status: 'partial', instructions: [expect.objectContaining({ key: 'MinimumPasswordLength' })],
diagnosticCodes: ['gpo-security-template-registry-value-invalid']
}]);
expect(result.backupInstances[0]).toMatchObject({
inventory: {
status: 'complete',
diagnosticCodes: expect.arrayContaining(['gpo-security-template-registry-value-invalid']),
entries: expect.arrayContaining([expect.objectContaining({ relativePath: path, disposition: 'inventory-only' })])
},
policyObservations: [expect.objectContaining({ sourceLines: [2], values: [{ kind: 'scalar', raw: '14' }] })]
});
expect(gpoBackupCompleteness(result.backupInstances[0])).toBe('partial');
});
it('blocks malformed Advanced Audit Policy without salvaging valid-looking rows', () => {
const path = 'one/DomainSysvol/GPO/Machine/Microsoft/Windows NT/Audit/audit.csv';
const content = [
'Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting,Setting Value',
',System,ignored,{0cce9210-69ae-11d9-bed3-505054503030},Success,,1',
',System,ignored,not-a-guid,Failure,,2'
].join('\r\n');
const result = ingest(zip([
{ path: 'one/Backup.xml', content: metadata() },
{ path, content }
]));
expect(result.advancedAuditDocuments).toMatchObject([{
status: 'invalid', instructions: [], diagnosticCodes: ['gpo-advanced-audit-instruction-invalid']
}]);
expect(result.backupInstances[0]).toMatchObject({
sections: { computer: 'unreadable' },
inventory: { status: 'blocked' },
policyObservations: []
});
});
it('separates multiple backup roots by metadata content rather than folder GUIDs', () => {
const secondGpo = '33333333-3333-4333-8333-333333333333';
const secondBackup = '44444444-4444-4444-8444-444444444444';
const result = ingest(zip([
{ path: 'first/Backup.xml', content: metadata() },
{ path: 'first/DomainSysvol/GPO/gpt.ini', content: '[General]' },
{ path: 'second/Backup.xml', content: metadata(secondGpo, secondBackup) },
{ path: 'second/DomainSysvol/GPO/gpt.ini', content: '[General]' }
]));
expect(result.backupInstances).toHaveLength(2);
expect(result.backupInstances.map(instance => instance.identity)).toEqual(expect.arrayContaining([
expect.objectContaining({ gpoId, backupId }),
expect.objectContaining({ gpoId: secondGpo, backupId: secondBackup })
]));
expect(new Set(result.backupInstances.map(instance => instance.backupSet.id))).toEqual(new Set(['gpo-backup-set:artifact-synthetic']));
expect(result.backupInstances.every(instance => instance.backupSet.scope === 'selected-gpos'
&& instance.backupSet.evidence === 'user-selection')).toBe(true);
const snapshots = buildGpoAnalysisSnapshots(result.backupInstances, { createdAt: discoveredAt });
expect(snapshots).toHaveLength(1);
expect(snapshots[0]).toMatchObject({
backupSetId: 'gpo-backup-set:artifact-synthetic',
completeness: 'selected-subset',
instanceIds: expect.arrayContaining(result.backupInstances.map(instance => instance.id)),
permitsSetAbsenceConclusions: false
});
});
it('blocks conflicting technical metadata instead of choosing one value', () => {
const conflictingGpo = '33333333-3333-4333-8333-333333333333';
const result = ingest(zip([
{ path: 'one/Backup.xml', content: metadata() },
{ path: 'one/bkupInfo.xml', content: metadata(conflictingGpo) },
{ path: 'one/DomainSysvol/GPO/gpt.ini', content: '[General]' }
]));
expect(result.backupInstances[0]).toMatchObject({
identity: { status: 'unresolved', reasonCode: 'metadata-conflict' },
metadataStatus: 'unreadable',
inventory: { status: 'blocked' }
});
expect(result.backupInstances[0].inventory.diagnosticCodes).toContain('gpo-metadata-gpoId-conflict');
});
it('detects but never returns a GPP-style secret value from metadata XML', () => {
const result = ingest(zip([
{ path: 'one/Backup.xml', content: metadata(gpoId, backupId, '<Properties cpassword="synthetic-secret" />') },
{ path: 'one/DomainSysvol/GPO/gpt.ini', content: '[General]' }
]));
expect(result.metadataDocuments[0].secretIndicatorStatus).toBe('present');
expect(result.metadataDocuments[0].diagnosticCodes).toContain('gpo-secret-indicator-present');
expect(JSON.stringify(result)).not.toContain('synthetic-secret');
});
it('does not combine metadata and SYSVOL evidence from different backup roots', () => {
const result = ingest(zip([
{ path: 'metadata-only/Backup.xml', content: metadata() },
{ path: 'different-root/DomainSysvol/GPO/gpt.ini', content: '[General]' }
]));
expect(result.container.recognition.status).toBe('unrecognized-zip');
expect(result.backupInstances).toEqual([]);
});
it('blocks ambiguous domain and non-ISO backup time fields', () => {
const ambiguous = `<?xml version="1.0"?><BackupInst><ID>{${backupId}}</ID><GPOGuid>{${gpoId}}</GPOGuid><GPODomain>invalid_domain</GPODomain><BackupTime>07/19/2026 10:30</BackupTime></BackupInst>`;
const result = ingest(zip([
{ path: 'one/Backup.xml', content: ambiguous },
{ path: 'one/DomainSysvol/GPO/gpt.ini', content: '[General]' }
]));
expect(result.metadataDocuments[0]).toMatchObject({ status: 'invalid', domainKey: undefined, backupCreatedAt: undefined });
expect(result.backupInstances[0]).toMatchObject({ metadataStatus: 'unreadable', inventory: { status: 'blocked' } });
});
it('does not salvage valid identity, domain or time values beside malformed recognized values', () => {
const mixed = `<?xml version="1.0"?><BackupInst><ID>{${backupId}}</ID><ID>not-a-guid</ID><GPOGuid>{${gpoId}}</GPOGuid><GPOGuid>not-a-guid</GPOGuid><GPODomain>synthetic.example</GPODomain><GPODomain>invalid_domain</GPODomain><BackupTime>2026-07-19T10:30:00Z</BackupTime><BackupTime>ambiguous</BackupTime></BackupInst>`;
const result = ingest(zip([
{ path: 'one/Backup.xml', content: mixed },
{ path: 'one/DomainSysvol/GPO/gpt.ini', content: '[General]' }
]));
expect(result.metadataDocuments[0]).toMatchObject({
status: 'invalid', gpoId: undefined, backupId: undefined, domainKey: undefined, backupCreatedAt: undefined
});
expect(result.backupInstances[0]).toMatchObject({
identity: { status: 'unresolved', reasonCode: 'metadata-conflict' },
domain: { status: 'unknown' },
backupCreatedAt: undefined,
metadataStatus: 'unreadable',
inventory: { status: 'blocked' }
});
});
it('rejects XML document types and external-reference mechanisms before DOM processing', () => {
const unsafe = '<!DOCTYPE x [<!ENTITY e SYSTEM "file:///secret">]><BackupInst><ID>&e;</ID></BackupInst>';
try {
ingest(zip([
{ path: 'one/Backup.xml', content: unsafe },
{ path: 'one/DomainSysvol/GPO/gpt.ini', content: '[General]' }
]));
throw new Error('Erwartete Ablehnung blieb aus.');
} catch (error) {
expect(error).toBeInstanceOf(ImportError);
expect((error as ImportError).diagnostics[0]?.code).toBe('xml-doctype-rejected');
}
});
});