feat(native): admit bounded MIPS ELF analysis through Ghidra (#1362)
* feat(native): admit bounded MIPS ELF analysis through Ghidra Retain MIPS ELF identity and interpretation through Evidence and snapshots. Keep provider admission bounded to ELF32 ET_EXEC o32 MIPS32r2, without implicitly widening Hopper, IDA or Windows P0 coverage. Add source-owned fixtures and an optional real-provider verifier; real Ghidra verification remains pending in this contribution environment. Refs #718 * fix(ghidra): bound MIPS floating-point mode admission Reject explicit EF_MIPS_FP64 and EF_MIPS_NAN2008 declarations until their semantics have a real-provider verification lane. Add PSP PRX/Allegrex, o64/EABI and floating-point-mode refusal regressions. Document that the header gate is not a full .MIPS.abiflags check and advance the profile lane identity to invalidate older interpretation bindings. Validation: 24 standalone checks against the transpiled production profile module passed; the two new flag cases failed before this change. Clang 17 and LLD compiled the source-owned fixture for both endian targets. The complete Vitest/typecheck suite and real Ghidra verification remain pending. Refs morluto/rea#718; PSP support remains follow-up morluto/rea#1330. * fix(native): validate MIPS ABI records before Ghidra admission Read bounded ELF32 PT_MIPS_ABIFLAGS and SHT_MIPS_ABIFLAGS records through the already-open target handle. Preserve declared ABI fields, reject conflicting/duplicate/truncated records, and keep missing records unknown. Wire inspection into target resolution before provider selection. Require compatible declarations for the deliberately narrow Ghidra lane and bind all ABI fields into profile identity (v3) for snapshot invalidation. Clang 17 produced eight source-owned fixtures spanning both byte orders and FPXX/FP32/FP64/soft-float modes with identical ELF header flags. The new reader and policy distinguish them. 24 focused regression test bodies passed after TypeScript transpilation using node:test in place of Vitest; strict isolated TypeScript checking of the reader and target/result types passed. These are limited checks, not full Vitest/project typechecking or real-Ghidra verification, which remain pending. Advances morluto/rea#718. PSP/Allegrex remains morluto/rea#1330 follow-up. * fix(native): repair MIPS integration boundaries and test resolver behavior Keep ELF targets out of Hopper Mach-O preparation and scope existing PE fixture types correctly. Move ABI tests into the filesystem boundary lane, use shared cleanup, add resolver regressions, apply the pinned formatter, and register the real MIPS verifier through the existing package-command pattern. Validated with repository-pinned Node/npm, check:ci, actual focused Vitest, discovery and docs:check. Real Ghidra conformance remains pending. * fix(test): honor caller-selected JDK in real MIPS verification * test(native): strengthen MIPS Ghidra conformance Cross-check the source-owned fixture with bounded GNU readelf inspection, compare production ELF/ABI interpretation and independent symbol addresses, and verify effective Ghidra language/compiler identity through existing public operations. Add a source-owned fixed-encoding probe for immediate and conditional branch checks plus global/string byte assertions. Add 16 assertion/producer-boundary regression cases without widening production admission, changing provider contracts, or introducing proprietary dependencies. Local focused suite: 99 passed, one existing root-permission test skipped. Typecheck, lint/module boundaries, formatting and 700-file discovery pass; full hosted checks and real Ghidra verification remain pending for this checkpoint. * fix(ghidra): preserve native-tool architecture checks after MIPS integration The conflict-free upstream merge failed TypeScript compilation because includes on the union of MIPS-only and other architecture arrays requires never. Use equality-based membership without casts or changing accepted native hosts. Extend the existing native-decompiler rejection table with a MIPS executable at a Linux x64 tool path; retain upstream FAT and unknown-header behavior. Hosted verification follows this commit; no success is claimed yet. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
96fd98cce0
commit
bddddccdee
32 files changed
+2028
-35
No files matched your search
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm } 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 { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import {
|
||||
inspectMipsReadelf,
|
||||
assertMipsLoadedImage,
|
||||
assertMipsProbe,
|
||||
assertMipsGlobal,
|
||||
} from "../tests/conformance/ghidra/mips-checks.mjs";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
|
||||
import { functionDossierSchema } from "../dist/domain/hopperValues.js";
|
||||
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
|
||||
import { buildMipsFixture } from "../tests/conformance/ghidra/mips-fixture.mjs";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
// Optional cross-target lane. No tool acquisition and no target execution.
|
||||
if (process.argv.length !== 2)
|
||||
throw new Error("Usage: node scripts/verify-real-ghidra-mips.mjs");
|
||||
if (!["linux", "darwin"].includes(process.platform))
|
||||
throw new Error(
|
||||
"MIPS verification requires a supported Linux/macOS Ghidra host",
|
||||
);
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR");
|
||||
const installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: process.env.GHIDRA_INSTALL_DIR,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { javaHome: process.env.JAVA_HOME }),
|
||||
});
|
||||
if (installation.status !== "available")
|
||||
throw new Error(
|
||||
`Ghidra prerequisite unavailable: ${JSON.stringify(installation)}`,
|
||||
);
|
||||
const execute = promisify(execFile);
|
||||
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||
const run = createVerifierRun();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-mips-proof-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
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: installation.installDir,
|
||||
JAVA_HOME: installation.javaHome,
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
||||
};
|
||||
const reports = [];
|
||||
let primaryFailure;
|
||||
try {
|
||||
for (const byteOrder of ["little", "big"]) {
|
||||
const fixture = await buildMipsFixture(workspace, byteOrder);
|
||||
const independent = await inspectMipsReadelf(fixture.path, byteOrder);
|
||||
const resolved = await parseBinaryTarget(fixture.path);
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
assert.deepEqual(
|
||||
resolved.value.mips,
|
||||
independent.mips,
|
||||
"REA ELF/ABI interpretation disagrees with GNU readelf",
|
||||
);
|
||||
assert.equal(resolved.value.sha256, fixture.sha256);
|
||||
const snapshotPath = join(workspace, `${byteOrder}.snapshot.json`);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-mips-proof", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
const call = async (name, args = {}) => {
|
||||
const reply = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
undefined,
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
return requireMcpOperationResult(reply, name);
|
||||
};
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const target = await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(target.architecture, "mips");
|
||||
assert.equal(target.sha256, fixture.sha256);
|
||||
// ELF load-image verification is not the DOS comparison lane. Its retained
|
||||
// observations still expose the actual Ghidra language and loaded bytes.
|
||||
const image = await call("inspect_native_load_image");
|
||||
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
|
||||
const procedures = await call("list_procedures");
|
||||
const entry = procedures.find((p) => p.value === "rea_mips_entry");
|
||||
const leaf = procedures.find((p) => p.value === "rea_mips_leaf");
|
||||
assert.ok(entry, "Entry function missing from real Ghidra inventory");
|
||||
assert.ok(leaf, "Leaf function missing from real Ghidra inventory");
|
||||
assert.equal(
|
||||
BigInt(entry.address),
|
||||
BigInt(independent.symbols.rea_mips_entry),
|
||||
);
|
||||
assert.equal(
|
||||
BigInt(leaf.address),
|
||||
BigInt(independent.symbols.rea_mips_leaf),
|
||||
);
|
||||
const probe = independent.symbols.rea_mips_probe;
|
||||
assert.ok(
|
||||
procedures.some(
|
||||
(p) =>
|
||||
p.value === "rea_mips_probe" && BigInt(p.address) === BigInt(probe),
|
||||
),
|
||||
);
|
||||
const move = await call("inspect_native_instruction", { address: probe });
|
||||
const branch = await call("inspect_native_instruction", {
|
||||
address: `0x${(BigInt(probe) + 4n).toString(16)}`,
|
||||
});
|
||||
assertMipsProbe(move, branch, byteOrder, probe);
|
||||
const global = await call("read_bytes", {
|
||||
address: independent.symbols.rea_mips_global,
|
||||
length: 4,
|
||||
});
|
||||
assert.equal(global.returned_bytes, 4);
|
||||
assertMipsGlobal(global.bytes_hex, byteOrder);
|
||||
const marker = await call("read_bytes", {
|
||||
address: independent.symbols.rea_mips_marker,
|
||||
length: 30,
|
||||
});
|
||||
assert.equal(
|
||||
marker.bytes_hex,
|
||||
Buffer.from("rea-mips-source-owned-fixture\0").toString("hex"),
|
||||
);
|
||||
const callees = await call("procedure_callees", {
|
||||
procedure: entry.address,
|
||||
});
|
||||
assert.ok(
|
||||
callees.includes(leaf.address),
|
||||
"Expected direct call not recovered",
|
||||
);
|
||||
const assembly = await call("procedure_assembly", {
|
||||
procedure: entry.address,
|
||||
});
|
||||
assert.ok(assembly.trim().length > 0);
|
||||
const dossier = functionDossierSchema.parse(
|
||||
await call("analyze_function", { procedure: entry.address }),
|
||||
);
|
||||
assert.ok(dossier.pseudocode.trim().length > 0);
|
||||
const mapping = await call("address_to_file_offset", {
|
||||
address: entry.address,
|
||||
});
|
||||
const memory = await call("read_bytes", {
|
||||
address: entry.address,
|
||||
length: 16,
|
||||
});
|
||||
const sourceBytes = await readFile(fixture.path);
|
||||
assert.equal(memory.returned_bytes, 16);
|
||||
assert.equal(
|
||||
memory.bytes_hex,
|
||||
sourceBytes
|
||||
.subarray(mapping.file_offset, mapping.file_offset + 16)
|
||||
.toString("hex"),
|
||||
);
|
||||
const saved = await call("close_binary", {
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
assert.ok(saved.evidence_records > 0);
|
||||
const snapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(await readFile(snapshotPath, "utf8")),
|
||||
);
|
||||
assert.equal(snapshot.target.architecture, "mips");
|
||||
assert.equal(
|
||||
snapshot.binding.analysis_profile.parameters.mips_elf.byte_order,
|
||||
byteOrder,
|
||||
);
|
||||
assert.ok(snapshot.evidence_bundle.records.length > 0);
|
||||
for (const evidence of snapshot.evidence_bundle.records)
|
||||
assert.equal(parseEvidence(evidence).subject?.architecture, "mips");
|
||||
await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
assert.deepEqual(await call("list_procedures"), procedures);
|
||||
await call("close_binary");
|
||||
|
||||
// Same public operation through CLI, not a private injected provider.
|
||||
const cli = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"function",
|
||||
fixture.path,
|
||||
"rea_mips_entry",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const evidence = parseEvidence(JSON.parse(cli.stdout));
|
||||
assert.equal(evidence.subject?.architecture, "mips");
|
||||
assert.equal(evidence.subject?.digest.sha256, fixture.sha256);
|
||||
const cliDossier = functionDossierSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assert.equal(cliDossier.procedure.address, dossier.procedure.address);
|
||||
assert.ok(cliDossier.pseudocode.trim().length > 0);
|
||||
assert.equal(
|
||||
createHash("sha256")
|
||||
.update(await readFile(fixture.path))
|
||||
.digest("hex"),
|
||||
fixture.sha256,
|
||||
"Analysis modified the source executable",
|
||||
);
|
||||
reports.push({
|
||||
...fixture,
|
||||
independent_reader: independent,
|
||||
effective_language: image.observations.language_id,
|
||||
effective_compiler_spec: image.observations.compiler_spec_id,
|
||||
checks: [
|
||||
"independent ELF/ABI and symbols",
|
||||
"loaded Ghidra identity",
|
||||
"fixed instruction bytes and immediate",
|
||||
"conditional branch destination",
|
||||
"global word and marker bytes",
|
||||
"CLI/MCP and snapshot identity",
|
||||
],
|
||||
procedures: procedures.length,
|
||||
status: "passed",
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`MIPS ${byteOrder} verification failed; MCP stderr: ${stderr}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
primaryFailure = cause;
|
||||
}
|
||||
const completed = await completeVerifierRun(run);
|
||||
if (
|
||||
completed.process_lineage.status !== "verified" ||
|
||||
completed.process_lineage.descendants.length !== 0
|
||||
)
|
||||
throw new Error(
|
||||
`Cleanup unverified; retained diagnostic workspace: ${workspace}`,
|
||||
{ cause: primaryFailure },
|
||||
);
|
||||
if (primaryFailure !== undefined)
|
||||
throw new Error(
|
||||
`MIPS verification failed; retained workspace: ${workspace}`,
|
||||
{
|
||||
cause: primaryFailure,
|
||||
},
|
||||
);
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2));
|
||||
Reference in new issue
Block a user