diff --git a/docs/ghidra-dos.md b/docs/ghidra-dos.md index 487899bb75..a9be485003 100644 --- a/docs/ghidra-dos.md +++ b/docs/ghidra-dos.md @@ -134,6 +134,10 @@ their contents and DOS runtime semantics are not verified. Independent verificat also covers explicit [raw-binary imports](raw-binary.md); other formats return `unsupported` with measured observations. +MZ verification retains the initialized image described by the header's page +count and streams appended overlay bytes into the complete original and modified +source digests. Overlay size does not increase the retained image allocation. + `read_bytes` returns initialized provider memory, including loader fixups; an unmapped or uninitialized byte ends the read with an explicit returned length and `complete: false`. `address_to_file_offset` uses the provider's source mapping; diff --git a/src/domain/dosMz.ts b/src/domain/dosMz.ts index c69eb36e56..785877a61f 100644 --- a/src/domain/dosMz.ts +++ b/src/domain/dosMz.ts @@ -1,5 +1,16 @@ import { err, ok, type Result } from "./result.js"; +/** The unsigned 16-bit page count bounds the initialized image, not overlays. */ +export const dosMzImageByteLength = (bytes: Buffer): Result => { + if (bytes.length < 28 || bytes.toString("latin1", 0, 2) !== "MZ") + return err("invalid or truncated DOS MZ header"); + const lastPageBytes = bytes.readUInt16LE(2); + const pages = bytes.readUInt16LE(4); + if (pages === 0 || lastPageBytes > 511) + return err("invalid DOS MZ page count or final-page size"); + return ok((pages - 1) * 512 + (lastPageBytes === 0 ? 512 : lastPageBytes)); +}; + /** DOS load-module coordinates before applying the loader's segment relocation. */ export interface DosMzHeader { readonly headerBytes: number; @@ -105,12 +116,9 @@ export const parseDosMzHeader = ( return err("MZ declares a Windows new header; it is not a DOS-only image"); if (windowsHeader.state === "signature-needed") return err("MZ Windows new-header signature is outside the bounded prefix"); - const lastPageBytes = bytes.readUInt16LE(2); - const pages = bytes.readUInt16LE(4); - if (pages === 0 || lastPageBytes > 511) - return err("invalid DOS MZ page count or final-page size"); - const imageBytes = - (pages - 1) * 512 + (lastPageBytes === 0 ? 512 : lastPageBytes); + const extent = dosMzImageByteLength(bytes); + if (!extent.ok) return extent; + const imageBytes = extent.value; const headerBytes = bytes.readUInt16LE(8) * 16; if (headerBytes < 28 || headerBytes >= imageBytes) return err("invalid DOS MZ header size or empty load module"); diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 98ed4490be..becbddafb5 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -62,10 +62,18 @@ import { type GhidraSessionInfo, } from "./GhidraSessionValues.js"; import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js"; +import { attestGhidraDosSnapshot } from "./GhidraDosSnapshot.js"; +import { + nativeLoadImagePartialObservationSchema, + type NativeLoadImage, + type NativeLoadImageObservation, + type NativeLoadImagePartialObservation, +} from "../domain/native/nativeLoadImage.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { artifactFileCancellationCause, + artifactFilePrimaryFailure, bindArtifactFileCleanup, withArtifactRegularFile, } from "../artifacts/ArtifactFileOperation.js"; @@ -467,12 +475,46 @@ export class GhidraClient { ); } + /** Compare MZ source identity without retaining unbounded appended overlays. */ + attestDosTargetSnapshot( + observations: NativeLoadImageObservation, + signal?: AbortSignal, + ): Promise> { + return this.#withTargetSnapshot( + async (handle, initial, path) => { + const result = await attestGhidraDosSnapshot( + handle, + initial.size, + path, + this.#options.targetSha256, + observations, + signal, + ); + if (!result.ok) throw this.#failure("protocol", result.error); + return result.value; + }, + (result) => + nativeLoadImagePartialObservationSchema.parse({ + kind: "native-load-image", + stage: "completed", + result, + }), + signal, + ); + } + async #withTargetSnapshot( read: (handle: FileHandle, initial: Stats, path: string) => Promise, - observe: (value: T, path: string) => FileSnapshotPartialObservation, + observe: ( + value: T, + path: string, + ) => FileSnapshotPartialObservation | NativeLoadImagePartialObservation, signal?: AbortSignal, ): Promise> { - let completedObservation: FileSnapshotPartialObservation | undefined; + let completedObservation: + | FileSnapshotPartialObservation + | NativeLoadImagePartialObservation + | undefined; try { return await this.#fileResources.run(async () => { const path = this.#snapshotPath; @@ -514,12 +556,16 @@ export class GhidraClient { cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" ? { reason: artifactFileCancellationCause(cause) } : undefined; - let failure = this.#failure( - cancellation === undefined ? "protocol" : "cancelled", - "Ghidra target snapshot read or finalization did not complete", - cause, - cancellation === undefined ? {} : { cancellation }, - ); + const primary = artifactFilePrimaryFailure(cause); + let failure = + primary instanceof GhidraSessionError + ? primary + : this.#failure( + cancellation === undefined ? "protocol" : "cancelled", + "Ghidra target snapshot read or finalization did not complete", + cause, + cancellation === undefined ? {} : { cancellation }, + ); if (completedObservation !== undefined) failure = failure.withPartialObservation(completedObservation); if ( diff --git a/src/ghidra/GhidraDosSnapshot.test.ts b/src/ghidra/GhidraDosSnapshot.test.ts new file mode 100644 index 0000000000..953af0754b --- /dev/null +++ b/src/ghidra/GhidraDosSnapshot.test.ts @@ -0,0 +1,151 @@ +import { createHash } from "node:crypto"; +import { open, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js"; +import { attestGhidraDosSnapshot } from "./GhidraDosSnapshot.js"; + +const hash = (bytes: Buffer) => + createHash("sha256").update(bytes).digest("hex"); + +it.each([23, 65539])( + "streams complete MZ identity with a %s-byte overlay", + async (overlay) => { + const fixture = fixtureDosLoadImage(Buffer.alloc(overlay, 0xa5)); + const directory = await createTestTempDirectory("rea-mz-source-"); + const path = join(directory, "inert.exe"); + await writeFile(path, fixture.bytes); + const handle = await open(path, "r"); + const allocation = vi.spyOn(Buffer, "allocUnsafe"); + const copies = vi.spyOn(Buffer, "from"); + try { + const result = await attestGhidraDosSnapshot( + handle, + fixture.bytes.length, + path, + fixture.sha256, + fixture.observation, + ); + expect(result).toMatchObject({ + ok: true, + value: { + status: "verified", + overlay_bytes: overlay, + observations: fixture.observation, + }, + }); + expect( + Math.max(...allocation.mock.calls.map(([size]) => size)), + ).toBeLessThanOrEqual(65536); + expect( + copies.mock.calls + .filter(([source]) => Buffer.isBuffer(source)) + .every(([source]) => (source as Buffer).length <= 160), + ).toBe(true); + expect(handle.fd).toBeGreaterThanOrEqual(0); + } finally { + allocation.mockRestore(); + copies.mockRestore(); + await handle.close(); + } + }, +); + +it.each([41, 170])( + "rejects changed source bytes at file offset %s", + async (offset) => { + const fixture = fixtureDosLoadImage(); + fixture.bytes[offset] = (fixture.bytes[offset] ?? 0) ^ 1; + const directory = await createTestTempDirectory("rea-mz-identity-"); + const path = join(directory, "inert.exe"); + await writeFile(path, fixture.bytes); + const handle = await open(path, "r"); + try { + expect( + await attestGhidraDosSnapshot( + handle, + fixture.bytes.length, + path, + fixture.sha256, + fixture.observation, + ), + ).toMatchObject({ ok: false, error: expect.stringContaining("digest") }); + } finally { + await handle.close(); + } + }, +); + +it.each(["ZZZZ", "NE00"])( + "observes distant Windows signatures (%s) without capturing the overlay", + async (signature) => { + const fixture = fixtureDosLoadImage(Buffer.alloc(1024)); + fixture.bytes.writeUInt32LE(512, 60); + fixture.bytes.write(signature, 512, "ascii"); + fixture.sha256 = hash(fixture.bytes); + const modified = Buffer.from(fixture.bytes); + modified.writeUInt16LE(0x1004, 41); + Object.assign(fixture.observation.source_files[0] ?? {}, { + original_sha256: fixture.sha256, + modified_sha256: hash(modified), + }); + Object.assign(fixture.observation.mappings[1] ?? {}, { + sha256: hash(modified.subarray(32, 160)), + }); + const directory = await createTestTempDirectory("rea-mz-signature-"); + const path = join(directory, "inert.exe"); + await writeFile(path, fixture.bytes); + const handle = await open(path, "r"); + try { + const result = await attestGhidraDosSnapshot( + handle, + fixture.bytes.length, + path, + fixture.sha256, + fixture.observation, + ); + expect(result).toMatchObject( + signature === "ZZZZ" + ? { ok: true, value: { status: "verified" } } + : { ok: false, error: expect.stringContaining("Windows new header") }, + ); + } finally { + await handle.close(); + } + }, +); + +it("preserves exact cancellation after an overlay descriptor read", async () => { + const fixture = fixtureDosLoadImage(Buffer.alloc(65539)); + const directory = await createTestTempDirectory("rea-mz-cancel-"); + const path = join(directory, "inert.exe"); + await writeFile(path, fixture.bytes); + const handle = await open(path, "r"); + const controller = new AbortController(); + const reason = { stage: "overlay read" }; + const original = handle.read.bind(handle); + const read = vi + .spyOn(handle, "read") + .mockImplementation(async (...args: Parameters) => { + const result = await original(...args); + if (Reflect.get(args, 3) === 160n) controller.abort(reason); + return result; + }); + try { + await expect( + attestGhidraDosSnapshot( + handle, + fixture.bytes.length, + path, + fixture.sha256, + fixture.observation, + controller.signal, + ), + ).rejects.toMatchObject({ reason: "cancelled", cause: reason }); + expect(handle.fd).toBeGreaterThanOrEqual(0); + } finally { + read.mockRestore(); + await handle.close(); + } +}); diff --git a/src/ghidra/GhidraDosSnapshot.ts b/src/ghidra/GhidraDosSnapshot.ts new file mode 100644 index 0000000000..143d715a70 --- /dev/null +++ b/src/ghidra/GhidraDosSnapshot.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; +import type { FileHandle } from "node:fs/promises"; +import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + dosMzImageByteLength, + observeMzWindowsHeader, +} from "../domain/dosMz.js"; +import { err } from "../domain/result.js"; +import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js"; +import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; +import { RegularFileChangedError } from "../filesystem/RegularFile.js"; +import { + attestGhidraDosLoadImage, + prepareGhidraDosLoadImage, +} from "./GhidraLoadImageValues.js"; + +const cancelled = (reason: unknown): Error => + new ArtifactReaderFailure("cancelled", "DOS snapshot read cancelled", { + cause: reason, + }); + +/** The caller owns admission, state validation and descriptor finalization. */ +export const attestGhidraDosSnapshot = async ( + handle: FileHandle, + fileBytes: number, + path: string, + targetSha256: string, + observations: NativeLoadImageObservation, + signal?: AbortSignal, +) => { + const headerBytes = Math.min(fileBytes, 64); + const header = await readBoundedFileBytes( + handle, + headerBytes, + signal, + headerBytes, + cancelled, + ); + if (header === undefined || header.length !== headerBytes) + throw new RegularFileChangedError(path); + const extent = dosMzImageByteLength(header); + if (!extent.ok) return extent; + if (extent.value > fileBytes) return err("truncated DOS MZ load module"); + // Retain the complete initialized image and the Windows-pointer header only. + // The unsigned 16-bit page count bounds this allocation independently of overlays. + const captureBytes = Math.max(headerBytes, extent.value); + const prefix = Buffer.allocUnsafe(captureBytes); + header.copy(prefix); + let captured = header.length; + if (captured < captureBytes) { + for await (const chunk of readArtifactFileChunks(handle, { + start: captured, + end: captureBytes - 1, + signal, + })) { + chunk.copy(prefix, captured); + captured += chunk.length; + } + } + if (captured !== captureBytes) throw new RegularFileChangedError(path); + const windowsHeader = observeMzWindowsHeader(prefix, fileBytes); + let signature: Buffer | undefined; + if (windowsHeader.state === "signature-needed") { + const size = Math.min(4, fileBytes - windowsHeader.offset); + signature = Buffer.allocUnsafe(size); + let read = 0; + while (read < size) { + if (signal?.aborted) throw cancelled(signal.reason); + const { bytesRead } = await handle.read( + signature, + read, + size - read, + windowsHeader.offset + read, + ); + if (bytesRead === 0) throw new RegularFileChangedError(path); + read += bytesRead; + } + } + const prepared = prepareGhidraDosLoadImage(prefix, fileBytes, signature); + if (!prepared.ok) return prepared; + const originalHash = createHash("sha256").update(prefix); + const modifiedHash = createHash("sha256") + .update(prepared.value.modified) + .update(prefix.subarray(prepared.value.header.imageBytes)); + let read = prefix.length; + if (read < fileBytes) { + for await (const chunk of readArtifactFileChunks(handle, { + start: read, + end: fileBytes - 1, + signal, + })) { + originalHash.update(chunk); + modifiedHash.update(chunk); + read += chunk.length; + } + } + if (read !== fileBytes) throw new RegularFileChangedError(path); + return attestGhidraDosLoadImage( + { + sha256: originalHash.digest("hex"), + fileBytes, + modifiedSha256: modifiedHash.digest("hex"), + image: prepared.value, + }, + targetSha256, + observations, + ); +}; diff --git a/src/ghidra/GhidraLoadImage.fixture.ts b/src/ghidra/GhidraLoadImage.fixture.ts index 31e55b0a9e..46b08f7ffd 100644 --- a/src/ghidra/GhidraLoadImage.fixture.ts +++ b/src/ghidra/GhidraLoadImage.fixture.ts @@ -1,12 +1,60 @@ import { createHash } from "node:crypto"; import { dosMz } from "../domain/binaryTarget.fixture.js"; import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js"; +import { + attestGhidraDosLoadImage, + prepareGhidraDosLoadImage, +} from "./GhidraLoadImageValues.js"; +import { err } from "../domain/result.js"; +import { GhidraSessionError } from "./GhidraSessionError.js"; const hash = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex"); +/** Buffered tiny-fixture input for comparison tests; production streams the descriptor. */ +export const compareDosFixture = ( + bytes: Buffer, + sha256: string, + observations: NativeLoadImageObservation, +) => { + if (hash(bytes) !== sha256) + return err( + "Ghidra load-image snapshot digest does not match the admitted target", + ); + const image = prepareGhidraDosLoadImage(bytes, bytes.length); + if (!image.ok) return image; + return attestGhidraDosLoadImage( + { + sha256, + fileBytes: bytes.length, + modifiedSha256: createHash("sha256") + .update(image.value.modified) + .update(bytes.subarray(image.value.header.imageBytes)) + .digest("hex"), + image: image.value, + }, + sha256, + observations, + ); +}; + +export const compareDosClientFixture = ( + ...input: Parameters +) => { + const compared = compareDosFixture(...input); + return compared.ok + ? compared + : err( + new GhidraSessionError("protocol", compared.error, { + reason: compared.error, + }), + ); +}; + /** Source-owned MZ with one relocated word, overlay and uninitialized allocation. */ -export const fixtureDosLoadImage = (): { +export const fixtureDosLoadImage = ( + overlay = Buffer.from("private-overlay-fixture"), +): { bytes: Buffer; sha256: string; observation: NativeLoadImageObservation; @@ -16,10 +64,7 @@ export const fixtureDosLoadImage = (): { original.writeUInt16LE(9, 28); original.writeUInt16LE(0, 30); original.writeUInt16LE(4, 41); - const bytes = Buffer.concat([ - original, - Buffer.from("private-overlay-fixture"), - ]); + const bytes = Buffer.concat([original, overlay]); const modified = Buffer.from(bytes); modified.writeUInt16LE(0x1004, 41); return { diff --git a/src/ghidra/GhidraLoadImageAttest.test.ts b/src/ghidra/GhidraLoadImageAttest.test.ts index bc5ba23435..2ab97623c1 100644 --- a/src/ghidra/GhidraLoadImageAttest.test.ts +++ b/src/ghidra/GhidraLoadImageAttest.test.ts @@ -16,6 +16,7 @@ import { } from "../domain/native/nativeLoadImage.js"; import { fixtureDosLoadImage, + compareDosClientFixture, fixtureDosComLoadImage, } from "./GhidraLoadImage.fixture.js"; import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js"; @@ -50,19 +51,31 @@ it.each(["dos-mz", "dos-com", "raw-binary"] as const)( target: fixture.target, measured: fixture.observation, client: - "bytes" in fixture + format === "dos-mz" && "bytes" in fixture ? { - readTargetSnapshot: async () => { + attestDosTargetSnapshot: async (observations) => { + const compared = compareDosClientFixture( + fixture.bytes, + fixture.sha256, + observations, + ); caller.abort(reason); - return ok({ kind: "captured", bytes: fixture.bytes }); + return compared; }, } - : { - hashTargetSnapshot: async () => { - caller.abort(reason); - return ok(fixture.snapshot); + : "bytes" in fixture + ? { + readTargetSnapshot: async () => { + caller.abort(reason); + return ok({ kind: "captured", bytes: fixture.bytes }); + }, + } + : { + hashTargetSnapshot: async () => { + caller.abort(reason); + return ok(fixture.snapshot); + }, }, - }, mapSessionError: (cause) => new ProviderAdapterError("ghidra", "inspect_native_load_image", { cause, @@ -99,7 +112,7 @@ it.each([null, "caller stopped", new Error("caller stopped")])( operation: "inspect_native_load_image", target: fixture.target, measured: fixture.observation, - client: { readTargetSnapshot: read }, + client: { attestDosTargetSnapshot: read }, mapSessionError: () => { throw new Error("No source failure expected"); }, @@ -134,7 +147,7 @@ it("preserves a snapshot failure before late cancellation", async () => { target: fixture.target, measured: fixture.observation, client: { - readTargetSnapshot: async () => { + attestDosTargetSnapshot: async () => { caller.abort("late caller"); return err(source); }, @@ -152,8 +165,8 @@ it("keeps ordinary DOS verification valid", async () => { target: fixture.target, measured: fixture.observation, client: { - readTargetSnapshot: async () => - ok({ kind: "captured", bytes: fixture.bytes }), + attestDosTargetSnapshot: async (observations) => + compareDosClientFixture(fixture.bytes, fixture.sha256, observations), }, mapSessionError: () => { throw new Error("No source failure expected"); diff --git a/src/ghidra/GhidraLoadImageAttest.ts b/src/ghidra/GhidraLoadImageAttest.ts index 232d7b496b..206a2150f9 100644 --- a/src/ghidra/GhidraLoadImageAttest.ts +++ b/src/ghidra/GhidraLoadImageAttest.ts @@ -12,7 +12,6 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { err, ok, type Result } from "../domain/result.js"; import { attestGhidraDosComLoadImage, - attestGhidraDosLoadImage, attestGhidraRawBinaryLoadImage, } from "./GhidraLoadImageValues.js"; import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js"; @@ -24,7 +23,10 @@ type NativeLoadImageAttestationInput = { readonly operation: AnalysisOperation; readonly measured: NativeLoadImageObservation; readonly client: Partial< - Pick + Pick< + GhidraClient, + "hashTargetSnapshot" | "readTargetSnapshot" | "attestDosTargetSnapshot" + > >; readonly mapSessionError: (failure: GhidraSessionError) => AnalysisError; readonly nativeAotResult?: NativeAotPeResult | undefined; @@ -157,6 +159,17 @@ const compareLoadImage = async ( ? attested : err(comparisonFailure(input, attested.error)); } + if (target.format === "dos-mz") { + if (client.attestDosTargetSnapshot === undefined) + return err( + comparisonFailure( + input, + "The Ghidra client does not expose its bounded DOS target snapshot comparison.", + ), + ); + const compared = await client.attestDosTargetSnapshot(observations, signal); + return compared.ok ? compared : err(sourceFailure(compared.error)); + } if (client.readTargetSnapshot === undefined) return err( comparisonFailure( @@ -173,10 +186,10 @@ const compareLoadImage = async ( "The uncapped load-image snapshot was unexpectedly not captured.", ), ); - const attested = ( - target.format === "dos-com" - ? attestGhidraDosComLoadImage - : attestGhidraDosLoadImage - )(snapshot.value.bytes, target.sha256, observations); + const attested = attestGhidraDosComLoadImage( + snapshot.value.bytes, + target.sha256, + observations, + ); return attested.ok ? attested : err(comparisonFailure(input, attested.error)); }; diff --git a/src/ghidra/GhidraLoadImageValues.test.ts b/src/ghidra/GhidraLoadImageValues.test.ts index 8fe25a9d7c..7f58e139ce 100644 --- a/src/ghidra/GhidraLoadImageValues.test.ts +++ b/src/ghidra/GhidraLoadImageValues.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { createHash } from "node:crypto"; -import { attestGhidraDosLoadImage } from "./GhidraLoadImageValues.js"; -import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js"; +import { + compareDosFixture, + fixtureDosLoadImage, +} from "./GhidraLoadImage.fixture.js"; import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js"; import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js"; @@ -214,7 +216,7 @@ const damage: ReadonlyArray< describe("independent DOS MZ import verification", () => { it("verifies every initialized source byte while retaining overlay and BSS scope", () => { const fixture = fixtureDosLoadImage(); - const result = attestGhidraDosLoadImage( + const result = compareDosFixture( fixture.bytes, fixture.sha256, fixture.observation, @@ -243,7 +245,7 @@ describe("independent DOS MZ import verification", () => { (_name, mutate) => { const fixture = fixtureDosLoadImage(); mutate(fixture.observation); - const result = attestGhidraDosLoadImage( + const result = compareDosFixture( fixture.bytes, fixture.sha256, fixture.observation, @@ -263,7 +265,7 @@ describe("independent DOS MZ import verification", () => { const mapping = fixture.observation.mappings[1]; if (mapping === undefined) throw new Error("Missing module mapping"); mapping.sha256 = "f".repeat(64); - const result = attestGhidraDosLoadImage( + const result = compareDosFixture( fixture.bytes, fixture.sha256, fixture.observation, @@ -284,11 +286,7 @@ describe("independent DOS MZ import verification", () => { const fixture = fixtureDosLoadImage(); fixture.observation.mappings.reverse(); expect( - attestGhidraDosLoadImage( - fixture.bytes, - fixture.sha256, - fixture.observation, - ), + compareDosFixture(fixture.bytes, fixture.sha256, fixture.observation), ).toMatchObject({ ok: true, value: { status: "verified" } }); }); @@ -300,11 +298,7 @@ describe("independent DOS MZ import verification", () => { relocation.segment = 0x0fff; relocation.offset = 25; expect( - attestGhidraDosLoadImage( - fixture.bytes, - fixture.sha256, - fixture.observation, - ), + compareDosFixture(fixture.bytes, fixture.sha256, fixture.observation), ).toMatchObject({ ok: true, value: { @@ -322,15 +316,11 @@ describe("independent DOS MZ import verification", () => { const fixture = fixtureDosLoadImage(); fixture.bytes[0] = 0; expect( - attestGhidraDosLoadImage( - fixture.bytes, - fixture.sha256, - fixture.observation, - ), + compareDosFixture(fixture.bytes, fixture.sha256, fixture.observation), ).toMatchObject({ ok: false, error: expect.stringContaining("digest") }); const digest = createHash("sha256").update(fixture.bytes).digest("hex"); expect( - attestGhidraDosLoadImage(fixture.bytes, digest, fixture.observation), + compareDosFixture(fixture.bytes, digest, fixture.observation), ).toMatchObject({ ok: false, error: expect.stringContaining("MZ header") }); }); diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index e5266c5df6..4655881dce 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -22,21 +22,25 @@ const limitations = [ "A mismatching memory digest identifies the reported source range; it does not locate the first differing byte inside that range.", ]; -/** Verify measured MZ import state against the session's immutable snapshot. */ -export const attestGhidraDosLoadImage = ( +export type GhidraDosImage = { + readonly header: DosMzHeader; + readonly modified: Buffer; + readonly expectedRelocations: NativeLoadImageObservation["relocations"]; +}; + +/** Prepare initialized bytes and relocation facts without copying overlays. */ +export const prepareGhidraDosLoadImage = ( bytes: Buffer, - targetSha256: string, - observations: NativeLoadImageObservation, -): Result => { - if (sha256(bytes) !== targetSha256) - return err( - "Ghidra load-image snapshot digest does not match the admitted target", - ); - const parsed = parseDosMzHeader(bytes); + fileBytes: number, + signatureAtOffset?: Buffer, +): Result => { + const parsed = parseDosMzHeader(bytes, fileBytes, signatureAtOffset); if (!parsed.ok) return parsed; const header = parsed.value; + if (header.imageBytes > bytes.length) + return err("DOS MZ initialized image was not completely captured"); const loadSegment = 0x1000; - const modified = Buffer.from(bytes); + const modified = Buffer.from(bytes.subarray(0, header.imageBytes)); const expectedRelocations = []; for (let index = 0; index < header.relocationCount; index += 1) { const table = header.relocationTableOffset + index * 4; @@ -62,6 +66,28 @@ export const attestGhidraDosLoadImage = ( .toString("hex"), }); } + return ok({ header, modified, expectedRelocations }); +}; + +export type GhidraDosLoadImageSource = { + readonly sha256: string; + readonly fileBytes: number; + readonly modifiedSha256: string; + readonly image: GhidraDosImage; +}; + +/** Verify measured MZ import state against complete identity and prepared image facts. */ +export const attestGhidraDosLoadImage = ( + source: GhidraDosLoadImageSource, + targetSha256: string, + observations: NativeLoadImageObservation, +): Result => { + if (source.sha256 !== targetSha256) + return err( + "Ghidra load-image snapshot digest does not match the admitted target", + ); + const { header, modified, expectedRelocations } = source.image; + const loadSegment = 0x1000; const checks: NativeLoadImageCheck[] = []; const check = ( name: string, @@ -89,13 +115,17 @@ export const attestGhidraDosLoadImage = ( check("image_base", "0x0", observations.image_base); check("default_address_space", "ram", observations.default_address_space); check("source_file_count", 1, observations.source_files.length); - const source = observations.source_files[0]; - check("source_file_size", bytes.length, source?.size ?? null); - check("original_file_sha256", targetSha256, source?.original_sha256 ?? null); + const observedSource = observations.source_files[0]; + check("source_file_size", source.fileBytes, observedSource?.size ?? null); + check( + "original_file_sha256", + targetSha256, + observedSource?.original_sha256 ?? null, + ); check( "modified_file_sha256", - sha256(modified), - source?.modified_sha256 ?? null, + source.modifiedSha256, + observedSource?.modified_sha256 ?? null, ); const entryAddress = address( ((loadSegment + header.entrySegment) & 0xffff) * 16 + header.entryOffset, @@ -124,7 +154,7 @@ export const attestGhidraDosLoadImage = ( rows(observations.relocations), ); - checkMappings(header, loadSegment * 16, bytes, modified, observations, check); + checkMappings(header, loadSegment * 16, modified, observations, check); return ok( nativeLoadImageSchema.parse({ status: checks.every((item) => item.matched) ? "verified" : "mismatch", @@ -149,7 +179,6 @@ export const attestGhidraDosLoadImage = ( const checkMappings = ( header: Pick, loadBase: number, - bytes: Buffer, modified: Buffer, observations: NativeLoadImageObservation, check: ( @@ -227,7 +256,7 @@ const checkMappings = ( check(`${at}.end`, expectedEnd, mapping.end, start, mapping.end); check( `${at}.memory_sha256`, - start !== null && end !== null && end <= bytes.length + start !== null && end !== null && end <= modified.length ? sha256(modified.subarray(start, end)) : null, mapping.sha256, @@ -302,7 +331,6 @@ export const attestGhidraDosComLoadImage = ( { headerBytes: 0, imageBytes: bytes.length }, 0x10100, bytes, - bytes, observations, check, ); diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index a6f6e51a42..0b5e1bc545 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -18,7 +18,10 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; import * as nativeLoader from "../windows/WindowsNativeLoader.js"; import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js"; -import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js"; +import { + fixtureDosLoadImage, + compareDosClientFixture, +} from "./GhidraLoadImage.fixture.js"; import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js"; import { nativeAotPeDigest, @@ -1500,8 +1503,12 @@ describe("Ghidra measured DOS load-image projection", () => { ...(state === "missing-snapshot" ? {} : { - readTargetSnapshot: () => - Promise.resolve(ok({ kind: "captured", bytes: fixture.bytes })), + attestDosTargetSnapshot: async (observations) => + compareDosClientFixture( + fixture.bytes, + fixture.sha256, + observations, + ), }), }); const ghidra = provider(installationHost(), factory); diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 8a679d8685..d8c1917c8b 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -113,6 +113,7 @@ export type GhidraProviderClientFactory = ( | "runtimeLineage" | "readTargetSnapshot" | "hashTargetSnapshot" + | "attestDosTargetSnapshot" > >; diff --git a/tests/boundary/mcp/ghidraFailureDiagnostics.test.ts b/tests/boundary/mcp/ghidraFailureDiagnostics.test.ts index 2e16235698..3378fb00f7 100644 --- a/tests/boundary/mcp/ghidraFailureDiagnostics.test.ts +++ b/tests/boundary/mcp/ghidraFailureDiagnostics.test.ts @@ -1,6 +1,8 @@ import { nativeLoadImagePartialObservationSchema } from "../../../src/domain/native/nativeLoadImage.js"; -import { fixtureDosLoadImage } from "../../../src/ghidra/GhidraLoadImage.fixture.js"; -import { attestGhidraDosLoadImage } from "../../../src/ghidra/GhidraLoadImageValues.js"; +import { + fixtureDosLoadImage, + compareDosFixture, +} from "../../../src/ghidra/GhidraLoadImage.fixture.js"; import { nativeAotSnapshotObservationSchema } from "../../../src/domain/native/nativeAotSnapshotObservation.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -132,7 +134,7 @@ it.each( capture: { status: "captured", bytes: bytes.length }, }) : (() => { - const compared = attestGhidraDosLoadImage( + const compared = compareDosFixture( dos.bytes, dos.sha256, dos.observation, diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index b820d15976..8bf670007b 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -286,8 +286,11 @@ it.each(["descriptor-close", "result-consumption"] as const)( () => ({ start: () => live.start(), callTool: async () => ok(jsonValueSchema.parse(fixture.observation)), - readTargetSnapshot: async (maximum, signal) => { - const result = await live.readTargetSnapshot(maximum, signal); + attestDosTargetSnapshot: async (observations, signal) => { + const result = await live.attestDosTargetSnapshot( + observations, + signal, + ); if (phase === "result-consumption" && result.ok) caller.abort(reason); return result; }, @@ -333,8 +336,9 @@ it.each(["descriptor-close", "result-consumption"] as const)( if (phase === "descriptor-close") { expect(closedOwner?.handle.fd).toBe(-1); expect(result.error.partialObservation).toMatchObject({ - kind: "file-snapshot-read", - capture: { status: "captured", bytes: fixture.bytes.length }, + kind: "native-load-image", + stage: "completed", + result: { status: "verified", observations: fixture.observation }, }); expect(result.error.diagnostics).toMatchObject({ load_image_observations: fixture.observation, @@ -351,6 +355,99 @@ it.each(["descriptor-close", "result-consumption"] as const)( }, ); +it.each([false, true])( + "retains a completed bounded MZ comparison through failed close (cancel=%s)", + async (cancel) => { + const fixture = fixtureDosLoadImage(); + const directory = await createTestTempDirectory("rea-mz-finalization-"); + const targetPath = join(directory, "inert.exe"); + await writeFile(targetPath, fixture.bytes); + const client = clientFor(new FixtureLauncher(), { + targetPath, + targetSha256: fixture.sha256, + }); + const started = await client.start(); + if (!started.ok) throw started.error; + const caller = new AbortController(); + const reason = { stage: "MZ descriptor close" }; + let allowClose = false; + const original = OwnedFileHandle.prototype.close; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(function (this: OwnedFileHandle) { + if (allowClose) return original.call(this); + if (cancel) caller.abort(reason); + return Promise.reject( + new Error("controlled MZ descriptor close failure"), + ); + }); + onTestFinished(async () => { + allowClose = true; + await client.close(); + close.mockRestore(); + }); + const result = await client.attestDosTargetSnapshot( + fixture.observation, + caller.signal, + ); + if (result.ok) throw new Error("Expected finalization failure"); + expect(result.error.kind).toBe(cancel ? "cancelled" : "protocol"); + if (cancel) expect(result.error.cancellation?.reason).toBe(reason); + expect(result.error.partialObservation).toMatchObject({ + kind: "native-load-image", + stage: "completed", + result: { status: "verified", observations: fixture.observation }, + }); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true); + const blocked = await client.attestDosTargetSnapshot(fixture.observation); + expect(blocked.ok).toBe(false); + expect(new Set(close.mock.contexts).size).toBe(1); + allowClose = true; + expect((await client.close()).ok).toBe(true); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false); + expect(result.error.cleanupFailure?.cleanupResources).toEqual([]); + }, +); + +it.each([false, true])( + "preserves an explicit MZ refusal through finalization (failed close=%s)", + async (failClose) => { + const fixture = fixtureDosLoadImage(); + fixture.bytes[0] = 0; + const directory = await createTestTempDirectory("rea-mz-refusal-"); + const targetPath = join(directory, "inert.exe"); + await writeFile(targetPath, fixture.bytes); + const client = clientFor(new FixtureLauncher(), { + targetPath, + targetSha256: createHash("sha256").update(fixture.bytes).digest("hex"), + }); + const started = await client.start(); + if (!started.ok) throw started.error; + const close = failClose + ? vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockRejectedValueOnce(new Error("controlled refusal close failure")) + : undefined; + onTestFinished(async () => { + close?.mockRestore(); + await client.close(); + }); + const result = await client.attestDosTargetSnapshot(fixture.observation); + expect(result).toMatchObject({ + ok: false, + error: { + kind: "protocol", + message: "invalid or truncated DOS MZ header", + diagnostics: { failure_message: "invalid or truncated DOS MZ header" }, + }, + }); + if (result.ok) throw new Error("Expected MZ refusal"); + expect(result.error.cleanupFailure?.cleanupIncomplete ?? false).toBe( + failClose, + ); + }, +); + it("reports snapshot descriptor cleanup immediately and resolves its exact error on retry", async () => { const client = clientFor(new FixtureLauncher()); const started = await client.start();