365 lines
19 KiB
TypeScript
365 lines
19 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');
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
|
|
describe('GPO backup link facts', () => {
|
|
it('retains per-backup link counts from its own GPReport and leaves backups without a report unknown', () => {
|
|
const second = '33333333-3333-4333-8333-333333333333';
|
|
const secondBackup = '44444444-4444-4444-8444-444444444444';
|
|
const report = (links: string) => `<?xml version="1.0"?><GPO xmlns="http://www.microsoft.com/GroupPolicy/Settings"><Name>Synthetic</Name>${links}</GPO>`;
|
|
const template = (root: string) => ({ path: `${root}/DomainSysvol/GPO/Machine/Microsoft/Windows NT/SecEdit/GptTmpl.inf`, content: '[System Access]\r\nMinimumPasswordLength=14' });
|
|
const result = ingest(zip([
|
|
{ path: `{${backupId}}/bkupInfo.xml`, content: metadata() },
|
|
{ path: `{${backupId}}/gpreport.xml`, content: report('') },
|
|
template(`{${backupId}}`),
|
|
{ path: `{${secondBackup}}/bkupInfo.xml`, content: metadata(second, secondBackup) },
|
|
template(`{${secondBackup}}`)
|
|
]));
|
|
const byBackup = new Map(result.backupInstances.map(instance => [instance.identity.status === 'resolved' ? instance.identity.backupId : '', instance]));
|
|
expect(byBackup.get(backupId)?.links).toEqual({ status: 'not-linked', linkCount: 0, enabledLinkCount: 0, evidence: 'gp-report' });
|
|
expect(byBackup.get(secondBackup)?.links).toBeUndefined();
|
|
const linked = ingest(zip([
|
|
{ path: `{${backupId}}/bkupInfo.xml`, content: metadata() },
|
|
{ path: `{${backupId}}/gpreport.xml`, content: report('<LinksTo><SOMPath>synthetic.example/Synthetic</SOMPath><Enabled>true</Enabled></LinksTo>') },
|
|
template(`{${backupId}}`)
|
|
]));
|
|
expect(linked.backupInstances[0].links).toEqual({ status: 'linked', linkCount: 1, enabledLinkCount: 1, evidence: 'gp-report' });
|
|
expect(JSON.stringify(linked.backupInstances[0])).not.toContain('synthetic.example/Synthetic');
|
|
});
|
|
});
|