Assessor_2.0/server/gpo/zip-inventory.ts

372 lines
20 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';
export interface SafeZipInventoryEntry {
index: number;
relativePath: string;
kind: 'file' | 'directory';
compressionMethod: 0 | 8;
compressedBytes: number;
uncompressedBytes: number;
crc32: string;
sha256?: string;
}
export interface SafeZipInventory {
version: 1;
containerSha256: string;
compressedBytes: number;
uncompressedBytes: number;
entries: SafeZipInventoryEntry[];
}
export interface VerifiedZipFile {
entry: SafeZipInventoryEntry & { kind: 'file'; sha256: string };
bytes: Uint8Array;
}
interface CentralEntry extends SafeZipInventoryEntry {
flags: number;
expectedCrc32: number;
localHeaderOffset: number;
encodedName: Uint8Array;
externalAttributes: number;
madeBySystem: number;
}
const centralHeaderSignature = 0x02014b50;
const localHeaderSignature = 0x04034b50;
const endSignature = 0x06054b50;
const allowedFlags = 0x080e;
function zipError(code: string, message: string, details: Record<string, string | number> = {}): never {
throw new ImportError(message, [{ severity: 'error', code, message, stage: 'format-decoding', details }]);
}
function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');
}
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 findEndRecord(bytes: Uint8Array): number {
const minimumOffset = Math.max(0, bytes.length - 65_557);
for (let offset = bytes.length - 22; offset >= minimumOffset; offset -= 1) {
if (bytes[offset] === 0x50 && bytes[offset + 1] === 0x4b
&& bytes[offset + 2] === 0x05 && bytes[offset + 3] === 0x06) return offset;
}
return -1;
}
function decodeEntryName(bytes: Uint8Array, flags: number, index: number): string {
if (!(flags & 0x0800) && bytes.some(byte => byte > 0x7f)) {
zipError('zip-entry-name-encoding-rejected', 'Nicht-ASCII-Archivpfade benötigen eine eindeutige UTF-8-Kennzeichnung.', { entryIndex: index });
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
zipError('zip-entry-name-encoding-rejected', 'Ein Archivpfad ist nicht gültig UTF-8-kodiert.', { entryIndex: index });
}
}
function canonicalEntryPath(rawName: string, index: number): { path: string; kind: 'file' | 'directory' } {
if (rawName.includes('\0')) zipError('zip-entry-path-rejected', 'Archivpfade dürfen keine Nullzeichen enthalten.', { entryIndex: index });
const slashNormalized = rawName.replaceAll('\\', '/');
const kind = slashNormalized.endsWith('/') ? 'directory' : 'file';
const candidate = kind === 'directory' ? slashNormalized.slice(0, -1) : slashNormalized;
const path = normalizeArtifactRelativePath(candidate);
if (!path) zipError('zip-entry-path-rejected', 'Ein Archivpfad ist unsicher oder nicht kanonisch.', { entryIndex: index });
return { path, kind };
}
function validateExternalType(entry: CentralEntry): void {
if (entry.madeBySystem !== 3) return;
const unixMode = entry.externalAttributes >>> 16;
const fileType = unixMode & 0xf000;
if (fileType === 0xa000) zipError('zip-link-rejected', 'Symbolische Links in Archiven werden nicht verarbeitet.', { entryIndex: entry.index });
if (fileType && fileType !== 0x8000 && fileType !== 0x4000) {
zipError('zip-special-file-rejected', 'Spezialdateien in Archiven werden nicht verarbeitet.', { entryIndex: entry.index });
}
if (fileType && (fileType === 0x4000) !== (entry.kind === 'directory')) {
zipError('zip-entry-type-conflict', 'Dateiname und externer Archivtyp widersprechen sich.', { entryIndex: entry.index });
}
}
function parseCentralDirectory(bytes: Uint8Array, policy: ImportResourcePolicy): { entries: CentralEntry[]; directoryOffset: number } {
if (bytes.length < 22) zipError('zip-directory-missing', 'Der ZIP-Container besitzt kein gültiges Zentralverzeichnis.', { sizeBytes: bytes.length });
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const endOffset = findEndRecord(bytes);
if (endOffset < 0 || view.getUint32(endOffset, true) !== endSignature) {
zipError('zip-directory-missing', 'Der ZIP-Container besitzt kein gültiges Zentralverzeichnis.', { sizeBytes: bytes.length });
}
const commentLength = view.getUint16(endOffset + 20, true);
if (endOffset + 22 + commentLength !== bytes.length) {
zipError('zip-trailing-data-rejected', 'Daten außerhalb des abschließenden ZIP-Verzeichniseintrags werden nicht akzeptiert.', { endOffset, sizeBytes: bytes.length });
}
const diskNumber = view.getUint16(endOffset + 4, true);
const directoryDisk = view.getUint16(endOffset + 6, true);
const entriesOnDisk = view.getUint16(endOffset + 8, true);
const entryCount = view.getUint16(endOffset + 10, true);
const directorySize = view.getUint32(endOffset + 12, true);
const directoryOffset = view.getUint32(endOffset + 16, true);
if (diskNumber || directoryDisk || entriesOnDisk !== entryCount) {
zipError('zip-multidisk-rejected', 'Mehrteilige ZIP-Archive werden nicht verarbeitet.', { diskNumber, directoryDisk });
}
if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) {
zipError('zip64-not-accepted', 'ZIP64-Archive werden innerhalb der aktuellen Importgrenzen nicht akzeptiert.', { entryCount });
}
if (!entryCount) zipError('zip-empty-rejected', 'Leere ZIP-Archive enthalten kein inventarisierbares GPO-Artefakt.');
if (entryCount > policy.maximumZipEntries) {
zipError('zip-entry-limit', 'Das Archiv überschreitet die zulässige Eintragsanzahl.', { entryCount, limit: policy.maximumZipEntries });
}
if (directoryOffset + directorySize !== endOffset) {
zipError('zip-directory-invalid', 'Das ZIP-Zentralverzeichnis besitzt eine inkonsistente Größe oder Position.', { directoryOffset, directorySize, endOffset });
}
const entries: CentralEntry[] = [];
const pathKeys = new Map<string, number>();
let offset = directoryOffset;
let declaredTotal = 0;
for (let index = 0; index < entryCount; index += 1) {
if (offset + 46 > endOffset || view.getUint32(offset, true) !== centralHeaderSignature) {
zipError('zip-directory-invalid', 'Ein ZIP-Zentralverzeichniseintrag ist beschädigt.', { entryIndex: index });
}
const madeBySystem = view.getUint16(offset + 4, true) >>> 8;
const flags = view.getUint16(offset + 8, true);
const compressionMethod = view.getUint16(offset + 10, true);
const expectedCrc32 = view.getUint32(offset + 16, true);
const compressedBytes = view.getUint32(offset + 20, true);
const uncompressedBytes = view.getUint32(offset + 24, true);
const nameLength = view.getUint16(offset + 28, true);
const extraLength = view.getUint16(offset + 30, true);
const entryCommentLength = view.getUint16(offset + 32, true);
const startDisk = view.getUint16(offset + 34, true);
const externalAttributes = view.getUint32(offset + 38, true);
const localHeaderOffset = view.getUint32(offset + 42, true);
const nextOffset = offset + 46 + nameLength + extraLength + entryCommentLength;
if (nextOffset > endOffset) zipError('zip-directory-invalid', 'Ein Zentralverzeichniseintrag liegt außerhalb seiner Grenzen.', { entryIndex: index });
if (flags & 0x0001 || flags & ~allowedFlags) {
zipError('zip-flags-rejected', 'Der Archivinhalt verwendet Verschlüsselung oder nicht unterstützte ZIP-Flags.', { entryIndex: index, flags });
}
if (compressionMethod !== 0 && compressionMethod !== 8) {
zipError('zip-compression-method-rejected', 'Das Archiv verwendet ein nicht zugelassenes Kompressionsverfahren.', { entryIndex: index, compressionMethod });
}
if (compressionMethod === 0 && (flags & 0x0006)) {
zipError('zip-flags-rejected', 'Unkomprimierte ZIP-Einträge dürfen keine Deflate-Optionsflags tragen.', { entryIndex: index, flags });
}
if (compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff || localHeaderOffset === 0xffffffff) {
zipError('zip64-not-accepted', 'ZIP64-Einträge werden innerhalb der aktuellen Importgrenzen nicht akzeptiert.', { entryIndex: index });
}
if (startDisk) zipError('zip-multidisk-rejected', 'Ein Archivinhalt verweist auf einen anderen Datenträger.', { entryIndex: index, startDisk });
if (uncompressedBytes > policy.maximumZipEntryUncompressedBytes) {
zipError('zip-entry-size-limit', 'Ein einzelner Archivinhalt überschreitet das Entpacklimit.', { entryIndex: index, uncompressedBytes, limit: policy.maximumZipEntryUncompressedBytes });
}
declaredTotal += uncompressedBytes;
if (declaredTotal > policy.maximumZipUncompressedBytes) {
zipError('zip-uncompressed-size-limit', 'Die deklarierte entpackte Archivgröße überschreitet das Limit.', { uncompressedBytes: declaredTotal, limit: policy.maximumZipUncompressedBytes });
}
const encodedName = bytes.slice(offset + 46, offset + 46 + nameLength);
const canonical = canonicalEntryPath(decodeEntryName(encodedName, flags, index), index);
const collisionKey = artifactPathCollisionKey(canonical.path);
if (!collisionKey) zipError('zip-entry-path-rejected', 'Ein Archivpfad ist nicht sicher normalisierbar.', { entryIndex: index });
const collidingIndex = pathKeys.get(collisionKey);
if (collidingIndex !== undefined) {
zipError('zip-entry-path-collision', 'Archivpfade kollidieren nach Unicode-/Case-Normalisierung.', { entryIndex: index, collidingIndex });
}
pathKeys.set(collisionKey, index);
const entry: CentralEntry = {
index,
relativePath: canonical.path,
kind: canonical.kind,
compressionMethod: compressionMethod as 0 | 8,
compressedBytes,
uncompressedBytes,
crc32: expectedCrc32.toString(16).padStart(8, '0'),
flags,
expectedCrc32,
localHeaderOffset,
encodedName,
externalAttributes,
madeBySystem
};
validateExternalType(entry);
entries.push(entry);
offset = nextOffset;
}
if (offset !== endOffset) zipError('zip-directory-invalid', 'Das Zentralverzeichnis wurde nicht vollständig und eindeutig gelesen.', { actualEnd: offset, expectedEnd: endOffset });
const fileKeys = new Set(entries.filter(entry => entry.kind === 'file').map(entry => artifactPathCollisionKey(entry.relativePath)!));
for (const entry of entries) {
const segments = entry.relativePath.split('/');
for (let count = 1; count < segments.length; count += 1) {
if (fileKeys.has(artifactPathCollisionKey(segments.slice(0, count).join('/'))!)) {
zipError('zip-entry-path-collision', 'Ein Archivpfad verwendet eine Datei zugleich als Verzeichnis.', { entryIndex: entry.index });
}
}
}
return { entries, directoryOffset };
}
function inflateEntries(
bytes: Uint8Array,
entries: CentralEntry[],
directoryOffset: number,
policy: ImportResourcePolicy,
onFile?: (entry: SafeZipInventoryEntry & { kind: 'file'; sha256: string }, content: Uint8Array) => void
): SafeZipInventoryEntry[] {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ordered = [...entries].sort((left, right) => left.localHeaderOffset - right.localHeaderOffset);
let actualTotal = 0;
let totalCompressed = 0;
if (ordered[0]?.localHeaderOffset !== 0) zipError('zip-leading-data-rejected', 'Daten vor dem ersten lokalen ZIP-Dateikopf werden nicht akzeptiert.');
for (let orderIndex = 0; orderIndex < ordered.length; orderIndex += 1) {
const entry = ordered[orderIndex];
const offset = entry.localHeaderOffset;
if (offset + 30 > directoryOffset || view.getUint32(offset, true) !== localHeaderSignature) {
zipError('zip-local-header-invalid', 'Ein lokaler ZIP-Dateikopf ist beschädigt oder fehlt.', { entryIndex: entry.index });
}
const localFlags = view.getUint16(offset + 6, true);
const localMethod = view.getUint16(offset + 8, true);
const localCrc32 = view.getUint32(offset + 14, true);
const localCompressedBytes = view.getUint32(offset + 18, true);
const localUncompressedBytes = view.getUint32(offset + 22, true);
const localNameLength = view.getUint16(offset + 26, true);
const localExtraLength = view.getUint16(offset + 28, true);
const localName = bytes.subarray(offset + 30, offset + 30 + localNameLength);
const dataOffset = offset + 30 + localNameLength + localExtraLength;
const dataEnd = dataOffset + entry.compressedBytes;
const nextBoundary = ordered[orderIndex + 1]?.localHeaderOffset ?? directoryOffset;
const descriptorFieldsValid = entry.flags & 0x0008
? (localCrc32 === 0 || localCrc32 === entry.expectedCrc32)
&& (localCompressedBytes === 0 || localCompressedBytes === entry.compressedBytes)
&& (localUncompressedBytes === 0 || localUncompressedBytes === entry.uncompressedBytes)
: localCrc32 === entry.expectedCrc32
&& localCompressedBytes === entry.compressedBytes
&& localUncompressedBytes === entry.uncompressedBytes;
if (localFlags !== entry.flags || localMethod !== entry.compressionMethod || !descriptorFieldsValid
|| localName.length !== entry.encodedName.length
|| !localName.every((byte, index) => byte === entry.encodedName[index])) {
zipError('zip-header-conflict', 'Lokaler Dateikopf und Zentralverzeichnis widersprechen sich.', { entryIndex: entry.index });
}
if (dataOffset > directoryOffset || dataEnd > nextBoundary) {
zipError('zip-entry-range-invalid', 'Komprimierte Archivdaten überlappen andere ZIP-Strukturen.', { entryIndex: entry.index });
}
if (entry.flags & 0x0008) {
const descriptorSize = nextBoundary - dataEnd;
const descriptorOffset = descriptorSize === 16 && view.getUint32(dataEnd, true) === 0x08074b50 ? dataEnd + 4 : dataEnd;
if (![12, 16].includes(descriptorSize)
|| view.getUint32(descriptorOffset, true) !== entry.expectedCrc32
|| view.getUint32(descriptorOffset + 4, true) !== entry.compressedBytes
|| view.getUint32(descriptorOffset + 8, true) !== entry.uncompressedBytes) {
zipError('zip-data-descriptor-invalid', 'Der ZIP-Datendeskriptor fehlt oder widerspricht dem Zentralverzeichnis.', { entryIndex: entry.index });
}
} else if (dataEnd !== nextBoundary) {
zipError('zip-entry-range-invalid', 'Nicht zugeordnete Daten zwischen ZIP-Einträgen werden nicht akzeptiert.', { entryIndex: entry.index });
}
const compressed = bytes.subarray(dataOffset, dataEnd);
let uncompressed: Uint8Array;
try {
uncompressed = entry.compressionMethod === 0
? compressed
: inflateRawSync(compressed, { maxOutputLength: Math.max(1, Math.min(policy.maximumZipEntryUncompressedBytes, policy.maximumZipUncompressedBytes - actualTotal)) });
} catch {
zipError('zip-entry-inflate-failed', 'Ein Archivinhalt konnte innerhalb der Ressourcenlimits nicht entpackt werden.', { entryIndex: entry.index });
}
if (entry.compressionMethod === 0 && entry.compressedBytes !== entry.uncompressedBytes) {
zipError('zip-entry-size-mismatch', 'Ein unkomprimierter Archivinhalt besitzt widersprüchliche Größenangaben.', { entryIndex: entry.index });
}
if (uncompressed.byteLength !== entry.uncompressedBytes) {
zipError('zip-entry-size-mismatch', 'Deklarierte und tatsächliche entpackte Größe stimmen nicht überein.', { entryIndex: entry.index, declaredBytes: entry.uncompressedBytes, actualBytes: uncompressed.byteLength });
}
const entryRatio = uncompressed.byteLength / Math.max(1, entry.compressedBytes);
if (entryRatio > policy.maximumZipCompressionRatio) {
zipError('zip-compression-ratio-limit', 'Das tatsächliche Kompressionsverhältnis eines Archivinhalts ist ungewöhnlich hoch.', { entryIndex: entry.index, ratio: Math.round(entryRatio * 100) / 100, limit: policy.maximumZipCompressionRatio });
}
actualTotal += uncompressed.byteLength;
totalCompressed += entry.compressedBytes;
if (actualTotal > policy.maximumZipUncompressedBytes) {
zipError('zip-uncompressed-size-limit', 'Die tatsächliche entpackte Archivgröße überschreitet das Limit.', { uncompressedBytes: actualTotal, limit: policy.maximumZipUncompressedBytes });
}
if (crc32(uncompressed) !== entry.expectedCrc32) zipError('zip-entry-crc-mismatch', 'Die CRC-Prüfsumme eines Archivinhalts ist ungültig.', { entryIndex: entry.index });
if (entry.kind === 'file' && uncompressed.length >= 4 && uncompressed[0] === 0x50 && uncompressed[1] === 0x4b
&& [[0x03, 0x04], [0x05, 0x06], [0x07, 0x08]].some(signature => uncompressed[2] === signature[0] && uncompressed[3] === signature[1])) {
zipError('zip-nested-archive-rejected', 'Verschachtelte ZIP-Archive werden nicht verarbeitet.', { entryIndex: entry.index });
}
entry.sha256 = entry.kind === 'file' ? sha256(uncompressed) : undefined;
if (entry.kind === 'file' && entry.sha256) {
onFile?.({
index: entry.index, relativePath: entry.relativePath, kind: 'file', compressionMethod: entry.compressionMethod,
compressedBytes: entry.compressedBytes, uncompressedBytes: entry.uncompressedBytes, crc32: entry.crc32, sha256: entry.sha256
}, uncompressed);
}
}
const ratio = actualTotal / Math.max(1, totalCompressed);
if (ratio > policy.maximumZipCompressionRatio) {
zipError('zip-compression-ratio-limit', 'Das tatsächliche Kompressionsverhältnis des Archivs ist ungewöhnlich hoch.', { ratio: Math.round(ratio * 100) / 100, limit: policy.maximumZipCompressionRatio });
}
return entries.map(({ flags: _flags, expectedCrc32: _expectedCrc32, localHeaderOffset: _localHeaderOffset,
encodedName: _encodedName, externalAttributes: _externalAttributes, madeBySystem: _madeBySystem, ...entry }) => entry);
}
export function inventoryZipArchive(bytes: Uint8Array, overrides?: Partial<ImportResourcePolicy>): SafeZipInventory {
const policy = resolveImportResourcePolicy(overrides);
validateImportSourceResources({ metadata: { name: 'server-container.zip', sizeBytes: bytes.byteLength, lastModified: 0 }, bytes }, policy);
const { entries, directoryOffset } = parseCentralDirectory(bytes, policy);
const inventoried = inflateEntries(bytes, entries, directoryOffset, policy);
return {
version: 1,
containerSha256: sha256(bytes),
compressedBytes: bytes.byteLength,
uncompressedBytes: inventoried.reduce((sum, entry) => sum + entry.uncompressedBytes, 0),
entries: inventoried
};
}
export function readVerifiedZipFiles(
bytes: Uint8Array,
relativePaths: string[],
overrides?: Partial<ImportResourcePolicy>,
maximumSelectedBytes = 8 * 1024 * 1024
): VerifiedZipFile[] {
const policy = resolveImportResourcePolicy(overrides);
if (!Number.isSafeInteger(maximumSelectedBytes) || maximumSelectedBytes < 1
|| maximumSelectedBytes > policy.maximumZipUncompressedBytes) {
zipError('zip-selected-size-limit-invalid', 'Das Auswahllimit für ZIP-Inhalte ist ungültig.');
}
validateImportSourceResources({ metadata: { name: 'server-container.zip', sizeBytes: bytes.byteLength, lastModified: 0 }, bytes }, policy);
const selectedKeys = new Set<string>();
for (const requestedPath of relativePaths) {
const normalized = normalizeArtifactRelativePath(requestedPath);
const key = artifactPathCollisionKey(requestedPath);
if (!normalized || !key || normalized !== requestedPath || selectedKeys.has(key)) {
zipError('zip-selection-path-rejected', 'Die ZIP-Inhaltsauswahl enthält einen unsicheren oder doppelten Pfad.');
}
selectedKeys.add(key);
}
const { entries, directoryOffset } = parseCentralDirectory(bytes, policy);
const selected: VerifiedZipFile[] = [];
let selectedBytes = 0;
inflateEntries(bytes, entries, directoryOffset, policy, (entry, content) => {
if (!selectedKeys.has(artifactPathCollisionKey(entry.relativePath)!)) return;
selectedBytes += content.byteLength;
if (selectedBytes > maximumSelectedBytes) {
zipError('zip-selected-size-limit', 'Die ausgewählten ZIP-Inhalte überschreiten das Verarbeitungslimit.', { selectedBytes, limit: maximumSelectedBytes });
}
selected.push({ entry, bytes: content.slice() });
});
return selected.sort((left, right) => left.entry.index - right.entry.index);
}