mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
* 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 <[email protected]>
464 lines
16 KiB
JavaScript
464 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import assert from "node:assert/strict";
|
|
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
|
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
import { Client } from "@modelcontextprotocol/client";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
|
|
if (process.platform !== "win32" || process.arch !== "x64")
|
|
throw new Error(
|
|
"Packaged Windows Ghidra acceptance requires a Windows x64 host.",
|
|
);
|
|
const exec = promisify(execFile);
|
|
const arguments_ = process.argv.slice(2);
|
|
const internalWorkspace = arguments_[0] === "--internal-workspace";
|
|
const fixtureArguments = internalWorkspace ? arguments_.slice(2) : arguments_;
|
|
const x86Fixture = fixtureArguments.includes("--x86");
|
|
const dllFixture = fixtureArguments.includes("--dll");
|
|
const [packageRootArgument, targetArgument, reportArgument] =
|
|
fixtureArguments.filter(
|
|
(argument) => argument !== "--x86" && argument !== "--dll",
|
|
);
|
|
// Windows locks the loaded package-owned addon until the verifier exits.
|
|
// Keep installation cleanup in a parent that never loads that addon.
|
|
if (!internalWorkspace) {
|
|
const ownedWorkspace = await mkdtemp(join(tmpdir(), "rea-packaged-ghidra-"));
|
|
let result;
|
|
try {
|
|
result = await exec(
|
|
process.execPath,
|
|
[
|
|
fileURLToPath(import.meta.url),
|
|
"--internal-workspace",
|
|
ownedWorkspace,
|
|
...arguments_,
|
|
],
|
|
{ timeout: 900_000, maxBuffer: 8 * 1024 * 1024 },
|
|
);
|
|
} catch (cause) {
|
|
if (typeof cause.stdout === "string") process.stdout.write(cause.stdout);
|
|
if (typeof cause.stderr === "string") process.stderr.write(cause.stderr);
|
|
throw cause;
|
|
} finally {
|
|
await rm(ownedWorkspace, { recursive: true, force: true });
|
|
}
|
|
const report = JSON.parse(result.stdout);
|
|
report.workspaceCleanup = true;
|
|
report.ok = true;
|
|
if (reportArgument !== undefined)
|
|
await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
|
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
process.stderr.write(result.stderr);
|
|
process.exit(0);
|
|
}
|
|
assert.ok(arguments_[1] !== undefined, "Missing verifier workspace.");
|
|
const workspace = resolve(arguments_[1]);
|
|
let packageRoot = resolve(packageRootArgument ?? ".");
|
|
// The default lane verifies the npm artifact in an isolated prefix. Explicit
|
|
// package roots support an already-installed artifact without a second install.
|
|
if (packageRootArgument === undefined) {
|
|
try {
|
|
const npmEntry =
|
|
process.env.npm_execpath ??
|
|
join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js");
|
|
const packed = JSON.parse(
|
|
(
|
|
await exec(
|
|
process.execPath,
|
|
[
|
|
npmEntry,
|
|
"pack",
|
|
"--json",
|
|
"--ignore-scripts",
|
|
"--pack-destination",
|
|
workspace,
|
|
],
|
|
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
|
|
)
|
|
).stdout,
|
|
);
|
|
assert.equal(
|
|
packed.length,
|
|
1,
|
|
"The package lane must produce one artifact.",
|
|
);
|
|
const prefix = join(workspace, "installed");
|
|
await exec(
|
|
process.execPath,
|
|
[
|
|
npmEntry,
|
|
"install",
|
|
"--prefix",
|
|
prefix,
|
|
"--ignore-scripts",
|
|
"--omit=dev",
|
|
"--no-audit",
|
|
"--no-fund",
|
|
join(workspace, packed[0].filename),
|
|
],
|
|
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
|
|
);
|
|
packageRoot = join(prefix, "node_modules", "rea-agents");
|
|
} catch (cause) {
|
|
throw new Error("Packaged Windows verifier installation failed.", {
|
|
cause,
|
|
});
|
|
}
|
|
}
|
|
const target = resolve(
|
|
targetArgument ??
|
|
`build/fixtures/rea-ghidra-windows${x86Fixture ? "-x86" : ""}.${dllFixture ? "dll" : "exe"}`,
|
|
);
|
|
let TOOL_CONTRACTS, native, token, sha256;
|
|
let parsedTarget;
|
|
try {
|
|
({ TOOL_CONTRACTS } = await import(
|
|
pathToFileURL(join(packageRoot, "dist/contracts/toolContracts.js"))
|
|
));
|
|
const { requireWindowsNativeAuthority } = await import(
|
|
pathToFileURL(join(packageRoot, "dist/windows/WindowsNativeLoader.js"))
|
|
);
|
|
native = requireWindowsNativeAuthority();
|
|
token = native.call("process_caller_token", []);
|
|
assert.equal(
|
|
token.administrator,
|
|
false,
|
|
"The real-provider lane must run as an ordinary user.",
|
|
);
|
|
assert.equal(
|
|
token.elevated,
|
|
false,
|
|
"The real-provider lane must use a non-elevated token.",
|
|
);
|
|
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
sha256 = digest(await readFile(target));
|
|
const { parseBinaryTarget } = await import(
|
|
pathToFileURL(join(packageRoot, "dist/application/BinaryTargetResolver.js"))
|
|
);
|
|
parsedTarget = await parseBinaryTarget(target);
|
|
assert.equal(parsedTarget.ok, true);
|
|
assert.equal(parsedTarget.value.format, "pe");
|
|
assert.equal(parsedTarget.value.managed, false);
|
|
if (targetArgument === undefined) {
|
|
assert.equal(
|
|
parsedTarget.value.architecture,
|
|
x86Fixture ? "x86" : "x86_64",
|
|
);
|
|
assert.equal(
|
|
parsedTarget.value.executableRole,
|
|
dllFixture ? "shared-library" : "application",
|
|
);
|
|
}
|
|
} catch (cause) {
|
|
throw new Error("Packaged Windows verifier prerequisites failed.", { cause });
|
|
}
|
|
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
const runtimeParent = join(workspace, "runtime with spaces");
|
|
await mkdir(runtimeParent);
|
|
const callerDirectory = join(workspace, "caller cwd with spaces");
|
|
const scriptCollisions = [
|
|
"ReaGhidraBridge.java",
|
|
"ReaGhidraPrepareCom.java",
|
|
"ReaGhidraApplySeeds.java",
|
|
"ReaGhidraNoReturnFix.java",
|
|
];
|
|
await mkdir(callerDirectory);
|
|
for (const name of scriptCollisions) await mkdir(join(callerDirectory, name));
|
|
const environment = {
|
|
...Object.fromEntries(
|
|
Object.entries(process.env).filter(
|
|
([name]) => name.toLowerCase() !== "comspec",
|
|
),
|
|
),
|
|
ComSpec: (
|
|
process.env.ComSpec ??
|
|
join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe")
|
|
).replaceAll("\\", "/"),
|
|
REA_LOG_LEVEL: "error",
|
|
GHIDRA_HEADLESS_MAXMEM: "512m",
|
|
NODE_OPTIONS: "--max-old-space-size=512",
|
|
TEMP: runtimeParent.replaceAll("\\", "/"),
|
|
TMP: runtimeParent.replaceAll("\\", "/"),
|
|
HOME: workspace,
|
|
USERPROFILE: workspace,
|
|
APPDATA: join(workspace, "appdata"),
|
|
LOCALAPPDATA: join(workspace, "localappdata"),
|
|
};
|
|
const entry = join(packageRoot, "scripts/rea.mjs");
|
|
const report = {
|
|
ok: false,
|
|
platform: "win32",
|
|
architecture: "x64",
|
|
nonAdmin: true,
|
|
targetSha256: sha256,
|
|
targetArchitecture: parsedTarget.value.architecture,
|
|
targetRole: parsedTarget.value.executableRole,
|
|
cli: {},
|
|
mcpOperations: [],
|
|
nativeControls: "available",
|
|
runtimeCleanup: false,
|
|
};
|
|
const assertCleanup = async () => {
|
|
const entries = await readdir(runtimeParent);
|
|
report.runtimeResidualCount = entries.length;
|
|
report.runtimeResidualKinds = entries.map((name) =>
|
|
name.startsWith("rea-")
|
|
? "rea-runtime"
|
|
: name.startsWith("hsperfdata_")
|
|
? "jvm-performance-data"
|
|
: "other",
|
|
);
|
|
assert.equal(entries.length, 0, "Provider runtime cleanup left files.");
|
|
};
|
|
try {
|
|
const providers = JSON.parse(
|
|
(
|
|
await exec(process.execPath, [entry, "providers", "--json"], {
|
|
env: environment,
|
|
cwd: callerDirectory,
|
|
timeout: 30_000,
|
|
})
|
|
).stdout,
|
|
);
|
|
assert.equal(providers.analysis_provider_binding, null);
|
|
assert.ok(Array.isArray(providers.analysis_provider_candidates));
|
|
const ghidra = providers.analysis_provider_candidates.find(
|
|
({ provider }) => provider.id === "ghidra",
|
|
);
|
|
assert.ok(ghidra !== undefined);
|
|
assert.equal(ghidra.selected, false);
|
|
assert.equal(ghidra.target_support.status, "unknown");
|
|
report.cli.providers = true;
|
|
const inspected = await exec(
|
|
process.execPath,
|
|
[entry, "inspect", target, "--provider", "ghidra", "--format", "json"],
|
|
{
|
|
env: environment,
|
|
cwd: callerDirectory,
|
|
timeout: 360_000,
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
},
|
|
);
|
|
const inspection = JSON.parse(inspected.stdout);
|
|
assert.ok(inspection.error === undefined);
|
|
report.cli.inspect = true;
|
|
await assertCleanup();
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [entry, "mcp"],
|
|
env: environment,
|
|
cwd: callerDirectory,
|
|
stderr: "pipe",
|
|
});
|
|
const client = new Client({
|
|
name: "rea-packaged-windows-ghidra",
|
|
version: "1.0.0",
|
|
});
|
|
const call = async (name, arguments_) => {
|
|
report.lastOperation = name;
|
|
if (reportArgument !== undefined)
|
|
await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
|
|
const contract = TOOL_CONTRACTS.find((value) => value.name === name);
|
|
assert.ok(contract !== undefined, `Missing canonical contract: ${name}`);
|
|
const argumentsParsed = contract.inputSchema.parse(arguments_);
|
|
const result = await client.callTool(
|
|
{ name, arguments: argumentsParsed },
|
|
{ timeout: 360_000 },
|
|
);
|
|
assert.notEqual(result.isError, true, `Packaged operation failed: ${name}`);
|
|
const projected = requireMcpOperationResult(result, name, contract);
|
|
report.mcpOperations.push(name);
|
|
return projected;
|
|
};
|
|
try {
|
|
await client.connect(transport);
|
|
await call("open_binary", { path: target });
|
|
await call("binary_overview", {});
|
|
assert.equal((await call("list_documents", {})).length, 1);
|
|
await call("list_names", {});
|
|
const procedures = await call("list_procedures", {});
|
|
assert.ok(procedures.length > 0);
|
|
if (targetArgument === undefined) {
|
|
const calleeAddress = x86Fixture ? "0x401010" : "0x140001010";
|
|
const calleeCode = await call("procedure_pseudo_code", {
|
|
procedure: calleeAddress,
|
|
});
|
|
assert.match(calleeCode, /\b(?:42|0x2a)\b/iu);
|
|
if (dllFixture) {
|
|
const callerAddress = x86Fixture ? "0x401000" : "0x140001000";
|
|
assert.ok(
|
|
(
|
|
await call("procedure_callees", { procedure: callerAddress })
|
|
).includes(calleeAddress),
|
|
);
|
|
assert.match(
|
|
await call("procedure_pseudo_code", { procedure: callerAddress }),
|
|
/\+\s*1\b/u,
|
|
);
|
|
}
|
|
}
|
|
assert.ok((await call("list_segments", {})).length > 0);
|
|
await call("list_strings", {});
|
|
const procedure =
|
|
procedures.find((item) => item.procedure?.external === false) ??
|
|
procedures[0];
|
|
assert.ok(procedure !== undefined);
|
|
const loadImage = await call("inspect_native_load_image", {});
|
|
assert.equal(
|
|
loadImage.status,
|
|
"unsupported",
|
|
"PE load-image attestation must remain explicitly unsupported.",
|
|
);
|
|
assert.ok(loadImage.observations !== undefined);
|
|
const memory = await call("read_bytes", {
|
|
address: procedure.address,
|
|
length: 16,
|
|
});
|
|
const mapping = await call("address_to_file_offset", {
|
|
address: procedure.address,
|
|
});
|
|
assert.equal(memory.complete, true);
|
|
assert.equal(memory.returned_bytes, 16);
|
|
assert.equal(
|
|
memory.bytes_hex,
|
|
(await readFile(target))
|
|
.subarray(mapping.file_offset, mapping.file_offset + 16)
|
|
.toString("hex"),
|
|
);
|
|
await call("address_name", { address: procedure.address });
|
|
assert.equal(
|
|
await call("procedure_address", { procedure: procedure.value }),
|
|
procedure.address,
|
|
);
|
|
await call("resolve_containing_procedure", { address: procedure.address });
|
|
await call("search_procedures", { pattern: procedure.value });
|
|
await call("search_strings", { pattern: "rea" });
|
|
for (const name of [
|
|
"procedure_assembly",
|
|
"procedure_callees",
|
|
"procedure_callers",
|
|
"procedure_info",
|
|
"procedure_pseudo_code",
|
|
"read_function_instructions",
|
|
"procedure_references",
|
|
"analyze_function",
|
|
])
|
|
await call(name, { procedure: procedure.value });
|
|
await call("xrefs", { address: procedure.address });
|
|
await call("inspect_native_data_type", { type: "/undefined8" });
|
|
await call("inspect_native_instruction", { address: procedure.address });
|
|
await call("resolve_native_call_targets", { address: procedure.address });
|
|
// Explicit caller-selected targets need not contain our fixture instructions.
|
|
const controlledTargets = [
|
|
"rea-ghidra-windows.exe",
|
|
"rea-ghidra-windows-x86.exe",
|
|
].map((name) => resolve("build", "fixtures", name));
|
|
if (controlledTargets.includes(target))
|
|
report.compactReferences = await verifyGhidraCompactReferences(
|
|
call,
|
|
target === controlledTargets[1],
|
|
async (command, address) => {
|
|
const result = await exec(
|
|
process.execPath,
|
|
[entry, command, target, address, "--provider", "ghidra", "--json"],
|
|
{
|
|
env: environment,
|
|
cwd: callerDirectory,
|
|
timeout: 360_000,
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
},
|
|
);
|
|
const evidence = JSON.parse(result.stdout);
|
|
assert.ok(evidence.error === undefined, `CLI ${command} failed`);
|
|
const contract = TOOL_CONTRACTS.find(
|
|
({ name }) => name === command.replaceAll("-", "_"),
|
|
);
|
|
assert.ok(contract, `Missing CLI ${command} contract`);
|
|
contract.outputSchema.parse(evidence);
|
|
return evidence.normalized_result;
|
|
},
|
|
);
|
|
const expected = (
|
|
await import(
|
|
pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js"))
|
|
)
|
|
).GHIDRA_SESSION_CAPABILITIES.filter(
|
|
(name) =>
|
|
name !== "ping" &&
|
|
name !== "shutdown" &&
|
|
name !== "annotate_native_function",
|
|
);
|
|
assert.ok(
|
|
expected.every((name) => report.mcpOperations.includes(name)),
|
|
"Packaged verification omitted an admitted operation.",
|
|
);
|
|
const session = await call("binary_session", {});
|
|
assert.equal(
|
|
session.analysis_provider_binding?.analysis_profile?.parameters
|
|
?.executable_role,
|
|
parsedTarget.value.executableRole,
|
|
"MCP profile must preserve the selected PE role.",
|
|
);
|
|
assert.equal(
|
|
session.analysis_provider_binding?.selection_source,
|
|
"auto-single-candidate",
|
|
"Packaged MCP verification must exercise automatic provider selection.",
|
|
);
|
|
assert.equal(
|
|
session.analysis_provider_binding?.provider?.id,
|
|
"ghidra",
|
|
"The sole available Windows analysis provider must be Ghidra.",
|
|
);
|
|
await call("close_binary", {});
|
|
await assertCleanup();
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
assert.equal(
|
|
digest(await readFile(target)),
|
|
sha256,
|
|
"Analysis modified the selected target.",
|
|
);
|
|
assert.deepEqual(
|
|
native.call("process_caller_token", []),
|
|
token,
|
|
"Analysis changed caller token authority.",
|
|
);
|
|
assert.deepEqual((await readdir(callerDirectory)).sort(), scriptCollisions);
|
|
for (const name of scriptCollisions)
|
|
assert.deepEqual(await readdir(join(callerDirectory, name)), []);
|
|
report.callerScriptCollisionsPreserved = true;
|
|
report.runtimeCleanup = true;
|
|
// Only the parent can attest success after removing the installation.
|
|
if (reportArgument !== undefined)
|
|
await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
|
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
} catch (cause) {
|
|
report.failure = {
|
|
name: cause.name,
|
|
message: cause.message
|
|
.replaceAll(packageRoot, "<package>")
|
|
.replaceAll(target, "<target>")
|
|
.replaceAll(workspace, "<workspace>"),
|
|
};
|
|
if (reportArgument !== undefined)
|
|
await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
|
|
throw cause;
|
|
}
|