mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
Scope expected Ghidra input failures to the active request after the authenticated startup handshake. Preserve operation output, identity and log coordinates while retaining cumulative output with its session owner; lifecycle and unexpected failures keep full diagnostics. Share remote input-code classification with domain error projection. Verify startup rejection, healthy continuation, cleanup, unknown errors, OOM diagnostics and real CLI/MCP workflows. The measured unknown-symbol reply falls from 13,170 to 2,209 bytes with unchanged successful facts and Evidence IDs.
545 lines
16 KiB
JavaScript
545 lines
16 KiB
JavaScript
import { rename, rm, writeFile } from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
|
|
const configuration = JSON.parse(
|
|
process.env.REA_GHIDRA_FIXTURE_CONFIG ?? "null",
|
|
);
|
|
const {
|
|
endpointPath,
|
|
token,
|
|
runId,
|
|
providerVersion,
|
|
profileDigest,
|
|
targetSha256,
|
|
transport,
|
|
mode = "success",
|
|
} = configuration ?? {};
|
|
|
|
if (
|
|
endpointPath === undefined ||
|
|
token === undefined ||
|
|
runId === undefined ||
|
|
providerVersion === undefined ||
|
|
profileDigest === undefined ||
|
|
targetSha256 === undefined ||
|
|
!["unix-socket", "authenticated-loopback-tcp"].includes(transport)
|
|
) {
|
|
process.exit(64);
|
|
}
|
|
|
|
const OOME_LINE =
|
|
"ERROR Abort due to Headless analyzer error: Java heap space (HeadlessAnalyzer) java.lang.OutOfMemoryError: Java heap space\n";
|
|
// Exceeds the 8 MiB diagnostic retention budget in many small chunks.
|
|
const floodStdout = async () => {
|
|
const line = "INFO fixture analyzer progress line\n";
|
|
const block = line.repeat(Math.ceil((64 * 1024) / line.length));
|
|
for (let written = 0; written < 10.5 * 1024 * 1024;) {
|
|
if (!process.stdout.write(block))
|
|
await new Promise((resolve) => process.stdout.once("drain", resolve));
|
|
written += block.length;
|
|
}
|
|
};
|
|
|
|
// The retention boundary ends exactly at the class-name prefix of a different identifier.
|
|
const cutIdentifierAtBudget = async () => {
|
|
const marker = "java.lang.OutOfMemoryError";
|
|
let remaining = 8 * 1024 * 1024 - marker.length;
|
|
const block = Buffer.alloc(64 * 1024, 0x70);
|
|
while (remaining > 0) {
|
|
const part = block.subarray(0, Math.min(remaining, block.length));
|
|
if (!process.stdout.write(part))
|
|
await new Promise((resolve) => process.stdout.once("drain", resolve));
|
|
remaining -= part.length;
|
|
}
|
|
await new Promise((resolve, reject) =>
|
|
process.stdout.write(`${marker}Custom\n`, (error) =>
|
|
error ? reject(error) : resolve(),
|
|
),
|
|
);
|
|
process.exit(74);
|
|
};
|
|
|
|
const writeThen = (stream, text, then) =>
|
|
stream.write(text, () => setTimeout(then, 10));
|
|
|
|
if (mode === "remote_error" || mode === "remote_startup_error")
|
|
await new Promise((resolve) =>
|
|
process.stdout.write("Fixture launcher startup output\n", resolve),
|
|
);
|
|
|
|
let endpointPublication;
|
|
if (mode === "exit") process.exit(73);
|
|
if (mode === "flood_silent") {
|
|
await floodStdout();
|
|
setInterval(() => undefined, 1_000);
|
|
} else if (mode === "silent") setInterval(() => undefined, 1_000);
|
|
else {
|
|
await rm(endpointPath, { force: true });
|
|
const sockets = new Set();
|
|
let rejectedConnections = 0;
|
|
const server = createServer((socket) => {
|
|
if (
|
|
mode === "reject_all_connections" ||
|
|
(mode === "reject_first_connection" && rejectedConnections++ === 0)
|
|
) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
sockets.add(socket);
|
|
socket.once("close", () => sockets.delete(socket));
|
|
socket.setEncoding("utf8");
|
|
const state = {
|
|
buffer: { value: "" },
|
|
pings: { value: 0 },
|
|
emittedOome: false,
|
|
mode,
|
|
token,
|
|
runId,
|
|
providerVersion,
|
|
profileDigest,
|
|
targetSha256,
|
|
};
|
|
socket.on("data", (chunk) => onSocketData(socket, server, chunk, state));
|
|
});
|
|
process.on("message", (message) => {
|
|
if (message !== "disconnect") return;
|
|
for (const socket of sockets) socket.destroy();
|
|
process.send?.({ event: "disconnected" });
|
|
});
|
|
if (transport === "unix-socket") server.listen(endpointPath);
|
|
else {
|
|
endpointPublication = (async () => {
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
server.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
const address = server.address();
|
|
if (address === null || typeof address === "string") process.exit(65);
|
|
const pending = `${endpointPath}.pending`;
|
|
await writeFile(
|
|
pending,
|
|
`${JSON.stringify({
|
|
host: "127.0.0.1",
|
|
port: address.port,
|
|
})}\n`,
|
|
{ flag: "wx" },
|
|
);
|
|
await rename(pending, endpointPath);
|
|
})();
|
|
}
|
|
}
|
|
|
|
const onSocketData = (socket, server, chunk, state) => {
|
|
state.buffer.value += chunk;
|
|
let newline = state.buffer.value.indexOf("\n");
|
|
while (newline >= 0) {
|
|
const line = state.buffer.value.slice(0, newline);
|
|
state.buffer.value = state.buffer.value.slice(newline + 1);
|
|
newline = state.buffer.value.indexOf("\n");
|
|
const request = JSON.parse(line);
|
|
process.send?.({ method: request.method });
|
|
if (request.token !== state.token) {
|
|
socket.end(
|
|
`${JSON.stringify({
|
|
id: request.id,
|
|
ok: false,
|
|
error: { code: "auth", message: "Authentication failed" },
|
|
})}\n`,
|
|
);
|
|
continue;
|
|
}
|
|
handleRequest(socket, server, request, state);
|
|
}
|
|
};
|
|
|
|
/** Tool-request failures that follow process output, as the Ghidra JVM prints them. */
|
|
const handleOutputFailure = (socket, state) => {
|
|
const oome = () =>
|
|
writeThen(process.stderr, OOME_LINE, () => process.exit(1));
|
|
if (state.mode === "prefix_cut_identifier_exit_tools")
|
|
void cutIdentifierAtBudget();
|
|
else if (state.mode === "oom_exit_tools") oome();
|
|
else if (state.mode === "sigkill_tools")
|
|
writeThen(process.stdout, "WARN Decompiling 14005c790, pcode error\n", () =>
|
|
process.kill(process.pid, "SIGKILL"),
|
|
);
|
|
else if (state.mode === "flood_then_oom_exit_tools")
|
|
void floodStdout().then(oome);
|
|
else if (state.mode === "flood_then_exit_tools")
|
|
void floodStdout().then(() => process.exit(74));
|
|
else if (state.mode === "flood_then_malformed")
|
|
void floodStdout().then(() => socket.write("{invalid\n"));
|
|
else return false;
|
|
return true;
|
|
};
|
|
|
|
const handleRequest = (socket, server, request, state) => {
|
|
if (request.method === "ping") {
|
|
handlePing(socket, request, state);
|
|
return;
|
|
}
|
|
if (
|
|
state.mode === "old_oom_then_malformed" &&
|
|
request.method === "list_documents" &&
|
|
!state.emittedOome
|
|
) {
|
|
state.emittedOome = true;
|
|
process.stderr.write(
|
|
"ERROR Abort due to Headless analyzer error: Java heap space (HeadlessAnalyzer) java.lang.OutOfMemoryError:\n",
|
|
() => setTimeout(() => handleRequest(socket, server, request, state), 10),
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
state.mode === "old_oom_then_malformed" &&
|
|
request.method === "list_procedures" &&
|
|
state.emittedOome
|
|
) {
|
|
process.stderr.write("Current operation diagnostic: 雪\n", () =>
|
|
setTimeout(() => socket.write("{invalid\n"), 10),
|
|
);
|
|
return;
|
|
}
|
|
if (state.mode === "remote_error" && request.method === "list_procedures") {
|
|
writeThen(
|
|
process.stdout,
|
|
"Fixture request rejected: missing procedure\n",
|
|
() =>
|
|
socket.write(
|
|
`${JSON.stringify({
|
|
id: request.id,
|
|
ok: false,
|
|
error: {
|
|
code: "not_found",
|
|
message: "Unknown Ghidra procedure name",
|
|
},
|
|
})}\n`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (state.mode === "exit_tools" && request.method !== "shutdown")
|
|
process.exit(74);
|
|
if (
|
|
state.mode === "sigkill_after_first_tool" &&
|
|
request.method !== "shutdown" &&
|
|
(state.toolRequests = (state.toolRequests ?? 0) + 1) > 1
|
|
)
|
|
process.kill(process.pid, "SIGKILL");
|
|
if (request.method !== "shutdown" && handleOutputFailure(socket, state))
|
|
return;
|
|
if (state.mode === "hang_tools" && request.method !== "shutdown") return;
|
|
const inventory =
|
|
request.method === "list_strings" && state.mode === "fragmented"
|
|
? [stringItem("0x2000", "fixture 雪🦊")]
|
|
: request.method === "list_strings" && state.mode === "oversized_result"
|
|
? [stringItem("0x2000", "x".repeat(64 * 1024 * 1024 + 1))]
|
|
: inventoryResultFor(request.method, request.params);
|
|
if (inventory !== undefined) {
|
|
const response = `${JSON.stringify({ id: request.id, ok: true, result: inventory })}\n`;
|
|
if (state.mode === "fragmented") {
|
|
const bytes = Buffer.from(response, "utf8");
|
|
const unicodeOffset = bytes.indexOf(Buffer.from("雪", "utf8"));
|
|
const midpoint =
|
|
unicodeOffset >= 0 ? unicodeOffset + 1 : Math.floor(bytes.length / 2);
|
|
socket.write(bytes.subarray(0, midpoint));
|
|
setTimeout(() => socket.write(bytes.subarray(midpoint)), 5);
|
|
} else socket.write(response);
|
|
return;
|
|
}
|
|
if (request.method === "shutdown") {
|
|
socket.end(
|
|
`${JSON.stringify({
|
|
id: request.id,
|
|
...(state.mode === "shutdown_error"
|
|
? {
|
|
ok: false,
|
|
error: {
|
|
code: "shutdown_fixture_failure",
|
|
message: `Shutdown denied for ${state.token}: /tmp/password-fixture http://localhost/secret?token=public-value`,
|
|
},
|
|
}
|
|
: {
|
|
ok: true,
|
|
result: {
|
|
shutdown: state.mode !== "invalid_shutdown_ack",
|
|
project_ephemeral: true,
|
|
},
|
|
}),
|
|
})}\n`,
|
|
);
|
|
server.close(() => process.exit(0));
|
|
}
|
|
};
|
|
|
|
const handlePing = (socket, request, state) => {
|
|
if (state.mode === "remote_startup_error") {
|
|
socket.write(
|
|
`${JSON.stringify({
|
|
id: request.id,
|
|
ok: false,
|
|
error: {
|
|
code: "invalid_request",
|
|
message: "Fixture rejected startup handshake",
|
|
},
|
|
})}\n`,
|
|
);
|
|
return;
|
|
}
|
|
state.pings.value += 1;
|
|
if (state.mode === "diagnostic_output" && state.pings.value === 1) {
|
|
const retainedTokenPrefixBytes = Math.floor(state.token.length / 2);
|
|
const preservedMarker = "source=/tmp/local-evidence.bin?cursor=keep&";
|
|
const fillLength = 8 * 1024 * 1024 - retainedTokenPrefixBytes;
|
|
const fill = "x".repeat(fillLength - Buffer.byteLength(preservedMarker));
|
|
process.stderr.write(`${fill}${preservedMarker}`);
|
|
setImmediate(() => {
|
|
process.stderr.write(state.token);
|
|
});
|
|
}
|
|
if (state.mode === "malformed") {
|
|
socket.write("{invalid\n");
|
|
return;
|
|
}
|
|
if (state.mode === "contradictory") {
|
|
socket.write(
|
|
`${JSON.stringify({
|
|
id: request.id,
|
|
ok: false,
|
|
result: { unexpected: true },
|
|
error: { code: "failure", message: "Failed" },
|
|
})}\n`,
|
|
);
|
|
return;
|
|
}
|
|
if (state.mode === "future_id") {
|
|
socket.write(
|
|
`${JSON.stringify({ id: request.id + 10, ok: true, result: null })}\n`,
|
|
);
|
|
return;
|
|
}
|
|
if (state.mode === "hang_after_start" && state.pings.value > 1) return;
|
|
const result = sessionInfo({
|
|
runId: state.runId,
|
|
providerVersion:
|
|
state.mode === "wrong_identity" ? "0.0.0" : state.providerVersion,
|
|
profileDigest: state.profileDigest,
|
|
targetSha256: state.targetSha256,
|
|
timedOut: state.mode === "analysis_timeout",
|
|
});
|
|
const response = `${JSON.stringify({ id: request.id, ok: true, result })}\n`;
|
|
if (state.mode === "fragmented") {
|
|
const split = Math.floor(response.length / 2);
|
|
socket.write(response.slice(0, split));
|
|
setTimeout(() => socket.write(response.slice(split)), 5);
|
|
} else socket.write(response);
|
|
};
|
|
|
|
const sessionInfo = ({
|
|
runId: sessionRunId,
|
|
providerVersion: version,
|
|
profileDigest: digest,
|
|
targetSha256: sha256,
|
|
timedOut,
|
|
}) => ({
|
|
name: "REA Ghidra bridge",
|
|
run_id: sessionRunId,
|
|
profile_digest: digest,
|
|
provider: { id: "ghidra", version },
|
|
read_only: transport !== "unix-socket",
|
|
analysis_complete: !timedOut,
|
|
analysis_timed_out: timedOut,
|
|
capabilities: [
|
|
"read_binary_inventory_summary",
|
|
"ping",
|
|
"shutdown",
|
|
"inspect_native_load_image",
|
|
"read_bytes",
|
|
"search_bytes",
|
|
"address_to_file_offset",
|
|
"address_name",
|
|
"list_documents",
|
|
"list_names",
|
|
"list_procedures",
|
|
"list_segments",
|
|
"list_strings",
|
|
"procedure_address",
|
|
"resolve_containing_procedure",
|
|
"search_procedures",
|
|
"search_strings",
|
|
"set_address_name",
|
|
"set_addresses_names",
|
|
"annotate_native_function",
|
|
"inspect_native_data_type",
|
|
"inspect_native_instruction",
|
|
"resolve_native_call_targets",
|
|
"analyze_function",
|
|
"procedure_assembly",
|
|
"procedure_callees",
|
|
"procedure_callers",
|
|
"procedure_info",
|
|
"procedure_pseudo_code",
|
|
"read_function_instructions",
|
|
"procedure_references",
|
|
"xrefs",
|
|
].filter(
|
|
(value) =>
|
|
transport === "unix-socket" ||
|
|
![
|
|
"annotate_native_function",
|
|
"set_address_name",
|
|
"set_addresses_names",
|
|
].includes(value),
|
|
),
|
|
target: {
|
|
name: "fixture",
|
|
language_id: "x86:LE:64:default",
|
|
compiler_spec_id: "gcc",
|
|
image_base: "0x1000",
|
|
default_address_space: "ram",
|
|
sha256,
|
|
},
|
|
});
|
|
|
|
const inventoryResultFor = (method, params) => {
|
|
switch (method) {
|
|
case "read_binary_inventory_summary":
|
|
return {
|
|
document: "fixture",
|
|
segments: inventoryResultFor("list_segments", {}),
|
|
procedure_count: inventoryResultFor("list_procedures", {}).length,
|
|
string_count: inventoryResultFor("list_strings", {}).length,
|
|
};
|
|
case "search_bytes": {
|
|
const continued = params.cursor === "fixture-second-page";
|
|
const paged = params.limit === 1 && !continued;
|
|
const addresses = continued
|
|
? ["0x1005"]
|
|
: paged
|
|
? ["0x1000"]
|
|
: ["0x1000", "0x1005"];
|
|
return {
|
|
matches: addresses.map((address) => ({
|
|
address,
|
|
segment: ".text",
|
|
file_offset: address === "0x1000" ? 0 : 5,
|
|
procedure: { address: "0x1000", name: "fixture_main" },
|
|
})),
|
|
searched: {
|
|
blocks: [
|
|
{
|
|
segment: ".text",
|
|
start: continued ? "0x1005" : "0x1000",
|
|
end: "0x1009",
|
|
bytes: continued ? 5 : 10,
|
|
},
|
|
],
|
|
bytes: continued ? 5 : 10,
|
|
skipped_uninitialized: [
|
|
{ segment: ".bss", start: "0x2000", end: "0x200f", bytes: 16 },
|
|
],
|
|
skipped_not_loaded: [],
|
|
},
|
|
complete: !paged,
|
|
next_cursor: paged ? "fixture-second-page" : null,
|
|
};
|
|
}
|
|
case "address_name":
|
|
return "fixture_main";
|
|
case "list_documents":
|
|
return ["fixture"];
|
|
case "list_names":
|
|
return [nameItem()];
|
|
case "list_procedures":
|
|
return [procedureItem()];
|
|
case "list_segments":
|
|
return [segmentItem()];
|
|
case "list_strings":
|
|
return [stringItem("0x2000", "fixture value")];
|
|
case "procedure_address":
|
|
return "0x1000";
|
|
case "resolve_containing_procedure":
|
|
return containingProcedureResult();
|
|
case "search_procedures":
|
|
return [procedureSearchItem()];
|
|
case "search_strings":
|
|
return [stringSearchItem()];
|
|
default:
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const nameItem = () => ({
|
|
address: "0x1000",
|
|
value: "fixture_main",
|
|
symbol: {
|
|
primary: true,
|
|
dynamic: false,
|
|
external: false,
|
|
type: "function",
|
|
source: "analysis",
|
|
},
|
|
});
|
|
|
|
const procedureItem = () => ({
|
|
address: "0x1000",
|
|
value: "fixture_main",
|
|
procedure: { external: false, thunk: false, thunk_target: null },
|
|
});
|
|
|
|
const segmentItem = () => ({
|
|
name: ".text",
|
|
start: "0x1000",
|
|
end: "0x1100",
|
|
readable: true,
|
|
writable: false,
|
|
executable: true,
|
|
permissions: { available: true, source: "ghidra-memory-block" },
|
|
provenance: "ghidra-memory-block",
|
|
address_space: "ram",
|
|
image_base: "0x1000",
|
|
initialized: true,
|
|
overlay: false,
|
|
sections: [],
|
|
});
|
|
|
|
const stringItem = (address, value) => ({
|
|
address,
|
|
value,
|
|
string: {
|
|
encoding: "UTF-8",
|
|
termination: "present_or_not_required",
|
|
byte_length: Buffer.byteLength(value, "utf8") + 1,
|
|
},
|
|
});
|
|
|
|
const containingProcedureResult = () => ({
|
|
query_address: "0x1001",
|
|
found: true,
|
|
procedure: {
|
|
address: "0x1000",
|
|
name: "fixture_main",
|
|
classification: {
|
|
external: false,
|
|
thunk: false,
|
|
thunk_target: null,
|
|
provenance: "ghidra-function-manager",
|
|
},
|
|
},
|
|
});
|
|
|
|
const procedureSearchItem = () => ({
|
|
address: "0x1000",
|
|
value: "fixture_main",
|
|
});
|
|
|
|
const stringSearchItem = () => ({
|
|
address: "0x2000",
|
|
value: "fixture value",
|
|
});
|
|
|
|
await endpointPublication;
|