155 lines
6.9 KiB
TypeScript
155 lines
6.9 KiB
TypeScript
import { deflateRawSync } from 'node:zlib';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { inventoryGpoContainer } from '../../server/gpo/gpo-container-inventory';
|
|
import { inventoryZipArchive } from '../../server/gpo/zip-inventory';
|
|
import { ImportError } from '../../src/shared/errors';
|
|
|
|
interface SyntheticZipEntry {
|
|
path: string;
|
|
content: string | Uint8Array;
|
|
method?: 0 | 8;
|
|
declaredUncompressedBytes?: number;
|
|
crcOverride?: number;
|
|
madeBySystem?: number;
|
|
externalAttributes?: number;
|
|
}
|
|
|
|
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 syntheticZip(entries: SyntheticZipEntry[]): Uint8Array {
|
|
const localParts: Buffer[] = [];
|
|
const centralParts: Buffer[] = [];
|
|
let localOffset = 0;
|
|
for (const entry of entries) {
|
|
const name = Buffer.from(entry.path, 'utf8');
|
|
const content = typeof entry.content === 'string' ? Buffer.from(entry.content, 'utf8') : Buffer.from(entry.content);
|
|
const method = entry.method ?? 8;
|
|
const compressed = method === 8 ? deflateRawSync(content) : content;
|
|
const checksum = entry.crcOverride ?? crc32(content);
|
|
const declaredSize = entry.declaredUncompressedBytes ?? content.length;
|
|
const local = Buffer.alloc(30 + name.length);
|
|
local.writeUInt32LE(0x04034b50, 0);
|
|
local.writeUInt16LE(20, 4);
|
|
local.writeUInt16LE(0x0800, 6);
|
|
local.writeUInt16LE(method, 8);
|
|
local.writeUInt32LE(checksum, 14);
|
|
local.writeUInt32LE(compressed.length, 18);
|
|
local.writeUInt32LE(declaredSize, 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(((entry.madeBySystem ?? 0) << 8) | 20, 4);
|
|
central.writeUInt16LE(20, 6);
|
|
central.writeUInt16LE(0x0800, 8);
|
|
central.writeUInt16LE(method, 10);
|
|
central.writeUInt32LE(checksum, 16);
|
|
central.writeUInt32LE(compressed.length, 20);
|
|
central.writeUInt32LE(declaredSize, 24);
|
|
central.writeUInt16LE(name.length, 28);
|
|
central.writeUInt32LE(entry.externalAttributes ?? 0, 38);
|
|
central.writeUInt32LE(localOffset, 42);
|
|
name.copy(central, 46);
|
|
centralParts.push(central);
|
|
localOffset += local.length + compressed.length;
|
|
}
|
|
const centralDirectory = Buffer.concat(centralParts);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(entries.length, 8);
|
|
end.writeUInt16LE(entries.length, 10);
|
|
end.writeUInt32LE(centralDirectory.length, 12);
|
|
end.writeUInt32LE(localOffset, 16);
|
|
return Buffer.concat([...localParts, centralDirectory, end]);
|
|
}
|
|
|
|
function diagnosticCode(action: () => unknown): string | undefined {
|
|
try {
|
|
action();
|
|
} catch (error) {
|
|
return error instanceof ImportError ? error.diagnostics[0]?.code : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
describe('server-side GPO ZIP inventory', () => {
|
|
it('inventories a synthetic GPO backup candidate without parsing or executing entries', () => {
|
|
const root = '11111111-1111-4111-8111-111111111111';
|
|
const bytes = syntheticZip([
|
|
{ path: `${root}/Backup.xml`, content: '<Backup />' },
|
|
{ path: `${root}/DomainSysvol/GPO/Machine/Registry.pol`, content: new Uint8Array([80, 82, 101, 103]) },
|
|
{ path: `${root}/DomainSysvol/GPO/Machine/Scripts/startup.ps1`, content: 'Write-Output synthetic' },
|
|
{ path: `${root}/DomainSysvol/GPO/gpt.ini`, content: '[General]' }
|
|
]);
|
|
|
|
const result = inventoryGpoContainer(bytes);
|
|
|
|
expect(result.recognition).toMatchObject({
|
|
status: 'gpo-backup-candidate',
|
|
evidenceCodes: ['gpo-backup-metadata-path-observed', 'gpo-domain-sysvol-structure-observed']
|
|
});
|
|
expect(result.archive.entries.every(entry => entry.kind !== 'file' || /^[a-f0-9]{64}$/.test(entry.sha256 ?? ''))).toBe(true);
|
|
expect(result.inventory.status).toBe('complete');
|
|
expect(result.inventory.entries).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ role: 'backup-metadata', scope: 'shared', disposition: 'inventory-only', secretIndicatorStatus: 'not-inspected' }),
|
|
expect.objectContaining({ role: 'registry-policy', scope: 'computer', disposition: 'inventory-only' }),
|
|
expect.objectContaining({ role: 'script', scope: 'computer', disposition: 'inventory-only' }),
|
|
expect.objectContaining({ role: 'unknown', disposition: 'unsupported' })
|
|
]));
|
|
expect(result.inventory.diagnosticCodes).toEqual(expect.arrayContaining([
|
|
'gpo-artifact-parser-pending', 'gpo-artifact-unsupported', 'gpo-script-inventory-only'
|
|
]));
|
|
});
|
|
|
|
it('does not recognize filename-only ZIP content as a GPO backup', () => {
|
|
const result = inventoryGpoContainer(syntheticZip([{ path: 'Backup.xml', content: '<Backup />' }]));
|
|
expect(result.recognition.status).toBe('unrecognized-zip');
|
|
expect(result.recognition.evidenceCodes).toContain('gpo-container-structure-unconfirmed');
|
|
});
|
|
|
|
it('rejects traversal, case collisions and file-as-directory aliases before inflation', () => {
|
|
expect(diagnosticCode(() => inventoryZipArchive(syntheticZip([{ path: '../escape.xml', content: 'x' }]))))
|
|
.toBe('zip-entry-path-rejected');
|
|
expect(diagnosticCode(() => inventoryZipArchive(syntheticZip([
|
|
{ path: 'safe/Backup.xml', content: 'a' },
|
|
{ path: 'SAFE/backup.XML', content: 'b' }
|
|
])))).toBe('zip-entry-path-collision');
|
|
expect(diagnosticCode(() => inventoryZipArchive(syntheticZip([
|
|
{ path: 'safe', content: 'file' },
|
|
{ path: 'safe/child.xml', content: 'child' }
|
|
])))).toBe('zip-entry-path-collision');
|
|
});
|
|
|
|
it('rejects symbolic links and nested ZIP content', () => {
|
|
const symlinkMode = ((0xa000 | 0o777) << 16) >>> 0;
|
|
expect(diagnosticCode(() => inventoryZipArchive(syntheticZip([{
|
|
path: 'link', content: 'target', madeBySystem: 3, externalAttributes: symlinkMode
|
|
}])))).toBe('zip-link-rejected');
|
|
const nested = syntheticZip([{ path: 'inner.txt', content: 'synthetic' }]);
|
|
expect(diagnosticCode(() => inventoryZipArchive(syntheticZip([{ path: 'nested.bin', content: nested }]))))
|
|
.toBe('zip-nested-archive-rejected');
|
|
});
|
|
|
|
it('verifies actual size, CRC and actual compression ratio', () => {
|
|
expect(diagnosticCode(() => inventoryZipArchive(syntheticZip([{
|
|
path: 'size.txt', content: 'actual', declaredUncompressedBytes: 5
|
|
}])))).toBe('zip-entry-size-mismatch');
|
|
expect(diagnosticCode(() => inventoryZipArchive(syntheticZip([{
|
|
path: 'crc.txt', content: 'actual', crcOverride: 0
|
|
}])))).toBe('zip-entry-crc-mismatch');
|
|
expect(diagnosticCode(() => inventoryZipArchive(
|
|
syntheticZip([{ path: 'ratio.txt', content: 'A'.repeat(10_000) }]),
|
|
{ maximumZipCompressionRatio: 2 }
|
|
))).toBe('zip-compression-ratio-limit');
|
|
});
|
|
});
|