import assert from "node:assert/strict"; import crypto from "node:crypto"; import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL, fileURLToPath } from "node:url"; import ts from "../../web/node_modules/typescript/lib/typescript.js"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const fixtureRoot = path.join(repoRoot, "tests/files/web/archive-security"); const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "malicious-archive-fixtures-")); const moduleRoot = path.join(temporary, "modules"); const regeneratedRoot = path.join(temporary, "regenerated"); fs.mkdirSync(moduleRoot, { recursive: true }); try { for (const sourceName of ["archive-link-safety.ts", "archive-conflicts.ts"]) { const sourcePath = path.join(repoRoot, "web/protocol", sourceName); const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true, }); assert.deepEqual(transpiled.diagnostics, []); fs.writeFileSync(path.join(moduleRoot, sourceName.replace(".ts", ".mjs")), transpiled.outputText); } const safety = await import(pathToFileURL(path.join(moduleRoot, "archive-link-safety.mjs"))); const conflicts = await import(pathToFileURL(path.join(moduleRoot, "archive-conflicts.mjs"))); const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); const decoder = new TextDecoder("utf-8", { fatal: true }); const evidence = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-04J/manifest.json"), "utf8")); assert.equal(evidence.task, "M12-04J"); assert.equal(evidence.parentTask, "M12-04I"); assert.equal(evidence.nextTask, "M12-05A"); for (const artifact of Object.values(evidence.artifacts)) { assert.equal(sha256(fs.readFileSync(path.join(repoRoot, artifact.path))), artifact.sha256, artifact.path); } function decode(bytes, offset, length) { return decoder.decode(bytes.subarray(offset, offset + length)); } function zipMetadata(bytes) { assert.ok(bytes.length >= 22, "ZIP fixture is shorter than EOCD"); const endOffset = bytes.length - 22; assert.equal(bytes.readUInt32LE(endOffset), 0x06054b50, "ZIP fixture EOCD is missing"); assert.equal(bytes.readUInt16LE(endOffset + 4), 0, "multi-disk ZIP fixture is forbidden"); assert.equal(bytes.readUInt16LE(endOffset + 6), 0, "multi-disk ZIP fixture is forbidden"); const entries = bytes.readUInt16LE(endOffset + 10); assert.equal(bytes.readUInt16LE(endOffset + 8), entries, "ZIP entry counts disagree"); const centralBytes = bytes.readUInt32LE(endOffset + 12); const centralOffset = bytes.readUInt32LE(endOffset + 16); assert.equal(centralOffset + centralBytes, endOffset, "ZIP central directory is not the first bounded metadata region"); const links = []; const ranges = []; let offset = centralOffset; for (let index = 0; index < entries; index++) { assert.equal(bytes.readUInt32LE(offset), 0x02014b50, `ZIP central entry ${index} is invalid`); const method = bytes.readUInt16LE(offset + 10); const compressedBytes = bytes.readUInt32LE(offset + 20); const uncompressedBytes = bytes.readUInt32LE(offset + 24); const nameBytes = bytes.readUInt16LE(offset + 28); const extraBytes = bytes.readUInt16LE(offset + 30); const commentBytes = bytes.readUInt16LE(offset + 32); const localOffset = bytes.readUInt32LE(offset + 42); const entryPath = decode(bytes, offset + 46, nameBytes); assert.equal(method, 0, "fixture ZIP entries must use deterministic STORE metadata"); assert.equal(bytes.readUInt32LE(localOffset), 0x04034b50, `ZIP local entry ${index} is invalid`); assert.equal(bytes.readUInt16LE(localOffset + 8), method, "ZIP local/central methods disagree"); assert.equal(bytes.readUInt32LE(localOffset + 18), compressedBytes, "ZIP local/central compressed sizes disagree"); assert.equal(bytes.readUInt32LE(localOffset + 22), uncompressedBytes, "ZIP local/central uncompressed sizes disagree"); const localNameBytes = bytes.readUInt16LE(localOffset + 26); const localExtraBytes = bytes.readUInt16LE(localOffset + 28); assert.equal(decode(bytes, localOffset + 30, localNameBytes), entryPath, "ZIP local/central names disagree"); const compressedOffset = localOffset + 30 + localNameBytes + localExtraBytes; assert.ok(compressedOffset + compressedBytes <= centralOffset, "ZIP payload range overlaps central metadata"); links.push({ path: entryPath, type: entryPath.endsWith("/") ? "DIRECTORY" : "FILE", target: null }); ranges.push({ path: entryPath, compressedOffset, compressedBytes, uncompressedBytes }); offset += 46 + nameBytes + extraBytes + commentBytes; } assert.equal(offset, endOffset, "ZIP central directory length disagrees with EOCD"); return { links, ranges }; } function tarString(bytes, offset, length) { const field = bytes.subarray(offset, offset + length); const end = field.indexOf(0); return decoder.decode(end === -1 ? field : field.subarray(0, end)); } function tarOctal(bytes, offset, length) { const value = tarString(bytes, offset, length).trim(); assert.match(value, /^[0-7]+$/, "TAR numeric field is not octal"); return Number.parseInt(value, 8); } function tarMetadata(bytes) { assert.equal(bytes.length % 512, 0, "TAR fixture is not block aligned"); const links = []; const ranges = []; let offset = 0; let zeroBlocks = 0; while (offset < bytes.length) { const header = bytes.subarray(offset, offset + 512); if (header.every((byte) => byte === 0)) { zeroBlocks++; offset += 512; if (zeroBlocks === 2) break; continue; } assert.equal(zeroBlocks, 0, "TAR has data after an end marker"); assert.equal(tarString(header, 257, 6), "ustar", "TAR fixture is not USTAR"); const expectedChecksum = tarOctal(header, 148, 8); const checksumHeader = Buffer.from(header); checksumHeader.fill(0x20, 148, 156); assert.equal(checksumHeader.reduce((sum, byte) => sum + byte, 0), expectedChecksum, "TAR header checksum mismatch"); const prefix = tarString(header, 345, 155); const name = tarString(header, 0, 100); const entryPath = prefix ? `${prefix}/${name}` : name; const uncompressedBytes = tarOctal(header, 124, 12); const typeFlag = String.fromCharCode(header[156] || 0x30); const type = { "0": "FILE", "1": "HARDLINK", "2": "SYMLINK", "5": "DIRECTORY" }[typeFlag]; assert.ok(type, `TAR entry type ${typeFlag} is unsupported by the fixture gate`); const target = type === "SYMLINK" || type === "HARDLINK" ? tarString(header, 157, 100) : null; const compressedOffset = offset + 512; assert.ok(compressedOffset + uncompressedBytes <= bytes.length, "TAR payload exceeds the fixture"); links.push({ path: entryPath, type, target }); ranges.push({ path: entryPath, compressedOffset, compressedBytes: uncompressedBytes, uncompressedBytes }); offset = compressedOffset + Math.ceil(uncompressedBytes / 512) * 512; } assert.equal(zeroBlocks, 2, "TAR fixture has no two-block end marker"); assert.equal(offset, bytes.length, "TAR fixture has trailing data after the end marker"); return { links, ranges }; } const manifestBytes = fs.readFileSync(path.join(fixtureRoot, "manifest.json")); const manifest = JSON.parse(manifestBytes); assert.equal(manifest.schemaVersion, 1); assert.equal(manifest.task, "M12-04J"); assert.equal(manifest.generator, "tools/web/generate-malicious-archive-fixtures.mjs"); assert.equal(manifest.extractionAllowed, false); const expected = [ ["ZIP_PATH_TRAVERSAL", "ZIP", "ARCHIVE_ROOT_ESCAPE", "LINK_SAFETY"], ["ZIP_COMPRESSION_BOMB", "ZIP", "COMPRESSION_RATIO", "CONFLICTS"], ["ZIP_DUPLICATE_PATH", "ZIP", "DUPLICATE_PATH", "LINK_SAFETY"], ["TAR_PATH_TRAVERSAL", "TAR", "ARCHIVE_ROOT_ESCAPE", "LINK_SAFETY"], ["TAR_SYMLINK_ESCAPE", "TAR", "SYMLINK_ESCAPE", "LINK_SAFETY"], ["TAR_PREFIX_CONFLICT", "TAR", "FILE_DIRECTORY_PREFIX_CONFLICT", "CONFLICTS"], ]; assert.deepEqual(manifest.cases.map((item) => [item.id, item.format, item.threat, item.gate]), expected); execFileSync(process.execPath, [path.join(repoRoot, manifest.generator), "--output", regeneratedRoot], { cwd: repoRoot }); assert.deepEqual(fs.readFileSync(path.join(regeneratedRoot, "manifest.json")), manifestBytes, "fixture manifest is not deterministic"); for (const fixture of manifest.cases) { assert.equal(fixture.file, path.basename(fixture.file), `${fixture.id} fixture path escapes its root`); assert.equal(fixture.expectedCode, "IO_ARCHIVE_UNSAFE"); const archiveBytes = fs.readFileSync(path.join(fixtureRoot, fixture.file)); assert.equal(archiveBytes.length, fixture.byteLength, `${fixture.id} byte length drifted`); assert.equal(sha256(archiveBytes), fixture.sha256, `${fixture.id} SHA-256 drifted`); assert.deepEqual(fs.readFileSync(path.join(regeneratedRoot, fixture.file)), archiveBytes, `${fixture.id} is not deterministic`); const metadata = fixture.format === "ZIP" ? zipMetadata(archiveBytes) : tarMetadata(archiveBytes); let failure; try { if (fixture.gate === "LINK_SAFETY") { safety.resolveArchiveLinkEntries({ schemaVersion: 1, temporaryRootId: `fixture:${fixture.id}`, entries: metadata.links }); } else { conflicts.validateArchiveConflicts({ schemaVersion: 1, byteLength: archiveBytes.length, ranges: metadata.ranges }); } } catch (error) { failure = error; } assert.equal(failure?.code, fixture.expectedCode, `${fixture.id} did not fail closed`); } process.stdout.write(`malicious-archive-fixtures-ok cases=${manifest.cases.length} zip=3 tar=3 extraction=disabled\n`); } finally { fs.rmSync(temporary, { recursive: true, force: true }); }