Assessor_2.0/server/gpo/microsoft-baseline-zip-read...

185 lines
12 KiB
TypeScript

import { createHash } from 'node:crypto';
import { inflateRawSync } from 'node:zlib';
import { artifactPathCollisionKey, normalizeArtifactRelativePath } from '../../src/domain/artifact-path';
import type { ImportResourcePolicy } from '../../src/importers/resource-policy';
import { resolveImportResourcePolicy, validateImportSourceResources } from '../../src/importers/resource-policy';
import { ImportError } from '../../src/shared/errors';
import type { SafeZipInventoryEntry, VerifiedZipFile } from './zip-inventory';
interface Entry extends SafeZipInventoryEntry {
flags: number;
crc: number;
offset: number;
encodedName: Uint8Array;
madeBySystem: number;
externalAttributes: number;
}
const centralSignature = 0x02014b50;
const localSignature = 0x04034b50;
const endSignature = 0x06054b50;
const allowedFlags = 0x080e;
function fail(code: string, message: string, details: Record<string, string | number> = {}): never {
throw new ImportError(message, [{ severity: 'error', code, message, stage: 'format-decoding', details }]);
}
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 endOffset(bytes: Uint8Array): number {
for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65_557); offset -= 1) {
if (bytes[offset] === 0x50 && bytes[offset + 1] === 0x4b && bytes[offset + 2] === 0x05 && bytes[offset + 3] === 0x06) return offset;
}
return -1;
}
function decodeName(bytes: Uint8Array, flags: number, index: number): string {
if (!(flags & 0x0800) && bytes.some(byte => byte > 0x7f)) fail('baseline-zip-name-encoding-rejected', 'Nicht-ASCII-Pfade benötigen eine eindeutige UTF-8-Kennzeichnung.', { entryIndex: index });
try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
catch { fail('baseline-zip-name-encoding-rejected', 'Ein Baseline-Archivpfad ist nicht gültig UTF-8-kodiert.', { entryIndex: index }); }
}
function parseEntries(bytes: Uint8Array, policy: ImportResourcePolicy): { entries: Entry[]; directoryOffset: number } {
if (bytes.length < 22) fail('baseline-zip-directory-missing', 'Das Baselinepaket besitzt kein gültiges ZIP-Zentralverzeichnis.');
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const end = endOffset(bytes);
if (end < 0 || view.getUint32(end, true) !== endSignature || end + 22 + view.getUint16(end + 20, true) !== bytes.length) {
fail('baseline-zip-directory-invalid', 'Das Baselinepaket besitzt ein ungültiges ZIP-Endverzeichnis.');
}
const count = view.getUint16(end + 10, true);
const directorySize = view.getUint32(end + 12, true);
const directoryOffset = view.getUint32(end + 16, true);
if (view.getUint16(end + 4, true) || view.getUint16(end + 6, true) || view.getUint16(end + 8, true) !== count) {
fail('baseline-zip-multidisk-rejected', 'Mehrteilige Baselinearchive werden nicht verarbeitet.');
}
if (!count || count === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff || directoryOffset + directorySize !== end) {
fail('baseline-zip-directory-invalid', 'ZIP64, leere oder inkonsistente Baselinearchive werden nicht verarbeitet.');
}
if (count > policy.maximumZipEntries) fail('baseline-zip-entry-limit', 'Das Baselinepaket überschreitet das Eintragslimit.', { count });
const entries: Entry[] = [];
const paths = new Set<string>();
let cursor = directoryOffset;
let total = 0;
for (let index = 0; index < count; index += 1) {
if (cursor + 46 > end || view.getUint32(cursor, true) !== centralSignature) fail('baseline-zip-directory-invalid', 'Ein ZIP-Zentralverzeichniseintrag ist beschädigt.', { entryIndex: index });
const madeBySystem = view.getUint16(cursor + 4, true) >>> 8;
const flags = view.getUint16(cursor + 8, true);
const method = view.getUint16(cursor + 10, true);
const crc = view.getUint32(cursor + 16, true);
const compressedBytes = view.getUint32(cursor + 20, true);
const uncompressedBytes = view.getUint32(cursor + 24, true);
const nameLength = view.getUint16(cursor + 28, true);
const extraLength = view.getUint16(cursor + 30, true);
const commentLength = view.getUint16(cursor + 32, true);
const startDisk = view.getUint16(cursor + 34, true);
const externalAttributes = view.getUint32(cursor + 38, true);
const offset = view.getUint32(cursor + 42, true);
const next = cursor + 46 + nameLength + extraLength + commentLength;
if (next > end || flags & 0x0001 || flags & ~allowedFlags || ![0, 8].includes(method) || startDisk
|| compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff || offset === 0xffffffff) {
fail('baseline-zip-entry-rejected', 'Ein ZIP-Eintrag verwendet eine nicht unterstützte Struktur.', { entryIndex: index });
}
if (uncompressedBytes > policy.maximumZipEntryUncompressedBytes) fail('baseline-zip-entry-size-limit', 'Ein ZIP-Eintrag überschreitet das Entpacklimit.', { entryIndex: index });
total += uncompressedBytes;
if (total > policy.maximumZipUncompressedBytes) fail('baseline-zip-size-limit', 'Das Baselinepaket überschreitet das kumulierte Entpacklimit.');
const encodedName = bytes.slice(cursor + 46, cursor + 46 + nameLength);
const raw = decodeName(encodedName, flags, index);
if (raw.includes('\0')) fail('baseline-zip-path-rejected', 'Ein Baseline-Archivpfad enthält ein Nullzeichen.', { entryIndex: index });
const slash = raw.replaceAll('\\', '/');
const kind = slash.endsWith('/') ? 'directory' as const : 'file' as const;
const relativePath = normalizeArtifactRelativePath(kind === 'directory' ? slash.slice(0, -1) : slash);
const collisionKey = artifactPathCollisionKey(relativePath ?? '');
if (!relativePath || !collisionKey || paths.has(collisionKey)) fail('baseline-zip-path-rejected', 'Ein Baseline-Archivpfad ist unsicher oder kollidiert.', { entryIndex: index });
paths.add(collisionKey);
if (madeBySystem === 3) {
const fileType = (externalAttributes >>> 16) & 0xf000;
if (fileType && fileType !== 0x8000 && fileType !== 0x4000) fail('baseline-zip-special-file-rejected', 'Links und Spezialdateien werden nicht verarbeitet.', { entryIndex: index });
if (fileType && (fileType === 0x4000) !== (kind === 'directory')) fail('baseline-zip-entry-type-conflict', 'Archivpfad und Dateityp widersprechen sich.', { entryIndex: index });
}
entries.push({
index, relativePath, kind, compressionMethod: method as 0 | 8, compressedBytes, uncompressedBytes,
crc32: crc.toString(16).padStart(8, '0'), flags, crc, offset, encodedName, madeBySystem, externalAttributes
});
cursor = next;
}
if (cursor !== end) fail('baseline-zip-directory-invalid', 'Das ZIP-Zentralverzeichnis enthält nicht zugeordnete Daten.');
return { entries, directoryOffset };
}
function readEntry(bytes: Uint8Array, entry: Entry, nextBoundary: number, directoryOffset: number, policy: ImportResourcePolicy): VerifiedZipFile {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const offset = entry.offset;
if (offset + 30 > directoryOffset || view.getUint32(offset, true) !== localSignature) fail('baseline-zip-local-header-invalid', 'Ein lokaler ZIP-Dateikopf fehlt.', { entryIndex: entry.index });
const nameLength = view.getUint16(offset + 26, true);
const extraLength = view.getUint16(offset + 28, true);
const localName = bytes.subarray(offset + 30, offset + 30 + nameLength);
const dataOffset = offset + 30 + nameLength + extraLength;
const dataEnd = dataOffset + entry.compressedBytes;
const descriptorSize = nextBoundary - dataEnd;
const headerCrc = view.getUint32(offset + 14, true);
const headerCompressed = view.getUint32(offset + 18, true);
const headerUncompressed = view.getUint32(offset + 22, true);
if (view.getUint16(offset + 6, true) !== entry.flags || view.getUint16(offset + 8, true) !== entry.compressionMethod
|| localName.length !== entry.encodedName.length || !localName.every((byte, index) => byte === entry.encodedName[index])
|| dataEnd > nextBoundary) fail('baseline-zip-header-conflict', 'Lokaler Dateikopf und Zentralverzeichnis widersprechen sich.', { entryIndex: entry.index });
if (entry.flags & 0x0008) {
const descriptorOffset = descriptorSize === 16 && view.getUint32(dataEnd, true) === 0x08074b50 ? dataEnd + 4 : dataEnd;
if (![12, 16].includes(descriptorSize) || view.getUint32(descriptorOffset, true) !== entry.crc
|| view.getUint32(descriptorOffset + 4, true) !== entry.compressedBytes
|| view.getUint32(descriptorOffset + 8, true) !== entry.uncompressedBytes) fail('baseline-zip-data-descriptor-invalid', 'Der ZIP-Datendeskriptor ist ungültig.', { entryIndex: entry.index });
} else if (descriptorSize !== 0 || headerCrc !== entry.crc || headerCompressed !== entry.compressedBytes || headerUncompressed !== entry.uncompressedBytes) {
fail('baseline-zip-header-conflict', 'Lokaler Dateikopf und Zentralverzeichnis widersprechen sich.', { entryIndex: entry.index });
}
let content: Uint8Array;
try {
const compressed = bytes.subarray(dataOffset, dataEnd);
content = entry.compressionMethod === 0 ? compressed.slice() : inflateRawSync(compressed, { maxOutputLength: policy.maximumZipEntryUncompressedBytes });
} catch { fail('baseline-zip-entry-inflate-failed', 'Ein ausgewählter Baselineinhalt konnte nicht begrenzt entpackt werden.', { entryIndex: entry.index }); }
if (content.byteLength !== entry.uncompressedBytes || crc32(content) !== entry.crc) fail('baseline-zip-entry-integrity-invalid', 'Ein ausgewählter Baselineinhalt besitzt ungültige Größe oder CRC.', { entryIndex: entry.index });
const ratio = content.byteLength / Math.max(1, entry.compressedBytes);
if (ratio > policy.maximumZipCompressionRatio) fail('baseline-zip-compression-ratio-limit', 'Ein ausgewählter Baselineinhalt überschreitet das Kompressionsverhältnis.', { entryIndex: entry.index });
const sha256 = createHash('sha256').update(content).digest('hex');
return { entry: { ...entry, kind: 'file', sha256 }, bytes: content };
}
export interface MicrosoftBaselineZipSelection {
version: 1;
containerSha256: string;
entries: SafeZipInventoryEntry[];
files: VerifiedZipFile[];
}
export function readMicrosoftBaselineZip(
bytes: Uint8Array,
select: (entry: SafeZipInventoryEntry) => boolean,
overrides?: Partial<ImportResourcePolicy>,
maximumSelectedBytes = 32 * 1024 * 1024
): MicrosoftBaselineZipSelection {
const policy = resolveImportResourcePolicy(overrides);
validateImportSourceResources({ metadata: { name: 'microsoft-baseline.zip', sizeBytes: bytes.byteLength, lastModified: 0 }, bytes }, policy);
const parsed = parseEntries(bytes, policy);
const ordered = parsed.entries.slice().sort((left, right) => left.offset - right.offset);
const files: VerifiedZipFile[] = [];
let selectedBytes = 0;
ordered.forEach((entry, index) => {
if (entry.kind !== 'file' || !select(entry)) return;
selectedBytes += entry.uncompressedBytes;
if (selectedBytes > maximumSelectedBytes) fail('baseline-zip-selected-size-limit', 'Ausgewählte Baselineinhalte überschreiten das Verarbeitungslimit.');
files.push(readEntry(bytes, entry, ordered[index + 1]?.offset ?? parsed.directoryOffset, parsed.directoryOffset, policy));
});
return {
version: 1,
containerSha256: createHash('sha256').update(bytes).digest('hex'),
entries: parsed.entries.map(({ flags: _flags, crc: _crc, offset: _offset, encodedName: _encodedName,
madeBySystem: _madeBySystem, externalAttributes: _externalAttributes, ...entry }) => entry),
files: files.sort((left, right) => left.entry.index - right.entry.index)
};
}