diff --git a/package-lock.json b/package-lock.json index 33332d40e..e4d1c0a19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@modelcontextprotocol/server": "2.3.1", "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "6.0.0", - "@zip.js/zip.js": "2.8.26", + "@zip.js/zip.js": "2.15.0", "canonicalize": "3.0.0", "cross-spawn": "7.0.6", "diff": "9.0.0", @@ -3945,9 +3945,9 @@ ] }, "node_modules/@zip.js/zip.js": { - "version": "2.8.26", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", - "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.15.0.tgz", + "integrity": "sha512-hYAuHAaWjt0axbofaDL5XUlmrQPsBDcK3f45ApZuh6N+8UGVk26GoGdtGDvcvzahsHytTQ6DtBVXwG9IcCMjjQ==", "license": "BSD-3-Clause", "engines": { "bun": ">=0.7.0", diff --git a/package.json b/package.json index 9ae878e5a..4b2b72f45 100644 --- a/package.json +++ b/package.json @@ -208,7 +208,7 @@ "@modelcontextprotocol/server": "2.3.1", "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "6.0.0", - "@zip.js/zip.js": "2.8.26", + "@zip.js/zip.js": "2.15.0", "canonicalize": "3.0.0", "cross-spawn": "7.0.6", "diff": "9.0.0", diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index f71a3b35b..8dd4471bd 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -53,6 +53,9 @@ export class ZipArtifactReader implements ArtifactReader { this.#reader = new ZipReader(this.#source, { checkSignature: true, checkOverlappingEntry: true, + // REA applies its own provider-neutral path validation in scanReader and + // must preserve that typed failure instead of zip.js rejecting first. + filenameValidation: "tolerant", }); } diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts new file mode 100644 index 000000000..10b221819 --- /dev/null +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -0,0 +1,113 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it } from "vitest"; + +import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; + +it("rejects overlapping ZIP member data ranges through artifact inventory", async () => { + const root = await createTestTempDirectory("rea-zip-overlap-"); + const path = join(root, "overlap.zip"); + await writeOrderedZip(path, ["a.txt", "b.txt"]); + const bytes = await readFile(path); + const centralDirectorySignature = Buffer.from([0x50, 0x4b, 0x01, 0x02]); + const first = bytes.indexOf(centralDirectorySignature); + const second = bytes.indexOf(centralDirectorySignature, first + 4); + if (first < 0 || second <= first) + throw new Error("Expected two ZIP central-directory entries"); + + const firstLocalOffset = bytes.readUInt32LE(first + 42); + const secondLocalOffset = bytes.readUInt32LE(second + 42); + expect(secondLocalOffset).not.toBe(firstLocalOffset); + bytes.writeUInt32LE(firstLocalOffset, second + 42); + await writeFile(path, bytes); + + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + const result = await new ArtifactProvider() + .createClient(target.value) + .execute("inventory_artifact", {}); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "io" }, + }); +}); + +it("preserves encrypted-member and corrupt-CRC observations", async () => { + const root = await createTestTempDirectory("rea-zip-integrity-"); + const encryptedPath = join(root, "encrypted.zip"); + const encryptedWriter = new ZipWriter(new Uint8ArrayWriter()); + await encryptedWriter.add("secret.txt", new TextReader("secret bytes"), { + password: "fixture-password", + }); + await writeFile(encryptedPath, await encryptedWriter.close()); + + const encryptedTarget = await parseBinaryTarget(encryptedPath); + if (!encryptedTarget.ok) throw encryptedTarget.error; + const encryptedResult = await new ArtifactProvider() + .createClient(encryptedTarget.value) + .execute("inventory_artifact", {}); + if (!encryptedResult.ok) throw encryptedResult.error; + const encryptedInventory = artifactInventoryResultSchema.parse( + encryptedResult.value.result, + ); + expect(encryptedInventory.occurrences).toContainEqual( + expect.objectContaining({ + logical_path: "secret.txt", + encrypted: true, + hash_status: "unavailable", + }), + ); + + const corruptPath = join(root, "corrupt-crc.zip"); + const corruptWriter = new ZipWriter(new Uint8ArrayWriter()); + await corruptWriter.add("data.txt", new TextReader("evidence\n")); + await writeFile(corruptPath, await corruptWriter.close()); + const bytes = await readFile(corruptPath); + const localFileHeaderSignature = Buffer.from([0x50, 0x4b, 0x03, 0x04]); + const localHeader = bytes.indexOf(localFileHeaderSignature); + if (localHeader < 0) throw new Error("Expected a ZIP local-file header"); + const dataOffset = + localHeader + + 30 + + bytes.readUInt16LE(localHeader + 26) + + bytes.readUInt16LE(localHeader + 28); + const firstByte = bytes[dataOffset]; + if (firstByte === undefined) throw new Error("Expected ZIP member bytes"); + bytes[dataOffset] = firstByte ^ 1; + await writeFile(corruptPath, bytes); + + const corruptTarget = await parseBinaryTarget(corruptPath); + if (!corruptTarget.ok) throw corruptTarget.error; + const corruptResult = await new ArtifactProvider() + .createClient(corruptTarget.value) + .execute("inventory_artifact", {}); + expect(corruptResult).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "io" }, + }); +}); + +it("rejects a truncated ZIP through artifact inventory", async () => { + const root = await createTestTempDirectory("rea-zip-malformed-"); + const path = join(root, "truncated.zip"); + await writeFile(path, Buffer.from([0x50, 0x4b, 0x03, 0x04])); + + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + const result = await new ArtifactProvider() + .createClient(target.value) + .execute("inventory_artifact", {}); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "io" }, + }); +});