From 7c207c79512f6baa70f4ed9d7d5334ab2d62f3ac Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sun, 11 Oct 2026 09:06:29 -0700 Subject: [PATCH] feat(native): admit raw binaries with caller-supplied architecture and entry (#1868) * feat(native): admit raw binaries with caller-supplied architecture and entry * fix(native): honor raw-binary preparation and entry identity * fix(ghidra): stream raw snapshot attestation with cancellation * fix(native): preserve and validate raw lifecycle entries --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../skills/reverse-engineer-anything/SKILL.md | 2 +- .../references/native-and-artifacts.md | 7 + bridge/ghidra/ReaGhidraBridge.java | 15 +- bridge/ghidra/ReaGhidraPrepareRaw.java | 36 +++ docs/.vitepress/config.ts | 1 + docs/cli.md | 3 +- docs/raw-binary.md | 72 ++++++ docs/testing.md | 8 + package.json | 2 + scripts/verify-packaged-ghidra-windows.mjs | 1 + scripts/verify-real-ghidra-raw.mjs | 219 ++++++++++++++++++ src/application/BinaryTargetResolver.ts | 56 ++++- src/application/DirectAnalysis.ts | 37 +++ src/application/binary/BinarySessionOpen.ts | 9 + src/application/binary/BinarySessionPort.ts | 7 +- src/application/binary/BinarySessionStatus.ts | 7 +- src/application/binary/BinaryTargetSummary.ts | 11 + src/cli/coreBinaryCommands.ts | 88 ++----- src/cli/coreNativeCommands.ts | 105 ++------- src/cli/options.ts | 56 ++++- src/contracts/sessionLifecycleInputs.test.ts | 50 ++++ src/contracts/sessionLifecycleInputs.ts | 60 ++++- src/contracts/sessionToolContracts.ts | 2 +- src/contracts/toolOutputSchemaGroups.ts | 57 +++-- src/contracts/toolOutputSchemaPrimitives.ts | 1 + src/domain/analysisSnapshot.test.ts | 50 ++++ src/domain/analysisSnapshot.ts | 37 ++- src/domain/artifactGraph.ts | 1 + src/domain/binaryTargetTypes.ts | 7 + src/domain/dosCom.ts | 4 +- src/domain/evidence.ts | 1 + src/domain/native/nativeLoadImage.ts | 19 +- src/domain/rawBinary.test.ts | 135 +++++++++++ src/domain/rawBinary.ts | 123 ++++++++++ src/generatedPackageMetadata.ts | 2 +- src/ghidra/GhidraAnalysisProfile.ts | 52 +++++ src/ghidra/GhidraClient.ts | 170 ++++++++------ src/ghidra/GhidraLauncher.ts | 78 +++++-- src/ghidra/GhidraLoadImageAttest.ts | 55 +++-- src/ghidra/GhidraLoadImageValues.ts | 110 +++++++++ src/ghidra/GhidraProvider.behavior.test.ts | 127 ++++++++++ src/ghidra/GhidraProviderClient.ts | 45 +++- src/ghidra/GhidraRawBinaryLanguage.ts | 26 +++ .../GhidraRawBinaryLoadImage.fixture.ts | 62 +++++ src/ghidra/GhidraRawBinaryLoadImage.test.ts | 98 ++++++++ src/hopper/HopperAnalysisProfile.ts | 1 + src/hopper/HopperProvider.test.ts | 21 ++ src/hopper/HopperProvider.ts | 8 + src/ida/IdaProvider.test.ts | 28 +++ src/ida/IdaProvider.ts | 26 ++- src/server/registerSessionTools.ts | 15 +- tests/boundary/cli/rawBinaryCli.test.ts | 103 ++++++++ .../filesystem/rawBinaryTarget.test.ts | 105 +++++++++ tests/boundary/mcp/rawBinaryMcp.test.ts | 219 ++++++++++++++++++ .../providers/ghidra/ghidraClient.test.ts | 81 ++++++- .../providers/ghidra/ghidraLauncher.test.ts | 47 ++++ .../hopper/hopperAnalysisProfile.test.ts | 21 ++ 57 files changed, 2453 insertions(+), 336 deletions(-) create mode 100644 bridge/ghidra/ReaGhidraPrepareRaw.java create mode 100644 docs/raw-binary.md create mode 100644 scripts/verify-real-ghidra-raw.mjs create mode 100644 src/application/binary/BinaryTargetSummary.ts create mode 100644 src/contracts/sessionLifecycleInputs.test.ts create mode 100644 src/domain/rawBinary.test.ts create mode 100644 src/domain/rawBinary.ts create mode 100644 src/ghidra/GhidraRawBinaryLanguage.ts create mode 100644 src/ghidra/GhidraRawBinaryLoadImage.fixture.ts create mode 100644 src/ghidra/GhidraRawBinaryLoadImage.test.ts create mode 100644 src/ida/IdaProvider.test.ts create mode 100644 tests/boundary/cli/rawBinaryCli.test.ts create mode 100644 tests/boundary/filesystem/rawBinaryTarget.test.ts create mode 100644 tests/boundary/mcp/rawBinaryMcp.test.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 5fe6b6f0a..45240351d 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -2,7 +2,7 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "38" + version: "39" --- # REA diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 0a2810676..9f09ad82f 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -150,6 +150,13 @@ For headerless DOS COM, explicitly open with `format: "dos-com"` (CLI analysis: the entry is `0x10100`, file offset zero, with imposed real-mode segment context. PSP/stack/device state remains unmodeled. See [DOS guide](https://github.com/morluto/rea/blob/main/docs/ghidra-dos.md). +For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ +headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`, +`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at +that address. Hopper and IDA refuse this interpretation; Ghidra uses +`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only, +and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md). + ### Function annotations in Ghidra Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier. diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 1a0413042..a35af8453 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -182,10 +182,17 @@ public final class ReaGhidraBridge extends HeadlessScript { } sessionDefaultAddressSpace = currentProgram.getAddressFactory().getDefaultAddressSpace(); - if (currentProgram.getExecutableFormat().equals("Raw Binary") && - !currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO) - .getBoolean("REA DOS COM prepared", false)) { - throw new IllegalStateException("REA DOS COM entry/context preparation failed"); + if (currentProgram.getExecutableFormat().equals("Raw Binary")) { + boolean dosCom = currentProgram.getLanguageID().getIdAsString() + .equals("x86:LE:16:Real Mode"); + String preparation = dosCom ? "REA DOS COM prepared" : "REA raw binary prepared"; + if (!currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO) + .getBoolean(preparation, false)) { + throw new IllegalStateException( + dosCom ? "REA DOS COM entry/context preparation failed" : + "REA raw binary entry preparation failed" + ); + } } if (!Application.getApplicationVersion().equals(descriptor.providerVersion)) { throw new IllegalStateException("Ghidra provider version does not match the session"); diff --git a/bridge/ghidra/ReaGhidraPrepareRaw.java b/bridge/ghidra/ReaGhidraPrepareRaw.java new file mode 100644 index 000000000..1a5db9627 --- /dev/null +++ b/bridge/ghidra/ReaGhidraPrepareRaw.java @@ -0,0 +1,36 @@ +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Program; + +/** Seed the caller-supplied raw-binary entry before default auto-analysis. */ +public final class ReaGhidraPrepareRaw extends GhidraScript { + @Override + public void run() throws Exception { + String[] arguments = getScriptArgs(); + if (arguments.length != 2) { + throw new IllegalArgumentException( + "Raw binary preparation requires the committed language and entry address" + ); + } + String expectedLanguage = arguments[0]; + String entryHex = arguments[1]; + if (!currentProgram.getLanguageID().getIdAsString().equals(expectedLanguage) || + !currentProgram.getExecutableFormat().equals("Raw Binary")) { + throw new IllegalArgumentException( + "Raw binary preparation requires the committed language and BinaryLoader import" + ); + } + Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex); + Address start = currentProgram.getMinAddress(); + if (entry == null || !entry.equals(start)) { + throw new IllegalArgumentException( + "Raw binary import must map file offset 0 at the caller-supplied entry" + ); + } + addEntryPoint(entry); + if (!disassemble(entry) || createFunction(entry, "entry") == null) { + throw new IllegalStateException("Raw binary entry cannot be decoded as a function"); + } + currentProgram.getOptions(Program.PROGRAM_INFO).setBoolean("REA raw binary prepared", true); + } +} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e1c184993..952266a68 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -54,6 +54,7 @@ export default defineConfig({ { text: "IDA", link: "/ida-provider" }, { text: "Ghidra on Windows", link: "/windows-ghidra-p0" }, { text: "Ghidra DOS analysis", link: "/ghidra-dos" }, + { text: "Ghidra raw binary", link: "/raw-binary" }, { text: "Ghidra NativeAOT", link: "/ghidra-nativeaot" }, { text: "Ghidra semantics", link: "/ghidra-provider" }, ], diff --git a/docs/cli.md b/docs/cli.md index 7f35eb40d..875c43147 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -59,7 +59,8 @@ alone, use `rea instructions`. A macOS `.app` bundle can be supplied directly. Run `rea --help` or a command's `--help` for arguments and output options. The [generated catalog](product-catalog.json) lists all CLI commands and MCP tools. Provider-specific target support is described in the -[native guide](native-investigation.md), [DOS guide](ghidra-dos.md), and +[native guide](native-investigation.md), [DOS guide](ghidra-dos.md), +[raw binary guide](raw-binary.md), and [Windows Ghidra guide](windows-ghidra-p0.md). ## Choose a provider diff --git a/docs/raw-binary.md b/docs/raw-binary.md new file mode 100644 index 000000000..dd86d0107 --- /dev/null +++ b/docs/raw-binary.md @@ -0,0 +1,72 @@ +# Headerless raw-binary analysis with Ghidra + +REA admits a headerless executable when the caller supplies the CPU family and +the address where file offset 0 should be mapped. That interpretation is not +inferred from a `.bin` suffix or from unrecognized bytes. + +## Open and inspect + +```bash +rea inspect /absolute/path/to/ecu.bin --target-format raw-binary --architecture arm --entry-point 0x8000000 --provider ghidra --json +rea function /absolute/path/to/ecu.bin 0x8000000 --target-format raw-binary --architecture arm --entry-point 0x8000000 --provider ghidra --json +rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-binary --architecture arm --entry-point 0x8000000 --provider ghidra --json +``` + +```json +{ + "name": "open_binary", + "arguments": { + "path": "/absolute/path/to/ecu.bin", + "format": "raw-binary", + "architecture": "arm", + "entry_point": "0x8000000", + "provider_id": "ghidra" + } +} +``` + +`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a +hexadecimal address of the form `0x…`. The complete file is mapped starting at +that address, and Ghidra seeds an `entry` function there before auto-analysis. +ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned. +`open_binary` and an open `binary_session` return the canonical `entry_point` +for raw targets so callers can verify the selected mapping directly. +Omitted `format` keeps header-based detection; architecture and entry without +`format=raw-binary` are rejected. + +Ghidra imports a private snapshot with `BinaryLoader` and these little-endian +generic languages: + +| architecture | language_id | +| ------------ | ------------------- | +| x86 | `x86:LE:32:default` | +| x86_64 | `x86:LE:64:default` | +| arm | `ARM:LE:32:v7` | +| arm64 | `AARCH64:LE:64:v8A` | + +Those settings, the caller-supplied entry, and `language_selection: +caller-supplied-v1` are committed in the analysis profile. A different entry +or architecture is a different profile; snapshots from header-detected targets +do not replay. + +The mapped start is independent of Ghidra's program image base, which remains +`0x0` for BinaryLoader. Ghidra may report additional language-defined entry +points, including unmapped ARM vectors. Load-image verification requires the +caller-supplied entry and preserves those other entries as observations. +Independent raw verification streams the complete private snapshot digest and +extent with bounded memory and supports cancellation during the read. + +Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit +PE applications only. + +## Limitations + +- Headers are not discovered from file bytes. This increment does not guess + load addresses, vector tables, or memory maps. +- Big-endian, Thumb-only, Cortex-M, and other Ghidra processor variants are + unsupported. +- Reset vectors, memory-mapped I/O, and firmware boot sequences are not + emulated. +- Independent load-image verification covers source identity, mapped file + bytes, the bound language, and the caller-supplied entry. Original bytes + stay unchanged. diff --git a/docs/testing.md b/docs/testing.md index dbbdb0b3c..e8a02713a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -793,6 +793,14 @@ truncation constraint, while leading-zero coordinates still resolve correctly. It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS runtime or PC-98 device execution. +`npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image +through the compiled CLI and stdio MCP server. With the same Ghidra/JDK +prerequisites, it checks preparation and bridge startup, verified load mappings, +instruction and byte readback, canonical-entry session reuse, replacement when +the entry changes without `provider_id`, unchanged source bytes, and owned +cleanup. Other raw CPU families remain covered by deterministic tests rather +than this real-provider lane. See [raw binary analysis](raw-binary.md). + ## Apple Interface Builder archives `npm run verify:interface-builder` compiles the source-owned AppKit XIB into a diff --git a/package.json b/package.json index 673b079ef..bdb4b4338 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "bridge/ghidra/ReaGhidraPrepareCom.java", "bridge/ghidra/ReaGhidraApplySeeds.java", "bridge/ghidra/ReaGhidraNoReturnFix.java", + "bridge/ghidra/ReaGhidraPrepareRaw.java", "bridge/android/ReaJadxBridge.java", "bridge/mitmproxy/capture.py", "scripts/rea.mjs", @@ -166,6 +167,7 @@ "verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs", "verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs", "verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs", + "verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs", "verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table", "verify:interface-builder": "npm run build:cached && node scripts/verify/apple/interface-builder.mjs", "verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format", diff --git a/scripts/verify-packaged-ghidra-windows.mjs b/scripts/verify-packaged-ghidra-windows.mjs index 4dad1b835..0266b5f65 100644 --- a/scripts/verify-packaged-ghidra-windows.mjs +++ b/scripts/verify-packaged-ghidra-windows.mjs @@ -177,6 +177,7 @@ const scriptCollisions = [ "ReaGhidraPrepareCom.java", "ReaGhidraApplySeeds.java", "ReaGhidraNoReturnFix.java", + "ReaGhidraPrepareRaw.java", ]; await mkdir(callerDirectory); for (const name of scriptCollisions) await mkdir(join(callerDirectory, name)); diff --git a/scripts/verify-real-ghidra-raw.mjs b/scripts/verify-real-ghidra-raw.mjs new file mode 100644 index 000000000..400c7f378 --- /dev/null +++ b/scripts/verify-real-ghidra-raw.mjs @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +import { parseEvidence } from "../dist/domain/evidence.js"; +import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js"; +import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +if (process.argv.length !== 2) + throw new Error("Usage: node scripts/verify-real-ghidra-raw.mjs"); +if (!["linux", "darwin"].includes(process.platform)) + throw new Error( + "Raw-binary verification requires a Linux or macOS Ghidra host.", + ); +if (process.env.GHIDRA_INSTALL_DIR === undefined) + throw new Error( + "Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.", + ); +const installation = inspectGhidraInstallation({ + environment: process.env, + installDir: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.JAVA_HOME === undefined + ? {} + : { javaHome: process.env.JAVA_HOME }), +}); +assert.equal(installation.status, "available", JSON.stringify(installation)); + +const run = createVerifierRun(); +const workspace = await mkdtemp(join(tmpdir(), "rea-raw-proof-")); +const runtime = join(workspace, "runtime"); +await mkdir(runtime); +const bytes = Buffer.from("1eff2fe1", "hex"); // ARM little-endian bx lr. +const sha256 = createHash("sha256").update(bytes).digest("hex"); +const path = join(workspace, "image.bin"); +await writeFile(path, bytes); +const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const env = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + TMPDIR: runtime, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "ghidra", + REA_PROCESS_RUN_ID: run.run_id, + GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.JAVA_HOME === undefined + ? {} + : { JAVA_HOME: process.env.JAVA_HOME }), +}; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", +}); +const client = new Client({ name: "rea-real-raw-proof", version: "1" }); +let stderr = ""; +transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); +}); +let report; +try { + await client.connect(transport); + const profiles = []; + const sessions = []; + for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) { + // Omit provider_id to exercise reuse of the current provider route. + const opened = await call("open_binary", { + path, + format: "raw-binary", + architecture: "arm", + entry_point: entryPoint, + }); + const entry = `0x${BigInt(entryPoint).toString(16)}`; + assert.equal(opened.entry_point, entry); + const image = await call("inspect_native_load_image"); + assert.equal( + image.normalized_result.status, + "verified", + JSON.stringify(image), + ); + assert.equal(image.normalized_result.base_address, entry); + assert.equal(image.normalized_result.entry.address, entry); + assert.equal( + image.normalized_result.observations.language_id, + "ARM:LE:32:v7", + ); + assert.ok( + image.normalized_result.observations.entry_points.includes(entry), + ); + assert.equal(image.analysis_profile.parameters.entry_point, entry); + profiles.push(image.analysis_profile.digest); + const status = await call("binary_session"); + assert.equal(status.entry_point, entry); + sessions.push(status.analysis_run.run_id); + const memory = await call("read_bytes", { + address: entry, + length: bytes.length, + }); + assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); + const procedures = await call("list_procedures"); + assert.ok( + procedures.normalized_result.some( + (procedure) => procedure.address === entry, + ), + ); + const instruction = await call("inspect_native_instruction", { + address: entry, + }); + assert.equal(instruction.normalized_result.address, entry); + } + assert.equal(profiles[0], profiles[1]); + assert.notEqual(profiles[1], profiles[2]); + assert.equal(sessions[0], sessions[1]); + assert.notEqual(sessions[1], sessions[2]); + const rejected = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture: "arm64", + entry_point: "0x8000001", + }, + }); + assert.equal(rejected.isError, true); + assert.match(JSON.stringify(rejected.content), /must be 4-byte aligned/); + const unchanged = await call("binary_session"); + assert.equal(unchanged.analysis_run.run_id, sessions[2]); + assert.equal(unchanged.entry_point, "0x9000000"); + await call("close_binary"); + + const { stdout } = await promisify(execFile)( + process.execPath, + [ + entrypoint, + "inspect-native-load-image", + path, + "--target-format", + "raw-binary", + "--architecture", + "arm", + "--entry-point", + "0x08000000", + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const cli = parseEvidence(JSON.parse(stdout)); + assert.equal(cli.subject.digest.sha256, sha256); + assert.equal(cli.normalized_result.status, "verified"); + assert.equal(cli.normalized_result.base_address, "0x8000000"); + assert.equal(cli.analysis_profile.digest, profiles[0]); + assert.deepEqual(await readFile(path), bytes); + report = { + ok: true, + mocked: false, + lane: "ghidra-raw", + host: process.platform, + provider: { id: "ghidra", version: installation.providerVersion }, + target: { + format: "raw-binary", + architecture: "arm", + sha256, + bytes: bytes.length, + }, + transport: { cli: true, stdio_mcp: true }, + canonical_entry_reuses_session: true, + changed_entry_replaces_session: true, + lifecycle_entry_preserved: true, + invalid_entry_preserves_active_session: true, + load_images_verified: true, + original_unchanged: true, + }; +} catch (error) { + if (stderr.trim()) process.stderr.write(stderr); + throw error; +} finally { + await client.close(); + await transport.close(); + await rm(workspace, { recursive: true, force: true }); +} +const verifierRun = await completeVerifierRun(run); +assert.equal(verifierRun.process_lineage.status, "verified"); +assert.deepEqual(verifierRun.process_lineage.descendants, []); +process.stdout.write( + JSON.stringify({ + verifier_run: verifierRun, + ...report, + cleanup: "complete", + }) + "\n", +); + +async function call(name, args = {}) { + const result = await client.callTool( + { name, arguments: args }, + { timeout: 240000 }, + ); + assert.notEqual(result.isError, true, JSON.stringify(result)); + const projected = requireMcpOperationResult(result, name); + if ( + name === "open_binary" || + name === "close_binary" || + name === "binary_session" + ) + return projected; + const evidence = parseEvidence(result.structuredContent); + assert.equal(evidence.subject.digest.sha256, sha256); + assert.equal(evidence.subject.format, "raw-binary"); + return evidence; +} diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 10f310731..ca8a4ab73 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -26,6 +26,8 @@ import { validateDosComLength, type ExecutableFormatHint, } from "../domain/dosCom.js"; +import { admitRawBinary } from "../domain/rawBinary.js"; +import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; import { mzWindowsHeaderOffset } from "../domain/dosMz.js"; import { hasZipSignature, @@ -45,6 +47,8 @@ export interface BinaryTargetResolverOptions { readonly hostArchitecture?: NodeJS.Architecture; readonly targetKind?: BinaryTarget["kind"]; readonly formatHint?: ExecutableFormatHint; + readonly architecture?: BinaryArchitecture; + readonly entryPoint?: string; readonly signal?: AbortSignal; } @@ -61,7 +65,7 @@ export const parseBinaryTarget = async ( > => { const cwd = options.cwd ?? process.cwd(); const hostArchitecture = options.hostArchitecture ?? process.arch; - const { targetKind, formatHint, signal } = options; + const { targetKind, formatHint, signal, architecture, entryPoint } = options; const candidate = isAbsolute(input) ? input : resolve(cwd, input); try { throwIfTargetResolutionCancelled(signal); @@ -83,6 +87,40 @@ export const parseBinaryTarget = async ( throwIfTargetResolutionCancelled(signal); const metadata = await handle.stat(); throwIfTargetResolutionCancelled(signal); + if (formatHint === "raw-binary") { + if (targetKind !== undefined && targetKind !== "executable") + return err( + new BinaryTargetError( + path, + "raw-binary format requires an executable target kind", + ), + ); + const admitted = admitRawBinary({ + architecture, + entryPoint, + fileBytes: metadata.size, + }); + if (!admitted.ok) + return err(new BinaryTargetError(path, admitted.error)); + return ok({ + path, + sourcePath: canonical, + sha256: await sha256Handle(handle, path, metadata, signal), + kind: "executable", + format: "raw-binary", + architecture: admitted.value.architecture, + availableArchitectures: [admitted.value.architecture], + entryPoint: admitted.value.entryPoint, + entryPointSource: admitted.value.entryPointSource, + }); + } + if (architecture !== undefined || entryPoint !== undefined) + return err( + new BinaryTargetError( + path, + "architecture and entry_point apply only with format=raw-binary", + ), + ); if (formatHint === "dos-com") { if (targetKind !== undefined && targetKind !== "executable") return err( @@ -194,7 +232,13 @@ const detectArtifactFormat = async ( ): Promise< | Exclude< BinaryTarget["format"], - "analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com" + | "analysis-database" + | "mach-o" + | "elf" + | "pe" + | "dos-mz" + | "dos-com" + | "raw-binary" > | undefined > => { @@ -244,7 +288,13 @@ const namedArtifactFormat = ( const isArchiveFormat = ( format: Exclude< BinaryTarget["format"], - "analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com" + | "analysis-database" + | "mach-o" + | "elf" + | "pe" + | "dos-mz" + | "dos-com" + | "raw-binary" >, ): format is Extract["format"] => isZipPackageFormat(format) || ["asar", "dmg", "pkg"].includes(format); diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 73b412237..2d5ac1762 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -1,4 +1,5 @@ import type { ExecutableFormatHint } from "../domain/dosCom.js"; +import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js"; import type { BinarySession } from "./binary/BinarySession.js"; @@ -91,6 +92,8 @@ export const runDirectAnalysis = async ( readonly signal?: AbortSignal; readonly providerId?: AnalysisProviderSelector; readonly formatHint?: ExecutableFormatHint; + readonly architecture?: BinaryArchitecture; + readonly entryPoint?: string; } = {}, ): Promise => withProcessCancellation(options.signal, (signal) => @@ -101,6 +104,12 @@ export const runDirectAnalysis = async ( ...(options.formatHint === undefined ? {} : { formatHint: options.formatHint }), + ...(options.architecture === undefined + ? {} + : { architecture: options.architecture }), + ...(options.entryPoint === undefined + ? {} + : { entryPoint: options.entryPoint }), ...(options.providerId === undefined ? {} : { providerId: options.providerId }), @@ -187,6 +196,8 @@ const runAnalysis = async ( readonly signal: AbortSignal; readonly providerId?: AnalysisProviderSelector; readonly formatHint?: ExecutableFormatHint; + readonly architecture?: BinaryArchitecture; + readonly entryPoint?: string; }, ): Promise => { const { logger, signal, snapshotPath } = options; @@ -203,6 +214,12 @@ const runAnalysis = async ( ...(options.formatHint === undefined ? {} : { formatHint: options.formatHint }), + ...(options.architecture === undefined + ? {} + : { architecture: options.architecture }), + ...(options.entryPoint === undefined + ? {} + : { entryPoint: options.entryPoint }), }); if (!prepared.ok) return cliError(prepared.error); const { snapshot } = prepared.value; @@ -214,6 +231,12 @@ const runAnalysis = async ( ...(options.formatHint === undefined ? {} : { formatHint: options.formatHint }), + ...(options.architecture === undefined + ? {} + : { architecture: options.architecture }), + ...(options.entryPoint === undefined + ? {} + : { entryPoint: options.entryPoint }), ...(options.providerId === undefined ? {} : { providerId: options.providerId }), @@ -246,6 +269,12 @@ const runAnalysis = async ( ...(options.formatHint === undefined ? {} : { formatHint: options.formatHint }), + ...(options.architecture === undefined + ? {} + : { architecture: options.architecture }), + ...(options.entryPoint === undefined + ? {} + : { entryPoint: options.entryPoint }), ...(snapshot === undefined ? {} : { snapshot }), ...(options.providerId === undefined ? {} @@ -440,6 +469,8 @@ const withProcessCancellation = async ( const prepareSnapshot = async (options: { readonly path: string; readonly formatHint?: ExecutableFormatHint; + readonly architecture?: BinaryArchitecture; + readonly entryPoint?: string; readonly snapshotPath: string | undefined; readonly signal: AbortSignal; }): Promise< @@ -458,6 +489,12 @@ const prepareSnapshot = async (options: { ...(options.formatHint === undefined ? {} : { formatHint: options.formatHint }), + ...(options.architecture === undefined + ? {} + : { architecture: options.architecture }), + ...(options.entryPoint === undefined + ? {} + : { entryPoint: options.entryPoint }), }); if (!target.ok) return target; if (!snapshotMatchesTarget(loaded.value.target, target.value)) diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index 714bed804..8d810e671 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -1,4 +1,5 @@ import type { ExecutableFormatHint } from "../../domain/dosCom.js"; +import type { BinaryArchitecture } from "../../domain/binaryTargetTypes.js"; import type { AnalysisProviderSelector } from "../../contracts/providerSelection.js"; import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; import { @@ -22,6 +23,8 @@ export interface BinarySessionOpenOptions { readonly signal?: AbortSignal; readonly targetKind?: BinaryTarget["kind"]; readonly formatHint?: ExecutableFormatHint; + readonly architecture?: BinaryArchitecture; + readonly entryPoint?: string; readonly snapshot?: AnalysisSnapshot; readonly providerId?: AnalysisProviderSelector; } @@ -72,6 +75,12 @@ export const resolveSessionOpen = async ( ...(options.formatHint === undefined ? {} : { formatHint: options.formatHint }), + ...(options.architecture === undefined + ? {} + : { architecture: options.architecture }), + ...(options.entryPoint === undefined + ? {} + : { entryPoint: options.entryPoint }), ...(options.signal === undefined ? {} : { signal: options.signal }), }); if (!parsed.ok) return parsed; diff --git a/src/application/binary/BinarySessionPort.ts b/src/application/binary/BinarySessionPort.ts index 166479a06..ee666f665 100644 --- a/src/application/binary/BinarySessionPort.ts +++ b/src/application/binary/BinarySessionPort.ts @@ -1,6 +1,9 @@ import type { InvestigationRecordPort } from "../investigation/InvestigationRecordPort.js"; import type { ExecutableFormatHint } from "../../domain/dosCom.js"; -import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import type { + BinaryArchitecture, + BinaryTarget, +} from "../../domain/binaryTargetTypes.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; @@ -44,6 +47,8 @@ export interface BinarySessionPort readonly signal?: AbortSignal; readonly targetKind?: BinaryTarget["kind"]; readonly formatHint?: ExecutableFormatHint; + readonly architecture?: BinaryArchitecture; + readonly entryPoint?: string; readonly snapshot?: AnalysisSnapshot; readonly providerId?: AnalysisProviderSelector; }, diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index 4c5fc34bb..9048c6de6 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -1,4 +1,5 @@ import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import { binaryTargetSummary } from "./BinaryTargetSummary.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js"; import type { @@ -92,11 +93,7 @@ export const binarySessionStatus = ({ : { open: true, ...common, - path: target.path, - sha256: target.sha256, - format: target.format, - kind: target.kind, - architecture: target.architecture ?? null, + ...binaryTargetSummary(target), }; }; diff --git a/src/application/binary/BinaryTargetSummary.ts b/src/application/binary/BinaryTargetSummary.ts new file mode 100644 index 000000000..83d721283 --- /dev/null +++ b/src/application/binary/BinaryTargetSummary.ts @@ -0,0 +1,11 @@ +import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; + +/** Preserve the admitted target interpretation in public lifecycle results. */ +export const binaryTargetSummary = (target: BinaryTarget) => ({ + path: target.path, + format: target.format, + kind: target.kind, + sha256: target.sha256, + architecture: target.architecture ?? null, + ...(target.format === "raw-binary" ? { entry_point: target.entryPoint } : {}), +}); diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index cf4a08236..6a117dada 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -8,9 +8,8 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; import { - directAnalysisOptions, - formatSelectionOption, - providerSelectionOption, + directAnalysisOptionsFromCli, + nativeOpenOptionFields, } from "./options.js"; import type { CliInstance } from "./types.js"; import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js"; @@ -43,8 +42,7 @@ const registerOverviewCommands = ( .min(1) .optional() .describe("Load and update a local analysis snapshot"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }); cli.command(CLI_COMMANDS.analyze, { description: "Get an overview of an app", @@ -75,12 +73,7 @@ const registerOverviewCommands = ( args.path, "binary_overview", {}, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ); }, @@ -97,12 +90,7 @@ const registerOverviewCommands = ( args.path, "binary_overview", {}, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -136,8 +124,7 @@ const registerDecompileCommand = ( .min(1) .optional() .describe("Load and update a local analysis snapshot"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, "decompile", () => @@ -152,12 +139,7 @@ const registerDecompileCommand = ( "procedure_pseudo_code", ), }, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -168,13 +150,17 @@ const routesToJavaScriptAnalysis = async ( path: string, options: { readonly snapshot?: string | undefined; - readonly "target-format"?: "dos-com" | undefined; + readonly "target-format"?: "dos-com" | "raw-binary" | undefined; + readonly architecture?: string | undefined; + readonly "entry-point"?: string | undefined; readonly provider?: string | undefined; }, ): Promise => options.provider === undefined && options.snapshot === undefined && options["target-format"] === undefined && + options.architecture === undefined && + options["entry-point"] === undefined && (await isJavaScriptApplicationPath(path)); const isJavaScriptApplicationPath = async (path: string): Promise => { @@ -215,8 +201,7 @@ const registerXrefsCommand = ( .min(1) .optional() .describe("Load and update a local analysis snapshot"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, "xrefs", () => @@ -231,12 +216,7 @@ const registerXrefsCommand = ( "xrefs", ), }, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -270,8 +250,7 @@ const registerTraceCommand = ( .min(1) .optional() .describe("Load and update a local analysis snapshot"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, "trace", () => @@ -287,12 +266,7 @@ const registerTraceCommand = ( ), case_sensitive: options.caseSensitive, }, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -326,8 +300,7 @@ const registerFunctionCommand = ( .min(1) .optional() .describe("Load and update a local analysis snapshot"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, "function", () => @@ -342,12 +315,7 @@ const registerFunctionCommand = ( "analyze_function", ), }, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -381,8 +349,7 @@ const registerInstructionsCommand = ( .min(1) .optional() .describe("Load and update a local analysis snapshot"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.instructions, () => @@ -397,12 +364,7 @@ const registerInstructionsCommand = ( "read_function_instructions", ), }, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -448,8 +410,7 @@ const registerSearchCommand = ( .min(1) .optional() .describe("Load and update a local analysis snapshot"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, "search", () => @@ -468,12 +429,7 @@ const registerSearchCommand = ( mode: options.mode, case_sensitive: options.caseSensitive, }, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index 77ded7347..016eedbc0 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -5,9 +5,8 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; import { - directAnalysisOptions, - formatSelectionOption, - providerSelectionOption, + directAnalysisOptionsFromCli, + nativeOpenOptionFields, } from "./options.js"; import type { CliInstance } from "./types.js"; @@ -23,8 +22,7 @@ export const registerCoreNativeCommands = ( "Verify loaded native bytes, source mappings, relocations and entry", args: z.object({ path: z.string().describe("Local executable path") }), options: z.object({ - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.inspectNativeLoadImage, () => @@ -32,12 +30,7 @@ export const registerCoreNativeCommands = ( args.path, "inspect_native_load_image", {}, - directAnalysisOptions( - logger, - undefined, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -54,8 +47,7 @@ export const registerCoreNativeCommands = ( .min(1) .default(256) .describe("Requested byte count"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.readBytes, () => @@ -63,12 +55,7 @@ export const registerCoreNativeCommands = ( args.path, "read_bytes", { address: args.address, length: options.length }, - directAnalysisOptions( - logger, - undefined, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -79,8 +66,7 @@ export const registerCoreNativeCommands = ( address: z.string().describe("Exact provider memory address"), }), options: z.object({ - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.addressToFileOffset, () => @@ -88,12 +74,7 @@ export const registerCoreNativeCommands = ( args.path, "address_to_file_offset", { address: args.address }, - directAnalysisOptions( - logger, - undefined, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -154,8 +135,7 @@ export const registerCoreNativeCommands = ( .max(5000) .default(1000) .describe("Maximum nodes on this page"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.traceNativeValues, () => @@ -172,12 +152,7 @@ export const registerCoreNativeCommands = ( offset: options.offset, limit: options.limit, }, - directAnalysisOptions( - logger, - undefined, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -189,8 +164,7 @@ export const registerCoreNativeCommands = ( options: z.object({ type: z.string().optional().describe("Exact database type category path"), address: z.string().optional().describe("Exact native address"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.inspectNativeDataType, () => @@ -203,12 +177,7 @@ export const registerCoreNativeCommands = ( ? {} : { address: options.address }), }, - directAnalysisOptions( - logger, - undefined, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -226,8 +195,7 @@ export const registerCoreNativeCommands = ( address: z.string().describe("Exact native address"), }), options: z.object({ - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, command, () => @@ -235,12 +203,7 @@ export const registerCoreNativeCommands = ( args.path, operation, { address: args.address }, - directAnalysisOptions( - logger, - undefined, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -273,8 +236,7 @@ const registerNativeDispatchMetadataCommand = ( .min(1) .optional() .describe("Load or update the local analysis snapshot"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: async ({ args, options }) => logCliCommand(logger, "inspect-native-dispatch-metadata", () => @@ -282,12 +244,7 @@ const registerNativeDispatchMetadataCommand = ( args.path, "inspect_native_dispatch_metadata", { max_records: options.maxRecords }, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -332,8 +289,7 @@ const registerNativeUiActionCommand = ( .max(5_000) .default(500) .describe("Maximum returned graph edges"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: async ({ args, options }) => logCliCommand(logger, "trace-native-ui-action", () => @@ -346,12 +302,7 @@ const registerNativeUiActionCommand = ( max_nodes: options.maxNodes, max_edges: options.maxEdges, }, - directAnalysisOptions( - logger, - undefined, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -374,8 +325,7 @@ const registerNativeApiCommand = ( .min(1) .optional() .describe("Load and update a local analysis snapshot"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, "inspect-native-api", () => @@ -383,12 +333,7 @@ const registerNativeApiCommand = ( args.path, "inspect_native_api", { procedure: args.address }, - directAnalysisOptions( - logger, - options.snapshot, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); @@ -417,8 +362,7 @@ const registerAnnotationCommand = ( .string() .optional() .describe("Inline entry comment; empty text clears it"), - "target-format": formatSelectionOption, - provider: providerSelectionOption, + ...nativeOpenOptionFields, }), run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.annotateNativeFunction, () => @@ -435,12 +379,7 @@ const registerAnnotationCommand = ( ? {} : { inline_comment: options["inline-comment"] }), }, - directAnalysisOptions( - logger, - undefined, - options.provider, - options["target-format"], - ), + directAnalysisOptionsFromCli(logger, options), ), ), }); diff --git a/src/cli/options.ts b/src/cli/options.ts index 22f63ab9c..e96414707 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -2,6 +2,11 @@ import { executableFormatHintSchema, type ExecutableFormatHint, } from "../domain/dosCom.js"; +import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; +import { + binaryArchitectureSchema, + rawBinaryEntryPointSchema, +} from "../domain/rawBinary.js"; import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js"; import type { AnalysisProviderSelector } from "../contracts/providerSelection.js"; import type { Logger } from "pino"; @@ -15,16 +20,65 @@ export const providerSelectionOption = analysisProviderSelectorSchema /** Explicit interpretation for headerless native executable formats. */ export const formatSelectionOption = executableFormatHintSchema .optional() - .describe("Explicit headerless DOS COM interpretation"); + .describe( + "Explicit headerless interpretation (dos-com or raw-binary); omission preserves header-based detection", + ); + +export const architectureSelectionOption = binaryArchitectureSchema + .optional() + .describe( + "Caller-supplied CPU family; required with --target-format raw-binary", + ); + +export const entryPointSelectionOption = rawBinaryEntryPointSchema + .optional() + .describe( + "Caller-supplied load and entry address (0xhex); required with --target-format raw-binary", + ); + +/** Shared CLI flags that bind a native target interpretation. */ +export const nativeOpenOptionFields = { + "target-format": formatSelectionOption, + architecture: architectureSelectionOption, + "entry-point": entryPointSelectionOption, + provider: providerSelectionOption, +}; + +/** Native CLI flags consumed when opening a target for one-shot analysis. */ +export interface NativeOpenCliFlags { + readonly snapshot?: string | undefined; + readonly provider?: AnalysisProviderSelector | undefined; + readonly "target-format"?: ExecutableFormatHint | undefined; + readonly architecture?: BinaryArchitecture | undefined; + readonly "entry-point"?: string | undefined; +} export const directAnalysisOptions = ( logger: Logger, snapshotPath: string | undefined, providerId: AnalysisProviderSelector | undefined, formatHint?: ExecutableFormatHint, + architecture?: BinaryArchitecture, + entryPoint?: string, ) => ({ logger, snapshotPath, ...(formatHint === undefined ? {} : { formatHint }), + ...(architecture === undefined ? {} : { architecture }), + ...(entryPoint === undefined ? {} : { entryPoint }), ...(providerId === undefined ? {} : { providerId }), }); + +/** Map native CLI flags onto the shared one-shot analysis options. */ +export const directAnalysisOptionsFromCli = ( + logger: Logger, + options: NativeOpenCliFlags, +) => + directAnalysisOptions( + logger, + options.snapshot, + options.provider, + options["target-format"], + options.architecture, + options["entry-point"], + ); diff --git a/src/contracts/sessionLifecycleInputs.test.ts b/src/contracts/sessionLifecycleInputs.test.ts new file mode 100644 index 000000000..34014b70b --- /dev/null +++ b/src/contracts/sessionLifecycleInputs.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { openBinaryInputSchema } from "./sessionLifecycleInputs.js"; + +describe("open_binary raw-binary input contract", () => { + it("accepts header detection without architecture", () => { + expect( + openBinaryInputSchema.safeParse({ path: "/tmp/fixture.bin" }).success, + ).toBe(true); + expect( + openBinaryInputSchema.safeParse({ + path: "/tmp/legacy.com", + format: "dos-com", + }).success, + ).toBe(true); + }); + it("accepts raw-binary with architecture and entry", () => { + expect( + openBinaryInputSchema.safeParse({ + path: "/tmp/ecu.bin", + format: "raw-binary", + architecture: "arm", + entry_point: "0x8000000", + }).success, + ).toBe(true); + }); + it("rejects raw-binary without architecture or entry", () => { + const parsed = openBinaryInputSchema.safeParse({ + path: "/tmp/ecu.bin", + format: "raw-binary", + }); + expect(parsed.success).toBe(false); + expect( + openBinaryInputSchema.safeParse({ + path: "/tmp/ecu.bin", + format: "raw-binary", + architecture: "arm", + }).success, + ).toBe(false); + }); + it("rejects architecture without format=raw-binary", () => { + expect( + openBinaryInputSchema.safeParse({ + path: "/tmp/ecu.bin", + architecture: "arm", + entry_point: "0x8000000", + }).success, + ).toBe(false); + }); +}); diff --git a/src/contracts/sessionLifecycleInputs.ts b/src/contracts/sessionLifecycleInputs.ts index ca11b03a0..775da39cd 100644 --- a/src/contracts/sessionLifecycleInputs.ts +++ b/src/contracts/sessionLifecycleInputs.ts @@ -1,6 +1,11 @@ import { isAbsolute } from "node:path"; import { executableFormatHintSchema } from "../domain/dosCom.js"; import { localPathStringSchema } from "../domain/localPath.js"; +import { + binaryArchitectureSchema, + rawBinaryEntryPointSchema, + rawBinarySelectionError, +} from "../domain/rawBinary.js"; import { z } from "zod"; import { analysisProviderSelectorSchema } from "./providerSelection.js"; @@ -25,16 +30,51 @@ const binaryTargetPathSchema = localPathStringSchema ); /** Input contract for opening a target with an optional staged snapshot. */ -export const openBinaryInputSchema = z.strictObject({ - path: binaryTargetPathSchema, - format: executableFormatHintSchema - .optional() - .describe( - "Explicit headerless DOS COM interpretation; omission preserves header-based detection", - ), - provider_id: analysisProviderSelectorSchema.optional(), - snapshot_path: snapshotPathSchema.optional(), -}); +export const openBinaryInputSchema = z + .strictObject({ + path: binaryTargetPathSchema, + format: executableFormatHintSchema + .optional() + .describe( + "Explicit headerless interpretation: dos-com or raw-binary. Omission preserves header-based detection.", + ), + architecture: binaryArchitectureSchema + .optional() + .describe( + "Caller-supplied CPU family. Required with format=raw-binary; rejected otherwise.", + ), + entry_point: rawBinaryEntryPointSchema + .optional() + .describe( + "Caller-supplied load and entry address (0xhex). File offset 0 is mapped here. Required with format=raw-binary; rejected otherwise.", + ), + provider_id: analysisProviderSelectorSchema.optional(), + snapshot_path: snapshotPathSchema.optional(), + }) + .superRefine((value, context) => { + const pairing = rawBinarySelectionError({ + ...(value.format === undefined ? {} : { format: value.format }), + ...(value.architecture === undefined + ? {} + : { architecture: value.architecture }), + ...(value.entry_point === undefined + ? {} + : { entryPoint: value.entry_point }), + }); + if (pairing === undefined) return; + context.addIssue({ + code: "custom", + path: + value.format === "raw-binary" + ? value.architecture === undefined + ? ["architecture"] + : ["entry_point"] + : value.architecture === undefined + ? ["entry_point"] + : ["architecture"], + message: pairing, + }); + }); /** Input contract for closing a target after an optional atomic snapshot. */ export const closeBinaryInputSchema = z.strictObject({ diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index e758335d7..b3ea2e318 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -69,7 +69,7 @@ export const exportEvidenceBundleInputSchema = z.strictObject({ export const SESSION_TOOL_CONTRACTS = [ session( "open_binary", - "Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image; omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first.", + "Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first.", openBinaryInputSchema, ), session( diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index f9b4c0b3a..c87c0f09c 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js"; import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js"; +import { + binaryArchitectureSchema, + rawBinaryEntryPointSchema, +} from "../domain/rawBinary.js"; import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunctionAnnotations.js"; import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js"; import { nativeUiResultSchema } from "../domain/native/nativeUiObservation.js"; @@ -409,16 +413,33 @@ export const managedWorkflowOutputSchemas: Readonly< ), }; +const lifecycleTargetFields = { + path: z.string(), + kind: targetKindSchema, + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + architecture: z.enum(BINARY_ARCHITECTURES).nullable(), +}; +const detectedLifecycleTargetSchema = z.object({ + ...lifecycleTargetFields, + format: targetFormatSchema.exclude(["raw-binary"]), +}); +const rawLifecycleTargetSchema = z.object({ + ...lifecycleTargetFields, + format: z.literal("raw-binary"), + kind: z.literal("executable"), + architecture: binaryArchitectureSchema, + entry_point: rawBinaryEntryPointSchema.describe( + "Canonical caller-selected address where raw file offset 0 is mapped and execution is seeded.", + ), +}); + /** Exact structured-content schemas for target lifecycle operations. */ export const sessionOutputSchemas = { open_binary: lifecycleResultOf( - z.object({ - path: z.string(), - format: targetFormatSchema, - kind: targetKindSchema, - sha256: z.string().regex(/^[a-f0-9]{64}$/u), - architecture: z.enum(BINARY_ARCHITECTURES).nullable(), - }), + z.discriminatedUnion("format", [ + detectedLifecycleTargetSchema, + rawLifecycleTargetSchema, + ]), ), close_binary: lifecycleResultOf( z.union([ @@ -448,19 +469,15 @@ export const sessionOutputSchemas = { ), binary_session: lifecycleResultOf( z.union([ - z.union([ - sessionProvider.extend({ - open: z.literal(false), - }), - sessionProvider.extend({ - open: z.literal(true), - path: z.string(), - format: targetFormatSchema, - kind: targetKindSchema, - sha256: z.string().regex(/^[a-f0-9]{64}$/u), - architecture: z.enum(BINARY_ARCHITECTURES).nullable(), - }), - ]), + sessionProvider.extend({ open: z.literal(false) }), + sessionProvider.extend({ + open: z.literal(true), + ...detectedLifecycleTargetSchema.shape, + }), + sessionProvider.extend({ + open: z.literal(true), + ...rawLifecycleTargetSchema.shape, + }), ]), ), export_evidence_bundle: lifecycleResultOf( diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 224245c37..1c4d449c8 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -56,6 +56,7 @@ export const targetFormatSchema = z.enum([ "pe", "dos-mz", "dos-com", + "raw-binary", "zip", "ipa", "apk", diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index 85580248e..e2c1483c9 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -8,6 +8,7 @@ import { snapshotBinding, snapshotEvidenceForQuery, snapshotTarget, + snapshotMatchesTarget, } from "./analysisSnapshot.js"; import { ANALYSIS_SNAPSHOT_PROFILE, @@ -16,6 +17,7 @@ import { } from "./analysisSnapshot.fixture.js"; import { createEvidence } from "./evidence.js"; import { createEvidenceBundle } from "./evidenceBundle.js"; +import type { BinaryTarget } from "./binaryTargetTypes.js"; const snapshotWithHistoricalEvidence = () => { const target = snapshotTarget(ANALYSIS_SNAPSHOT_TARGET); @@ -68,6 +70,54 @@ const snapshotWithHistoricalEvidence = () => { }; describe("analysis snapshot contract", () => { + it("partitions raw snapshots and query identities by the canonical entry", () => { + const target: BinaryTarget = { + path: "/fixture/image.bin", + sha256: ANALYSIS_SNAPSHOT_TARGET.sha256, + kind: "executable", + format: "raw-binary", + architecture: "arm", + availableArchitectures: ["arm"], + entryPoint: "0x8000000", + entryPointSource: "0x08000000", + }; + const snapshot = snapshotTarget(target); + const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE); + const changed = { + ...target, + entryPoint: "0x9000000", + entryPointSource: "0x09000000", + }; + expect( + snapshotMatchesTarget(snapshot, { + ...target, + entryPointSource: "0x8000000", + }), + ).toBe(true); + expect(snapshotMatchesTarget(snapshot, changed)).toBe(false); + expect(analysisQueryId(snapshot, binding, "list_procedures", {})).not.toBe( + analysisQueryId(snapshotTarget(changed), binding, "list_procedures", {}), + ); + const parsed = analysisSnapshotSchema.parse({ + target: snapshot, + binding, + entries: [], + workflow_entries: [], + evidence_bundle: createEvidenceBundle([]), + }); + expect(parsed.target).toHaveProperty("entry_point", target.entryPoint); + const { entry_point: _entry, ...legacy } = { + ...snapshot, + entry_point: target.entryPoint, + }; + expect( + analysisSnapshotSchema.safeParse({ ...parsed, target: legacy }).success, + ).toBe(false); + expect(snapshotTarget(ANALYSIS_SNAPSHOT_TARGET)).not.toHaveProperty( + "entry_point", + ); + }); + it("preserves DOS MZ target and subject formats with the x86 family", () => { const target = { path: ANALYSIS_SNAPSHOT_TARGET.path, diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index e839b1e97..d6ca67e85 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -40,6 +40,7 @@ import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; +import { rawBinaryEntryPointSchema } from "./rawBinary.js"; const architectureSchema = z.enum(BINARY_ARCHITECTURES); const formatSchema = z.enum([ @@ -49,6 +50,7 @@ const formatSchema = z.enum([ "pe", "dos-mz", "dos-com", + "raw-binary", "zip", "ipa", "apk", @@ -80,12 +82,22 @@ const subjectSchema = z.object({ ]), architecture: architectureSchema.nullable(), }); -const targetSchema = z.object({ +const targetIdentitySchema = z.object({ sha256: digestSchema, kind: kindSchema, - format: formatSchema, architecture: architectureSchema.nullable(), }); +const targetSchema = z.discriminatedUnion("format", [ + targetIdentitySchema.extend({ + format: z.enum( + formatSchema.options.filter((format) => format !== "raw-binary"), + ), + }), + targetIdentitySchema.extend({ + format: z.literal("raw-binary"), + entry_point: rawBinaryEntryPointSchema, + }), +]); const bindingSchema = z .object({ provider: committedProviderSchema, @@ -311,12 +323,16 @@ const canonicalJson = (value: unknown): string => { /** Project a binary into the path-free identity used for cache invalidation. */ export const snapshotTarget = ( target: BinaryTarget, -): AnalysisSnapshotTarget => ({ - sha256: target.sha256, - kind: target.kind, - format: target.format, - architecture: target.architecture ?? null, -}); +): AnalysisSnapshotTarget => { + const identity = { + sha256: target.sha256, + kind: target.kind, + architecture: target.architecture ?? null, + }; + return target.format === "raw-binary" + ? { ...identity, format: target.format, entry_point: target.entryPoint } + : { ...identity, format: target.format }; +}; /** Build the immutable provider/profile binding used by a cached snapshot. */ export const snapshotBinding = ( @@ -335,7 +351,10 @@ export const snapshotMatchesTarget = ( snapshot.sha256 === target.sha256 && snapshot.kind === target.kind && snapshot.format === target.format && - snapshot.architecture === (target.architecture ?? null); + snapshot.architecture === (target.architecture ?? null) && + (snapshot.format !== "raw-binary" || + (target.format === "raw-binary" && + snapshot.entry_point === target.entryPoint)); /** Compare a snapshot binding with the selected concrete analysis profile. */ export const snapshotMatchesProfile = ( diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 8b5d763eb..a3f301c09 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -50,6 +50,7 @@ const artifactFormatSchema = z.enum([ "pe", "dos-mz", "dos-com", + "raw-binary", "hopper", "analysis-database", "ipa", diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 9296109cd..5d7deba68 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -78,6 +78,13 @@ export type BinaryTarget = readonly executableRole?: never; readonly managed?: never; }) + | (ExecutableTarget & { + readonly format: "raw-binary"; + readonly entryPoint: string; + readonly entryPointSource: string; + readonly executableRole?: never; + readonly managed?: never; + }) | (ExecutableTarget & { readonly format: "pe"; /** Provider-neutral PE image role observed from COFF characteristics. */ diff --git a/src/domain/dosCom.ts b/src/domain/dosCom.ts index 4139e14dc..1e6b0a747 100644 --- a/src/domain/dosCom.ts +++ b/src/domain/dosCom.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { err, ok, type Result } from "./result.js"; -/** Headerless DOS COM interpretation explicitly selected by the caller. */ -export const executableFormatHintSchema = z.literal("dos-com"); +/** Headerless executable interpretation explicitly selected by the caller. */ +export const executableFormatHintSchema = z.enum(["dos-com", "raw-binary"]); /** Explicit executable interpretation for formats with no identifying header. */ export type ExecutableFormatHint = z.infer; diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 4e6fbe372..8edb9c897 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -44,6 +44,7 @@ const subjectSchema = z.object({ "pe", "dos-mz", "dos-com", + "raw-binary", "zip", "ipa", "apk", diff --git a/src/domain/native/nativeLoadImage.ts b/src/domain/native/nativeLoadImage.ts index 568a0de25..6a3be4421 100644 --- a/src/domain/native/nativeLoadImage.ts +++ b/src/domain/native/nativeLoadImage.ts @@ -84,7 +84,7 @@ export const nativeLoadImageCheckSchema = z.strictObject({ }); /** Read-only native image verification; verified applies only to declared coverage. */ -export const nativeLoadImageSchema = z.discriminatedUnion("status", [ +export const nativeLoadImageSchema = z.union([ z.strictObject({ status: z.literal("unsupported"), reason: z.string().min(1), @@ -108,6 +108,23 @@ export const nativeLoadImageSchema = z.discriminatedUnion("status", [ checks: z.array(nativeLoadImageCheckSchema), limitations: z.array(z.string().min(1)), }), + z.strictObject({ + status: z.enum(["verified", "mismatch"]), + format: z.literal("raw-binary"), + target_sha256: digest, + architecture: z.enum(["x86", "x86_64", "arm", "arm64"]), + language_id: z.string().min(1), + compiler_spec_id: z.string().min(1), + base_address: address, + file_bytes: integer.min(1), + entry: z.strictObject({ + address: address, + file_offset: z.literal(0), + }), + observations: nativeLoadImageObservationSchema, + checks: z.array(nativeLoadImageCheckSchema), + limitations: z.array(z.string().min(1)), + }), ]); /** Provider-neutral native load-image result. */ diff --git a/src/domain/rawBinary.test.ts b/src/domain/rawBinary.test.ts new file mode 100644 index 000000000..ca4d7f0d6 --- /dev/null +++ b/src/domain/rawBinary.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { executableFormatHintSchema } from "./dosCom.js"; +import { + admitRawBinary, + parseRawBinaryEntryPoint, + rawBinarySelectionError, + validateRawBinaryLength, +} from "./rawBinary.js"; + +describe("headerless format hints", () => { + it("admits dos-com and raw-binary names only", () => { + expect(executableFormatHintSchema.safeParse("dos-com").success).toBe(true); + expect(executableFormatHintSchema.safeParse("raw-binary").success).toBe( + true, + ); + expect(executableFormatHintSchema.safeParse("raw").success).toBe(false); + }); +}); + +describe("raw-binary entry addresses", () => { + it("canonicalizes equivalent hex spellings without dropping the source", () => { + const parsed = parseRawBinaryEntryPoint("0x08000000"); + expect(parsed).toEqual({ + ok: true, + value: { + source: "0x08000000", + canonical: "0x8000000", + value: 0x8000000n, + }, + }); + }); + it.each(["8000000", "0x", "0xgg", "0X8000000", ""])( + "rejects malformed entry %s", + (value) => { + expect(parseRawBinaryEntryPoint(value).ok).toBe(false); + }, + ); +}); + +describe("raw-binary admission", () => { + it("admits a little-endian ARM image mapped at the caller entry", () => { + expect( + admitRawBinary({ + architecture: "arm", + entryPoint: "0x8000000", + fileBytes: 4, + }), + ).toEqual({ + ok: true, + value: { + architecture: "arm", + entryPoint: "0x8000000", + entryPointSource: "0x8000000", + }, + }); + }); + it("rejects an empty file", () => { + expect(validateRawBinaryLength(0).ok).toBe(false); + expect( + admitRawBinary({ + architecture: "x86", + entryPoint: "0x1000", + fileBytes: 0, + }).ok, + ).toBe(false); + }); + it("rejects a 32-bit mapping that exceeds the architecture width", () => { + const result = admitRawBinary({ + architecture: "arm", + entryPoint: "0xfffffffc", + fileBytes: 8, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected overflow rejection"); + expect(result.error).toContain("arm address width"); + }); + it("allows a 64-bit mapping that would overflow 32-bit ARM", () => { + expect( + admitRawBinary({ + architecture: "arm64", + entryPoint: "0xfffffffc", + fileBytes: 8, + }).ok, + ).toBe(true); + }); + it.each(["arm", "arm64"])( + "rejects unaligned %s entries before provider selection", + (architecture) => { + for (const entryPoint of ["0x8000001", "0x8000002", "0x8000003"]) { + expect( + admitRawBinary({ architecture, entryPoint, fileBytes: 4 }), + ).toEqual({ + ok: false, + error: `raw-binary ${architecture} entry_point ${entryPoint} must be 4-byte aligned`, + }); + } + }, + ); + it.each(["x86", "x86_64"])( + "admits byte-aligned %s entries", + (architecture) => { + const admitted = admitRawBinary({ + architecture, + entryPoint: "0x1001", + fileBytes: 1, + }); + if (!admitted.ok) throw new Error(admitted.error); + expect(admitted.value.entryPoint).toBe("0x1001"); + }, + ); +}); + +describe("raw-binary selection pairing", () => { + it("requires architecture and entry with format=raw-binary", () => { + expect(rawBinarySelectionError({ format: "raw-binary" })).toBe( + "format=raw-binary requires architecture and entry_point", + ); + expect( + rawBinarySelectionError({ + format: "raw-binary", + architecture: "arm", + entryPoint: "0x8000000", + }), + ).toBeUndefined(); + }); + it("rejects architecture or entry without format=raw-binary", () => { + expect( + rawBinarySelectionError({ architecture: "arm", entryPoint: "0x1" }), + ).toBe("architecture and entry_point apply only with format=raw-binary"); + expect( + rawBinarySelectionError({ format: "dos-com", architecture: "x86" }), + ).toBe("architecture and entry_point apply only with format=raw-binary"); + }); +}); diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts new file mode 100644 index 000000000..a389bf7c3 --- /dev/null +++ b/src/domain/rawBinary.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { ExecutableFormatHint } from "./dosCom.js"; +import { err, ok, type Result } from "./result.js"; + +/** Caller-visible CPU families admitted for headerless raw binaries. */ +export const BINARY_ARCHITECTURES = [ + "x86", + "x86_64", + "arm", + "arm64", +] as const satisfies readonly BinaryArchitecture[]; + +/** Provider-neutral CPU family for a caller-supplied raw-binary load. */ +export const binaryArchitectureSchema = z.enum(BINARY_ARCHITECTURES); + +const RAW_BINARY_ENTRY_POINT = /^0x[0-9A-Fa-f]{1,16}$/u; + +/** Hexadecimal load and entry address; file offset 0 is mapped here. */ +export const rawBinaryEntryPointSchema = z + .string() + .regex(RAW_BINARY_ENTRY_POINT, { + message: "entry_point must be a hexadecimal address of the form 0x…", + }); + +/** Canonicalized caller-supplied raw-binary entry with the original spelling. */ +export interface CanonicalRawBinaryEntry { + readonly source: string; + readonly canonical: string; + readonly value: bigint; +} + +/** Parse a caller-supplied hex entry while preserving the source spelling. */ +export const parseRawBinaryEntryPoint = ( + value: string, +): Result => { + if (!RAW_BINARY_ENTRY_POINT.test(value)) + return err("entry_point must be a hexadecimal address of the form 0x…"); + const parsed = BigInt(value); + return ok({ + source: value, + canonical: `0x${parsed.toString(16)}`, + value: parsed, + }); +}; + +/** Reject empty or non-integral file extents for a headerless image. */ +export const validateRawBinaryLength = ( + length: number, +): Result => + Number.isSafeInteger(length) && length >= 1 + ? ok(null) + : err("raw-binary requires at least one file byte"); + +/** Admitted headerless load identity used by target resolution and profiles. */ +export interface RawBinaryAdmission { + readonly architecture: Exclude; + readonly entryPoint: string; + readonly entryPointSource: string; +} + +const addressWidth = (architecture: BinaryArchitecture): bigint => + architecture === "x86" || architecture === "arm" + ? 0xffffffffn + : 0xffffffffffffffffn; + +/** Validate architecture, entry, and that the mapped image fits the CPU width. */ +export const admitRawBinary = (input: { + readonly architecture: unknown; + readonly entryPoint: unknown; + readonly fileBytes: number; +}): Result => { + const architecture = binaryArchitectureSchema.safeParse(input.architecture); + if (!architecture.success) + return err( + "raw-binary format requires architecture (x86, x86_64, arm, or arm64)", + ); + if (typeof input.entryPoint !== "string") + return err( + "raw-binary format requires entry_point as a hexadecimal address of the form 0x…", + ); + const entry = parseRawBinaryEntryPoint(input.entryPoint); + if (!entry.ok) return entry; + if ( + (architecture.data === "arm" || architecture.data === "arm64") && + entry.value.value % 4n !== 0n + ) + return err( + `raw-binary ${architecture.data} entry_point ${entry.value.canonical} must be 4-byte aligned`, + ); + const length = validateRawBinaryLength(input.fileBytes); + if (!length.ok) return length; + const last = entry.value.value + BigInt(input.fileBytes) - 1n; + const max = addressWidth(architecture.data); + if (entry.value.value > max || last > max) + return err( + `raw-binary mapping from ${entry.value.canonical} must fit in the ${architecture.data} address width`, + ); + return ok({ + architecture: architecture.data, + entryPoint: entry.value.canonical, + entryPointSource: entry.value.source, + }); +}; + +/** Describe the format/architecture/entry pairing without performing I/O. */ +export const rawBinarySelectionError = (input: { + readonly format?: ExecutableFormatHint; + readonly architecture?: unknown; + readonly entryPoint?: unknown; +}): string | undefined => { + const hasArchitecture = input.architecture !== undefined; + const hasEntry = input.entryPoint !== undefined; + if (input.format === "raw-binary") { + if (!hasArchitecture || !hasEntry) + return "format=raw-binary requires architecture and entry_point"; + return undefined; + } + if (hasArchitecture || hasEntry) + return "architecture and entry_point apply only with format=raw-binary"; + return undefined; +}; diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index b4795e7d8..00d5f109f 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -5,5 +5,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "38", + skillVersion: "39", } as const; diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index a057ce4ba..259b89285 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -14,6 +14,7 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; import type { GhidraInstallationInspection } from "./GhidraInstallation.js"; import { ghidraMipsProfileParameters } from "./GhidraMipsProfile.js"; +import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; import { ghidraProcessorUnsupportedReason, @@ -69,6 +70,57 @@ export const resolveGhidraAnalysisProfile = async ( : null; if (pspExtension !== null && !pspExtension.ok) return pspExtension; const provider = { ...identity, version: installation.providerVersion }; + if (target.format === "raw-binary") { + if (languageOverride !== undefined) + return Promise.resolve( + err( + new AnalysisUnsupportedTargetError( + "resolve_analysis_profile", + target.path, + "REA_GHIDRA_LANGUAGE_ID cannot override the admitted raw-binary import.", + ), + ), + ); + const language = ghidraRawBinaryLanguage(target.architecture); + return Promise.resolve( + ok({ + profile: createAnalysisProfile(provider, { + target_kind: target.kind, + target_format: target.format, + architecture: target.architecture, + available_architectures: [...target.availableArchitectures].sort(), + import_mode: "ephemeral-source-immutable", + annotation_policy: "atomic-function-entry-metadata-v1", + load_image_observations: "source-mappings-entry-context-v2", + function_body_evidence: "complete-inclusive-ranges-v1", + function_references: "complete-body-and-entry-reference-manager-v2", + string_inventory_evidence: "defined-data-coverage-v1", + location_resolution: "explicit-address-exact-entry-symbol-first-v3", + instruction_flow_evidence: "decoded-return-pcode-v1", + instruction_references: "exact-address-incoming-v1", + function_boundary_observations: "ghidra-terminal-call-limitations-v1", + no_return_repair: + installation.platform === "win32" + ? "disabled-windows-p0" + : "returning-imports-decoded-return-v2", + process_launch: + installation.platform === "win32" + ? "official-headless-script-v1" + : "inspected-jvm-launch-support-v1", + load_image_evidence: "independent-raw-mapping-context-v2", + jump_table_evidence: "typed-case-default-blocks-v1", + decompiler_jump_loads: true, + loader: "BinaryLoader", + language_id: language.languageId, + compiler_spec_id: language.compilerSpecId, + language_selection: "caller-supplied-v1", + entry_point: target.entryPoint, + entry_seed: "external-entry-and-function-before-analysis-v1", + analyzer_preset: "ghidra-default", + }), + }), + ); + } const dosMz = target.format === "dos-mz"; const dosCom = target.format === "dos-com"; const dos = dosMz || dosCom; diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 8b2b49c44..f2ccd381d 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -1,4 +1,6 @@ -import { randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type { Stats } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; import type { Socket } from "node:net"; import type { ProgressReporter } from "../application/ProgressReporter.js"; @@ -43,11 +45,12 @@ import { type GhidraSessionInfo, } from "./GhidraSessionValues.js"; import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js"; -import { readFile } from "node:fs/promises"; +import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { openRegularFile, RegularFileChangedError, + RegularFileAdmissionFailure, sameRegularFileState, } from "../filesystem/RegularFile.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; @@ -286,12 +289,66 @@ export class GhidraClient { ); } - /** Read the private immutable source for independent import verification. */ - async readTargetSnapshot( + /** Read the private immutable source for format parsers that require captured bytes. */ + readTargetSnapshot( maximumBytes?: number, signal?: AbortSignal, ): Promise> { - if (this.#snapshotPath === undefined) + return this.#withTargetSnapshot(async (handle, initial, path) => { + if ( + maximumBytes !== undefined && + (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) + ) + throw new RangeError("Snapshot byte limit must be a safe integer"); + if (maximumBytes !== undefined && initial.size > maximumBytes) + return { + kind: "over-capacity", + sourceBytesAtLeast: initial.size, + maximumBytes, + }; + const bytes = await readBoundedFileBytes( + handle, + maximumBytes ?? initial.size, + signal, + initial.size, + ); + if (bytes === undefined || bytes.length !== initial.size) + throw new RegularFileChangedError(path); + return { kind: "captured", bytes }; + }, signal); + } + + /** Measure the complete snapshot identity without retaining its bytes in memory. */ + hashTargetSnapshot( + signal?: AbortSignal, + ): Promise< + Result< + { readonly sha256: string; readonly bytes: number }, + GhidraSessionError + > + > { + return this.#withTargetSnapshot(async (handle, initial, path) => { + const hash = createHash("sha256"); + let bytes = 0; + for await (const chunk of readFileHandleChunks(handle, { + end: initial.size, + signal, + })) { + bytes += chunk.length; + if (bytes > initial.size) throw new RegularFileChangedError(path); + hash.update(chunk); + } + if (bytes !== initial.size) throw new RegularFileChangedError(path); + return { sha256: hash.digest("hex"), bytes }; + }, signal); + } + + async #withTargetSnapshot( + read: (handle: FileHandle, initial: Stats, path: string) => Promise, + signal?: AbortSignal, + ): Promise> { + const path = this.#snapshotPath; + if (path === undefined) return err( this.#failure( "protocol", @@ -299,77 +356,42 @@ export class GhidraClient { ), ); try { - if (maximumBytes !== undefined) { - signal?.throwIfAborted(); - if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) - throw new RangeError("Snapshot byte limit must be a safe integer"); - const handle = await openRegularFile(this.#snapshotPath, { - symlinks: "reject", - }); - const owner = new OwnedFileHandle(handle); - this.#snapshotOwners.add(owner); - let result: GhidraTargetSnapshot; - let readFailure: unknown; - try { - const metadata = await handle.stat(); - signal?.throwIfAborted(); - if (metadata.size > maximumBytes) - result = { - kind: "over-capacity", - sourceBytesAtLeast: metadata.size, - maximumBytes, - }; - else { - const bytes = await readBoundedFileBytes( - handle, - maximumBytes, - signal, - metadata.size, - ); - signal?.throwIfAborted(); - const after = await handle.stat(); - if ( - bytes !== undefined && - bytes.length === metadata.size && - sameRegularFileState(metadata, after) - ) { - result = { kind: "captured", bytes }; - } else if ( - bytes === undefined && - after.isFile() && - after.size > maximumBytes - ) { - result = { - kind: "over-capacity", - sourceBytesAtLeast: after.size, - maximumBytes, - }; - } else { - throw new RegularFileChangedError(this.#snapshotPath); - } - } - } catch (cause: unknown) { - readFailure = cause; - } - try { - await owner.close(); - this.#snapshotOwners.delete(owner); - } catch (closeFailure: unknown) { - throw new AggregateError( - readFailure === undefined - ? [closeFailure] - : [readFailure, closeFailure], - "Ghidra target snapshot read or descriptor cleanup failed", - ); - } - if (readFailure !== undefined) throw readFailure; - return ok(result!); - } - return ok({ - kind: "captured", - bytes: await readFile(this.#snapshotPath), + const handle = await openRegularFile(path, { + symlinks: "reject", + signal, }); + const owner = new OwnedFileHandle(handle); + this.#snapshotOwners.add(owner); + let result: Result; + try { + const initial = await handle.stat(); + signal?.throwIfAborted(); + if (!Number.isSafeInteger(initial.size) || initial.size < 0) + throw new RangeError("Snapshot size must be a safe integer"); + const value = await read(handle, initial, path); + signal?.throwIfAborted(); + const after = await handle.stat(); + signal?.throwIfAborted(); + if (!sameRegularFileState(initial, after)) + throw new RegularFileChangedError(path); + result = ok(value); + } catch (cause: unknown) { + result = err(cause); + } + try { + await owner.close(); + this.#snapshotOwners.delete(owner); + } catch (closeFailure: unknown) { + throw new AggregateError( + result.ok ? [closeFailure] : [result.error, closeFailure], + "Ghidra target snapshot read or descriptor cleanup failed", + ); + } + if (!result.ok) throw result.error; + return result; } catch (cause: unknown) { + if (cause instanceof RegularFileAdmissionFailure) + this.#snapshotOwners.add(cause.owner); return err( this.#failure( signal?.aborted === true ? "cancelled" : "protocol", diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index e203030e5..a6b459d83 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -101,6 +101,11 @@ export interface GhidraHeadlessLauncherOptions { /** Select the admitted 16-bit real-mode MZ import instead of auto-detection. */ readonly dosMz?: true; readonly dosCom?: true; + readonly rawBinary?: { + readonly languageId: string; + readonly compilerSpecId: string; + readonly baseAddr: string; + }; /** Explicit import language committed by the analysis profile. */ readonly languageOverride?: GhidraLanguageOverride; /** Caller seeds committed by the analysis profile, applied before analysis. */ @@ -201,6 +206,9 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { ...(this.options.dosMz === undefined ? {} : { dosMz: this.options.dosMz }), + ...(this.options.rawBinary === undefined + ? {} + : { rawBinary: this.options.rawBinary }), ...(this.options.languageOverride === undefined ? {} : { languageOverride: this.options.languageOverride }), @@ -411,6 +419,11 @@ export interface GhidraHeadlessArgumentOptions { readonly psp?: true; readonly dosMz?: true; readonly dosCom?: true; + readonly rawBinary?: { + readonly languageId: string; + readonly compilerSpecId: string; + readonly baseAddr: string; + }; readonly languageOverride?: GhidraLanguageOverride; /** Canonical seed snapshot passed to the pre-analysis seed script. */ readonly seedPath?: string; @@ -426,6 +439,7 @@ export const ghidraHeadlessArguments = ( (options.platform ?? process.platform) === "win32" ? win32 : posix; const bridgeDirectory = scriptPath.dirname(options.bridgeScriptPath); const override = options.languageOverride; + const rawBinary = options.rawBinary; if ( options.psp === true && (options.dosMz === true || @@ -435,6 +449,10 @@ export const ghidraHeadlessArguments = ( throw new Error( "A PSP ELF import cannot be combined with DOS or a generic Ghidra language override.", ); + if (options.psp === true && rawBinary !== undefined) + throw new Error( + "A PSP ELF import cannot be combined with a raw-binary import.", + ); if ( override !== undefined && (options.dosMz === true || options.dosCom === true) @@ -442,6 +460,17 @@ export const ghidraHeadlessArguments = ( throw new Error( "A Ghidra language override cannot be combined with a DOS import.", ); + if (override !== undefined && rawBinary !== undefined) + throw new Error( + "A Ghidra language override cannot be combined with a raw-binary import.", + ); + if ( + rawBinary !== undefined && + (options.dosMz === true || options.dosCom === true) + ) + throw new Error( + "A raw-binary import cannot be combined with a DOS import.", + ); return [ options.projectRoot, "rea-project", @@ -467,24 +496,35 @@ export const ghidraHeadlessArguments = ( "-cspec", "default", ] - : options.psp === true - ? [ - "-loader", - "PspElfLoader", - "-processor", - "Allegrex:LE:32:default", - "-cspec", - "default", - ] - : override !== undefined + : rawBinary === undefined + ? options.psp === true ? [ + "-loader", + "PspElfLoader", "-processor", - override.languageId, - ...(override.compilerSpecId === undefined - ? [] - : ["-cspec", override.compilerSpecId]), + "Allegrex:LE:32:default", + "-cspec", + "default", ] - : []), + : override !== undefined + ? [ + "-processor", + override.languageId, + ...(override.compilerSpecId === undefined + ? [] + : ["-cspec", override.compilerSpecId]), + ] + : [] + : [ + "-loader", + "BinaryLoader", + "-loader-baseAddr", + rawBinary.baseAddr, + "-processor", + rawBinary.languageId, + "-cspec", + rawBinary.compilerSpecId, + ]), "-readOnly", "-deleteProject", "-log", @@ -499,6 +539,14 @@ export const ghidraHeadlessArguments = ( scriptPath.join(bridgeDirectory, "ReaGhidraPrepareCom.java"), ] : []), + ...(rawBinary === undefined + ? [] + : [ + "-preScript", + scriptPath.join(bridgeDirectory, "ReaGhidraPrepareRaw.java"), + rawBinary.languageId, + rawBinary.baseAddr, + ]), // Seeds follow any loader preparation and precede default auto-analysis. ...(options.seedPath === undefined ? [] diff --git a/src/ghidra/GhidraLoadImageAttest.ts b/src/ghidra/GhidraLoadImageAttest.ts index 5034cec43..b61cd4c3b 100644 --- a/src/ghidra/GhidraLoadImageAttest.ts +++ b/src/ghidra/GhidraLoadImageAttest.ts @@ -12,17 +12,15 @@ import { err, ok, type Result } from "../domain/result.js"; import { attestGhidraDosComLoadImage, attestGhidraDosLoadImage, + attestGhidraRawBinaryLoadImage, } from "./GhidraLoadImageValues.js"; import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js"; -import type { GhidraTargetSnapshot } from "./GhidraClient.js"; +import type { GhidraClient } from "./GhidraClient.js"; import type { GhidraSessionError } from "./GhidraSessionError.js"; -type LoadImageSnapshotClient = { - readonly readTargetSnapshot?: ( - maximumBytes?: number, - signal?: AbortSignal, - ) => Promise>; -}; +type LoadImageSnapshotClient = Partial< + Pick +>; type NativeLoadImageAttestationInput = { readonly target: BinaryTarget; @@ -37,7 +35,7 @@ type NativeLoadImageAttestationInput = { const isSignalAborted = (signal: AbortSignal | undefined): boolean => signal?.aborted === true; -/** Independently verify measured load-image observations for admitted DOS targets. */ +/** Independently verify measured load-image observations for admitted DOS and raw-binary targets. */ export const attestGhidraNativeLoadImage = async ( input: NativeLoadImageAttestationInput, ): Promise> => { @@ -71,7 +69,7 @@ export const attestGhidraNativeLoadImage = async ( nativeLoadImageSchema.parse({ status: "unsupported", reason: - "Independent load-image verification currently supports DOS MZ and explicit DOS COM targets only.", + "Independent load-image verification currently supports DOS MZ, explicit DOS COM, and caller-supplied raw-binary targets only.", observations: { ...observations, metadata_recovery: [ @@ -98,13 +96,17 @@ export const attestGhidraNativeLoadImage = async ( ); } } - if (target.format !== "dos-mz" && target.format !== "dos-com") { + if ( + target.format !== "dos-mz" && + target.format !== "dos-com" && + target.format !== "raw-binary" + ) { return ok( jsonValueSchema.parse( nativeLoadImageSchema.parse({ status: "unsupported", reason: - "Independent load-image verification currently supports DOS MZ and explicit DOS COM targets only.", + "Independent load-image verification currently supports DOS MZ, explicit DOS COM, and caller-supplied raw-binary targets only.", observations, limitations: [ "Other formats expose measured mappings and source identities; no format-specific verification was performed.", @@ -113,6 +115,33 @@ export const attestGhidraNativeLoadImage = async ( ), ); } + if (target.format === "raw-binary") { + if (client.hashTargetSnapshot === undefined) + return err( + new ProviderAdapterError("ghidra", operation, { + diagnostics: { + reason: + "The Ghidra client does not expose its immutable target snapshot identity for independent raw-binary load-image verification.", + }, + }), + ); + const snapshot = await client.hashTargetSnapshot(signal); + if (!snapshot.ok) return err(mapSessionError(snapshot.error)); + if (isSignalAborted(signal)) + return err(new AnalysisCancelledError(operation)); + const attested = attestGhidraRawBinaryLoadImage( + snapshot.value, + target, + observations, + ); + return attested.ok + ? ok(jsonValueSchema.parse(attested.value)) + : err( + new ProviderAdapterError("ghidra", operation, { + diagnostics: { reason: attested.error }, + }), + ); + } if (client.readTargetSnapshot === undefined) return err( new ProviderAdapterError("ghidra", operation, { @@ -122,14 +151,14 @@ export const attestGhidraNativeLoadImage = async ( }, }), ); - const snapshot = await client.readTargetSnapshot(); + const snapshot = await client.readTargetSnapshot(undefined, signal); if (!snapshot.ok) return err(mapSessionError(snapshot.error)); if (snapshot.value.kind !== "captured") return err( new ProviderAdapterError("ghidra", operation, { diagnostics: { reason: - "The uncapped DOS load-image snapshot was unexpectedly not captured.", + "The uncapped load-image snapshot was unexpectedly not captured.", }, }), ); diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index b8aec0a3d..1c80bb635 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -7,8 +7,10 @@ import { } from "../domain/native/nativeLoadImage.js"; import { parseDosMzHeader, type DosMzHeader } from "../domain/dosMz.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { validateDosComLength } from "../domain/dosCom.js"; import { err, ok, type Result } from "../domain/result.js"; +import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; const sha256 = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex"); @@ -325,3 +327,111 @@ export const attestGhidraDosComLoadImage = ( }), ); }; + +const hexAddress = (value: bigint): string => `0x${value.toString(16)}`; + +/** Verify a caller-supplied raw-binary mapping against measured Ghidra import facts. */ +export const attestGhidraRawBinaryLoadImage = ( + snapshot: { readonly sha256: string; readonly bytes: number }, + target: Extract, + observations: NativeLoadImageObservation, +): Result => { + if (snapshot.sha256 !== target.sha256) + return err( + "Ghidra raw-binary snapshot digest does not match the admitted target", + ); + const language = ghidraRawBinaryLanguage(target.architecture); + const base = BigInt(target.entryPoint); + const last = hexAddress(base + BigInt(snapshot.bytes) - 1n); + const checks: NativeLoadImageCheck[] = []; + const check = ( + name: string, + expected: JsonValue, + observed: JsonValue, + fileOffset: number | null = null, + location: string | null = null, + ): void => { + checks.push({ + name, + matched: JSON.stringify(expected) === JSON.stringify(observed), + expected, + observed, + file_offset: fileOffset, + address: location, + }); + }; + const source = observations.source_files[0]; + const mapping = observations.mappings[0]; + check("executable_format", "Raw Binary", observations.executable_format); + check("language_id", language.languageId, observations.language_id); + check( + "compiler_spec_id", + language.compilerSpecId, + observations.compiler_spec_id, + ); + // BinaryLoader's block base is independent of the Program image base. + check("image_base", "0x0", observations.image_base); + check("source_file_count", 1, observations.source_files.length); + check("source_file_size", snapshot.bytes, source?.size ?? null); + check("original_file_sha256", target.sha256, source?.original_sha256 ?? null); + check("modified_file_sha256", target.sha256, source?.modified_sha256 ?? null); + check("mapping_count", 1, observations.mappings.length); + check( + "mapping.file_offset", + 0, + mapping?.file_offset ?? null, + 0, + mapping?.start ?? null, + ); + check( + "mapping.start", + target.entryPoint, + mapping?.start ?? null, + 0, + mapping?.start ?? null, + ); + check("mapping.end", last, mapping?.end ?? null, 0, mapping?.end ?? null); + check( + "mapping.length", + snapshot.bytes, + mapping?.length ?? null, + 0, + mapping?.start ?? null, + ); + check( + "mapping.sha256", + snapshot.sha256, + mapping?.sha256 ?? null, + 0, + mapping?.start ?? null, + ); + check( + "entry_point", + target.entryPoint, + observations.entry_points.find((entry) => entry === target.entryPoint) ?? + null, + ); + check("relocation_records", [], observations.relocations); + return ok( + nativeLoadImageSchema.parse({ + status: checks.every((item) => item.matched) ? "verified" : "mismatch", + format: "raw-binary", + target_sha256: target.sha256, + architecture: target.architecture, + language_id: language.languageId, + compiler_spec_id: language.compilerSpecId, + base_address: target.entryPoint, + file_bytes: snapshot.bytes, + entry: { address: target.entryPoint, file_offset: 0 }, + observations, + checks, + limitations: [ + "Raw binary is a headerless interpretation explicitly selected by the caller, not format identification from file bytes.", + "Verification covers complete original/modified source identity, mapped file bytes, the caller-supplied entry, and the bound little-endian Ghidra language. File offset 0 is mapped at the entry address.", + "Ghidra may report additional language-defined external entries outside the mapped image. They are preserved as observations; verification requires the caller-supplied entry and does not attest other entries.", + "Reset vectors, memory-mapped I/O, big-endian and Thumb-only variants, and runtime boot behavior are not modeled or verified. No original executable bytes are changed.", + "Mapping ends are inclusive; a mismatching digest identifies its source range, not the first differing byte.", + ], + }), + ); +}; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 759d5f559..b0b8fbd87 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js"; +import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js"; import { nativeAotPeDigest, nativeAotPeFixture, @@ -177,6 +178,50 @@ describe("Ghidra provider", () => { }); }); +describe("Ghidra raw-binary profile", () => { + it("commits BinaryLoader language and caller-supplied entry", async () => { + const ghidra = provider(); + const target: BinaryTarget = { + path: "/tmp/ecu.bin", + sha256: "a".repeat(64), + kind: "executable", + format: "raw-binary", + architecture: "arm", + availableArchitectures: ["arm"], + entryPoint: "0x8000000", + entryPointSource: "0x08000000", + }; + expect(ghidra.inspectTargetSupport(target).status).toBe("supported"); + const resolved = await ghidra.resolveAnalysisProfile(target); + expect(resolved.ok && resolved.value).toMatchObject({ + profile: { + parameters: { + target_format: "raw-binary", + architecture: "arm", + loader: "BinaryLoader", + language_id: "ARM:LE:32:v7", + compiler_spec_id: "default", + language_selection: "caller-supplied-v1", + entry_point: "0x8000000", + }, + }, + }); + const shifted = await ghidra.resolveAnalysisProfile({ + ...target, + entryPoint: "0x8001000", + entryPointSource: "0x8001000", + }); + expect(resolved.ok && shifted.ok).toBe(true); + if (!resolved.ok || !shifted.ok || resolved.value.profile === null) + throw new Error("expected raw-binary profiles"); + expect(shifted.value.profile?.digest).not.toBe( + resolved.value.profile.digest, + ); + const windows = provider({ ...installationHost(), platform: "win32" }); + expect(windows.inspectTargetSupport(target).status).toBe("unsupported"); + }); +}); + describe("Ghidra Mach-O slice support", () => { it("refuses universal targets whose selected slice cannot be enforced", () => { const ghidra = provider(); @@ -718,6 +763,88 @@ describe("Ghidra result projection", () => { }); describe("Ghidra measured load-image projection", () => { + it.each(["verified", "changed-snapshot", "cancelled"] as const)( + "streams raw snapshot identity and preserves %s outcomes", + async (state) => { + const fixture = fixtureRawBinaryLoadImage(); + const controller = new AbortController(); + const signals: (AbortSignal | undefined)[] = []; + const ghidra = provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: (operation) => { + if (operation !== "inspect_native_load_image") + throw new Error(`unexpected operation ${operation}`); + return Promise.resolve( + ok(jsonValueSchema.parse(fixture.observation)), + ); + }, + readTargetSnapshot: () => { + throw new Error( + "raw verification must not capture the complete file", + ); + }, + hashTargetSnapshot: (signal) => { + signals.push(signal); + if (state === "cancelled") { + controller.abort(); + return Promise.resolve( + err( + new GhidraSessionError( + "cancelled", + "Snapshot hashing cancelled", + ), + ), + ); + } + return Promise.resolve( + ok({ + ...fixture.snapshot, + ...(state === "changed-snapshot" + ? { sha256: "f".repeat(64) } + : {}), + }), + ); + }, + close: () => Promise.resolve(ok(null)), + })); + const resolved = await ghidra.resolveAnalysisProfile(fixture.target); + if (!resolved.ok) throw resolved.error; + if (resolved.value.profile === null) + throw new Error("expected raw profile"); + const client = ghidra.createClient( + fixture.target, + resolved.value.profile, + ); + try { + const result = await client.execute( + "inspect_native_load_image", + {}, + { signal: controller.signal }, + ); + expect(signals).toEqual([controller.signal]); + if (state === "verified") { + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + status: "verified", + observations: fixture.observation, + }); + } else { + expect(result).toMatchObject({ + ok: false, + error: { + _tag: + state === "cancelled" + ? "AnalysisCancelledError" + : "ProviderAdapterError", + }, + }); + } + } finally { + await client.close(); + } + }, + ); + it.each([ "verified", "mismatch", diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 91d793c12..2c37cfd24 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -87,12 +87,18 @@ import { windowsNativeAuthorityUnavailableReason, hasWindowsNativeAuthority, } from "../process/WindowsAuthority.js"; +import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; /** Production seam for exercising provider projection without a real process. */ export type GhidraProviderClientFactory = ( options: GhidraClientOptions, ) => Pick & - Partial>; + Partial< + Pick< + GhidraClient, + "runtimeLineage" | "readTargetSnapshot" | "hashTargetSnapshot" + > + >; type GhidraProviderClientInstance = ReturnType; @@ -440,6 +446,10 @@ export const createGhidraProviderClient = (input: { let extensionFailure: AnalysisError | undefined; // Replaced by the reported outcome once the session handshake is checked. const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)]; + const rawLanguage = + target.format === "raw-binary" + ? ghidraRawBinaryLanguage(target.architecture) + : undefined; const targetLimitations = [ ...(target.format === "dos-mz" ? [ @@ -447,6 +457,13 @@ export const createGhidraProviderClient = (input: { "Static DOS analysis does not emulate BIOS, DOS interrupts, device ports, or self-modifying unpacking code. Packed targets require a separately identified unpacked artifact for original-program analysis; appended overlays are not the initialized load module.", ] : []), + ...(target.format === "raw-binary" + ? [ + "Headerless raw-binary analysis uses the caller-supplied architecture and entry address; file offset 0 is mapped at that address. Headers are not discovered from file bytes.", + "Little-endian generic Ghidra languages are used for x86, x86_64, arm, and arm64. Big-endian, Thumb-only, Cortex-M, and other processor variants are unsupported in this increment.", + "Static analysis does not emulate reset vectors, memory-mapped I/O, or firmware boot sequences.", + ] + : []), ...(target.kind === "executable" && target.architecture === "mips" ? (target.mips.limitations ?? []) : []), @@ -474,6 +491,15 @@ export const createGhidraProviderClient = (input: { : {}), ...(target.format === "dos-mz" ? { dosMz: true } : {}), ...(target.format === "dos-com" ? { dosCom: true } : {}), + ...(rawLanguage === undefined || target.format !== "raw-binary" + ? {} + : { + rawBinary: { + languageId: rawLanguage.languageId, + compilerSpecId: rawLanguage.compilerSpecId, + baseAddr: target.entryPoint, + }, + }), ...(languageOverride === undefined ? {} : { languageOverride }), ...(seeds === undefined ? {} : { analysisSeeds: seeds }), platform: installation.platform, @@ -495,13 +521,18 @@ export const createGhidraProviderClient = (input: { expectedLanguageId: "x86:LE:16:Real Mode", expectedCompilerSpecId: "default", } - : languageOverride === undefined - ? {} + : rawLanguage === undefined + ? languageOverride === undefined + ? {} + : { + expectedLanguageId: languageOverride.languageId, + ...(languageOverride.compilerSpecId === undefined + ? {} + : { expectedCompilerSpecId: languageOverride.compilerSpecId }), + } : { - expectedLanguageId: languageOverride.languageId, - ...(languageOverride.compilerSpecId === undefined - ? {} - : { expectedCompilerSpecId: languageOverride.compilerSpecId }), + expectedLanguageId: rawLanguage.languageId, + expectedCompilerSpecId: rawLanguage.compilerSpecId, }), ...(psp ? { diff --git a/src/ghidra/GhidraRawBinaryLanguage.ts b/src/ghidra/GhidraRawBinaryLanguage.ts new file mode 100644 index 000000000..d63592e76 --- /dev/null +++ b/src/ghidra/GhidraRawBinaryLanguage.ts @@ -0,0 +1,26 @@ +import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; + +/** Bound Ghidra language for a caller-supplied raw-binary architecture. */ +export interface GhidraRawBinaryLanguage { + readonly languageId: string; + readonly compilerSpecId: "default"; +} + +/** + * Map the four caller-visible CPU families to little-endian generic Ghidra + * languages. Big-endian, Thumb-only, and Cortex-M variants are out of scope. + */ +export const ghidraRawBinaryLanguage = ( + architecture: Exclude, +): GhidraRawBinaryLanguage => { + switch (architecture) { + case "x86": + return { languageId: "x86:LE:32:default", compilerSpecId: "default" }; + case "x86_64": + return { languageId: "x86:LE:64:default", compilerSpecId: "default" }; + case "arm": + return { languageId: "ARM:LE:32:v7", compilerSpecId: "default" }; + case "arm64": + return { languageId: "AARCH64:LE:64:v8A", compilerSpecId: "default" }; + } +}; diff --git a/src/ghidra/GhidraRawBinaryLoadImage.fixture.ts b/src/ghidra/GhidraRawBinaryLoadImage.fixture.ts new file mode 100644 index 000000000..c25859263 --- /dev/null +++ b/src/ghidra/GhidraRawBinaryLoadImage.fixture.ts @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; + +import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; + +const hash = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +export const fixtureRawBinaryLoadImage = (): { + snapshot: { sha256: string; bytes: number }; + target: Extract; + observation: NativeLoadImageObservation; +} => { + const bytes = Buffer.from("1eff2fe1", "hex"); + const digest = hash(bytes); + return { + snapshot: { sha256: digest, bytes: bytes.length }, + target: { + path: "/tmp/ecu.bin", + sha256: digest, + kind: "executable", + format: "raw-binary", + architecture: "arm", + availableArchitectures: ["arm"], + entryPoint: "0x8000000", + entryPointSource: "0x08000000", + }, + observation: { + executable_format: "Raw Binary", + language_id: "ARM:LE:32:v7", + compiler_spec_id: "default", + image_base: "0x0", + default_address_space: "ram", + source_files: [ + { + name: "ecu.bin", + size: bytes.length, + original_sha256: digest, + modified_sha256: digest, + }, + ], + mappings: [ + { + block: "ram", + start: "0x8000000", + end: "0x8000003", + address_space: "ram", + initialized: true, + loaded: true, + overlay: false, + length: bytes.length, + source_file_index: 0, + file_offset: 0, + sha256: digest, + }, + ], + relocations: [], + entry_points: ["0x8000000"], + entry_context: [], + }, + }; +}; diff --git a/src/ghidra/GhidraRawBinaryLoadImage.test.ts b/src/ghidra/GhidraRawBinaryLoadImage.test.ts new file mode 100644 index 000000000..a20fd9642 --- /dev/null +++ b/src/ghidra/GhidraRawBinaryLoadImage.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { attestGhidraRawBinaryLoadImage } from "./GhidraLoadImageValues.js"; +import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js"; +import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; + +describe("raw-binary Ghidra language binding", () => { + it("maps the four admitted families to little-endian generic languages", () => { + expect(ghidraRawBinaryLanguage("x86")).toEqual({ + languageId: "x86:LE:32:default", + compilerSpecId: "default", + }); + expect(ghidraRawBinaryLanguage("x86_64")).toEqual({ + languageId: "x86:LE:64:default", + compilerSpecId: "default", + }); + expect(ghidraRawBinaryLanguage("arm")).toEqual({ + languageId: "ARM:LE:32:v7", + compilerSpecId: "default", + }); + expect(ghidraRawBinaryLanguage("arm64")).toEqual({ + languageId: "AARCH64:LE:64:v8A", + compilerSpecId: "default", + }); + }); +}); + +describe("raw-binary independent load-image checks", () => { + it("verifies mapped bytes at the caller-supplied entry", () => { + const f = fixtureRawBinaryLoadImage(); + const result = attestGhidraRawBinaryLoadImage( + f.snapshot, + f.target, + f.observation, + ); + expect(result).toMatchObject({ + ok: true, + value: { + status: "verified", + format: "raw-binary", + architecture: "arm", + language_id: "ARM:LE:32:v7", + base_address: "0x8000000", + file_bytes: 4, + entry: { address: "0x8000000", file_offset: 0 }, + }, + }); + }); + it("retains mismatch evidence for a drifted language", () => { + const f = fixtureRawBinaryLoadImage(); + f.observation.language_id = "x86:LE:32:default"; + const result = attestGhidraRawBinaryLoadImage( + f.snapshot, + f.target, + f.observation, + ); + expect(result).toMatchObject({ + ok: true, + value: { status: "mismatch", observations: f.observation }, + }); + }); + it("preserves additional language entries while requiring the caller entry", () => { + const f = fixtureRawBinaryLoadImage(); + f.observation.entry_points = ["0x0", f.target.entryPoint, "0xffff0000"]; + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { status: "verified", observations: f.observation }, + }); + f.observation.entry_points = ["0x0", "0xffff0000"]; + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { + status: "mismatch", + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "entry_point", + matched: false, + observed: null, + }), + ]), + }, + }); + }); + it("rejects stale snapshot identity", () => { + const f = fixtureRawBinaryLoadImage(); + expect( + attestGhidraRawBinaryLoadImage( + f.snapshot, + { ...f.target, sha256: "f".repeat(64) }, + f.observation, + ).ok, + ).toBe(false); + }); +}); diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 9c1f40a1a..9dfef9e07 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -140,6 +140,7 @@ export const hopperLoaderArgsForTarget = ( return ok(["-l", "WinPE", flag]); case "dos-mz": case "dos-com": + case "raw-binary": return err( new ProviderAdapterError("hopper", "resolve_analysis_profile"), ); diff --git a/src/hopper/HopperProvider.test.ts b/src/hopper/HopperProvider.test.ts index 3265b26c5..94715034c 100644 --- a/src/hopper/HopperProvider.test.ts +++ b/src/hopper/HopperProvider.test.ts @@ -5,6 +5,27 @@ import { silentLogger } from "../logger.js"; import { HopperProvider } from "./HopperProvider.js"; describe("Hopper provider capabilities", () => { + it("declines headerless raw-binary targets with a Ghidra-directed reason", () => { + const config = parseConfig({}); + if (!config.ok) throw new Error("expected valid configuration"); + const provider = new HopperProvider(config.value, silentLogger, {}); + expect( + provider.inspectTargetSupport({ + path: "/tmp/ecu.bin", + sha256: "a".repeat(64), + kind: "executable", + format: "raw-binary", + architecture: "arm", + availableArchitectures: ["arm"], + entryPoint: "0x8000000", + entryPointSource: "0x8000000", + }), + ).toMatchObject({ + status: "unsupported", + code: "target_format_unsupported", + reason: expect.stringContaining("Ghidra"), + }); + }); it("declines DOS MZ targets with a provider-specific support reason", () => { const config = parseConfig({}); if (!config.ok) throw new Error("expected valid configuration"); diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index e0cd543bf..d8b2dafa6 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -123,6 +123,14 @@ export class HopperProvider implements AnalysisProviderCandidate { reason: "REA's Hopper adapter does not admit MIPS targets.", diagnostics, }; + if (target.format === "raw-binary") + return { + status: "unsupported", + code: "target_format_unsupported", + reason: + "Headerless raw-binary analysis requires the Ghidra adapter with a caller-supplied architecture and entry point.", + diagnostics, + }; if (target.format === "dos-mz" || target.format === "dos-com") return { status: "unsupported", diff --git a/src/ida/IdaProvider.test.ts b/src/ida/IdaProvider.test.ts new file mode 100644 index 000000000..ccf67c5b4 --- /dev/null +++ b/src/ida/IdaProvider.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { parseConfig } from "../config/parseConfig.js"; +import { IdaProvider } from "./IdaProvider.js"; + +describe("IDA provider target support", () => { + it("declines headerless raw-binary targets", () => { + const config = parseConfig({}); + if (!config.ok) throw new Error("expected valid configuration"); + const provider = new IdaProvider(config.value, {}); + expect( + provider.inspectTargetSupport({ + path: "/tmp/ecu.bin", + sha256: "a".repeat(64), + kind: "executable", + format: "raw-binary", + architecture: "arm", + availableArchitectures: ["arm"], + entryPoint: "0x8000000", + entryPointSource: "0x8000000", + }), + ).toMatchObject({ + status: "unsupported", + code: "target_format_unsupported", + reason: expect.stringContaining("Ghidra"), + }); + }); +}); diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 691a666c2..7fefce825 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -109,15 +109,23 @@ export class IdaProvider implements AnalysisProviderCandidate { "REA's IDA adapter has no verified MIPS lane. Select a supported Ghidra profile instead.", diagnostics, }; - return target.kind === "executable" - ? { status: "supported", code: null, reason: null, diagnostics } - : { - status: "unsupported", - code: "target_kind_unsupported", - reason: - "IDA MCP integration admits executable inputs, not analysis databases. Select the original input binary.", - diagnostics, - }; + if (target.kind !== "executable") + return { + status: "unsupported", + code: "target_kind_unsupported", + reason: + "IDA MCP integration admits executable inputs, not analysis databases. Select the original input binary.", + diagnostics, + }; + if (target.format === "raw-binary") + return { + status: "unsupported", + code: "target_format_unsupported", + reason: + "IDA does not admit headerless raw binaries. Open the target with Ghidra and supply architecture and entry_point.", + diagnostics, + }; + return { status: "supported", code: null, reason: null, diagnostics }; } async resolveAnalysisProfile( _target: BinaryTarget, diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 7b7e5ebb5..1c4b89a54 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -12,6 +12,7 @@ import { ProcessCaptureResourceScope } from "../process/capture/ProcessCaptureLi import { ProcessCaptureError } from "../process/capture/ProcessCaptureError.js"; import { toolContract } from "../contracts/toolContracts.js"; import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js"; +import { binaryTargetSummary } from "../application/binary/BinaryTargetSummary.js"; import { UnknownRegistryError } from "../domain/unknownRegistryError.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { Evidence } from "../domain/evidence.js"; @@ -203,6 +204,12 @@ const registerOpenLifecycleTool = ({ session.open(input.path, { signal: context.mcpReq.signal, ...(input.format === undefined ? {} : { formatHint: input.format }), + ...(input.architecture === undefined + ? {} + : { architecture: input.architecture }), + ...(input.entry_point === undefined + ? {} + : { entryPoint: input.entry_point }), ...(input.provider_id === undefined ? {} : { providerId: input.provider_id }), @@ -213,13 +220,7 @@ const registerOpenLifecycleTool = ({ ? server.delivery.toCallToolResult( { ok: true, - value: { - path: opened.value.path, - format: opened.value.format, - kind: opened.value.kind, - sha256: opened.value.sha256, - architecture: opened.value.architecture ?? null, - }, + value: binaryTargetSummary(opened.value), }, openContract, ) diff --git a/tests/boundary/cli/rawBinaryCli.test.ts b/tests/boundary/cli/rawBinaryCli.test.ts new file mode 100644 index 000000000..b16d296ce --- /dev/null +++ b/tests/boundary/cli/rawBinaryCli.test.ts @@ -0,0 +1,103 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "rejects a headerless image until architecture and entry are supplied", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-raw-cli-"); + const path = join(root, "ecu.bin"); + await writeFile(path, Buffer.from("1eff2fe1", "hex")); + const before = await cli.run({ + arguments: ["inspect", path, "--json"], + environment: { + HOME: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + }, + }); + expect(before.exitCode).not.toBe(0); + expect(JSON.stringify(before.json)).not.toContain("raw-binary"); + const missing = await cli.run({ + arguments: ["inspect", path, "--target-format", "raw-binary", "--json"], + environment: { + HOME: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + }, + }); + expect(missing.exitCode).toBe(1); + expect(missing.json).toMatchObject({ + code: "target_unavailable", + details: { reason: expect.stringContaining("architecture") }, + }); + }, +); + +cliTest( + "rejects an unaligned AArch64 entry before provider startup", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-raw-cli-alignment-"); + const path = join(root, "image.bin"); + await writeFile(path, Buffer.from("c0035fd6", "hex")); + const result = await cli.run({ + arguments: [ + "inspect", + path, + "--target-format", + "raw-binary", + "--architecture", + "arm64", + "--entry-point", + "0x8000001", + "--provider", + "ghidra", + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "target_unavailable", + details: { reason: expect.stringContaining("must be 4-byte aligned") }, + }); + }, +); + +cliTest( + "stops Hopper from claiming a caller-supplied raw-binary image", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-raw-cli-hopper-"); + const path = join(root, "ecu.bin"); + await writeFile(path, Buffer.from("1eff2fe1", "hex")); + const result = await cli.run({ + arguments: [ + "inspect", + path, + "--target-format", + "raw-binary", + "--architecture", + "arm", + "--entry-point", + "0x8000000", + "--provider", + "hopper", + "--json", + ], + environment: { + HOME: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + }, + }); + expect(result.exitCode).toBe(1); + expect(JSON.stringify(result.json)).toMatch(/Hopper|ghidra|Ghidra|hopper/i); + expect(JSON.stringify(result.json)).not.toMatch( + /unsupported binary format/i, + ); + }, +); diff --git a/tests/boundary/filesystem/rawBinaryTarget.test.ts b/tests/boundary/filesystem/rawBinaryTarget.test.ts new file mode 100644 index 000000000..5fd171d20 --- /dev/null +++ b/tests/boundary/filesystem/rawBinaryTarget.test.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("raw-binary admission", () => { + it("requires explicit architecture and entry even with a misleading suffix", async () => { + const directory = await createTestTempDirectory("rea-raw-target-"); + const path = join(directory, "fixture.js"); + const bytes = Buffer.from("1eff2fe1", "hex"); + await writeFile(path, bytes); + const target = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "arm64", + formatHint: "raw-binary", + architecture: "arm", + entryPoint: "0x08000000", + }); + expect(target).toMatchObject({ + ok: true, + value: { + path, + format: "raw-binary", + kind: "executable", + architecture: "arm", + availableArchitectures: ["arm"], + entryPoint: "0x8000000", + entryPointSource: "0x08000000", + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }); + expect(await readFile(path)).toEqual(bytes); + const detected = await parseBinaryTarget(path); + expect(detected.ok && detected.value.format).not.toBe("raw-binary"); + }); + it("rejects raw-binary without architecture and entry", async () => { + const directory = await createTestTempDirectory("rea-raw-missing-"); + const path = join(directory, "fixture.bin"); + await writeFile(path, Buffer.from("c3", "hex")); + expect( + await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + formatHint: "raw-binary", + }), + ).toMatchObject({ + ok: false, + error: { + _tag: "BinaryTargetError", + reason: expect.stringContaining("architecture"), + }, + }); + }); + it("rejects architecture without format=raw-binary", async () => { + const directory = await createTestTempDirectory("rea-raw-unpaired-"); + const path = join(directory, "fixture.bin"); + await writeFile(path, Buffer.from("c3", "hex")); + expect( + await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + architecture: "arm", + entryPoint: "0x8000000", + }), + ).toMatchObject({ + ok: false, + error: { + _tag: "BinaryTargetError", + reason: expect.stringContaining("format=raw-binary"), + }, + }); + }); + it("rejects an empty image", async () => { + const directory = await createTestTempDirectory("rea-raw-empty-"); + const path = join(directory, "fixture.bin"); + await writeFile(path, Buffer.alloc(0)); + expect( + await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + formatHint: "raw-binary", + architecture: "x86", + entryPoint: "0x1000", + }), + ).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } }); + }); + it("rejects a contradictory target kind", async () => { + const directory = await createTestTempDirectory("rea-raw-kind-"); + const path = join(directory, "fixture.bin"); + await writeFile(path, Buffer.from("c3", "hex")); + expect( + await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + targetKind: "database", + formatHint: "raw-binary", + architecture: "x86", + entryPoint: "0x1000", + }), + ).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } }); + }); +}); diff --git a/tests/boundary/mcp/rawBinaryMcp.test.ts b/tests/boundary/mcp/rawBinaryMcp.test.ts new file mode 100644 index 000000000..c1d2029c2 --- /dev/null +++ b/tests/boundary/mcp/rawBinaryMcp.test.ts @@ -0,0 +1,219 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { createServer } from "../../../src/server/createServer.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { ok } from "../../../src/domain/result.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { + createCacheProvider, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { silentLogger } from "../../../src/logger.js"; +import type { CallToolResult } from "@modelcontextprotocol/server"; + +const connect = async ( + provider: AnalysisProvider = createCacheProvider([]), +) => { + const session = createTestBinarySession(provider); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ name: "raw-binary-mcp", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { client, session }; +}; + +const structured = (result: CallToolResult): Record => { + if ( + typeof result.structuredContent !== "object" || + result.structuredContent === null + ) + throw new Error("missing structured result"); + return z.record(z.string(), z.unknown()).parse(result.structuredContent); +}; + +it("opens a headerless image when architecture and entry are supplied", async () => { + const root = await createTestTempDirectory("rea-raw-mcp-"); + const path = join(root, "ecu.bin"); + await writeFile(path, Buffer.from("1eff2fe1", "hex")); + const { client } = await connect(); + const opened = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture: "arm", + entry_point: "0x08000000", + }, + }); + expect(opened.isError).not.toBe(true); + expect(structured(opened).result).toMatchObject({ + path, + format: "raw-binary", + kind: "executable", + architecture: "arm", + entry_point: "0x8000000", + }); +}); + +it("rejects an unaligned AArch64 entry before selecting or starting a provider", async () => { + const root = await createTestTempDirectory("rea-raw-mcp-alignment-"); + const path = join(root, "image.bin"); + await writeFile(path, Buffer.from("c0035fd6", "hex")); + const { client, session } = await connect({ + ...createCacheProvider([]), + resolveAnalysisProfile: () => { + throw new Error("unaligned input must not select a provider"); + }, + createClient: () => { + throw new Error("unaligned input must not launch a provider"); + }, + }); + const opened = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture: "arm64", + entry_point: "0x8000001", + }, + }); + expect(opened.isError).toBe(true); + expect(JSON.stringify(opened.content)).toContain("must be 4-byte aligned"); + expect(session.activeTarget()).toBeUndefined(); +}); + +it("rejects raw-binary open requests that omit architecture", async () => { + const root = await createTestTempDirectory("rea-raw-mcp-missing-"); + const path = join(root, "ecu.bin"); + await writeFile(path, Buffer.from("1eff2fe1", "hex")); + const { client } = await connect(); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path, format: "raw-binary" }, + }); + expect(opened.isError).toBe(true); +}); + +it("reuses a canonical raw entry and replaces the session when the entry changes", async () => { + const root = await createTestTempDirectory("rea-raw-mcp-reopen-"); + const path = join(root, "ecu.bin"); + await writeFile(path, Buffer.from("1eff2fe1", "hex")); + const fixture = createCacheProvider([]); + const resolved: string[] = []; + const created: string[] = []; + const closed: string[] = []; + const { client, session } = await connect({ + ...fixture, + resolveAnalysisProfile: (target) => { + if (target.format !== "raw-binary") + throw new Error("expected raw binary"); + resolved.push(target.entryPoint); + return Promise.resolve( + ok({ + profile: createAnalysisProfile( + { ...fixture.identity(), version: "1" }, + { + entry_point: target.entryPoint, + }, + ), + }), + ); + }, + createClient: (target) => { + if (target.format !== "raw-binary") + throw new Error("expected raw binary"); + created.push(target.entryPoint); + return { + execute: (operation) => { + if (operation !== "health" && operation !== "address_name") + throw new Error(`unexpected fixture operation: ${operation}`); + return Promise.resolve(observed(target.entryPoint)); + }, + close: () => { + closed.push(target.entryPoint); + return Promise.resolve(ok(null)); + }, + }; + }, + }); + const tools = (await client.listTools()).tools; + const validateLifecycle = ( + name: "open_binary" | "binary_session", + response: CallToolResult, + ) => { + const tool = tools.find((candidate) => candidate.name === name); + if (tool?.outputSchema === undefined) + throw new Error(`missing ${name} output schema`); + const schema = z.record(z.string(), z.unknown()).parse(tool.outputSchema); + const validate = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(schema); + expect( + validate(response.structuredContent), + JSON.stringify(validate.errors), + ).toBe(true); + const parsed = toolContract(name).outputSchema.parse( + response.structuredContent, + ); + const target = z.record(z.string(), z.unknown()).parse(parsed.result); + const { entry_point: _entry, ...missingEntry } = target; + expect(validate({ ...structured(response), result: missingEntry })).toBe( + false, + ); + return target; + }; + for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) { + const opened = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture: "arm", + entry_point: entryPoint, + }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const canonical = `0x${BigInt(entryPoint).toString(16)}`; + expect(validateLifecycle("open_binary", opened).entry_point).toBe( + canonical, + ); + const status = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + expect(status.isError, JSON.stringify(status)).not.toBe(true); + expect(validateLifecycle("binary_session", status).entry_point).toBe( + canonical, + ); + const result = await client.callTool({ + name: "address_name", + arguments: { address: entryPoint }, + }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect(structured(result).normalized_result).toBe(canonical); + } + expect(session.analysisProfile()?.parameters.entry_point).toBe("0x9000000"); + expect(resolved).toEqual(["0x8000000", "0x9000000"]); + expect(created).toEqual(["0x8000000", "0x9000000"]); + expect(closed).toEqual(["0x8000000"]); +}); diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index b93884792..5b04f6c47 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -12,6 +12,7 @@ import type { ProgressUpdate } from "../../../../src/application/ProgressReporte import { ok } from "../../../../src/domain/result.js"; import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js"; +import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; import { GHIDRA_PROCESS_DIAGNOSTIC_BYTES } from "../../../../src/ghidra/GhidraDiagnostics.js"; import { GhidraHeadlessLauncher } from "../../../../src/ghidra/GhidraLauncher.js"; import type { GhidraDiagnostic } from "../../../../src/ghidra/GhidraClientTypes.js"; @@ -122,12 +123,14 @@ const clientFor = ( readonly transport?: GhidraTransportKind; readonly runId?: string; readonly logger?: Logger; + readonly targetPath?: string; + readonly targetSha256?: string; } = {}, ): GhidraClient => { const client = new GhidraClient({ launcher, - targetPath: fixturePath, - targetSha256: TARGET_SHA256, + targetPath: options.targetPath ?? fixturePath, + targetSha256: options.targetSha256 ?? TARGET_SHA256, transport: options.transport ?? HOST_TRANSPORT, providerVersion: PROVIDER_VERSION, profileDigest: PROFILE_DIGEST, @@ -223,6 +226,80 @@ it("keeps one shared startup despite a failed progress sink and stops reports af }); describe("GhidraClient captured source snapshot", () => { + it("retains a snapshot descriptor whose close failed until session cleanup succeeds", async () => { + const client = clientFor(new FixtureLauncher()); + const started = await client.start(); + if (!started.ok) throw started.error; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockRejectedValueOnce(new Error("snapshot descriptor close failed")); + try { + await expect(client.hashTargetSnapshot()).resolves.toMatchObject({ + ok: false, + error: { kind: "protocol" }, + }); + const owner = close.mock.contexts[0]; + expect(owner).toBeInstanceOf(OwnedFileHandle); + const closed = await client.close(); + if (!closed.ok) throw closed.error; + expect( + close.mock.contexts.filter((context) => context === owner), + ).toHaveLength(2); + } finally { + close.mockRestore(); + } + }); + + it("hashes complete private snapshots with bounded chunks and abortable reads", async () => { + const root = await createTestTempDirectory("rea-ghidra-hash-"); + const source = join(root, "firmware.bin"); + const bytes = Buffer.alloc(512 * 1024 + 3, 0xa5); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await writeFile(source, bytes); + const client = clientFor(new FixtureLauncher(), { + targetPath: source, + targetSha256: sha256, + }); + await expect(client.hashTargetSnapshot()).resolves.toMatchObject({ + ok: false, + error: { kind: "protocol" }, + }); + const started = await client.start(); + if (!started.ok) throw started.error; + // Observe the admitted private snapshot even if the original path changes. + await writeFile(source, "changed original"); + const allocate = Buffer.allocUnsafe; + const allocations = vi.spyOn(Buffer, "allocUnsafe"); + try { + const identity = await client.hashTargetSnapshot(); + if (!identity.ok) throw identity.error; + expect(identity.value).toEqual({ sha256, bytes: bytes.length }); + expect(allocations.mock.calls.length).toBeGreaterThan(1); + expect( + Math.max(...allocations.mock.calls.map(([size]) => size)), + ).toBeLessThan(bytes.length); + const controller = new AbortController(); + allocations.mockImplementationOnce((size) => { + controller.abort(); + return allocate(size); + }); + await expect( + client.hashTargetSnapshot(controller.signal), + ).resolves.toMatchObject({ ok: false, error: { kind: "cancelled" } }); + } finally { + allocations.mockRestore(); + } + await expect(client.hashTargetSnapshot()).resolves.toMatchObject({ + ok: true, + value: { sha256, bytes: bytes.length }, + }); + await client.close(); + await expect(client.hashTargetSnapshot()).resolves.toMatchObject({ + ok: false, + error: { kind: "protocol" }, + }); + }); + it("exposes the immutable import snapshot only during the live session", async () => { const client = clientFor(new FixtureLauncher()); await expect(client.readTargetSnapshot()).resolves.toMatchObject({ diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index 1811b3108..89d8d5baa 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -113,6 +113,39 @@ describe("Ghidra COM loader", () => { }); }); +describe("Ghidra raw-binary loader", () => { + it("loads caller-supplied architecture and entry before auto-analysis", () => { + const arguments_ = ghidraHeadlessArguments({ + projectRoot: "/tmp/project", + targetPath: "/tmp/ecu.bin", + bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java", + descriptorPath: "/tmp/session.json", + ghidraLogPath: "/tmp/ghidra.log", + scriptLogPath: "/tmp/script.log", + rawBinary: { + languageId: "ARM:LE:32:v7", + compilerSpecId: "default", + baseAddr: "0x8000000", + }, + }); + expectOptions(arguments_, [ + ["-loader", "BinaryLoader"], + ["-loader-baseAddr", "0x8000000"], + ["-processor", "ARM:LE:32:v7"], + ["-cspec", "default"], + ]); + const prepare = arguments_.indexOf("-preScript"); + expect(arguments_[prepare + 1]).toBe( + join("/package/bridge", "ReaGhidraPrepareRaw.java"), + ); + expect(arguments_[prepare + 2]).toBe("ARM:LE:32:v7"); + expect(arguments_[prepare + 3]).toBe("0x8000000"); + expect(prepare).toBeLessThan(arguments_.indexOf("-postScript")); + expect(arguments_).toContain("-readOnly"); + expect(arguments_).toContain("-deleteProject"); + }); +}); + describe("Ghidra headless launcher", () => { it("forces the admitted real-mode language and loader for DOS MZ", () => { const arguments_ = ghidraHeadlessArguments({ @@ -208,6 +241,20 @@ describe("Ghidra configured import language and seeds", () => { ).toThrow(/cannot be combined with a DOS import/u); }); + it("never combines a configured language with a raw-binary import", () => { + expect(() => + ghidraHeadlessArguments({ + ...base, + languageOverride: { languageId: "x86:LE:32:default" }, + rawBinary: { + languageId: "ARM:LE:32:v7", + compilerSpecId: "default", + baseAddr: "0x8000000", + }, + }), + ).toThrow(/cannot be combined with a raw-binary import/u); + }); + it.each(["darwin", "win32"] as const)( "applies seeds after loader preparation and before analysis scripts on %s", (platform) => { diff --git a/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts b/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts index fd2a9c0db..a8f121e61 100644 --- a/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts +++ b/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts @@ -31,6 +31,27 @@ describe("Hopper analysis profiles", () => { }); }); + it("does not map caller-supplied raw binaries onto a Hopper loader", () => { + expect( + hopperLoaderArgsForTarget({ + path: "/tmp/ecu.bin", + sha256: "a".repeat(64), + kind: "executable", + format: "raw-binary", + architecture: "arm", + availableArchitectures: ["arm"], + entryPoint: "0x8000000", + entryPointSource: "0x8000000", + }), + ).toMatchObject({ + ok: false, + error: { + _tag: "ProviderAdapterError", + operation: "resolve_analysis_profile", + }, + }); + }); + it.each([ [target("elf", "x86_64"), ["-l", "ELF", "--intel-64"]], [target("pe", "x86"), ["-l", "WinPE", "--intel-32"]],