diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index 81fac3bda1..7c5c4d5478 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -126,10 +126,16 @@ distinct hashes leave a single commit unknown. The selected file is limited to 256 MiB. A dependency section is limited to 1 MiB compressed. Inflated JSON is limited to the smaller of 8 MiB and 64 times the compressed size. The complete report is limited to 8 MiB. Empty sections, -truncated or trailing zlib bytes, invalid JSON, unknown fields, dependency +truncated streams, nonpadding bytes after a zlib stream, invalid JSON, duplicate +object fields, unknown fields, dependency indices outside the package list, more than one root package, and compressed dependency sections are typed failures. A cycle is reported and is not walked. +For PE images with `VirtualSize` zero, the raw-size fallback can include file +alignment bytes. REA excludes a zero suffix only when the raw section size is +the zlib stream length rounded up to the declared `FileAlignment`; nonzero or +oversized suffixes and truncated streams remain failures. + A missing section returns success with `auditable_metadata: null` and a limitation. Absence of `.dep-v0` does not mean the binary has no dependencies. Unknown embedded format revisions are retained in `format_version` and diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index 27904c9678..e0742bf36a 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -73,6 +73,7 @@ export type ImageSection = { readonly virtual_address: number; readonly virtual_size: number; readonly raw_size: number; + readonly file_alignment: number; readonly characteristics: number; } | { diff --git a/src/native/metadata/PeImage.ts b/src/native/metadata/PeImage.ts index 1a9fc16e2d..3d03b2e1c9 100644 --- a/src/native/metadata/PeImage.ts +++ b/src/native/metadata/PeImage.ts @@ -90,6 +90,7 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => { return { virtual_address: reader.u32(entry), size: reader.u32(entry + 4) }; }); const imageBase = reader.word(optional + (bits === 64 ? 24 : 28), bits); + const fileAlignment = reader.u32(optional + 36); const headerMapping: FileMapping = { address: imageBase, offset: 0, @@ -127,6 +128,7 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => { virtualAddress, virtualSize, rawSize: size, + fileAlignment, }), ); const address = imageBase + BigInt(virtualAddress); @@ -185,6 +187,7 @@ const peImageSection = ({ virtualAddress, virtualSize, rawSize, + fileAlignment, }: { readonly reader: BinaryReader; readonly name: string; @@ -194,6 +197,7 @@ const peImageSection = ({ readonly virtualAddress: number; readonly virtualSize: number; readonly rawSize: number; + readonly fileAlignment: number; }): ImageSection => { const range = declaredFileRange( reader.bytes.length, @@ -212,6 +216,7 @@ const peImageSection = ({ virtual_address: virtualAddress, virtual_size: virtualSize, raw_size: rawSize, + file_alignment: fileAlignment, characteristics, ...range, read_only: readOnly, diff --git a/src/rust/RustAuditableReader.test.ts b/src/rust/RustAuditableReader.test.ts index 52590f673d..a37b6cd3de 100644 --- a/src/rust/RustAuditableReader.test.ts +++ b/src/rust/RustAuditableReader.test.ts @@ -14,6 +14,8 @@ const section = (document: unknown) => 0, deflateSync(Buffer.from(JSON.stringify(document))), ); +const sectionJson = (document: string) => + readAuditableSection(".dep-v0", 0, deflateSync(Buffer.from(document))); it("preserves package order, omitted kind, and the root index", () => { const parsed = section({ @@ -194,6 +196,23 @@ it("rejects invalid JSON, trailing zlib bytes, and an empty section", () => { ).toThrow(RustAuditableFormatFailure); }); +it("rejects duplicate decoded JSON fields before object conversion", () => { + for (const document of [ + '{"packages":[],"packages":[]}', + '{"packages":[{"name":"alpha","name":"beta","version":"1","source":"local"}]}', + '{"packages":[{"name":"alpha","version":"1","source":"local","dependencies":[0],"dependencies":[]}]}', + '{"packages":[],"meta":{"name":"alpha","na\\u006de":"beta"}}', + ]) + expect(() => sectionJson(document)).toThrow( + /duplicate field "(?:packages|name|dependencies)"/u, + ); + expect( + sectionJson( + '{"packages":[{"name":"alpha","version":"1","source":"local"}]}', + ).packages, + ).toHaveLength(1); +}); + it("stops an inflate that exceeds the section ratio", () => { const raw = Buffer.alloc(8000); const compressed = deflateSync(raw); diff --git a/src/rust/RustAuditableReader.ts b/src/rust/RustAuditableReader.ts index b8e54dfa03..754c963ea1 100644 --- a/src/rust/RustAuditableReader.ts +++ b/src/rust/RustAuditableReader.ts @@ -1,4 +1,6 @@ -import { deflateSync, inflateSync } from "node:zlib"; +import { nodeCodecResult } from "../compression/NodeCodecResult.js"; +import { inflateSync } from "node:zlib"; +import { visit } from "jsonc-parser"; import { z } from "zod"; import { cyclicDependencyIndices, @@ -81,6 +83,7 @@ export const readAuditableSection = ( sectionName: RustAuditableMetadata["section_name"], fileOffset: number, bytes: Buffer, + options: { readonly peFileAlignment?: number | undefined } = {}, ): RustAuditableMetadata => { if (bytes.length === 0) throw new RustAuditableFormatFailure( @@ -95,16 +98,16 @@ export const readAuditableSection = ( }, "Dependency section exceeds the 1 MiB compressed limit", ); - const inflated = inflateSection(bytes); - const document = parseDocument(inflated); + const inflated = inflateSection(bytes, options.peFileAlignment); + const document = parseDocument(inflated.bytes); const packages = normalizePackages(document.packages); const root = rootIndex(packages); return { section_name: sectionName, file_offset: fileOffset, - section_bytes: bytes.length, - compressed_bytes: bytes.length, - uncompressed_bytes: inflated.length, + section_bytes: inflated.compressedBytes, + compressed_bytes: inflated.compressedBytes, + uncompressed_bytes: inflated.bytes.length, format_version: document.format ?? null, packages, root_package_index: root, @@ -118,78 +121,45 @@ export const readAuditableSection = ( }; }; -const inflateSection = (bytes: Buffer): Buffer => { +const inflateSection = ( + bytes: Buffer, + peFileAlignment: number | undefined, +): { readonly bytes: Buffer; readonly compressedBytes: number } => { const maximum = Math.min( RUST_UNCOMPRESSED_BYTES, bytes.length * RUST_INFLATE_RATIO, ); try { - const inflated = inflateSync(bytes, { - maxOutputLength: maximum, - ...(rejectsTrailingJunk() ? { rejectGarbageAfterEnd: true } : {}), - }); - if ( - !rejectsTrailingJunk() && - shortestPrefix(bytes, inflated, maximum) !== bytes.length - ) + const result = nodeCodecResult( + inflateSync(bytes, { + info: true, + maxOutputLength: maximum, + }), + ); + const compressedBytes = result.consumedBytes; + if (peFileAlignment !== undefined && peFileAlignment > 0) { + const padding = bytes.subarray(compressedBytes); + const alignedRawSize = + Math.ceil(compressedBytes / peFileAlignment) * peFileAlignment; + if (bytes.length !== alignedRawSize || padding.some((byte) => byte !== 0)) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section has bytes after the zlib stream", + ); + return { bytes: result.buffer, compressedBytes }; + } + if (compressedBytes !== bytes.length) throw new RustAuditableFormatFailure( "malformed", "Dependency section has bytes after the zlib stream", ); - return inflated; + return { bytes: result.buffer, compressedBytes }; } catch (cause) { if (cause instanceof RustAuditableFormatFailure) throw cause; throw inflateFailure(cause, maximum); } }; -let trailingJunkRejected: boolean | undefined; - -const rejectsTrailingJunk = (): boolean => { - if (trailingJunkRejected !== undefined) return trailingJunkRejected; - const trailed = Buffer.concat([ - deflateSync(Buffer.from("a")), - Buffer.from([0]), - ]); - try { - inflateSync(trailed, { rejectGarbageAfterEnd: true }); - trailingJunkRejected = false; - } catch (cause) { - trailingJunkRejected = - cause instanceof Error && - "code" in cause && - cause.code === "ERR_TRAILING_JUNK_AFTER_STREAM_END"; - } - return trailingJunkRejected; -}; - -const shortestPrefix = ( - input: Buffer, - output: Buffer, - maximum: number, -): number => { - let low = 0; - let high = input.length; - while (low < high) { - const mid = Math.floor((low + high) / 2); - if (prefixMatches(input.subarray(0, mid), output, maximum)) high = mid; - else low = mid + 1; - } - return high; -}; - -const prefixMatches = ( - prefix: Buffer, - output: Buffer, - maximum: number, -): boolean => { - try { - return inflateSync(prefix, { maxOutputLength: maximum }).equals(output); - } catch { - return false; - } -}; - const inflateFailure = (cause: unknown, maximum: number): Error => { const code = cause instanceof Error && "code" in cause ? String(cause.code) : ""; @@ -222,6 +192,44 @@ const parseDocument = (bytes: Buffer): z.infer => { ); } assertDepth(text); + let invalidJson = false; + let duplicate: string | undefined; + const objectNames: Set[] = []; + visit( + text, + { + onObjectBegin: () => { + objectNames.push(new Set()); + }, + onObjectProperty: (name) => { + const names = objectNames.at(-1); + if (names === undefined) return; + if (names.has(name)) duplicate ??= name; + names.add(name); + }, + onObjectEnd: () => { + objectNames.pop(); + }, + onError: () => { + invalidJson = true; + }, + }, + { + allowTrailingComma: false, + disallowComments: true, + }, + ); + if (invalidJson) { + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section JSON is invalid", + ); + } + if (duplicate !== undefined) + throw new RustAuditableFormatFailure( + "malformed", + `Dependency JSON contains duplicate field ${JSON.stringify(duplicate)}`, + ); let value: unknown; try { value = JSON.parse(text); diff --git a/src/rust/RustBinaryImage.ts b/src/rust/RustBinaryImage.ts index ba81f3a07c..03abfec473 100644 --- a/src/rust/RustBinaryImage.ts +++ b/src/rust/RustBinaryImage.ts @@ -109,6 +109,15 @@ const readDependency = ( format === "macho" ? "__DATA,.dep-v0" : ".dep-v0", section.offset, bytes.subarray(section.offset, section.offset + section.size), + { + peFileAlignment: + section.format === "pe" && + section.virtual_size === 0 && + section.file_alignment > 0 && + section.raw_size % section.file_alignment === 0 + ? section.file_alignment + : undefined, + }, ); const rustcCommit = ( diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index aa04f97b22..20e71cc66e 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -394,6 +394,78 @@ cliTest("reports malformed zlib as invalid input", async ({ cli }) => { }); }); +cliTest( + "rejects duplicate Rust dependency JSON through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-duplicate-json-"); + const { client, call } = await connectLocalToolsMcp(); + const path = join(root, "duplicate.elf"); + await writeFile( + path, + createRustImage({ + format: "elf", + payload: deflateSync(Buffer.from('{"packages":[],"packages":[]}')), + includeRustc: false, + }), + ); + + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_rust_binary" }, + }); + const response = await call("inspect_rust_binary", { path }); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_rust_binary" }, + }); + await client.ping(); + }, + 30000, +); + +cliTest( + "reads PE cargo-auditable data padded to FileAlignment through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-pe-padding-"); + const { client, call } = await connectLocalToolsMcp(); + const path = join(root, "padded.exe"); + const bytes = createRustImage({ format: "pe", includeRustc: false }); + const compressedBytes = rustDependencyPayload().length; + const alignedSize = 512; + expect(compressedBytes).toBeLessThan(alignedSize); + bytes.writeUInt32LE(alignedSize, 152 + 36); // OptionalHeader.FileAlignment + bytes.writeUInt32LE(0, 264 + 8); // .dep-v0 VirtualSize + bytes.writeUInt32LE(alignedSize, 264 + 16); // .dep-v0 SizeOfRawData + await writeFile(path, bytes); + + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_rust_binary").outputSchema.parse( + result.json, + ); + expect( + rustBinarySchema.parse(evidence.normalized_result).auditable_metadata, + ).toMatchObject({ + compressed_bytes: compressedBytes, + packages: [ + { name: "itoa", version: "1.0.18" }, + { name: "sample", version: "0.1.0", root: true }, + ], + }); + const response = await call("inspect_rust_binary", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(evidence); + await client.ping(); + }, + 30000, +); + cliTest( "releases a failed Rust descriptor close before projecting the CLI failure", async ({ cli }) => { diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts index 672b7e4d40..6dfec5c166 100644 --- a/tests/boundary/rust/rustBinaryImage.test.ts +++ b/tests/boundary/rust/rustBinaryImage.test.ts @@ -118,6 +118,44 @@ it("ignores PE file-alignment padding after the dependency content", () => { }); }); +it("recognizes zero-virtual-size PE raw padding without accepting payload junk", () => { + const payloadBytes = rustDependencyPayload().length; + const entry = 264; + const bytes = createRustImage({ format: "pe", includeRustc: false }); + bytes.writeUInt32LE(0, entry + 8); + bytes.writeUInt32LE(512, entry + 16); + bytes.writeUInt32LE(512, 188); // Optional-header FileAlignment. + expect(readRustBinaryImage(bytes).auditable_metadata).toMatchObject({ + section_bytes: payloadBytes, + compressed_bytes: payloadBytes, + packages, + }); + + const payloadOffset = bytes.readUInt32LE(entry + 20); + const nonzeroSuffix = Buffer.from(bytes); + nonzeroSuffix[payloadOffset + payloadBytes] = 1; + expect(() => readRustBinaryImage(nonzeroSuffix)).toThrow( + /bytes after the zlib stream/u, + ); + + const truncated = Buffer.from(bytes); + truncated[payloadOffset + payloadBytes - 1] = + truncated[payloadOffset + payloadBytes - 1]! ^ 0xff; + expect(() => readRustBinaryImage(truncated)).toThrow(/complete zlib stream/u); + + const nonzeroVirtualSize = Buffer.from(bytes); + nonzeroVirtualSize.writeUInt32LE(payloadBytes + 1, entry + 8); + expect(() => readRustBinaryImage(nonzeroVirtualSize)).toThrow( + /bytes after the zlib stream/u, + ); + + const unknownAlignment = Buffer.from(bytes); + unknownAlignment.writeUInt32LE(0, 188); + expect(() => readRustBinaryImage(unknownAlignment)).toThrow( + /bytes after the zlib stream/u, + ); +}); + it("returns null metadata when the dependency section is absent", () => { const image = readRustBinaryImage( createRustImage({ format: "elf", omitSection: true }),