183 lines
9.9 KiB
JavaScript
183 lines
9.9 KiB
JavaScript
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 });
|
|
}
|