From af88184eeec544867904dedf0b30867a22be7abf Mon Sep 17 00:00:00 2001 From: sqmagellan <116528231+sqmagellan@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:38 -0700 Subject: [PATCH] feat(ghidra): configurable import language and analysis seeds (#1667) * feat(ghidra): configurable import language and analysis seeds REA_GHIDRA_LANGUAGE_ID and REA_GHIDRA_COMPILER_SPEC_ID set the import language when the header cannot identify the compiler (for example, Borland C++ PE images). REA_GHIDRA_SEED_FILE seeds functions, code and labels before auto-analysis, for code reached only through computed jumps or data. Both are validated before launch, recorded in the analysis profile and verified in the bridge handshake. * fix(ghidra): parse language XML and preserve seed ordering * test(ghidra): use explicit platform paths for seed launch checks * fix(javascript): retain cleanup failure reason in error causes --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- bridge/ghidra/ReaGhidraApplySeeds.java | 151 +++++++++ bridge/ghidra/ReaGhidraBridge.java | 5 + docs/ghidra-provider.md | 3 + docs/installation.md | 21 ++ docs/testing.md | 1 + package-lock.json | 1 + package.json | 3 + scripts/verify-packaged-ghidra-windows.mjs | 1 + scripts/verify/ghidra/seeds.mjs | 97 ++++++ src/config/environment.ts | 11 + src/config/ghidraLanguageOverride.ts | 28 ++ src/config/parseConfig.ts | 30 +- src/config/types.ts | 3 + src/ghidra/GhidraAnalysisProfile.ts | 48 ++- src/ghidra/GhidraAnalysisSeeds.test.ts | 258 ++++++++++++++ src/ghidra/GhidraAnalysisSeeds.ts | 286 ++++++++++++++++ src/ghidra/GhidraLanguageCatalog.ts | 118 +++++++ src/ghidra/GhidraLanguageOverride.test.ts | 320 ++++++++++++++++++ src/ghidra/GhidraLauncher.ts | 50 ++- src/ghidra/GhidraProvider.behavior.test.ts | 152 +++++++++ src/ghidra/GhidraProvider.ts | 47 ++- src/ghidra/GhidraProviderClient.ts | 78 ++++- src/ghidra/GhidraSessionValues.ts | 2 + .../analysis/JavaScriptAnalysisWorker.ts | 5 +- .../javascriptSourceAnalysisWorker.test.ts | 6 +- .../providers/ghidra/ghidraLauncher.test.ts | 84 ++++- .../ghidra/ReaSeedRegressionProbe.java | 38 +++ 27 files changed, 1827 insertions(+), 20 deletions(-) create mode 100644 bridge/ghidra/ReaGhidraApplySeeds.java create mode 100644 scripts/verify/ghidra/seeds.mjs create mode 100644 src/config/ghidraLanguageOverride.ts create mode 100644 src/ghidra/GhidraAnalysisSeeds.test.ts create mode 100644 src/ghidra/GhidraAnalysisSeeds.ts create mode 100644 src/ghidra/GhidraLanguageCatalog.ts create mode 100644 src/ghidra/GhidraLanguageOverride.test.ts create mode 100644 tests/conformance/ghidra/ReaSeedRegressionProbe.java diff --git a/bridge/ghidra/ReaGhidraApplySeeds.java b/bridge/ghidra/ReaGhidraApplySeeds.java new file mode 100644 index 000000000..d4ee8236e --- /dev/null +++ b/bridge/ghidra/ReaGhidraApplySeeds.java @@ -0,0 +1,151 @@ +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressSpace; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.Program; +import ghidra.program.model.mem.MemoryBlock; +import ghidra.program.model.symbol.SourceType; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +/** + * Apply caller-declared analysis seeds before default auto-analysis. + * + * The single argument is REA's canonical seed snapshot: one + * {@code 0x\t(function|code|label)[\tname]} line per seed. Code seeds are + * applied after functions (highest address first), then labels. The outcome is stored as a + * program property that the bridge reports during its handshake. + */ +public final class ReaGhidraApplySeeds extends GhidraScript { + static final String REPORT_OPTION = "REA analysis seeds"; + + private record Seed(Address address, String kind, String name) {} + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length != 1) { + throw new IllegalArgumentException("REA seed script requires exactly one seed file"); + } + Path path = Path.of(args[0]); + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalArgumentException("REA seed file is not a regular file"); + } + byte[] bytes = Files.readAllBytes(path); + String sha256 = HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(bytes) + ); + AddressSpace space = currentProgram.getAddressFactory().getDefaultAddressSpace(); + List seeds = new ArrayList<>(); + for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) { + if (line.isEmpty()) continue; + String[] fields = line.split("\t", -1); + if (fields.length < 2 || fields.length > 3 || !fields[0].startsWith("0x")) { + throw new IllegalArgumentException("REA seed snapshot is malformed"); + } + Address address; + try { + address = space.getAddress(Long.parseUnsignedLong(fields[0].substring(2), 16)); + } catch (ghidra.program.model.address.AddressOutOfBoundsException e) { + address = null; + } + seeds.add(new Seed(address, fields[1], fields.length == 3 ? fields[2] : null)); + } + + // Highest address first: a function body computed before analysis stops at + // an existing function entry, so later creations cannot swallow earlier ones. + seeds.sort((left, right) -> { + if (left.address() == null) return right.address() == null ? 0 : 1; + if (right.address() == null) return -1; + return right.address().compareTo(left.address()); + }); + int unmapped = 0; + int logged = 0; + int codeDecoded = 0, codeFailed = 0; + int functionCreated = 0, functionExisting = 0, functionFailed = 0; + int labelApplied = 0, labelFailed = 0; + for (String pass : new String[] { "function", "code", "label" }) { + for (Seed seed : seeds) { + if (!seed.kind().equals(pass)) continue; + monitor.checkCancelled(); + MemoryBlock block = seed.address() == null + ? null + : currentProgram.getMemory().getBlock(seed.address()); + if (block == null || (!pass.equals("label") && !block.isInitialized())) { + unmapped++; + continue; + } + switch (pass) { + case "code" -> { + if (decoded(seed.address())) codeDecoded++; + else { + codeFailed++; + if (logged++ < 20) println("REA seed failed: code " + seed.address() + " " + why(seed.address())); + } + } + case "function" -> { + Function existing = getFunctionAt(seed.address()); + if (existing != null) { + functionExisting++; + if (seed.name() != null) { + existing.setName(seed.name(), SourceType.USER_DEFINED); + } + } else if (decoded(seed.address()) && + createFunction(seed.address(), seed.name()) != null) { + functionCreated++; + } else { + functionFailed++; + if (logged++ < 20) println("REA seed failed: function " + seed.address() + " " + why(seed.address())); + } + } + default -> { + try { + createLabel(seed.address(), seed.name(), true, SourceType.USER_DEFINED); + labelApplied++; + } catch (Exception e) { + labelFailed++; + } + } + } + } + } + String report = "{\"format\":\"rea-ghidra-seeds-v1\",\"sha256\":\"" + sha256 + "\"" + + ",\"entries\":" + seeds.size() + + ",\"function_created\":" + functionCreated + + ",\"function_existing\":" + functionExisting + + ",\"function_failed\":" + functionFailed + + ",\"code_decoded\":" + codeDecoded + + ",\"code_failed\":" + codeFailed + + ",\"label_applied\":" + labelApplied + + ",\"label_failed\":" + labelFailed + + ",\"unmapped\":" + unmapped + "}"; + currentProgram.getOptions(Program.PROGRAM_INFO).setString(REPORT_OPTION, report); + println("REA seeds applied: " + report); + } + + private String why(Address address) { + if (getDataAt(address) != null) return "defined data " + getDataAt(address).getDataType().getName(); + var containing = getInstructionContaining(address); + if (containing != null && !containing.getAddress().equals(address)) + return "inside instruction at " + containing.getAddress(); + Function owner = getFunctionContaining(address); + if (owner != null) return "inside function " + owner.getEntryPoint(); + return getInstructionAt(address) == null ? "undecodable" : "function creation refused"; + } + + /** Decode one instruction flow at an address, keeping an existing decode. */ + private boolean decoded(Address address) { + if (getInstructionAt(address) != null) return true; + if (getDataAt(address) != null || getInstructionContaining(address) != null) { + return false; + } + disassemble(address); + return getInstructionAt(address) != null; + } +} diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 4635a183a..c29ed30d4 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -438,6 +438,11 @@ public final class ReaGhidraBridge extends HeadlessScript { result.add("target", target); JsonArray extensions = analysisExtensions.reports(); if (extensions.size() > 0) result.add("analysis_extensions", extensions); + // Written by ReaGhidraApplySeeds before auto-analysis; REA checks it + // against the committed profile. + String seeds = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO) + .getString("REA analysis seeds", null); + if (seeds != null) result.add("analysis_seeds", JsonParser.parseString(seeds)); return result; } diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index cdb9e1336..3c1dbf5b2 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -12,6 +12,9 @@ configuration, startup deadlines, resource controls, and cleanup recovery. extension. Check the [release boundary](installation.md#released-package-and-main) when using npm. +See [import language and analysis seeds](installation.md#ghidra-import-language-and-analysis-seeds) +to override the header-detected language or seed analysis. + Provider selection and profile identity follow [MCP contracts](mcp-contracts.md#identity-and-discovery). One deep provider stays bound to the target; runtime failure never selects another diff --git a/docs/installation.md b/docs/installation.md index 0049a9323..b4e108650 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -733,6 +733,27 @@ is no fixed per-operation or response-size ceiling. Unresolved computed calls remain unknown, reference-kind provenance is preserved, and provider-specific pseudocode is never treated as original source or Hopper-equivalent text. +### Ghidra import language and analysis seeds + +Ghidra detects the language from the executable header. To set it (for example, Borland C++ PE images): + +```bash +export REA_GHIDRA_LANGUAGE_ID="x86:LE:32:default" +export REA_GHIDRA_COMPILER_SPEC_ID="borlandcpp" # optional; requires a language +``` + +REA passes `-processor`/`-cspec`, checks both against the installation's `.ldefs`, and records them in the analysis profile. DOS imports reject an override. + +To seed code that auto-analysis cannot reach (computed jumps, pointers in data): + +```bash +export REA_GHIDRA_SEED_FILE=/absolute/path/to/seeds.tsv +``` + +Each line is `addresskind[name]`, where kind is `function`, `code` or `label` (a label needs a name). Addresses are `0x` hex in the default space; `#` lines are comments. A pre-script applies seeds before auto-analysis: functions from the highest address down, then code, then labels. The seed digest is part of the analysis profile, and the session fails if the applied seeds differ. Result limitations report the outcome counts; a seed fails when it overlaps an instruction Ghidra already decoded. + +Label seeds can name mapped uninitialized storage such as `.bss`; function and code seeds require initialized bytes. + ### Ghidra heap and CPU controls Set resource controls in the environment that launches the CLI or MCP server, diff --git a/docs/testing.md b/docs/testing.md index bd32a6ea0..f1d43ee3a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -453,6 +453,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build. | Ghidra lane | Supported runner/target | Additional local tools | | ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 | | `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 | diff --git a/package-lock.json b/package-lock.json index 7edec6958..352867fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@lydell/node-pty": "1.1.0", "@modelcontextprotocol/client": "2.3.1", "@modelcontextprotocol/server": "2.3.1", + "@xmldom/xmldom": "0.9.12", "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "6.0.0", "@zip.js/zip.js": "2.15.0", diff --git a/package.json b/package.json index 37eae2b93..f045fe8de 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "bridge/ghidra/ReaGhidraBridge.java", "bridge/ghidra/ReaGhidraExtensions.java", "bridge/ghidra/ReaGhidraPrepareCom.java", + "bridge/ghidra/ReaGhidraApplySeeds.java", "bridge/ghidra/ReaGhidraNoReturnFix.java", "bridge/android/ReaJadxBridge.java", "bridge/mitmproxy/capture.py", @@ -155,6 +156,7 @@ "verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs", "verify:adb": "npm run build:cached && node scripts/verify-adb.mjs", "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", + "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", "verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs", "verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs", "verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs", @@ -267,6 +269,7 @@ "@lydell/node-pty": "1.1.0", "@modelcontextprotocol/client": "2.3.1", "@modelcontextprotocol/server": "2.3.1", + "@xmldom/xmldom": "0.9.12", "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "6.0.0", "@zip.js/zip.js": "2.15.0", diff --git a/scripts/verify-packaged-ghidra-windows.mjs b/scripts/verify-packaged-ghidra-windows.mjs index b906e31b9..6221c122c 100644 --- a/scripts/verify-packaged-ghidra-windows.mjs +++ b/scripts/verify-packaged-ghidra-windows.mjs @@ -175,6 +175,7 @@ const callerDirectory = join(workspace, "caller cwd with spaces"); const scriptCollisions = [ "ReaGhidraBridge.java", "ReaGhidraPrepareCom.java", + "ReaGhidraApplySeeds.java", "ReaGhidraNoReturnFix.java", ]; await mkdir(callerDirectory); diff --git a/scripts/verify/ghidra/seeds.mjs b/scripts/verify/ghidra/seeds.mjs new file mode 100644 index 000000000..b653fc2ec --- /dev/null +++ b/scripts/verify/ghidra/seeds.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + spawnOwnedProviderProcess, + ProviderProcessSupervisor, +} from "../../../dist/process/ProviderProcess.js"; +import { cleanupOwnedProcessGroup } from "../../../dist/process/ProcessOwnership.js"; +import { createVerifierRun } from "../../lib/verifier-run.mjs"; + +const installation = process.env.GHIDRA_INSTALL_DIR; +assert.ok( + installation && isAbsolute(installation), + "Set GHIDRA_INSTALL_DIR to a local Ghidra installation", +); +assert.notEqual( + process.platform, + "win32", + "This direct headless regression requires a POSIX host; Windows uses its controlled provider lane", +); +const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-seed-regression-")); +const run = createVerifierRun(); +let supervisor; +try { + const target = join(workspace, "seed-functions.bin"); + const seeds = join(workspace, "seeds.tsv"); + // Falling through from the lower entry reaches the higher entry. Creating + // the higher function first is what keeps the two function bodies separate. + await writeFile(target, Buffer.from([0x90, 0x90, 0xc3])); + await writeFile( + seeds, + [ + "0x0\tfunction\tseed_low", + "0x100000000\tfunction\tout_of_range", + "0x1\tfunction\tseed_high", + "0x1000\tlabel\tseed_global", + "0x1000\tcode", + "", + ].join("\n"), + ); + const launch = await spawnOwnedProviderProcess({ + command: join(installation, "support", "analyzeHeadless"), + arguments: [ + workspace, + "seed-regression", + "-import", + target, + "-loader", + "BinaryLoader", + "-processor", + "x86:LE:32:default", + "-noanalysis", + "-readOnly", + "-deleteProject", + "-scriptPath", + [ + fileURLToPath(new URL("../../../bridge/ghidra", import.meta.url)), + fileURLToPath( + new URL("../../../tests/conformance/ghidra", import.meta.url), + ), + ].join(";"), + "-postScript", + "ReaSeedRegressionProbe.java", + seeds, + ], + runId: run.run_id, + expectedCommand: null, + }); + supervisor = new ProviderProcessSupervisor({ + ...launch, + ownsProcessLifetime: true, + cleanup: () => cleanupOwnedProcessGroup(launch.ownership), + }); + assert.ok( + await supervisor.waitForExit(180_000), + "Ghidra seed regression exceeded its startup deadline", + ); + const snapshot = supervisor.snapshot(); + const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`; + assert.equal(snapshot.exitCode, 0, output); + // Headless can exit zero after a script fails, so require the probe's marker. + assert.ok(output.includes("REA_SEED_REGRESSION_OK "), output); +} finally { + if (supervisor !== undefined) { + const stopped = await supervisor.stop(); + assert.notEqual(stopped.status, "incomplete", JSON.stringify(stopped)); + supervisor.dispose(); + } + await rm(workspace, { recursive: true, force: true }); +} +console.log( + "Real Ghidra seed regression passed: descending function order, out-of-range seed, BSS label, BSS code rejection, and owned-process cleanup.", +); diff --git a/src/config/environment.ts b/src/config/environment.ts index 3e02f68a2..e1bc93f6d 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -4,6 +4,10 @@ import { isAbsolute } from "node:path"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js"; +import { + ghidraCompilerSpecIdSchema, + ghidraLanguageIdSchema, +} from "./ghidraLanguageOverride.js"; import { ghidraStartupTimeoutSchema } from "./ghidraStartupTimeout.js"; import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js"; import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js"; @@ -33,6 +37,13 @@ const environmentSchema = z.object({ .refine(isAbsolute, "REA_GHIDRA_NATIVEAOT_JAR must be absolute") .optional(), REA_GHIDRA_STARTUP_TIMEOUT_MS: ghidraStartupTimeoutSchema, + REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(), + REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(), + REA_GHIDRA_SEED_FILE: z + .string() + .min(1) + .refine(isAbsolute, "REA_GHIDRA_SEED_FILE must be absolute") + .optional(), REA_ILSPY_CMD_PATH: z .string() .min(1) diff --git a/src/config/ghidraLanguageOverride.ts b/src/config/ghidraLanguageOverride.ts new file mode 100644 index 000000000..6e1b3d1f7 --- /dev/null +++ b/src/config/ghidraLanguageOverride.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +/** + * Explicit Ghidra import language for targets whose header cannot identify the + * producing compiler, such as Borland-built PE images. + */ +export interface GhidraLanguageOverride { + /** Ghidra language ID: `processor:endian:size:variant`. */ + readonly languageId: string; + /** Ghidra compiler-spec ID; absent selects the language's default spec. */ + readonly compilerSpecId?: string; +} + +// Ghidra language IDs are four colon-separated fields; variants may contain +// spaces ("x86:LE:16:Real Mode") but never leading or trailing whitespace. +export const ghidraLanguageIdSchema = z + .string() + .regex( + /^[A-Za-z0-9_.+-]+:(?:LE|BE):[1-9]\d*:[A-Za-z0-9_.+-](?:[A-Za-z0-9_.+ -]*[A-Za-z0-9_.+-])?$/u, + "must be a Ghidra language ID such as x86:LE:32:default", + ); + +export const ghidraCompilerSpecIdSchema = z + .string() + .regex( + /^[A-Za-z0-9_.+-]+$/u, + "must be a Ghidra compiler-spec ID such as default or borlandcpp", + ); diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index da6de5be2..f4379bf22 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -2,7 +2,7 @@ import { accessSync, constants } from "node:fs"; import { join } from "node:path"; import { ConfigurationError } from "../domain/configurationErrors.js"; -import { ok, type Result } from "../domain/result.js"; +import { err, ok, type Result } from "../domain/result.js"; import { parseEnvironment } from "./environment.js"; import { homeDirectoryFromEnvironment } from "./homeDirectory.js"; import { parseStringArray, parseLoaderArgs } from "./parsers.js"; @@ -46,6 +46,21 @@ export const parseConfig = ( "REA_REFERENCE_SECRET_PATTERNS_JSON", ); if (!secretPatterns.ok) return secretPatterns; + // Ghidra's analyzeHeadless accepts -cspec only together with -processor. + if ( + env.REA_GHIDRA_COMPILER_SPEC_ID !== undefined && + env.REA_GHIDRA_LANGUAGE_ID === undefined + ) + return err( + new ConfigurationError("Invalid REA environment configuration", { + settings: [ + { + setting: "REA_GHIDRA_COMPILER_SPEC_ID", + constraint: "requires REA_GHIDRA_LANGUAGE_ID", + }, + ], + }), + ); return ok({ ...(env.REA_MCP_MAX_RESPONSE_BYTES === undefined ? {} @@ -60,6 +75,19 @@ export const parseConfig = ( ghidraInstallDir: env.GHIDRA_INSTALL_DIR, ghidraJavaHome: env.JAVA_HOME, ghidraStartupTimeoutMs: env.REA_GHIDRA_STARTUP_TIMEOUT_MS, + ...(env.REA_GHIDRA_LANGUAGE_ID === undefined + ? {} + : { + ghidraLanguageOverride: { + languageId: env.REA_GHIDRA_LANGUAGE_ID, + ...(env.REA_GHIDRA_COMPILER_SPEC_ID === undefined + ? {} + : { compilerSpecId: env.REA_GHIDRA_COMPILER_SPEC_ID }), + }, + }), + ...(env.REA_GHIDRA_SEED_FILE === undefined + ? {} + : { ghidraSeedFile: env.REA_GHIDRA_SEED_FILE }), ...(env.REA_GHIDRA_NATIVEAOT_JAR === undefined ? {} : { ghidraNativeAotJar: env.REA_GHIDRA_NATIVEAOT_JAR }), diff --git a/src/config/types.ts b/src/config/types.ts index 706db8e5c..3f5d06fd9 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,6 +2,7 @@ import type { LogLevel } from "../logger.js"; import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; import type { AnalysisProviderSelector } from "../contracts/providerSelection.js"; +import type { GhidraLanguageOverride } from "./ghidraLanguageOverride.js"; import type { McpInputSchemaProfile } from "./mcpInputSchemaProfile.js"; export interface AppConfig { @@ -12,6 +13,8 @@ export interface AppConfig { readonly ghidraInstallDir: string | undefined; readonly ghidraJavaHome: string | undefined; readonly ghidraStartupTimeoutMs: number; + readonly ghidraLanguageOverride?: GhidraLanguageOverride; + readonly ghidraSeedFile?: string; readonly ghidraNativeAotJar?: string; readonly ilspyCmdPath: string | undefined; readonly hopperLauncherPath: string; diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 36b7d32eb..ed0a2f734 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -3,6 +3,7 @@ import type { ProviderIdentity, } from "../application/AnalysisProvider.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, @@ -23,6 +24,7 @@ export const resolveGhidraAnalysisProfile = ( identity: ProviderIdentity, installation: GhidraInstallationInspection, signal?: AbortSignal, + languageOverride?: GhidraLanguageOverride, ): Promise> => { if (signal?.aborted === true) return Promise.resolve(err(new AnalysisCancelledError("open_binary"))); @@ -47,6 +49,17 @@ export const resolveGhidraAnalysisProfile = ( const dosMz = target.format === "dos-mz"; const dosCom = target.format === "dos-com"; const dos = dosMz || dosCom; + // DOS imports already force an admitted loader, language, and context. + if (dos && languageOverride !== undefined) + return Promise.resolve( + err( + new AnalysisUnsupportedTargetError( + "resolve_analysis_profile", + target.path, + "REA_GHIDRA_LANGUAGE_ID cannot override the admitted DOS real-mode import.", + ), + ), + ); return Promise.resolve( ok({ profile: createAnalysisProfile(provider, { @@ -89,8 +102,17 @@ export const resolveGhidraAnalysisProfile = ( : dosCom ? "BinaryLoader" : "auto-from-header", - language_id: dos ? "x86:LE:16:Real Mode" : "auto-from-header", - compiler_spec_id: dos ? "default" : "auto-default", + language_id: dos + ? "x86:LE:16:Real Mode" + : (languageOverride?.languageId ?? "auto-from-header"), + compiler_spec_id: dos + ? "default" + : languageOverride === undefined + ? "auto-default" + : (languageOverride.compilerSpecId ?? "language-default"), + ...(languageOverride === undefined + ? {} + : { language_selection: "configured-v1" }), ...(dos ? { load_segment: "0x1000", @@ -115,3 +137,25 @@ export const resolveGhidraAnalysisProfile = ( }), ); }; + +/** Read the import-language commitment back from a resolved profile. */ +export const ghidraProfileLanguageOverride = ( + parameters: Readonly>, +): GhidraLanguageOverride | undefined => { + if (parameters.language_selection !== "configured-v1") return undefined; + const languageId = parameters.language_id; + const compilerSpecId = parameters.compiler_spec_id; + if (typeof languageId !== "string" || typeof compilerSpecId !== "string") + return undefined; + return compilerSpecId === "language-default" + ? { languageId } + : { languageId, compilerSpecId }; +}; + +/** Whether a committed profile and the active configuration select the same language. */ +export const sameGhidraLanguageOverride = ( + left: GhidraLanguageOverride | undefined, + right: GhidraLanguageOverride | undefined, +): boolean => + left?.languageId === right?.languageId && + left?.compilerSpecId === right?.compilerSpecId; diff --git a/src/ghidra/GhidraAnalysisSeeds.test.ts b/src/ghidra/GhidraAnalysisSeeds.test.ts new file mode 100644 index 000000000..8af76a4da --- /dev/null +++ b/src/ghidra/GhidraAnalysisSeeds.test.ts @@ -0,0 +1,258 @@ +import { createHash } from "node:crypto"; +import { + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseConfig } from "../config/parseConfig.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { silentLogger } from "../logger.js"; +import { + ghidraSeedFailure, + ghidraSeedLimitations, + parseGhidraSeeds, + resolveGhidraAnalysisSeeds, + snapshotGhidraAnalysisSeeds, + type GhidraSeedCommitment, + type GhidraSeedReport, +} from "./GhidraAnalysisSeeds.js"; +import type { GhidraInstallationHost } from "./GhidraInstallation.js"; +import { GhidraProvider } from "./GhidraProvider.js"; + +const directories: string[] = []; +afterEach(async () => { + for (const directory of directories.splice(0)) + await rm(directory, { recursive: true, force: true }); +}); +const workspace = async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-ghidra-seeds-")); + directories.push(directory); + return directory; +}; + +const SEEDS = [ + "# comment and blank lines are ignored", + "", + "0x401000\tfunction\tentry", + "0x00401046\tfunction", + "0x40A1C4\tcode\r", + "0x402200\tlabel\tExample::render", + "", +].join("\n"); +const CANONICAL = + "0x401000\tfunction\tentry\n0x401046\tfunction\n0x40a1c4\tcode\n0x402200\tlabel\tExample::render\n"; + +describe("Ghidra seed parsing", () => { + it("accepts the documented line forms", () => { + const parsed = parseGhidraSeeds(SEEDS); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.map((entry) => entry.kind)).toEqual([ + "function", + "function", + "code", + "label", + ]); + }); + + it.each([ + ["401000\tfunction", "line 1: address must be 0x-prefixed hex"], + ["0x401000\tdata", "line 1: kind must be function, code or label"], + ["0x401000", "line 1: expected addresskind[name]"], + ["0x401000\tlabel", "line 1: a label seed requires a name"], + [ + "0x401000\tcode\tname", + "line 1: a code seed takes no name; add a label seed", + ], + [ + "0x401000\tfunction\ttwo words", + "line 1: name must be 1..2000 characters without whitespace", + ], + [ + "0x401000\tcode\n0x0401000\tcode", + "line 2: duplicate code seed at 0x0401000", + ], + ])("rejects %j", (text, constraint) => { + const parsed = parseGhidraSeeds(text); + if (parsed.ok) throw new Error("Expected a seed error"); + expect(parsed.error.settings).toEqual([ + { setting: "REA_GHIDRA_SEED_FILE", constraint }, + ]); + }); +}); + +describe("Ghidra seed commitment", () => { + it("digests the canonical form and snapshots exactly that", async () => { + const directory = await workspace(); + const path = join(directory, "seeds.tsv"); + await writeFile(path, SEEDS); + const resolved = await resolveGhidraAnalysisSeeds(path); + if (!resolved.ok) throw resolved.error; + const commitment = resolved.value; + if (commitment === undefined) throw new Error("Expected a commitment"); + expect(commitment).toMatchObject({ + format: "rea-ghidra-seeds-v1", + configured_path: path, + sha256: createHash("sha256").update(CANONICAL).digest("hex"), + entries: { function: 2, code: 1, label: 1 }, + }); + const runtime = await workspace(); + const snapshot = await snapshotGhidraAnalysisSeeds(commitment, runtime); + expect(await readFile(snapshot, "utf8")).toBe(CANONICAL); + }); + + it("is absent when no seed file is configured", async () => { + const resolved = await resolveGhidraAnalysisSeeds(undefined); + expect(resolved.ok && resolved.value).toBeUndefined(); + }); + + it("refuses a seed file that changed after the profile was committed", async () => { + const directory = await workspace(); + const path = join(directory, "seeds.tsv"); + await writeFile(path, SEEDS); + const resolved = await resolveGhidraAnalysisSeeds(path); + if (!resolved.ok || resolved.value === undefined) + throw new Error("Expected a commitment"); + await writeFile(path, `${SEEDS}0x401100\tcode\n`); + await expect( + snapshotGhidraAnalysisSeeds(resolved.value, await workspace()), + ).rejects.toThrow(/changed after the analysis profile was committed/u); + }); + + it("commits the resolved path behind a configured symbolic link", async () => { + const directory = await workspace(); + const path = join(directory, "seeds.tsv"); + await writeFile(path, SEEDS); + const link = join(directory, "link.tsv"); + await symlink(path, link); + const resolved = await resolveGhidraAnalysisSeeds(link); + if (!resolved.ok || resolved.value === undefined) + throw new Error("Expected a commitment"); + expect(resolved.value.path).toBe(await realpath(path)); + }); + + it("reports an unreadable seed file as a provider error", async () => { + const resolved = await resolveGhidraAnalysisSeeds( + join(await workspace(), "absent.tsv"), + ); + if (resolved.ok) throw new Error("Expected an error"); + expect(projectAnalysisError(resolved.error)).toMatchObject({ + details: { diagnostics: { setting: "REA_GHIDRA_SEED_FILE" } }, + }); + }); +}); + +describe("Ghidra seed session verification", () => { + const commitment: GhidraSeedCommitment = { + format: "rea-ghidra-seeds-v1", + path: "/seeds.tsv", + configured_path: "/seeds.tsv", + sha256: "b".repeat(64), + entries: { function: 2, code: 1, label: 1 }, + }; + const report: GhidraSeedReport = { + format: "rea-ghidra-seeds-v1", + sha256: "b".repeat(64), + entries: 4, + function_created: 1, + function_existing: 0, + function_failed: 1, + code_decoded: 1, + code_failed: 0, + label_applied: 1, + label_failed: 0, + unmapped: 0, + }; + + it("accepts exactly the committed seeds and reports their outcome", () => { + expect(ghidraSeedFailure(commitment, report, "xrefs")).toBeUndefined(); + expect(ghidraSeedFailure(undefined, undefined, "xrefs")).toBeUndefined(); + expect(ghidraSeedLimitations(commitment, report)).toEqual([ + expect.stringContaining("caller assertions, not Ghidra discoveries"), + expect.stringContaining( + "1 functions created, 0 already present, 1 failed", + ), + ]); + expect(ghidraSeedLimitations(undefined)).toEqual([]); + }); + + it.each([ + ["a missing report", commitment, undefined], + ["an uncommitted report", undefined, report], + ["a different digest", commitment, { ...report, sha256: "c".repeat(64) }], + ["a different count", commitment, { ...report, entries: 3 }], + ])("fails closed on %s", (_label, committed, reported) => { + expect(ghidraSeedFailure(committed, reported, "xrefs")).toBeDefined(); + }); +}); + +describe("Ghidra seed profile", () => { + const host: GhidraInstallationHost = { + platform: "linux", + architecture: "x64", + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.11", + major: 21, + home: "/jdk-21", + bits: 64, + runtime: "jdk", + }), + }; + const pe: BinaryTarget = { + path: "/fixture.exe", + sha256: "a".repeat(64), + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], + executableRole: "application", + managed: false, + }; + + it("commits the seed digest and counts into the analysis profile", async () => { + const path = join(await workspace(), "seeds.tsv"); + await writeFile(path, SEEDS); + const parsed = parseConfig({ + GHIDRA_INSTALL_DIR: "/ghidra", + REA_GHIDRA_SEED_FILE: path, + }); + if (!parsed.ok) throw parsed.error; + const ghidra = new GhidraProvider(parsed.value, silentLogger, {}, host); + const resolved = await ghidra.resolveAnalysisProfile(pe); + if (!resolved.ok) throw resolved.error; + expect(resolved.value.profile?.parameters.analysis_seeds).toMatchObject({ + format: "rea-ghidra-seeds-v1", + entries: { function: 2, code: 1, label: 1 }, + }); + }); + + it("refuses a malformed seed file before launching Ghidra", async () => { + const path = join(await workspace(), "seeds.tsv"); + await writeFile(path, "0x401000\tdata\n"); + const parsed = parseConfig({ + GHIDRA_INSTALL_DIR: "/ghidra", + REA_GHIDRA_SEED_FILE: path, + }); + if (!parsed.ok) throw parsed.error; + const ghidra = new GhidraProvider(parsed.value, silentLogger, {}, host); + const resolved = await ghidra.resolveAnalysisProfile(pe); + if (resolved.ok) throw new Error("Expected a configuration error"); + expect(projectAnalysisError(resolved.error)).toMatchObject({ + code: "configuration_invalid", + details: { settings: [{ setting: "REA_GHIDRA_SEED_FILE" }] }, + }); + }); + + it("requires an absolute seed path", () => { + const parsed = parseConfig({ REA_GHIDRA_SEED_FILE: "seeds.tsv" }); + expect(parsed.ok).toBe(false); + }); +}); diff --git a/src/ghidra/GhidraAnalysisSeeds.ts b/src/ghidra/GhidraAnalysisSeeds.ts new file mode 100644 index 000000000..7b5ca46d5 --- /dev/null +++ b/src/ghidra/GhidraAnalysisSeeds.ts @@ -0,0 +1,286 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, open, realpath, writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { z } from "zod"; + +import type { AnalysisOperation } from "../application/AnalysisProvider.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { err, ok, type Result } from "../domain/result.js"; + +/** Seed file format admitted by this adapter. */ +export const GHIDRA_SEED_FORMAT = "rea-ghidra-seeds-v1"; +export const GHIDRA_SEED_SCRIPT = "ReaGhidraApplySeeds.java"; +export const MAX_GHIDRA_SEED_BYTES = 32 * 1024 * 1024; +export const MAX_GHIDRA_SEED_ENTRIES = 1_000_000; +const MAX_SEED_NAME_LENGTH = 2000; + +const digest = z.string().regex(/^[a-f0-9]{64}$/u); +const count = z.number().int().nonnegative(); + +/** Caller-declared seeds committed into the analysis profile before import. */ +export const ghidraSeedCommitmentSchema = z.strictObject({ + format: z.literal(GHIDRA_SEED_FORMAT), + path: z.string().refine(isAbsolute, "Seed path must be absolute"), + configured_path: z.string().refine(isAbsolute, "Seed path must be absolute"), + sha256: digest, + entries: z.strictObject({ function: count, code: count, label: count }), +}); +export type GhidraSeedCommitment = z.infer; + +/** What the pre-analysis script applied, as reported by the bridge handshake. */ +export const ghidraSeedReportSchema = z.strictObject({ + format: z.literal(GHIDRA_SEED_FORMAT), + sha256: digest, + entries: count, + function_created: count, + function_existing: count, + function_failed: count, + code_decoded: count, + code_failed: count, + label_applied: count, + label_failed: count, + unmapped: count, +}); +export type GhidraSeedReport = z.infer; + +type SeedKind = "function" | "code" | "label"; +interface SeedEntry { + readonly address: bigint; + readonly kind: SeedKind; + readonly name?: string; +} + +const seedLineError = (line: number, constraint: string) => + new ConfigurationError("Invalid REA environment configuration", { + settings: [ + { + setting: "REA_GHIDRA_SEED_FILE", + constraint: `line ${line}: ${constraint}`, + }, + ], + }); + +/** + * Parse `addresskind[name]` lines. Blank lines and `#` comments are + * ignored. Addresses are hexadecimal offsets in the default address space. + */ +export const parseGhidraSeeds = ( + text: string, +): Result => { + const entries: SeedEntry[] = []; + const seen = new Set(); + const lines = text.split("\n"); + for (const [index, raw] of lines.entries()) { + const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; + if (line.trim() === "" || line.startsWith("#")) continue; + const number = index + 1; + const fields = line.split("\t"); + if (fields.length < 2 || fields.length > 3) + return err(seedLineError(number, "expected addresskind[name]")); + const [address, kind, name] = fields; + if (address === undefined || !/^0x[0-9a-fA-F]{1,16}$/u.test(address)) + return err(seedLineError(number, "address must be 0x-prefixed hex")); + if (kind !== "function" && kind !== "code" && kind !== "label") + return err(seedLineError(number, "kind must be function, code or label")); + if ( + name !== undefined && + (name.length === 0 || + name.length > MAX_SEED_NAME_LENGTH || + /[\s\0]/u.test(name)) + ) + return err( + seedLineError( + number, + `name must be 1..${MAX_SEED_NAME_LENGTH} characters without whitespace`, + ), + ); + if (kind === "label" && name === undefined) + return err(seedLineError(number, "a label seed requires a name")); + if (kind === "code" && name !== undefined) + return err( + seedLineError(number, "a code seed takes no name; add a label seed"), + ); + const value = BigInt(address); + const key = `${value.toString(16)}:${kind}`; + if (seen.has(key)) + return err(seedLineError(number, `duplicate ${kind} seed at ${address}`)); + seen.add(key); + entries.push({ + address: value, + kind, + ...(name === undefined ? {} : { name }), + }); + if (entries.length > MAX_GHIDRA_SEED_ENTRIES) + return err( + seedLineError( + number, + `more than ${MAX_GHIDRA_SEED_ENTRIES} seed entries`, + ), + ); + } + return ok(entries); +}; + +/** Canonical script input: lowercase addresses, fixed field order, LF endings. */ +const normalizedSeeds = (entries: readonly SeedEntry[]): string => + entries + .map( + (entry) => + `0x${entry.address.toString(16)}\t${entry.kind}${entry.name === undefined ? "" : `\t${entry.name}`}\n`, + ) + .join(""); + +/** Read a bounded regular file through one handle without following links. */ +const readSeedFile = async (path: string): Promise => { + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.size > MAX_GHIDRA_SEED_BYTES) + throw new Error( + `Ghidra seed file must be a regular file of at most ${MAX_GHIDRA_SEED_BYTES} bytes: ${path}`, + ); + const bytes = Buffer.alloc(stat.size); + let offset = 0; + while (offset < bytes.length) { + const read = await file.read( + bytes, + offset, + bytes.length - offset, + offset, + ); + if (read.bytesRead === 0) + throw new Error(`Incomplete Ghidra seed file read: ${path}`); + offset += read.bytesRead; + } + return bytes; + } finally { + await file.close(); + } +}; + +const sha256 = (bytes: Buffer | string): string => + createHash("sha256").update(bytes).digest("hex"); + +const decodeSeeds = (bytes: Buffer) => { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return parseGhidraSeeds(text); +}; + +/** Resolve the configured seed file into an immutable profile commitment. */ +export const resolveGhidraAnalysisSeeds = async ( + configured: string | undefined, +): Promise< + Result< + GhidraSeedCommitment | undefined, + ConfigurationError | ProviderAdapterError + > +> => { + if (configured === undefined) return ok(undefined); + let bytes: Buffer; + let path: string; + try { + path = await realpath(configured); + bytes = await readSeedFile(path); + } catch (cause: unknown) { + return err( + new ProviderAdapterError("ghidra", "resolve_analysis_profile", { + cause, + diagnostics: { + setting: "REA_GHIDRA_SEED_FILE", + path: configured, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }), + ); + } + let parsed: ReturnType; + try { + parsed = decodeSeeds(bytes); + } catch { + return err(seedLineError(0, "file is not valid UTF-8")); + } + if (!parsed.ok) return parsed; + const normalized = normalizedSeeds(parsed.value); + const tally = { function: 0, code: 0, label: 0 }; + for (const entry of parsed.value) tally[entry.kind] += 1; + return ok({ + format: GHIDRA_SEED_FORMAT, + path, + configured_path: configured, + sha256: sha256(normalized), + entries: tally, + }); +}; + +/** Re-read the committed seeds and write the canonical copy the script applies. */ +export const snapshotGhidraAnalysisSeeds = async ( + commitment: GhidraSeedCommitment, + runtimeRoot: string, +): Promise => { + ghidraSeedCommitmentSchema.parse(commitment); + if ((await realpath(commitment.configured_path)) !== commitment.path) + throw new Error( + "Ghidra seed file resolved path differs from explicit configuration", + ); + const parsed = decodeSeeds(await readSeedFile(commitment.path)); + if (!parsed.ok) throw parsed.error; + const normalized = normalizedSeeds(parsed.value); + if (sha256(normalized) !== commitment.sha256) + throw new Error( + `Ghidra seed file changed after the analysis profile was committed: ${commitment.path}`, + ); + const directory = join(runtimeRoot, "seeds"); + await mkdir(directory, { mode: 0o700 }); + const path = join(directory, "seeds.tsv"); + await writeFile(path, normalized, { flag: "wx", mode: 0o600 }); + return path; +}; + +const totalEntries = (commitment: GhidraSeedCommitment): number => + commitment.entries.function + + commitment.entries.code + + commitment.entries.label; + +/** Fail closed when the session did not apply exactly the committed seeds. */ +export const ghidraSeedFailure = ( + commitment: GhidraSeedCommitment | undefined, + report: GhidraSeedReport | undefined, + operation: AnalysisOperation, +): ProviderAdapterError | undefined => { + if (commitment === undefined && report === undefined) return undefined; + const reason = + commitment === undefined + ? "The Ghidra session reported analysis seeds that the profile did not commit." + : report === undefined + ? "The Ghidra session did not report the committed analysis seeds." + : report.sha256 !== commitment.sha256 || + report.entries !== totalEntries(commitment) + ? "The Ghidra session applied analysis seeds that differ from the committed profile." + : undefined; + return reason === undefined + ? undefined + : new ProviderAdapterError("ghidra", operation, { + diagnostics: { reason }, + }); +}; + +/** Session limitations that keep seeded facts distinct from Ghidra discoveries. */ +export const ghidraSeedLimitations = ( + commitment: GhidraSeedCommitment | undefined, + report?: GhidraSeedReport, +): readonly string[] => + commitment === undefined + ? [] + : [ + `Ghidra analysis was seeded before auto-analysis from ${commitment.path} (sha256 ${commitment.sha256}: ${commitment.entries.function} function, ${commitment.entries.code} code, ${commitment.entries.label} label entries). Seeded functions, code and names are caller assertions, not Ghidra discoveries.`, + ...(report === undefined + ? [] + : [ + `Seed outcome: ${report.function_created} functions created, ${report.function_existing} already present, ${report.function_failed} failed; ${report.code_decoded} code seeds decoded, ${report.code_failed} failed; ${report.label_applied} labels applied, ${report.label_failed} failed; ${report.unmapped} seeds outside initialized memory.`, + ]), + ]; diff --git a/src/ghidra/GhidraLanguageCatalog.ts b/src/ghidra/GhidraLanguageCatalog.ts new file mode 100644 index 000000000..5da5943dc --- /dev/null +++ b/src/ghidra/GhidraLanguageCatalog.ts @@ -0,0 +1,118 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DOMParser, onWarningStopParsing } from "@xmldom/xmldom"; + +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; + +/** One language definition and the compiler specs it declares. */ +export interface GhidraLanguageDefinition { + readonly id: string; + readonly compilerSpecIds: readonly string[]; +} + +// Language definitions are small XML files; refuse anything implausibly large. +const MAX_LDEFS_BYTES = 4 * 1024 * 1024; +// Processor modules ship under Processors; installed extensions may add more. +const LANGUAGE_ROOTS = ["Ghidra/Processors", "Ghidra/Extensions"] as const; + +/** Parse the `` elements of one `.ldefs` document. */ +export const parseGhidraLanguageDefinitions = ( + text: string, +): readonly GhidraLanguageDefinition[] => { + const definitions: GhidraLanguageDefinition[] = []; + const document = new DOMParser({ + onError: onWarningStopParsing, + }).parseFromString(text, "application/xml"); + const root = document.documentElement; + if (root?.tagName !== "language_definitions") + throw new SyntaxError("Expected Ghidra language_definitions XML root"); + for (const language of Array.from(root.getElementsByTagName("language"))) { + if (language.parentNode !== root) continue; + const id = language.getAttribute("id"); + if (id === null) continue; + const compilerSpecIds: string[] = []; + for (const compiler of Array.from( + language.getElementsByTagName("compiler"), + )) { + if (compiler.parentNode !== language) continue; + const compilerId = compiler.getAttribute("id"); + if (compilerId !== null) compilerSpecIds.push(compilerId); + } + definitions.push({ id, compilerSpecIds }); + } + return definitions; +}; + +const languageDirectories = async (installDir: string): Promise => { + const directories: string[] = []; + for (const root of LANGUAGE_ROOTS) { + let modules: string[]; + try { + modules = await readdir(join(installDir, root)); + } catch { + continue; + } + for (const module of modules.sort()) + directories.push(join(installDir, root, module, "data", "languages")); + } + return directories; +}; + +/** Read every language definition shipped by one Ghidra installation. */ +export const readGhidraLanguageCatalog = async ( + installDir: string, +): Promise => { + const definitions: GhidraLanguageDefinition[] = []; + for (const directory of await languageDirectories(installDir)) { + let entries: string[]; + try { + entries = await readdir(directory); + } catch { + continue; + } + for (const entry of entries.sort()) { + if (!entry.endsWith(".ldefs")) continue; + const bytes = await readFile(join(directory, entry)); + if (bytes.length > MAX_LDEFS_BYTES) continue; + definitions.push( + ...parseGhidraLanguageDefinitions(bytes.toString("utf8")), + ); + } + } + return definitions; +}; + +/** + * Check a configured import language against the installation before launch, + * so an unknown ID fails as configuration instead of as a failed import. + */ +export const admitGhidraLanguageOverride = ( + override: GhidraLanguageOverride, + catalog: readonly GhidraLanguageDefinition[], + providerVersion: string, +): ConfigurationError | null => { + const language = catalog.find((value) => value.id === override.languageId); + if (language === undefined) + return new ConfigurationError("Invalid REA environment configuration", { + settings: [ + { + setting: "REA_GHIDRA_LANGUAGE_ID", + constraint: `is not a language defined by Ghidra ${providerVersion}`, + }, + ], + }); + if ( + override.compilerSpecId !== undefined && + !language.compilerSpecIds.includes(override.compilerSpecId) + ) + return new ConfigurationError("Invalid REA environment configuration", { + settings: [ + { + setting: "REA_GHIDRA_COMPILER_SPEC_ID", + constraint: `must be one of the selected language's compiler specs: ${language.compilerSpecIds.join(", ")}`, + }, + ], + }); + return null; +}; diff --git a/src/ghidra/GhidraLanguageOverride.test.ts b/src/ghidra/GhidraLanguageOverride.test.ts new file mode 100644 index 000000000..f29294d4f --- /dev/null +++ b/src/ghidra/GhidraLanguageOverride.test.ts @@ -0,0 +1,320 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { parseConfig } from "../config/parseConfig.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { silentLogger } from "../logger.js"; +import { + ghidraProfileLanguageOverride, + sameGhidraLanguageOverride, +} from "./GhidraAnalysisProfile.js"; +import type { GhidraInstallationHost } from "./GhidraInstallation.js"; +import { + admitGhidraLanguageOverride, + parseGhidraLanguageDefinitions, + readGhidraLanguageCatalog, +} from "./GhidraLanguageCatalog.js"; +import { GhidraProvider } from "./GhidraProvider.js"; + +// Abbreviated from Ghidra 12.1.4 Processors/x86/data/languages/x86.ldefs. +const X86_LDEFS = ` + + + Intel/AMD 32-bit x86 + + + + + + + + +`; + +// Fake installation inspection; the language catalog is a real directory. +const host: GhidraInstallationHost = { + platform: "linux", + architecture: "x64", + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.11", + major: 21, + home: "/jdk-21", + bits: 64, + runtime: "jdk", + }), +}; + +const pe: BinaryTarget = { + path: "/fixture.exe", + sha256: "a".repeat(64), + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], + executableRole: "application", + managed: false, +}; + +let installDir = ""; +beforeAll(async () => { + installDir = await mkdtemp(join(tmpdir(), "rea-ghidra-languages-")); + const languages = join(installDir, "Ghidra/Processors/x86/data/languages"); + await mkdir(languages, { recursive: true }); + await writeFile(join(languages, "x86.ldefs"), X86_LDEFS); +}); +afterAll(async () => { + await rm(installDir, { recursive: true, force: true }); +}); + +const provider = (environment: Record) => { + const parsed = parseConfig({ + GHIDRA_INSTALL_DIR: installDir, + ...environment, + }); + if (!parsed.ok) throw parsed.error; + return new GhidraProvider(parsed.value, silentLogger, {}, host); +}; + +describe("Ghidra language override configuration", () => { + it("accepts a language with an optional compiler spec", () => { + const parsed = parseConfig({ + REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default", + REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp", + }); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.ghidraLanguageOverride).toEqual({ + languageId: "x86:LE:32:default", + compilerSpecId: "borlandcpp", + }); + const languageOnly = parseConfig({ + REA_GHIDRA_LANGUAGE_ID: "x86:LE:16:Real Mode", + }); + if (!languageOnly.ok) throw languageOnly.error; + expect(languageOnly.value.ghidraLanguageOverride).toEqual({ + languageId: "x86:LE:16:Real Mode", + }); + }); + + it("requires a language for a compiler spec, as analyzeHeadless does", () => { + const parsed = parseConfig({ REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" }); + if (parsed.ok) throw new Error("Expected a configuration error"); + expect(projectAnalysisError(parsed.error)).toMatchObject({ + code: "configuration_invalid", + details: { + settings: [ + { + setting: "REA_GHIDRA_COMPILER_SPEC_ID", + constraint: "requires REA_GHIDRA_LANGUAGE_ID", + }, + ], + }, + }); + }); + + it.each([ + ["REA_GHIDRA_LANGUAGE_ID", "x86 LE 32"], + ["REA_GHIDRA_LANGUAGE_ID", "x86:LE:32: default"], + ["REA_GHIDRA_LANGUAGE_ID", "x86:XE:32:default"], + ["REA_GHIDRA_COMPILER_SPEC_ID", "borland cpp"], + ])("rejects malformed %s %j without echoing it", (setting, value) => { + const parsed = parseConfig({ + REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default", + [setting]: value, + }); + if (parsed.ok) throw new Error("Expected a configuration error"); + const projected = projectAnalysisError(parsed.error); + expect(projected).toMatchObject({ + code: "configuration_invalid", + details: { settings: [{ setting, constraint: expect.any(String) }] }, + }); + expect(JSON.stringify(projected)).not.toContain(value); + }); +}); + +describe("Ghidra language catalog", () => { + it("reads quoted and escaped XML attributes without admitting comments or CDATA", () => { + const catalog = parseGhidraLanguageDefinitions(` + + + ]]> + + + + `); + expect(catalog).toEqual([ + { id: "x86:LE:32:default", compilerSpecIds: ["borlandcpp"] }, + ]); + expect( + admitGhidraLanguageOverride( + { languageId: "x86:LE:32:default", compilerSpecId: "removed" }, + catalog, + "12.1.4", + ), + ).not.toBeNull(); + }); + + it.each([ + '', + "", + '', + ])("rejects malformed language-definition documents", (text) => { + expect(() => parseGhidraLanguageDefinitions(text)).toThrow(); + }); + + it("parses language IDs and their compiler specs", async () => { + expect(parseGhidraLanguageDefinitions(X86_LDEFS)).toEqual([ + { + id: "x86:LE:32:default", + compilerSpecIds: ["windows", "gcc", "borlandcpp"], + }, + { id: "x86:LE:16:Real Mode", compilerSpecIds: ["default"] }, + ]); + expect(await readGhidraLanguageCatalog(installDir)).toHaveLength(2); + expect(await readGhidraLanguageCatalog(join(installDir, "absent"))).toEqual( + [], + ); + }); + + it("admits only languages and compiler specs the installation defines", () => { + const catalog = parseGhidraLanguageDefinitions(X86_LDEFS); + expect( + admitGhidraLanguageOverride( + { languageId: "x86:LE:32:default", compilerSpecId: "borlandcpp" }, + catalog, + "12.1.4", + ), + ).toBeNull(); + const language = admitGhidraLanguageOverride( + { languageId: "x86:LE:31:default" }, + catalog, + "12.1.4", + ); + expect(language?.settings).toEqual([ + { + setting: "REA_GHIDRA_LANGUAGE_ID", + constraint: "is not a language defined by Ghidra 12.1.4", + }, + ]); + const spec = admitGhidraLanguageOverride( + { languageId: "x86:LE:32:default", compilerSpecId: "golang" }, + catalog, + "12.1.4", + ); + expect(spec?.settings).toEqual([ + { + setting: "REA_GHIDRA_COMPILER_SPEC_ID", + constraint: + "must be one of the selected language's compiler specs: windows, gcc, borlandcpp", + }, + ]); + }); +}); + +describe("Ghidra language override profile", () => { + it("returns a provider failure for a malformed installed language definition", async () => { + const path = join( + installDir, + "Ghidra/Processors/x86/data/languages/broken.ldefs", + ); + await writeFile( + path, + "", + ); + try { + const resolved = await provider({ + REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default", + }).resolveAnalysisProfile(pe); + if (resolved.ok) throw new Error("Expected a language catalog failure"); + expect(resolved.error).toMatchObject({ + providerId: "ghidra", + operation: "resolve_analysis_profile", + }); + } finally { + await rm(path); + } + }); + + it("commits the configured language and compiler spec", async () => { + const resolved = await provider({ + REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default", + REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp", + }).resolveAnalysisProfile(pe); + if (!resolved.ok) throw resolved.error; + const parameters = resolved.value.profile?.parameters ?? {}; + expect(parameters).toMatchObject({ + loader: "auto-from-header", + language_id: "x86:LE:32:default", + compiler_spec_id: "borlandcpp", + language_selection: "configured-v1", + }); + expect(ghidraProfileLanguageOverride(parameters)).toEqual({ + languageId: "x86:LE:32:default", + compilerSpecId: "borlandcpp", + }); + }); + + it("records the language default when no compiler spec is configured", async () => { + const resolved = await provider({ + REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default", + }).resolveAnalysisProfile(pe); + if (!resolved.ok) throw resolved.error; + const parameters = resolved.value.profile?.parameters ?? {}; + expect(parameters.compiler_spec_id).toBe("language-default"); + expect(ghidraProfileLanguageOverride(parameters)).toEqual({ + languageId: "x86:LE:32:default", + }); + }); + + it("keeps header auto-detection and a distinct digest without an override", async () => { + const automatic = await provider({}).resolveAnalysisProfile(pe); + const configured = await provider({ + REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default", + REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp", + }).resolveAnalysisProfile(pe); + if (!automatic.ok) throw automatic.error; + if (!configured.ok) throw configured.error; + const parameters = automatic.value.profile?.parameters ?? {}; + expect(parameters).toMatchObject({ + language_id: "auto-from-header", + compiler_spec_id: "auto-default", + }); + expect(parameters).not.toHaveProperty("language_selection"); + expect(ghidraProfileLanguageOverride(parameters)).toBeUndefined(); + expect(automatic.value.profile?.digest).not.toBe( + configured.value.profile?.digest, + ); + }); + + it("refuses an undefined compiler spec before launching Ghidra", async () => { + const resolved = await provider({ + REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default", + REA_GHIDRA_COMPILER_SPEC_ID: "golang", + }).resolveAnalysisProfile(pe); + if (resolved.ok) throw new Error("Expected a configuration error"); + expect(projectAnalysisError(resolved.error)).toMatchObject({ + code: "configuration_invalid", + details: { + settings: [{ setting: "REA_GHIDRA_COMPILER_SPEC_ID" }], + }, + }); + }); + + it("compares committed and configured selections exactly", () => { + expect(sameGhidraLanguageOverride(undefined, undefined)).toBe(true); + expect( + sameGhidraLanguageOverride( + { languageId: "x86:LE:32:default" }, + { languageId: "x86:LE:32:default", compilerSpecId: "borlandcpp" }, + ), + ).toBe(false); + }); +}); diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index d6baacca1..9488bee2e 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -1,3 +1,9 @@ +import { + GHIDRA_SEED_SCRIPT, + snapshotGhidraAnalysisSeeds, + type GhidraSeedCommitment, +} from "./GhidraAnalysisSeeds.js"; +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { mkdir } from "node:fs/promises"; import { basename, dirname, join, posix, win32 } from "node:path"; @@ -85,6 +91,10 @@ export interface GhidraHeadlessLauncherOptions { /** Select the admitted 16-bit real-mode MZ import instead of auto-detection. */ readonly dosMz?: true; readonly dosCom?: true; + /** Explicit import language committed by the analysis profile. */ + readonly languageOverride?: GhidraLanguageOverride; + /** Caller seeds committed by the analysis profile, applied before analysis. */ + readonly analysisSeeds?: GhidraSeedCommitment; readonly analysisExtensions?: readonly GhidraExtension[]; /** Spawn seam for provider-boundary lifecycle tests. */ readonly spawnProcess?: typeof spawnOwnedProviderProcess; @@ -111,6 +121,13 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { let started: SpawnedOwnedProviderProcess | undefined; try { await createGhidraRuntimeDirectories(paths, platform); + const seedPath = + this.options.analysisSeeds === undefined + ? undefined + : await snapshotGhidraAnalysisSeeds( + this.options.analysisSeeds, + session.runtimeRoot, + ); const extensions = await snapshotGhidraExtensions( this.options.analysisExtensions ?? [], session.runtimeRoot, @@ -147,6 +164,10 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { ...(this.options.dosMz === undefined ? {} : { dosMz: this.options.dosMz }), + ...(this.options.languageOverride === undefined + ? {} + : { languageOverride: this.options.languageOverride }), + ...(seedPath === undefined ? {} : { seedPath }), }); const scriptCommand = ghidraHeadlessCommand({ environment: this.options.environment, @@ -368,6 +389,9 @@ export interface GhidraHeadlessArgumentOptions { readonly scriptLogPath: string; readonly dosMz?: true; readonly dosCom?: true; + readonly languageOverride?: GhidraLanguageOverride; + /** Canonical seed snapshot passed to the pre-analysis seed script. */ + readonly seedPath?: string; } /** Build the complete read-only headless invocation in deterministic order. */ @@ -379,6 +403,14 @@ export const ghidraHeadlessArguments = ( const scriptPath = (options.platform ?? process.platform) === "win32" ? win32 : posix; const bridgeDirectory = scriptPath.dirname(options.bridgeScriptPath); + const override = options.languageOverride; + if ( + override !== undefined && + (options.dosMz === true || options.dosCom === true) + ) + throw new Error( + "A Ghidra language override cannot be combined with a DOS import.", + ); return [ options.projectRoot, "rea-project", @@ -404,7 +436,15 @@ export const ghidraHeadlessArguments = ( "-cspec", "default", ] - : []), + : override !== undefined + ? [ + "-processor", + override.languageId, + ...(override.compilerSpecId === undefined + ? [] + : ["-cspec", override.compilerSpecId]), + ] + : []), "-readOnly", "-deleteProject", "-log", @@ -419,6 +459,14 @@ export const ghidraHeadlessArguments = ( scriptPath.join(bridgeDirectory, "ReaGhidraPrepareCom.java"), ] : []), + // Seeds follow any loader preparation and precede default auto-analysis. + ...(options.seedPath === undefined + ? [] + : [ + "-preScript", + scriptPath.join(bridgeDirectory, GHIDRA_SEED_SCRIPT), + options.seedPath, + ]), ...((options.platform ?? process.platform) === "win32" ? [] : [ diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index fd0a3f5f0..7686f837e 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -1,3 +1,4 @@ +import type { GhidraSeedReport } from "./GhidraAnalysisSeeds.js"; import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; // Fake-backed provider coverage; real Ghidra verification lives in @@ -1010,3 +1011,154 @@ it("projects Ghidra startup failure and its incomplete cleanup together", async }, }); }); + +describe("Ghidra analysis seeds and configured language", () => { + const seedSession = async ( + report: (sha256: string) => GhidraSeedReport | undefined, + ) => { + const directory = await mkdtemp(join(tmpdir(), "rea-provider-seeds-")); + const seeds = join(directory, "seeds.tsv"); + await writeFile(seeds, "0x401000\tfunction\tentry\n0x401010\tcode\n"); + const config = parseConfig({ + GHIDRA_INSTALL_DIR: INSTALL, + REA_GHIDRA_SEED_FILE: seeds, + }); + if (!config.ok) throw config.error; + const counts = { starts: 0, calls: 0, closes: 0 }; + const ghidra = new GhidraProvider( + config.value, + silentLogger, + {}, + installationHost(), + () => ({ + start: () => { + counts.starts++; + const seedReport = report( + createHash("sha256") + .update("0x401000\tfunction\tentry\n0x401010\tcode\n") + .digest("hex"), + ); + return Promise.resolve( + ok({ + ...sessionInfo(), + ...(seedReport === undefined + ? {} + : { analysis_seeds: seedReport }), + }), + ); + }, + callTool: () => { + counts.calls++; + return Promise.resolve(ok(null)); + }, + close: () => { + counts.closes++; + return Promise.resolve(ok(null)); + }, + }), + ); + const target = executableTarget("elf", "x86_64"); + const resolved = await ghidra.resolveAnalysisProfile(target); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected seed profile"); + return { + directory, + counts, + client: ghidra.createClient(target, resolved.value.profile), + }; + }; + const applied = (sha256: string): GhidraSeedReport => ({ + format: "rea-ghidra-seeds-v1", + sha256, + entries: 2, + function_created: 1, + function_existing: 0, + function_failed: 0, + code_decoded: 0, + code_failed: 1, + label_applied: 0, + label_failed: 0, + unmapped: 0, + }); + + it("reports the verified seed outcome as a session limitation", async () => { + const { directory, client } = await seedSession(applied); + try { + const health = await client.execute("health", {}); + if (!health.ok) throw health.error; + expect(health.value.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("caller assertions, not Ghidra discoveries"), + expect.stringContaining( + "1 functions created, 0 already present, 0 failed; 0 code seeds decoded, 1 failed", + ), + ]), + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it.each([ + ["no seed report", () => undefined], + [ + "a report for other seeds", + (sha256: string): GhidraSeedReport => ({ + ...applied(sha256), + sha256: "0".repeat(64), + }), + ], + ])("fails closed on %s before any analysis call", async (_label, report) => { + const { directory, counts, client } = await seedSession(report); + try { + const failed = await client.execute("list_procedures", {}); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError"); + expect(counts).toEqual({ starts: 1, calls: 0, closes: 1 }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("refuses a profile resolved without the configured seeds or language", async () => { + const plain = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL }); + if (!plain.ok) throw plain.error; + const target = executableTarget("elf", "x86_64"); + const resolved = await new GhidraProvider( + plain.value, + silentLogger, + {}, + installationHost(), + ).resolveAnalysisProfile(target); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected profile"); + for (const environment of [ + { REA_GHIDRA_SEED_FILE: "/seeds.tsv" }, + { REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default" }, + ]) { + const configured = parseConfig({ + GHIDRA_INSTALL_DIR: INSTALL, + ...environment, + }); + if (!configured.ok) throw configured.error; + let starts = 0; + const client = new GhidraProvider( + configured.value, + silentLogger, + {}, + installationHost(), + () => ({ + start: () => { + starts++; + return Promise.resolve(ok(sessionInfo())); + }, + callTool: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(ok(null)), + }), + ).createClient(target, resolved.value.profile); + const failed = await client.execute("health", {}); + expect(failed.ok).toBe(false); + expect(starts).toBe(0); + } + }); +}); diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index e8958b172..58501bc2a 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -20,7 +20,8 @@ import { jsonValueSchema, type JsonValue, } from "../domain/jsonValue.js"; -import { ok } from "../domain/result.js"; +import { err, ok } from "../domain/result.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { Logger } from "pino"; import { GhidraClient } from "./GhidraClient.js"; import { @@ -30,6 +31,12 @@ import { type GhidraInstallationInspection, } from "./GhidraInstallation.js"; import { resolveGhidraAnalysisProfile } from "./GhidraAnalysisProfile.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { resolveGhidraAnalysisSeeds } from "./GhidraAnalysisSeeds.js"; +import { + admitGhidraLanguageOverride, + readGhidraLanguageCatalog, +} from "./GhidraLanguageCatalog.js"; import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js"; import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js"; import { @@ -184,8 +191,32 @@ export class GhidraProvider implements AnalysisProviderCandidate { GHIDRA_PROVIDER_IDENTITY, installation, options?.signal, + this.config.ghidraLanguageOverride, ); if (!resolved.ok || resolved.value.profile === null) return resolved; + const override = this.config.ghidraLanguageOverride; + if (override !== undefined && installation.status === "available") { + let catalog: Awaited>; + try { + catalog = await readGhidraLanguageCatalog(installation.installDir); + } catch (cause: unknown) { + return err( + new ProviderAdapterError("ghidra", "resolve_analysis_profile", { + cause, + diagnostics: { + reason: + "Could not read the installed Ghidra language definitions.", + }, + }), + ); + } + const refused = admitGhidraLanguageOverride( + override, + catalog, + installation.providerVersion, + ); + if (refused !== null) return err(refused); + } const extensions = await resolveGhidraExtensions( this.config, target, @@ -193,12 +224,22 @@ export class GhidraProvider implements AnalysisProviderCandidate { options?.signal, ); if (!extensions.ok) return extensions; - if (extensions.value.length === 0) return resolved; + if (options?.signal?.aborted === true) + return err(new AnalysisCancelledError("open_binary")); + const seeds = await resolveGhidraAnalysisSeeds(this.config.ghidraSeedFile); + if (!seeds.ok) return seeds; + if (extensions.value.length === 0 && seeds.value === undefined) + return resolved; return ok({ ...resolved.value, profile: createAnalysisProfile(resolved.value.profile.provider, { ...resolved.value.profile.parameters, - analysis_extensions: jsonValueSchema.parse(extensions.value), + ...(extensions.value.length === 0 + ? {} + : { analysis_extensions: jsonValueSchema.parse(extensions.value) }), + ...(seeds.value === undefined + ? {} + : { analysis_seeds: jsonValueSchema.parse(seeds.value) }), }), }); } diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index f659eb12c..49f66a10d 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -9,6 +9,15 @@ import { } from "../application/AnalysisProvider.js"; import type { AppConfig } from "../config/types.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { + ghidraSeedCommitmentSchema, + ghidraSeedFailure, + ghidraSeedLimitations, +} from "./GhidraAnalysisSeeds.js"; +import { + ghidraProfileLanguageOverride, + sameGhidraLanguageOverride, +} from "./GhidraAnalysisProfile.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { @@ -105,6 +114,36 @@ export const createGhidraProviderClient = (input: { }), ); const extensions = extensionProfile.data; + const seedProfile = ghidraSeedCommitmentSchema + .optional() + .safeParse(committedProfile.parameters.analysis_seeds); + if ( + !seedProfile.success || + (config.ghidraSeedFile === undefined) !== (seedProfile.data === undefined) + ) + return unavailableClient( + new ProviderAdapterError("ghidra", "open_binary", { + diagnostics: { + reason: + "Resolve REA_GHIDRA_SEED_FILE into an analysis profile before opening the session.", + }, + }), + ); + const seeds = seedProfile.data; + const languageOverride = ghidraProfileLanguageOverride( + committedProfile.parameters, + ); + if ( + !sameGhidraLanguageOverride(languageOverride, config.ghidraLanguageOverride) + ) + return unavailableClient( + new ProviderAdapterError("ghidra", "open_binary", { + diagnostics: { + reason: + "The committed analysis profile and REA_GHIDRA_LANGUAGE_ID/REA_GHIDRA_COMPILER_SPEC_ID differ; reopen the target to resolve a new profile.", + }, + }), + ); const invalidProfile = validateGhidraExtensionProfile( extensions, config, @@ -118,6 +157,8 @@ 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 targetLimitations = [ ...(target.format === "dos-mz" ? [ @@ -149,6 +190,8 @@ export const createGhidraProviderClient = (input: { ), ...(target.format === "dos-mz" ? { dosMz: true } : {}), ...(target.format === "dos-com" ? { dosCom: true } : {}), + ...(languageOverride === undefined ? {} : { languageOverride }), + ...(seeds === undefined ? {} : { analysisSeeds: seeds }), platform: installation.platform, ...(extensions.length === 0 ? {} : { analysisExtensions: extensions }), }), @@ -168,7 +211,14 @@ export const createGhidraProviderClient = (input: { expectedLanguageId: "x86:LE:16:Real Mode", expectedCompilerSpecId: "default", } - : {}), + : languageOverride === undefined + ? {} + : { + expectedLanguageId: languageOverride.languageId, + ...(languageOverride.compilerSpecId === undefined + ? {} + : { expectedCompilerSpecId: languageOverride.compilerSpecId }), + }), ...(context === undefined ? {} : { runId: context.runId }), logger: logger.child({ layer: "ghidra-bridge" }), }); @@ -176,11 +226,18 @@ export const createGhidraProviderClient = (input: { operation: AnalysisOperation, info: GhidraSessionInfo, ): Promise => { - extensionFailure = ghidraExtensionFailure( - extensions, - info.analysis_extensions ?? [], - operation, - ); + extensionFailure = + ghidraExtensionFailure( + extensions, + info.analysis_extensions ?? [], + operation, + ) ?? ghidraSeedFailure(seeds, info.analysis_seeds, operation); + if (extensionFailure === undefined && info.analysis_seeds !== undefined) + seedLimitations.splice( + 0, + seedLimitations.length, + ...ghidraSeedLimitations(seeds, info.analysis_seeds), + ); if (extensionFailure === undefined) return undefined; const closed = await client.close(); return closed.ok @@ -194,10 +251,11 @@ export const createGhidraProviderClient = (input: { const releaseLimitation = unverifiedGhidraBuildLimitation( prerequisites.value.providerVersion, ); - const sessionLimitations = [ + const sessionLimitations = (): readonly string[] => [ ...providerLimitations, ...targetLimitations, ...ghidraExtensionLimitations(extensions), + ...seedLimitations, ...(releaseLimitation === undefined ? [] : [releaseLimitation]), ]; return { @@ -230,7 +288,7 @@ export const createGhidraProviderClient = (input: { return ok( createAnalysisExecution(started.value, committedProfile.provider, { analysisProfile: committedProfile, - limitations: [...healthLimitations, ...sessionLimitations], + limitations: [...healthLimitations, ...sessionLimitations()], }), ); } @@ -238,7 +296,7 @@ export const createGhidraProviderClient = (input: { ? parseGhidraFunctionInput(operation, parameters) : parseGhidraInventoryInput(operation, parameters); if (!input.ok) return input; - if (extensions.length > 0) { + if (extensions.length > 0 || seeds !== undefined) { const started = await client.start(options?.signal); if (!started.ok) return err( @@ -299,7 +357,7 @@ export const createGhidraProviderClient = (input: { limitations: [ ...limitationsFor(operation), ...observationLimitations, - ...sessionLimitations, + ...sessionLimitations(), ], }), ); diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index 81fc273a4..40487ead9 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -1,3 +1,4 @@ +import { ghidraSeedReportSchema } from "./GhidraAnalysisSeeds.js"; import { z } from "zod"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -40,6 +41,7 @@ const sessionInfoSchema = z analysis_complete: z.boolean(), analysis_timed_out: z.boolean(), analysis_extensions: z.array(ghidraExtensionResultSchema).optional(), + analysis_seeds: ghidraSeedReportSchema.optional(), capabilities: z.array(capabilitySchema), target: z .object({ diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index e679ee80a..cfbbc0083 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -702,7 +702,10 @@ export class JavaScriptAnalysisWorker { }, { operation: OPERATION, - ...(original === undefined ? {} : { cause: original }), + cause: new Error( + reason, + original === undefined ? undefined : { cause: original }, + ), }, ); } diff --git a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts index 248267576..41c71638e 100644 --- a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts +++ b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts @@ -123,7 +123,11 @@ it("preserves a failed owned cleanup retry when the next caller cancels before a const file = source("app.mjs", "export const observed = 7;"); const first = await worker.analyze(file, 100); if (!first.ok) throw first.error.error; - await expect(worker.close()).rejects.toBeInstanceOf(ProviderCleanupError); + const closing = worker.close(); + await expect(closing).rejects.toBeInstanceOf(ProviderCleanupError); + await expect(closing).rejects.toMatchObject({ + cause: { message: "Fixture owned cleanup is unavailable" }, + }); const interrupted = await next.analyze(file, 100, controller.signal); if (interrupted.ok) throw new Error("Unreleased ownership must block subsequent analysis"); diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index e10141531..1811b3108 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -1,5 +1,5 @@ import { access, chmod, readFile, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { join, posix, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; @@ -160,6 +160,88 @@ describe("Ghidra headless launcher", () => { }); }); +describe("Ghidra configured import language and seeds", () => { + const base = { + platform: "darwin" as const, + projectRoot: "/tmp/project", + targetPath: "/tmp/target", + bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java", + descriptorPath: "/tmp/session.json", + ghidraLogPath: "/tmp/ghidra.log", + scriptLogPath: "/tmp/script.log", + }; + + it("passes a configured language and compiler spec to the import", () => { + const arguments_ = ghidraHeadlessArguments({ + ...base, + languageOverride: { + languageId: "x86:LE:32:default", + compilerSpecId: "borlandcpp", + }, + }); + expectOptions(arguments_, [ + ["-processor", "x86:LE:32:default"], + ["-cspec", "borlandcpp"], + ]); + expect(arguments_).not.toContain("-loader"); + expect(arguments_.indexOf("-processor")).toBeGreaterThan( + arguments_.indexOf("-import"), + ); + }); + + it("lets Ghidra choose the language default compiler spec", () => { + const arguments_ = ghidraHeadlessArguments({ + ...base, + languageOverride: { languageId: "x86:LE:32:default" }, + }); + expectOptions(arguments_, [["-processor", "x86:LE:32:default"]]); + expect(arguments_).not.toContain("-cspec"); + }); + + it("never combines a configured language with a DOS import", () => { + expect(() => + ghidraHeadlessArguments({ + ...base, + dosMz: true, + languageOverride: { languageId: "x86:LE:32:default" }, + }), + ).toThrow(/cannot be combined with a DOS import/u); + }); + + it.each(["darwin", "win32"] as const)( + "applies seeds after loader preparation and before analysis scripts on %s", + (platform) => { + const path = platform === "win32" ? win32 : posix; + const bridgeDirectory = path.join("/package", "bridge"); + const seedPath = path.join("/tmp/runtime/seeds", "seeds.tsv"); + const arguments_ = ghidraHeadlessArguments({ + ...base, + platform, + bridgeScriptPath: path.join(bridgeDirectory, "ReaGhidraBridge.java"), + dosCom: true, + seedPath, + }); + const prepare = arguments_.indexOf( + path.join(bridgeDirectory, "ReaGhidraPrepareCom.java"), + ); + const seeds = arguments_.indexOf( + path.join(bridgeDirectory, "ReaGhidraApplySeeds.java"), + ); + expect(prepare).toBeGreaterThanOrEqual(0); + expect(arguments_[seeds - 1]).toBe("-preScript"); + expect(arguments_[seeds + 1]).toBe(seedPath); + expect(prepare).toBeLessThan(seeds); + expect(seeds).toBeLessThan(arguments_.indexOf("-postScript")); + }, + ); + + it("adds no seed script without a seed snapshot", () => { + expect(ghidraHeadlessArguments(base)).not.toContain( + posix.join("/package/bridge", "ReaGhidraApplySeeds.java"), + ); + }); +}); + describe("Ghidra Windows launcher", () => { it("keeps analysis repair outside Windows P0 authority", () => { const args = ghidraHeadlessArguments({ diff --git a/tests/conformance/ghidra/ReaSeedRegressionProbe.java b/tests/conformance/ghidra/ReaSeedRegressionProbe.java new file mode 100644 index 000000000..1dd16ae41 --- /dev/null +++ b/tests/conformance/ghidra/ReaSeedRegressionProbe.java @@ -0,0 +1,38 @@ +//@category REA Verification + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import ghidra.app.script.GhidraScript; +import ghidra.program.model.listing.Program; + +/** Run the production seed script against a minimal 32-bit import. */ +public final class ReaSeedRegressionProbe extends GhidraScript { + @Override + public void run() throws Exception { + currentProgram.getMemory().createUninitializedBlock( + ".bss", toAddr(0x1000), 0x20, false + ); + runScript("ReaGhidraApplySeeds.java", new String[] { getScriptArgs()[0] }); + JsonObject report = JsonParser.parseString( + currentProgram.getOptions(Program.PROGRAM_INFO) + .getString("REA analysis seeds", null) + ).getAsJsonObject(); + require(report.get("entries").getAsInt() == 5, "all seeds accounted for"); + require(report.get("function_created").getAsInt() == 2, + "out-of-range seed must not disturb descending function order"); + require(report.get("function_failed").getAsInt() == 0, "no swallowed function entry"); + require(getFunctionAt(toAddr(0)).getName().equals("seed_low"), "lower function name"); + require(getFunctionAt(toAddr(1)).getName().equals("seed_high"), "higher function name"); + require(report.get("label_applied").getAsInt() == 1, "mapped BSS label applied"); + require(currentProgram.getSymbolTable().getPrimarySymbol(toAddr(0x1000)) + .getName().equals("seed_global"), "BSS label name"); + require(report.get("unmapped").getAsInt() == 2, + "only out-of-range function and uninitialized code seed are skipped"); + require(report.get("code_decoded").getAsInt() == 0, "BSS is not decoded"); + println("REA_SEED_REGRESSION_OK " + report); + } + + private void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +}