import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const outputArgument = process.argv.indexOf("--output"); const outputRoot = outputArgument === -1 ? path.join(repoRoot, "tests/files/web/archive-security") : path.resolve(process.argv[outputArgument + 1] ?? ""); if (!outputRoot) throw new Error("--output requires a directory"); const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); const text = (value) => Buffer.from(value, "utf8"); const crcTable = Array.from({ length: 256 }, (_, input) => { let value = input; for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ value >>> 1 : value >>> 1; return value >>> 0; }); function crc32(bytes) { let value = 0xffffffff; for (const byte of bytes) value = crcTable[(value ^ byte) & 0xff] ^ value >>> 8; return (value ^ 0xffffffff) >>> 0; } function zipArchive(entries) { const localParts = []; const centralParts = []; let localOffset = 0; for (const entry of entries) { const name = text(entry.path); const data = Buffer.from(entry.data); const uncompressedBytes = entry.uncompressedBytes ?? data.length; const crc = crc32(data); const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(0, 8); local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(uncompressedBytes, 22); local.writeUInt16LE(name.length, 26); localParts.push(local, name, data); const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(0x0314, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x0800, 8); central.writeUInt16LE(0, 10); central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(uncompressedBytes, 24); central.writeUInt16LE(name.length, 28); central.writeUInt32LE((0o100644 << 16) >>> 0, 38); central.writeUInt32LE(localOffset, 42); centralParts.push(central, name); localOffset += local.length + name.length + data.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 writeTarText(header, value, offset, length) { const bytes = text(value); if (bytes.length > length) throw new Error(`tar field exceeds ${length} bytes`); bytes.copy(header, offset); } function writeTarOctal(header, value, offset, length) { const encoded = value.toString(8).padStart(length - 2, "0"); writeTarText(header, `${encoded}\0 `, offset, length); } function tarHeader(entry) { const header = Buffer.alloc(512); writeTarText(header, entry.path, 0, 100); writeTarOctal(header, entry.type === "DIRECTORY" ? 0o755 : 0o644, 100, 8); writeTarOctal(header, 0, 108, 8); writeTarOctal(header, 0, 116, 8); const data = entry.type === "FILE" ? Buffer.from(entry.data) : Buffer.alloc(0); writeTarOctal(header, data.length, 124, 12); writeTarOctal(header, 0, 136, 12); header.fill(0x20, 148, 156); header[156] = { FILE: 0x30, SYMLINK: 0x32, HARDLINK: 0x31, DIRECTORY: 0x35 }[entry.type]; if (entry.target) writeTarText(header, entry.target, 157, 100); writeTarText(header, "ustar\0", 257, 6); writeTarText(header, "00", 263, 2); writeTarText(header, "root", 265, 32); writeTarText(header, "root", 297, 32); const checksum = header.reduce((sum, byte) => sum + byte, 0); writeTarOctal(header, checksum, 148, 8); return { header, data }; } function tarArchive(entries) { const parts = []; for (const entry of entries) { const { header, data } = tarHeader(entry); parts.push(header, data); if (data.length % 512 !== 0) parts.push(Buffer.alloc(512 - data.length % 512)); } parts.push(Buffer.alloc(1024)); return Buffer.concat(parts); } const definitions = [ { id: "ZIP_PATH_TRAVERSAL", format: "ZIP", file: "zip-path-traversal.zip", threat: "ARCHIVE_ROOT_ESCAPE", gate: "LINK_SAFETY", bytes: zipArchive([{ path: "../outside.txt", data: text("escape") }]), }, { id: "ZIP_COMPRESSION_BOMB", format: "ZIP", file: "zip-compression-bomb.zip", threat: "COMPRESSION_RATIO", gate: "CONFLICTS", bytes: zipArchive([{ path: "bomb.bin", data: Buffer.from([0]), uncompressedBytes: 101 }]), }, { id: "ZIP_DUPLICATE_PATH", format: "ZIP", file: "zip-duplicate-path.zip", threat: "DUPLICATE_PATH", gate: "LINK_SAFETY", bytes: zipArchive([{ path: "same.bin", data: text("one") }, { path: "same.bin", data: text("two") }]), }, { id: "TAR_PATH_TRAVERSAL", format: "TAR", file: "tar-path-traversal.tar", threat: "ARCHIVE_ROOT_ESCAPE", gate: "LINK_SAFETY", bytes: tarArchive([{ path: "../../outside.txt", type: "FILE", data: text("escape") }]), }, { id: "TAR_SYMLINK_ESCAPE", format: "TAR", file: "tar-symlink-escape.tar", threat: "SYMLINK_ESCAPE", gate: "LINK_SAFETY", bytes: tarArchive([ { path: "safe", type: "DIRECTORY" }, { path: "safe/link", type: "SYMLINK", target: "../../outside" }, ]), }, { id: "TAR_PREFIX_CONFLICT", format: "TAR", file: "tar-prefix-conflict.tar", threat: "FILE_DIRECTORY_PREFIX_CONFLICT", gate: "CONFLICTS", bytes: tarArchive([ { path: "folder", type: "FILE", data: text("one") }, { path: "folder/payload.bin", type: "FILE", data: text("two") }, ]), }, ]; fs.mkdirSync(outputRoot, { recursive: true }); for (const definition of definitions) fs.writeFileSync(path.join(outputRoot, definition.file), definition.bytes); const manifest = { schemaVersion: 1, task: "M12-04J", generator: "tools/web/generate-malicious-archive-fixtures.mjs", extractionAllowed: false, cases: definitions.map(({ bytes, ...definition }) => ({ ...definition, byteLength: bytes.length, sha256: sha256(bytes), expectedCode: "IO_ARCHIVE_UNSAFE", })), }; fs.writeFileSync(path.join(outputRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); process.stdout.write(`malicious-archive-fixtures-generated cases=${definitions.length} output=${outputRoot}\n`);