1101 lines
35 KiB
JavaScript
1101 lines
35 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { access, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { isAbsolute, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
import { resolveGhidraAnalysisProfile } from "../dist/ghidra/GhidraAnalysisProfile.js";
|
|
import { GhidraClient } from "../dist/ghidra/GhidraClient.js";
|
|
import { GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS } from "../dist/ghidra/GhidraDefaults.js";
|
|
import {
|
|
parseGhidraFunctionInput,
|
|
parseGhidraFunctionResult,
|
|
} from "../dist/ghidra/GhidraFunctionValues.js";
|
|
import { parseGhidraInventoryResult } from "../dist/ghidra/GhidraInventoryValues.js";
|
|
import {
|
|
inspectGhidraInstallation,
|
|
SUPPORTED_GHIDRA_VERSION,
|
|
} from "../dist/ghidra/GhidraInstallation.js";
|
|
import { GhidraHeadlessLauncher } from "../dist/ghidra/GhidraLauncher.js";
|
|
import { GHIDRA_PROVIDER_IDENTITY } from "../dist/ghidra/GhidraProvider.js";
|
|
import { GHIDRA_SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js";
|
|
import { parseBinaryTarget } from "../dist/domain/binaryTarget.js";
|
|
|
|
const exec = promisify(execFile);
|
|
const installDir = process.env.GHIDRA_INSTALL_DIR;
|
|
if (installDir === undefined || !isAbsolute(installDir))
|
|
throw new Error(
|
|
"Set GHIDRA_INSTALL_DIR to the absolute root of an extracted Ghidra 12.1.2 release.",
|
|
);
|
|
const installation = inspectGhidraInstallation({
|
|
installDir,
|
|
...(process.env.JAVA_HOME === undefined
|
|
? {}
|
|
: { javaHome: process.env.JAVA_HOME }),
|
|
});
|
|
if (!installation.available || installation.analyzeHeadlessPath === null)
|
|
throw new Error(
|
|
`Ghidra installation is unavailable: ${JSON.stringify(installation)}`,
|
|
);
|
|
if (installation.providerVersion !== SUPPORTED_GHIDRA_VERSION)
|
|
throw new Error("Ghidra provider version commitment drifted");
|
|
|
|
const fixtureRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-fixtures-"));
|
|
const sourcePath = fileURLToPath(
|
|
new URL("../tests/conformance/ghidra/inventory.c", import.meta.url),
|
|
);
|
|
const crossFormatSourcePath = fileURLToPath(
|
|
new URL("../tests/conformance/ghidra/cross-format.c", import.meta.url),
|
|
);
|
|
const debugPath = join(fixtureRoot, "rea-ghidra-inventory-debug");
|
|
const strippedPath = join(fixtureRoot, "rea-ghidra-inventory-stripped");
|
|
const arm64ElfPath = join(fixtureRoot, "rea-ghidra-cross-arm64");
|
|
const peObjectPath = join(fixtureRoot, "rea-ghidra-cross-x86_64.obj");
|
|
const pePath = join(fixtureRoot, "rea-ghidra-cross-x86_64.exe");
|
|
const peImportLibraryPath = join(fixtureRoot, "rea-ghidra-cross-x86_64.lib");
|
|
const machObjectPath = join(fixtureRoot, "rea-ghidra-cross-x86_64.o");
|
|
const malformedPath = join(fixtureRoot, "rea-ghidra-malformed");
|
|
const compiler = process.env.REA_CC ?? "cc";
|
|
const clang = process.env.REA_CLANG ?? "clang";
|
|
const lldLink = process.env.REA_LLD_LINK ?? "lld-link";
|
|
try {
|
|
const common = ["-O0", "-g", "-fno-inline", "-fno-pie", "-no-pie"];
|
|
await exec(compiler, [...common, sourcePath, "-o", debugPath]);
|
|
await exec(compiler, [...common, "-s", sourcePath, "-o", strippedPath]);
|
|
await exec(clang, [
|
|
"--target=aarch64-linux-gnu",
|
|
"-O0",
|
|
"-g",
|
|
"-fno-inline",
|
|
"-fno-pie",
|
|
"-nostdlib",
|
|
"-static",
|
|
"-fuse-ld=lld",
|
|
crossFormatSourcePath,
|
|
"-Wl,-e,rea_cross_start",
|
|
"-o",
|
|
arm64ElfPath,
|
|
]);
|
|
await exec(clang, [
|
|
"--target=x86_64-pc-windows-msvc",
|
|
"-O0",
|
|
"-gcodeview",
|
|
"-fno-inline",
|
|
"-c",
|
|
crossFormatSourcePath,
|
|
"-o",
|
|
peObjectPath,
|
|
]);
|
|
await exec(lldLink, [
|
|
"/entry:rea_cross_start",
|
|
"/subsystem:console",
|
|
"/nodefaultlib",
|
|
"/export:rea_cross_entry",
|
|
`/implib:${peImportLibraryPath}`,
|
|
`/out:${pePath}`,
|
|
peObjectPath,
|
|
]);
|
|
await exec(clang, [
|
|
"--target=x86_64-apple-darwin",
|
|
"-O0",
|
|
"-g",
|
|
"-fno-inline",
|
|
"-c",
|
|
crossFormatSourcePath,
|
|
"-o",
|
|
machObjectPath,
|
|
]);
|
|
await writeFile(malformedPath, Buffer.from("not-a-binary\n", "utf8"));
|
|
|
|
const debug = await verifyTarget(debugPath, "debug", {
|
|
format: "elf",
|
|
architecture: "x86_64",
|
|
});
|
|
const stripped = await verifyTarget(strippedPath, "stripped", {
|
|
format: "elf",
|
|
architecture: "x86_64",
|
|
});
|
|
const arm64Elf = await verifyTarget(arm64ElfPath, "cross-arm64-elf", {
|
|
format: "elf",
|
|
architecture: "arm64",
|
|
});
|
|
const pe = await verifyTarget(pePath, "cross-x86_64-pe", {
|
|
format: "pe",
|
|
architecture: "x86_64",
|
|
});
|
|
const machObject = await verifyTarget(machObjectPath, "cross-x86_64-mach-o", {
|
|
format: "mach-o",
|
|
architecture: "x86_64",
|
|
});
|
|
assertDebugFixture(debug);
|
|
assertStrippedFixture(stripped);
|
|
for (const fixture of [arm64Elf, pe, machObject]) assertCrossFixture(fixture);
|
|
await assertMalformedFixture(malformedPath);
|
|
|
|
const customPath = process.env.GHIDRA_TARGET_PATH;
|
|
const custom =
|
|
customPath === undefined ? null : await verifyTarget(customPath, "custom");
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
ok: true,
|
|
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
|
|
fixture_sources: [sourcePath, crossFormatSourcePath],
|
|
fixtures: [
|
|
summary(debug),
|
|
summary(stripped),
|
|
summary(arm64Elf),
|
|
summary(pe),
|
|
summary(machObject),
|
|
],
|
|
malformed_target: "rejected-before-provider-start",
|
|
custom_target: custom === null ? null : summary(custom),
|
|
cleanup: "complete",
|
|
})}\n`,
|
|
);
|
|
} finally {
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
|
const parsedTarget = await parseBinaryTarget(targetPath);
|
|
if (!parsedTarget.ok) throw parsedTarget.error;
|
|
if (
|
|
expectedTarget !== null &&
|
|
(parsedTarget.value.format !== expectedTarget.format ||
|
|
parsedTarget.value.architecture !== expectedTarget.architecture)
|
|
)
|
|
throw new Error(
|
|
`Fixture header classification drifted for ${variant}: ${JSON.stringify(parsedTarget.value)}`,
|
|
);
|
|
const profile = await resolveGhidraAnalysisProfile(
|
|
parsedTarget.value,
|
|
GHIDRA_PROVIDER_IDENTITY,
|
|
installation,
|
|
);
|
|
if (!profile.ok || profile.value.profile === null)
|
|
throw new Error("Ghidra analysis profile could not be committed");
|
|
|
|
const client = new GhidraClient({
|
|
launcher: new GhidraHeadlessLauncher({
|
|
analyzeHeadlessPath: installation.analyzeHeadlessPath,
|
|
...(process.env.JAVA_HOME === undefined
|
|
? {}
|
|
: { javaHome: process.env.JAVA_HOME }),
|
|
bridgeScriptPath: fileURLToPath(
|
|
new URL("../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
|
|
),
|
|
}),
|
|
targetPath: parsedTarget.value.path,
|
|
providerVersion: SUPPORTED_GHIDRA_VERSION,
|
|
profileDigest: profile.value.profile.digest,
|
|
requestTimeoutMs: GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS,
|
|
});
|
|
|
|
let runtimeCoordinates;
|
|
try {
|
|
const started = await client.start();
|
|
if (!started.ok) throw started.error;
|
|
assertSession(started.value, profile.value.profile.digest);
|
|
const pinged = await client.ping();
|
|
if (!pinged.ok) throw pinged.error;
|
|
assertSession(pinged.value, profile.value.profile.digest);
|
|
runtimeCoordinates = client.diagnostics();
|
|
assertRuntimeCoordinates(runtimeCoordinates);
|
|
|
|
const documents = await call(client, "list_documents", {});
|
|
const segments = await call(client, "list_segments", { document: null });
|
|
const procedures = await allItems(client, "list_procedures", {
|
|
document: null,
|
|
});
|
|
const names = await allItems(client, "list_names", {
|
|
document: null,
|
|
address: null,
|
|
});
|
|
const strings = await allItems(client, "list_strings", {
|
|
document: null,
|
|
address: null,
|
|
});
|
|
const probes =
|
|
variant === "custom"
|
|
? null
|
|
: await verifyInventoryOperations({
|
|
client,
|
|
variant,
|
|
procedures,
|
|
names,
|
|
strings,
|
|
});
|
|
return {
|
|
variant,
|
|
target: parsedTarget.value,
|
|
profile: profile.value.profile,
|
|
session: pinged.value,
|
|
documents,
|
|
segments,
|
|
procedures,
|
|
names,
|
|
strings,
|
|
probes,
|
|
};
|
|
} finally {
|
|
await client.close();
|
|
if (runtimeCoordinates !== undefined)
|
|
await assertCleanup(runtimeCoordinates);
|
|
}
|
|
}
|
|
|
|
async function verifyInventoryOperations({
|
|
client,
|
|
variant,
|
|
procedures,
|
|
names,
|
|
strings,
|
|
}) {
|
|
if (variant.startsWith("cross-"))
|
|
return verifyCrossFormatOperations({
|
|
client,
|
|
variant,
|
|
procedures,
|
|
names,
|
|
strings,
|
|
});
|
|
const rejections =
|
|
variant === "debug" ? await verifyBridgeRejections(client) : null;
|
|
const stringSearch = await call(client, "search_strings", {
|
|
pattern: String.raw`^REA_GHIDRA_(?:INVENTORY_ENTRY|LEAF_VALUE)$`,
|
|
mode: "regex",
|
|
case_sensitive: true,
|
|
offset: 0,
|
|
limit: 100,
|
|
document: null,
|
|
});
|
|
if (stringSearch.total !== 2 || stringSearch.items.length !== 2)
|
|
throw new Error("Ghidra bounded regex string search drifted");
|
|
const stringItem = strings.find(
|
|
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
|
|
);
|
|
if (stringItem === undefined)
|
|
throw new Error("Ghidra string probe address is unavailable");
|
|
const filteredStrings = await call(client, "list_strings", {
|
|
document: null,
|
|
address: stringItem.address,
|
|
offset: 0,
|
|
limit: 500,
|
|
});
|
|
if (
|
|
filteredStrings.total !== 1 ||
|
|
filteredStrings.items[0]?.address !== stringItem.address
|
|
)
|
|
throw new Error("Ghidra exact-address string filter drifted");
|
|
if (variant === "stripped") {
|
|
const functionAnalysis = await verifyStrippedFunctionOperations(
|
|
client,
|
|
stringItem,
|
|
);
|
|
return { rejections, stringSearch, filteredStrings, functionAnalysis };
|
|
}
|
|
|
|
const entry = findValue(procedures, "rea_ghidra_inventory_entry");
|
|
if (entry === undefined)
|
|
throw new Error("Ghidra entry procedure probe is unavailable");
|
|
const addressName = await call(client, "address_name", {
|
|
document: null,
|
|
address: entry.address,
|
|
});
|
|
if (symbolTail(addressName) !== "rea_ghidra_inventory_entry")
|
|
throw new Error("Ghidra primary address-name resolution drifted");
|
|
const resolvedAddress = await call(client, "procedure_address", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
});
|
|
if (resolvedAddress !== entry.address)
|
|
throw new Error("Ghidra procedure-name resolution drifted");
|
|
const interior = `0x${(BigInt(entry.address) + 1n).toString(16)}`;
|
|
const containing = await call(client, "resolve_containing_procedure", {
|
|
document: null,
|
|
address: interior,
|
|
});
|
|
if (!containing.found || containing.procedure.address !== entry.address)
|
|
throw new Error("Ghidra containing-procedure resolution drifted");
|
|
const procedureSearch = await call(client, "search_procedures", {
|
|
pattern: "rea_ghidra_inventory_",
|
|
mode: "literal",
|
|
case_sensitive: true,
|
|
offset: 0,
|
|
limit: 1,
|
|
document: null,
|
|
});
|
|
if (
|
|
procedureSearch.total < 2 ||
|
|
procedureSearch.items.length !== 1 ||
|
|
!procedureSearch.has_more ||
|
|
procedureSearch.next_offset !== 1
|
|
)
|
|
throw new Error("Ghidra literal procedure-search pagination drifted");
|
|
const name = names.find((item) => item.address === entry.address);
|
|
if (name === undefined)
|
|
throw new Error("Ghidra entry name probe is unavailable");
|
|
const filteredNames = await call(client, "list_names", {
|
|
document: null,
|
|
address: entry.address,
|
|
offset: 0,
|
|
limit: 500,
|
|
});
|
|
if (
|
|
filteredNames.total < 1 ||
|
|
!filteredNames.items.some((item) => item.value === name.value)
|
|
)
|
|
throw new Error("Ghidra exact-address name filter drifted");
|
|
const functionAnalysis = await verifyDebugFunctionOperations(client, {
|
|
procedures,
|
|
strings,
|
|
entry,
|
|
});
|
|
return {
|
|
rejections,
|
|
addressName,
|
|
resolvedAddress,
|
|
containing,
|
|
procedureSearch,
|
|
stringSearch,
|
|
filteredNames,
|
|
filteredStrings,
|
|
functionAnalysis,
|
|
};
|
|
}
|
|
|
|
async function verifyDebugFunctionOperations(
|
|
client,
|
|
{ procedures, strings, entry },
|
|
) {
|
|
const leaf = requireProcedure(procedures, "rea_ghidra_inventory_leaf");
|
|
const branch = requireProcedure(procedures, "rea_ghidra_inventory_branch");
|
|
const indirect = requireProcedure(
|
|
procedures,
|
|
"rea_ghidra_inventory_indirect",
|
|
);
|
|
const main = requireProcedure(procedures, "main");
|
|
const entryString = strings.find(
|
|
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
|
|
);
|
|
if (entryString === undefined)
|
|
throw new Error("Ghidra debug string function probe is unavailable");
|
|
|
|
const [info, assembly, pseudocode, callees, callers, outgoing, entryXrefs] =
|
|
await Promise.all([
|
|
functionCall(client, "procedure_info", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "procedure_assembly", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "procedure_pseudo_code", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "procedure_callees", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "procedure_callers", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
}),
|
|
functionCall(client, "procedure_references", {
|
|
document: null,
|
|
procedure: entry.value,
|
|
direction: "outgoing",
|
|
}),
|
|
functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: entry.address,
|
|
}),
|
|
]);
|
|
assertLocalProcedureInfo(info, entry.address);
|
|
assertProviderText(assembly, pseudocode, entry.address);
|
|
for (const expected of [branch.address, indirect.address])
|
|
if (!callees.includes(expected))
|
|
throw new Error(`Ghidra call graph missed ${expected}`);
|
|
if (!callers.includes(main.address) || entryXrefs.length === 0)
|
|
throw new Error("Ghidra caller or xref relation missed main");
|
|
const xrefOwners = [];
|
|
const xrefOwnerFailures = [];
|
|
for (const address of entryXrefs) {
|
|
try {
|
|
xrefOwners.push(
|
|
await call(client, "resolve_containing_procedure", {
|
|
document: null,
|
|
address,
|
|
}),
|
|
);
|
|
} catch (cause) {
|
|
xrefOwnerFailures.push({
|
|
address,
|
|
message: cause instanceof Error ? cause.message : String(cause),
|
|
});
|
|
}
|
|
}
|
|
if (
|
|
!xrefOwners.some(
|
|
(owner) => owner.found && owner.procedure.address === main.address,
|
|
)
|
|
)
|
|
throw new Error(
|
|
`Ghidra entry xrefs were not attributable to main: ${JSON.stringify({ entryXrefs, callers, xrefOwners, xrefOwnerFailures })}`,
|
|
);
|
|
assertReferenceMetadata(outgoing);
|
|
const stringXrefs = await functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: entryString.address,
|
|
});
|
|
if (stringXrefs.length === 0)
|
|
throw new Error("Ghidra xrefs missed the entry string");
|
|
|
|
const entryDossier = await functionCall(client, "analyze_function", {
|
|
procedure: entry.address,
|
|
include_assembly: true,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(entryDossier, {
|
|
address: entry.address,
|
|
expectedCallees: [branch.address, indirect.address],
|
|
expectedString: "REA_GHIDRA_INVENTORY_ENTRY",
|
|
requireAssembly: true,
|
|
});
|
|
|
|
const branchDossier = await functionCall(client, "analyze_function", {
|
|
procedure: branch.address,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(branchDossier, {
|
|
address: branch.address,
|
|
expectedCallees: [leaf.address],
|
|
requireMultiBlock: true,
|
|
});
|
|
|
|
const indirectDossier = await functionCall(client, "analyze_function", {
|
|
procedure: indirect.address,
|
|
include_assembly: true,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(indirectDossier, {
|
|
address: indirect.address,
|
|
requireAssembly: true,
|
|
});
|
|
if (
|
|
indirectDossier.callees.items.some(
|
|
({ address }) => address === leaf.address,
|
|
)
|
|
)
|
|
throw new Error(
|
|
"Ghidra dossier falsely resolved a targetless callback as the fixture leaf",
|
|
);
|
|
if (!/\bcall\b/iu.test(indirectDossier.assembly.items.join("\n")))
|
|
throw new Error("Ghidra indirect-call fixture lost its computed call site");
|
|
|
|
const aborted = new AbortController();
|
|
aborted.abort();
|
|
const cancelled = await client.callTool(
|
|
"procedure_info",
|
|
{ document: null, procedure: entry.address },
|
|
{ signal: aborted.signal },
|
|
);
|
|
if (cancelled.ok || cancelled.error.kind !== "cancelled")
|
|
throw new Error("Ghidra established-request cancellation drifted");
|
|
const timedOut = await client.callTool(
|
|
"procedure_info",
|
|
{ document: null, procedure: entry.address },
|
|
{ timeoutMs: 0 },
|
|
);
|
|
if (timedOut.ok || timedOut.error.kind !== "timeout")
|
|
throw new Error("Ghidra established-request deadline drifted");
|
|
const concurrent = await Promise.all([
|
|
functionCall(client, "procedure_info", {
|
|
document: null,
|
|
procedure: entry.address,
|
|
}),
|
|
functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: leaf.address,
|
|
}),
|
|
]);
|
|
if (concurrent.length !== 2)
|
|
throw new Error("Ghidra serial request queue lost a concurrent result");
|
|
|
|
return {
|
|
entry: dossierSummary(entryDossier),
|
|
branch: dossierSummary(branchDossier),
|
|
indirect: dossierSummary(indirectDossier),
|
|
cancellation: "cancelled-before-wire",
|
|
timeout: "expired-before-wire",
|
|
concurrency: "serialized-two-results",
|
|
};
|
|
}
|
|
|
|
async function verifyStrippedFunctionOperations(client, entryString) {
|
|
const stringXrefs = await functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: entryString.address,
|
|
});
|
|
if (stringXrefs.length === 0)
|
|
throw new Error("Ghidra stripped string xref probe is unavailable");
|
|
const containing = await call(client, "resolve_containing_procedure", {
|
|
document: null,
|
|
address: stringXrefs[0],
|
|
});
|
|
if (!containing.found)
|
|
throw new Error("Ghidra could not recover a stripped containing function");
|
|
const procedure = containing.procedure;
|
|
const pseudocode = await functionCall(client, "procedure_pseudo_code", {
|
|
document: null,
|
|
procedure: procedure.address,
|
|
});
|
|
if (typeof pseudocode !== "string" || pseudocode.trim().length === 0)
|
|
throw new Error("Ghidra did not decompile the stripped function");
|
|
const dossier = await functionCall(client, "analyze_function", {
|
|
procedure: procedure.address,
|
|
include_assembly: true,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(dossier, {
|
|
address: procedure.address,
|
|
expectedString: "REA_GHIDRA_INVENTORY_ENTRY",
|
|
requireAssembly: true,
|
|
});
|
|
if (symbolTail(dossier.procedure.name) === "rea_ghidra_inventory_entry")
|
|
throw new Error("Ghidra stripped dossier invented a source symbol");
|
|
return dossierSummary(dossier);
|
|
}
|
|
|
|
async function verifyCrossFormatOperations({
|
|
client,
|
|
variant,
|
|
procedures,
|
|
names,
|
|
strings,
|
|
}) {
|
|
const message = strings.find(
|
|
(item) => item.value === "REA_GHIDRA_CROSS_FORMAT",
|
|
);
|
|
if (message === undefined)
|
|
throw new Error(`${variant} string inventory missed the source oracle`);
|
|
const entry =
|
|
procedures.find((item) => matchesSymbol(item.value, "rea_cross_entry")) ??
|
|
names.find((item) => matchesSymbol(item.value, "rea_cross_entry"));
|
|
if (entry === undefined)
|
|
throw new Error(`${variant} did not retain the exported entry symbol`);
|
|
const entryAddress = entry.address;
|
|
const info = await functionCall(client, "procedure_info", {
|
|
document: null,
|
|
procedure: entryAddress,
|
|
});
|
|
assertLocalProcedureInfo(info, entryAddress);
|
|
const [pseudocode, assembly, callees, messageXrefs] = await Promise.all([
|
|
functionCall(client, "procedure_pseudo_code", {
|
|
document: null,
|
|
procedure: entryAddress,
|
|
}),
|
|
functionCall(client, "procedure_assembly", {
|
|
document: null,
|
|
procedure: entryAddress,
|
|
}),
|
|
functionCall(client, "procedure_callees", {
|
|
document: null,
|
|
procedure: entryAddress,
|
|
}),
|
|
functionCall(client, "xrefs", {
|
|
document: null,
|
|
address: message.address,
|
|
}),
|
|
]);
|
|
assertProviderText(assembly, pseudocode, entryAddress);
|
|
if (callees.length < 2)
|
|
throw new Error(`${variant} call graph missed direct fixture calls`);
|
|
if (messageXrefs.length === 0)
|
|
throw new Error(`${variant} xrefs missed the fixture string`);
|
|
|
|
let messageOwner = null;
|
|
for (const address of messageXrefs) {
|
|
const candidate = await call(client, "resolve_containing_procedure", {
|
|
document: null,
|
|
address,
|
|
});
|
|
if (candidate.found) {
|
|
messageOwner = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (messageOwner === null)
|
|
throw new Error(`${variant} did not resolve the string-owning function`);
|
|
const stringDossier = await functionCall(client, "analyze_function", {
|
|
procedure: messageOwner.procedure.address,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(stringDossier, {
|
|
address: messageOwner.procedure.address,
|
|
expectedString: "REA_GHIDRA_CROSS_FORMAT",
|
|
});
|
|
|
|
let branchAddress = null;
|
|
for (const address of callees) {
|
|
const candidate = await functionCall(client, "procedure_info", {
|
|
document: null,
|
|
procedure: address,
|
|
});
|
|
if (candidate.basicblock_count > 1) {
|
|
branchAddress = address;
|
|
break;
|
|
}
|
|
}
|
|
if (branchAddress === null)
|
|
throw new Error(`${variant} did not recover the multi-block callee`);
|
|
const branchDossier = await functionCall(client, "analyze_function", {
|
|
procedure: branchAddress,
|
|
limit: 500,
|
|
max_pseudocode_chars: 100_000,
|
|
max_instructions: 5_000,
|
|
});
|
|
assertDossier(branchDossier, {
|
|
address: branchAddress,
|
|
requireMultiBlock: true,
|
|
});
|
|
return {
|
|
entry: entryAddress,
|
|
exported_symbol: entry.value,
|
|
string_owner: dossierSummary(stringDossier),
|
|
branch: dossierSummary(branchDossier),
|
|
};
|
|
}
|
|
|
|
async function verifyBridgeRejections(client) {
|
|
return {
|
|
unknownDocument: await expectRemoteFailure(
|
|
client,
|
|
"list_segments",
|
|
{ document: "not-the-imported-program" },
|
|
"not_found",
|
|
),
|
|
malformedParameter: await expectRemoteFailure(
|
|
client,
|
|
"procedure_address",
|
|
{ document: null, procedure: 17 },
|
|
"invalid_request",
|
|
),
|
|
unboundedRegex: await expectRemoteFailure(
|
|
client,
|
|
"search_strings",
|
|
{
|
|
pattern: "a+",
|
|
mode: "regex",
|
|
case_sensitive: true,
|
|
offset: 0,
|
|
limit: 100,
|
|
document: null,
|
|
},
|
|
"invalid_request",
|
|
),
|
|
undeclaredMutation: await expectRemoteFailure(
|
|
client,
|
|
"set_comment",
|
|
{},
|
|
"method_unavailable",
|
|
),
|
|
};
|
|
}
|
|
|
|
async function expectRemoteFailure(client, operation, parameters, code) {
|
|
const result = await client.callTool(operation, parameters);
|
|
if (
|
|
result.ok ||
|
|
result.error.kind !== "remote" ||
|
|
result.error.remoteCode !== code ||
|
|
result.error.diagnostics.remote_code !== code
|
|
)
|
|
throw new Error(
|
|
`Ghidra ${operation} rejection drifted: ${JSON.stringify(result)}`,
|
|
);
|
|
return { operation, code };
|
|
}
|
|
|
|
async function call(client, operation, parameters) {
|
|
const called = await client.callTool(operation, parameters);
|
|
if (!called.ok)
|
|
throw new Error(
|
|
`Ghidra ${operation} failed for ${JSON.stringify(parameters)}: ${called.error.message}`,
|
|
{ cause: called.error },
|
|
);
|
|
const parsed = parseGhidraInventoryResult(operation, called.value);
|
|
if (!parsed.ok) throw parsed.error;
|
|
return parsed.value;
|
|
}
|
|
|
|
async function functionCall(client, operation, parameters) {
|
|
const input = parseGhidraFunctionInput(operation, parameters);
|
|
if (!input.ok) throw input.error;
|
|
const called = await client.callTool(operation, input.value);
|
|
if (!called.ok)
|
|
throw new Error(
|
|
`Ghidra ${operation} failed for ${JSON.stringify(input.value)}: ${called.error.message}`,
|
|
{ cause: called.error },
|
|
);
|
|
const parsed = parseGhidraFunctionResult(operation, called.value);
|
|
if (!parsed.ok) throw parsed.error;
|
|
return parsed.value;
|
|
}
|
|
|
|
function assertLocalProcedureInfo(info, expectedAddress) {
|
|
if (
|
|
info.entrypoint !== expectedAddress ||
|
|
info.basicblock_count < 1 ||
|
|
info.length < 1 ||
|
|
typeof info.signature !== "string" ||
|
|
info.signature.length === 0 ||
|
|
info.classification.external ||
|
|
info.classification.provenance !== "ghidra-function-manager"
|
|
)
|
|
throw new Error(
|
|
`Ghidra procedure metadata drifted: ${JSON.stringify(info)}`,
|
|
);
|
|
}
|
|
|
|
function assertProviderText(assembly, pseudocode, expectedAddress) {
|
|
if (
|
|
typeof assembly !== "string" ||
|
|
!assembly.includes(`${expectedAddress}:`) ||
|
|
assembly.trim().length === 0
|
|
)
|
|
throw new Error("Ghidra assembly rendering lost its address-bearing lines");
|
|
if (typeof pseudocode !== "string" || pseudocode.trim().length === 0)
|
|
throw new Error("Ghidra local function pseudocode is unavailable");
|
|
}
|
|
|
|
function assertReferenceMetadata(observed) {
|
|
if (
|
|
observed.instructions_scanned < 1 ||
|
|
observed.instruction_scan_truncated ||
|
|
observed.references.items.length === 0 ||
|
|
!observed.references.items.every(
|
|
({ kind }) =>
|
|
kind.available && kind.provenance === "ghidra-reference-manager",
|
|
) ||
|
|
!observed.references.items.some(({ kind }) => kind.call) ||
|
|
!observed.references.items.some(({ kind }) => kind.data)
|
|
)
|
|
throw new Error(
|
|
`Ghidra typed reference metadata drifted: ${JSON.stringify(observed)}`,
|
|
);
|
|
}
|
|
|
|
function assertDossier(
|
|
dossier,
|
|
{
|
|
address,
|
|
expectedCallees = [],
|
|
expectedString = null,
|
|
requireAssembly = false,
|
|
requireMultiBlock = false,
|
|
},
|
|
) {
|
|
if (
|
|
dossier.procedure.address !== address ||
|
|
dossier.procedure.classification === null ||
|
|
dossier.procedure.classification.provenance !== "ghidra-function-manager" ||
|
|
dossier.procedure.classification.external ||
|
|
dossier.pseudocode.text.trim().length === 0 ||
|
|
dossier.pseudocode.returned_chars !== [...dossier.pseudocode.text].length ||
|
|
dossier.instruction_scan.scanned < 1 ||
|
|
dossier.instruction_scan.truncated ||
|
|
!dossier.limitations.some((value) => /indirect|computed/u.test(value)) ||
|
|
!dossier.limitations.some((value) => /Ghidra|Hopper|provider/u.test(value))
|
|
)
|
|
throw new Error(
|
|
`Ghidra dossier truthfulness drifted: ${JSON.stringify(dossier)}`,
|
|
);
|
|
if (
|
|
![
|
|
...dossier.incoming_references.items,
|
|
...dossier.outgoing_references.items,
|
|
].every(
|
|
({ kind }) =>
|
|
kind.available && kind.provenance === "ghidra-reference-manager",
|
|
)
|
|
)
|
|
throw new Error("Ghidra dossier lost typed reference provenance");
|
|
for (const expected of expectedCallees)
|
|
if (!dossier.callees.items.some(({ address: value }) => value === expected))
|
|
throw new Error(`Ghidra dossier missed callee ${expected}`);
|
|
if (
|
|
expectedString !== null &&
|
|
!dossier.referenced_strings.items.some(
|
|
({ value }) => value === expectedString,
|
|
)
|
|
)
|
|
throw new Error(
|
|
`Ghidra dossier missed referenced string ${expectedString}`,
|
|
);
|
|
if (
|
|
requireAssembly &&
|
|
(dossier.assembly.items.length === 0 || dossier.assembly.truncated)
|
|
)
|
|
throw new Error("Ghidra dossier assembly was unavailable or truncated");
|
|
if (
|
|
requireMultiBlock &&
|
|
(dossier.basic_blocks.items.length < 2 ||
|
|
!dossier.basic_blocks.items.some(
|
|
({ successors }) => successors.length > 0,
|
|
))
|
|
)
|
|
throw new Error(
|
|
"Ghidra dossier CFG missed the multi-block branch structure",
|
|
);
|
|
}
|
|
|
|
function dossierSummary(dossier) {
|
|
return {
|
|
address: dossier.procedure.address,
|
|
pseudocode_chars: dossier.pseudocode.total_chars,
|
|
instructions: dossier.instruction_scan.scanned,
|
|
callees: dossier.callees.returned,
|
|
outgoing_references: dossier.outgoing_references.returned,
|
|
referenced_strings: dossier.referenced_strings.returned,
|
|
basic_blocks: dossier.basic_blocks.returned,
|
|
};
|
|
}
|
|
|
|
async function allItems(client, operation, parameters) {
|
|
const items = [];
|
|
let offset = 0;
|
|
let total;
|
|
while (true) {
|
|
const page = await call(client, operation, {
|
|
...parameters,
|
|
offset,
|
|
limit: 500,
|
|
});
|
|
if (typeof page !== "object" || page === null || Array.isArray(page))
|
|
throw new Error(`${operation} did not return a page`);
|
|
items.push(...page.items);
|
|
total ??= page.total;
|
|
if (page.total !== total)
|
|
throw new Error(`${operation} total changed during immutable paging`);
|
|
if (!page.has_more) break;
|
|
if (page.next_offset === null || page.next_offset <= offset)
|
|
throw new Error(`${operation} returned a non-advancing page`);
|
|
offset = page.next_offset;
|
|
}
|
|
if (items.length !== total)
|
|
throw new Error(`${operation} pagination was not exhaustive`);
|
|
return items;
|
|
}
|
|
|
|
function assertDebugFixture(observed) {
|
|
if (
|
|
!Array.isArray(observed.documents) ||
|
|
observed.documents.length !== 1 ||
|
|
!observed.documents[0].includes("debug")
|
|
)
|
|
throw new Error("Ghidra did not expose exactly one debug Program identity");
|
|
const entry = findValue(observed.procedures, "rea_ghidra_inventory_entry");
|
|
const leaf = findValue(observed.procedures, "rea_ghidra_inventory_leaf");
|
|
const external = observed.procedures.find(
|
|
(item) => item.procedure.external && symbolTail(item.value) === "puts",
|
|
);
|
|
if (entry === undefined || leaf === undefined || external === undefined)
|
|
throw new Error(
|
|
"Ghidra procedure inventory missed local or external functions",
|
|
);
|
|
if (
|
|
!observed.procedures.some(
|
|
(item) => item.procedure.thunk && item.procedure.thunk_target !== null,
|
|
)
|
|
)
|
|
throw new Error(
|
|
"Ghidra procedure inventory did not distinguish a resolved thunk",
|
|
);
|
|
const entryName = findValue(observed.names, "rea_ghidra_inventory_entry");
|
|
if (entryName === undefined || !entryName.symbol.primary)
|
|
throw new Error("Ghidra symbol inventory missed the primary entry symbol");
|
|
if (!observed.names.some((item) => item.symbol.external))
|
|
throw new Error("Ghidra symbol inventory missed external symbols");
|
|
assertStrings(observed.strings);
|
|
assertSegments(observed.segments, observed.session.target.image_base);
|
|
}
|
|
|
|
function assertStrippedFixture(observed) {
|
|
if (
|
|
observed.procedures.length === 0 ||
|
|
observed.names.length === 0 ||
|
|
findValue(observed.procedures, "rea_ghidra_inventory_entry") !== undefined
|
|
)
|
|
throw new Error(
|
|
"Stripped Ghidra fixture did not preserve stripped semantics",
|
|
);
|
|
assertStrings(observed.strings);
|
|
assertSegments(observed.segments, observed.session.target.image_base);
|
|
}
|
|
|
|
function assertCrossFixture(observed) {
|
|
if (
|
|
observed.procedures.length === 0 ||
|
|
observed.names.length === 0 ||
|
|
observed.probes === null ||
|
|
typeof observed.probes.entry !== "string" ||
|
|
observed.probes.string_owner.referenced_strings < 1 ||
|
|
observed.probes.branch.basic_blocks < 2
|
|
)
|
|
throw new Error(
|
|
`Cross-format Ghidra semantics drifted: ${JSON.stringify(summary(observed))}`,
|
|
);
|
|
assertSegments(observed.segments, observed.session.target.image_base);
|
|
}
|
|
|
|
async function assertMalformedFixture(path) {
|
|
const parsed = await parseBinaryTarget(path);
|
|
if (parsed.ok || parsed.error._tag !== "BinaryTargetError")
|
|
throw new Error(
|
|
"Malformed executable was not rejected before provider start",
|
|
);
|
|
}
|
|
|
|
function assertStrings(strings) {
|
|
for (const expected of [
|
|
"REA_GHIDRA_INVENTORY_ENTRY",
|
|
"REA_GHIDRA_LEAF_VALUE",
|
|
]) {
|
|
const item = strings.find((candidate) => candidate.value === expected);
|
|
if (
|
|
item === undefined ||
|
|
item.value_truncated ||
|
|
typeof item.string.encoding !== "string" ||
|
|
item.string.byte_length < expected.length
|
|
)
|
|
throw new Error(`Ghidra string inventory missed ${expected}`);
|
|
}
|
|
}
|
|
|
|
function assertSegments(segments, imageBase) {
|
|
if (
|
|
!Array.isArray(segments) ||
|
|
segments.length === 0 ||
|
|
!segments.some((item) => item.executable) ||
|
|
!segments.some((item) => item.writable) ||
|
|
!segments.every(
|
|
(item) =>
|
|
item.permissions.available === true &&
|
|
item.permissions.source === "ghidra-memory-block" &&
|
|
item.image_base === imageBase,
|
|
)
|
|
)
|
|
throw new Error("Ghidra memory-block permissions or image base drifted");
|
|
}
|
|
|
|
function findValue(items, expected) {
|
|
return items.find((item) => symbolTail(item.value) === expected);
|
|
}
|
|
|
|
function requireProcedure(items, expected) {
|
|
const observed = findValue(items, expected);
|
|
if (observed === undefined)
|
|
throw new Error(`Ghidra procedure probe is unavailable: ${expected}`);
|
|
return observed;
|
|
}
|
|
|
|
function matchesSymbol(value, expected) {
|
|
const tail = symbolTail(value);
|
|
return tail === expected || tail === `_${expected}`;
|
|
}
|
|
|
|
function symbolTail(value) {
|
|
if (typeof value !== "string") return undefined;
|
|
return value.split("::").at(-1);
|
|
}
|
|
|
|
function assertSession(session, expectedDigest) {
|
|
if (
|
|
session.provider.id !== "ghidra" ||
|
|
session.provider.version !== SUPPORTED_GHIDRA_VERSION ||
|
|
session.profile_digest !== expectedDigest ||
|
|
session.read_only !== true ||
|
|
session.analysis_complete !== true ||
|
|
session.analysis_timed_out !== false ||
|
|
session.capabilities.join(",") !== GHIDRA_SESSION_CAPABILITIES.join(",") ||
|
|
!/^0x[0-9a-f]+$/u.test(session.target.image_base) ||
|
|
session.target.default_address_space.length === 0
|
|
)
|
|
throw new Error(
|
|
`Ghidra session commitment drifted: ${JSON.stringify(session)}`,
|
|
);
|
|
}
|
|
|
|
function assertRuntimeCoordinates(coordinates) {
|
|
for (const name of ["runtime_root", "socket_path", "project_root"])
|
|
if (typeof coordinates[name] !== "string")
|
|
throw new Error(`Ghidra verifier did not observe ${name}`);
|
|
if (typeof coordinates.process_id !== "number")
|
|
throw new Error("Ghidra verifier did not observe its process ID");
|
|
}
|
|
|
|
async function assertCleanup(coordinates) {
|
|
for (const name of ["runtime_root", "socket_path", "project_root"]) {
|
|
const path = coordinates[name];
|
|
if (typeof path === "string" && (await exists(path)))
|
|
throw new Error(`Ghidra cleanup left ${name}: ${path}`);
|
|
}
|
|
const pid = coordinates.process_id;
|
|
if (typeof pid === "number" && processExists(pid))
|
|
throw new Error(`Ghidra cleanup left process ${String(pid)}`);
|
|
}
|
|
|
|
function summary(observed) {
|
|
return {
|
|
variant: observed.variant,
|
|
target_path: observed.target.path,
|
|
target_sha256: observed.target.sha256,
|
|
analysis_profile_digest: observed.profile.digest,
|
|
language_id: observed.session.target.language_id,
|
|
compiler_spec_id: observed.session.target.compiler_spec_id,
|
|
image_base: observed.session.target.image_base,
|
|
procedures: observed.procedures.length,
|
|
names: observed.names.length,
|
|
strings: observed.strings.length,
|
|
memory_blocks: observed.segments.length,
|
|
};
|
|
}
|
|
|
|
async function exists(path) {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function processExists(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (cause) {
|
|
return !(
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
cause.code === "ESRCH"
|
|
);
|
|
}
|
|
}
|