feat(ghidra): add authenticated Windows loopback transport
Keep private Unix sockets on supported POSIX hosts and add a Windows P0 transport that binds the Java bridge to IPv4 loopback on an ephemeral port. Publish only a strict, token-free endpoint record through an atomic rename, validate it before connecting, and retain the same authenticated request protocol, bounded queue, handshake, cancellation, and shutdown semantics across both transports.
This commit is contained in:
parent
be19c3fd12
commit
629ffc3872
|
|
@ -7,15 +7,20 @@ import java.io.ByteArrayOutputStream;
|
|||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.math.BigInteger;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.StandardProtocolFamily;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
|
|
@ -85,7 +90,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
||||
private static final Set<String> DESCRIPTOR_KEYS = Set.of(
|
||||
"schema_version",
|
||||
"socket_path",
|
||||
"transport",
|
||||
"endpoint_path",
|
||||
"token",
|
||||
"run_id",
|
||||
"target_sha256",
|
||||
|
|
@ -167,22 +173,77 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
}
|
||||
|
||||
private void serve(SessionDescriptor descriptor) throws Exception {
|
||||
Path socketPath = Path.of(descriptor.socketPath);
|
||||
if (descriptor.transport.equals("unix-socket")) {
|
||||
serveUnixSocket(descriptor);
|
||||
return;
|
||||
}
|
||||
if (descriptor.transport.equals("authenticated-loopback-tcp")) {
|
||||
serveLoopbackTcp(descriptor);
|
||||
return;
|
||||
}
|
||||
throw new IllegalArgumentException("REA bridge transport is invalid");
|
||||
}
|
||||
|
||||
private void serveUnixSocket(SessionDescriptor descriptor) throws Exception {
|
||||
Path socketPath = Path.of(descriptor.endpointPath);
|
||||
Files.deleteIfExists(socketPath);
|
||||
try (ServerSocketChannel server = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) {
|
||||
server.bind(UnixDomainSocketAddress.of(socketPath));
|
||||
Files.setPosixFilePermissions(socketPath, PosixFilePermissions.fromString("rw-------"));
|
||||
try (SocketChannel client = server.accept();
|
||||
BufferedReader reader = new BufferedReader(Channels.newReader(client, StandardCharsets.UTF_8));
|
||||
BufferedWriter writer = new BufferedWriter(Channels.newWriter(client, StandardCharsets.UTF_8))) {
|
||||
serveClient(descriptor, reader, writer);
|
||||
}
|
||||
acceptClient(server, descriptor);
|
||||
}
|
||||
finally {
|
||||
Files.deleteIfExists(socketPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void serveLoopbackTcp(SessionDescriptor descriptor) throws Exception {
|
||||
Path endpointPath = Path.of(descriptor.endpointPath);
|
||||
Path pendingPath = endpointPath.resolveSibling(endpointPath.getFileName() + ".pending");
|
||||
Files.deleteIfExists(endpointPath);
|
||||
Files.deleteIfExists(pendingPath);
|
||||
try (ServerSocketChannel server = ServerSocketChannel.open()) {
|
||||
server.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0));
|
||||
InetSocketAddress address = (InetSocketAddress) server.getLocalAddress();
|
||||
JsonObject endpoint = new JsonObject();
|
||||
endpoint.addProperty("schema_version", 1);
|
||||
endpoint.addProperty("host", "127.0.0.1");
|
||||
endpoint.addProperty("port", address.getPort());
|
||||
Files.writeString(
|
||||
pendingPath,
|
||||
GSON.toJson(endpoint) + "\n",
|
||||
StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE_NEW,
|
||||
StandardOpenOption.WRITE
|
||||
);
|
||||
try {
|
||||
Files.move(pendingPath, endpointPath, StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(pendingPath, endpointPath);
|
||||
}
|
||||
acceptClient(server, descriptor);
|
||||
}
|
||||
finally {
|
||||
Files.deleteIfExists(pendingPath);
|
||||
Files.deleteIfExists(endpointPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void acceptClient(
|
||||
ServerSocketChannel server,
|
||||
SessionDescriptor descriptor) throws Exception {
|
||||
try (SocketChannel client = server.accept();
|
||||
BufferedReader reader = new BufferedReader(
|
||||
Channels.newReader(client, StandardCharsets.UTF_8)
|
||||
);
|
||||
BufferedWriter writer = new BufferedWriter(
|
||||
Channels.newWriter(client, StandardCharsets.UTF_8)
|
||||
)) {
|
||||
serveClient(descriptor, reader, writer);
|
||||
}
|
||||
}
|
||||
|
||||
private void serveClient(
|
||||
SessionDescriptor descriptor,
|
||||
BufferedReader reader,
|
||||
|
|
@ -1598,11 +1659,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)),
|
||||
DESCRIPTOR_KEYS
|
||||
);
|
||||
if (requireInteger(object, "schema_version") != 1) {
|
||||
if (requireInteger(object, "schema_version") != 2) {
|
||||
throw new IllegalArgumentException("REA session descriptor version is invalid");
|
||||
}
|
||||
return new SessionDescriptor(
|
||||
requireString(object, "socket_path"),
|
||||
requireString(object, "transport"),
|
||||
requireString(object, "endpoint_path"),
|
||||
requireString(object, "token"),
|
||||
requireString(object, "run_id"),
|
||||
requireSha256(object, "target_sha256"),
|
||||
|
|
@ -1816,7 +1878,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
private record InstructionScan(List<Instruction> instructions, boolean truncated) {}
|
||||
private record TruncatedValue(String value, boolean truncated) {}
|
||||
private record SessionDescriptor(
|
||||
String socketPath,
|
||||
String transport,
|
||||
String endpointPath,
|
||||
String token,
|
||||
String runId,
|
||||
String targetSha256,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { access } from "node:fs/promises";
|
||||
import type { Socket } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
|
@ -46,9 +46,13 @@ import {
|
|||
attachGhidraSocket,
|
||||
connectGhidraSocketOnce,
|
||||
} from "./GhidraSocketConnection.js";
|
||||
import {
|
||||
observeGhidraEndpoint,
|
||||
type GhidraEndpoint,
|
||||
} from "./GhidraTransport.js";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||
const SESSION_ROOT = "/tmp";
|
||||
const SESSION_ROOT = tmpdir();
|
||||
|
||||
export type {
|
||||
GhidraClientOptions,
|
||||
|
|
@ -65,7 +69,10 @@ export type GhidraOperation =
|
|||
/** Owns one authenticated, private, read-only Ghidra headless session. */
|
||||
export class GhidraClient {
|
||||
readonly #options: Required<
|
||||
Pick<GhidraClientOptions, "requestTimeoutMs" | "startupTimeoutMs">
|
||||
Pick<
|
||||
GhidraClientOptions,
|
||||
"requestTimeoutMs" | "startupTimeoutMs" | "transport"
|
||||
>
|
||||
> &
|
||||
GhidraClientOptions;
|
||||
readonly #pending = new PendingOperations<
|
||||
|
|
@ -120,6 +127,7 @@ export class GhidraClient {
|
|||
...options,
|
||||
requestTimeoutMs: options.requestTimeoutMs ?? GHIDRA_REQUEST_TIMEOUT_MS,
|
||||
startupTimeoutMs: options.startupTimeoutMs ?? GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
transport: options.transport ?? "unix-socket",
|
||||
};
|
||||
this.#requestQueue = new GhidraRequestQueue(
|
||||
GHIDRA_MAX_QUEUED_REQUESTS,
|
||||
|
|
@ -233,7 +241,15 @@ export class GhidraClient {
|
|||
);
|
||||
}
|
||||
if (deadline.signal.aborted) return this.#startupInterrupted(deadline);
|
||||
const socketPath = join(this.#runtimeRoot.path, "bridge.sock");
|
||||
const endpoint: GhidraEndpoint = {
|
||||
transport: this.#options.transport,
|
||||
path: join(
|
||||
this.#runtimeRoot.path,
|
||||
this.#options.transport === "unix-socket"
|
||||
? "bridge.sock"
|
||||
: "bridge-endpoint.json",
|
||||
),
|
||||
};
|
||||
try {
|
||||
const snapshot = await createGhidraTargetSnapshot(
|
||||
this.#options.targetPath,
|
||||
|
|
@ -257,7 +273,8 @@ export class GhidraClient {
|
|||
.launch(
|
||||
{
|
||||
runtimeRoot: this.#runtimeRoot.path,
|
||||
socketPath,
|
||||
transport: endpoint.transport,
|
||||
endpointPath: endpoint.path,
|
||||
token: this.#token,
|
||||
runId: this.#runId,
|
||||
targetPath: this.#snapshotPath,
|
||||
|
|
@ -283,7 +300,7 @@ export class GhidraClient {
|
|||
this.#process = new ProviderProcessSupervisor(launched.value, {
|
||||
onDiagnostic: (event) => this.#onProcessDiagnostic(event),
|
||||
});
|
||||
const connected = await this.#connect(socketPath, deadline);
|
||||
const connected = await this.#connect(endpoint, deadline);
|
||||
if (!connected.ok) {
|
||||
const failure = connected.error;
|
||||
await this.#cleanup();
|
||||
|
|
@ -319,7 +336,7 @@ export class GhidraClient {
|
|||
}
|
||||
|
||||
async #connect(
|
||||
socketPath: string,
|
||||
endpoint: GhidraEndpoint,
|
||||
deadline: ProviderStartupDeadline,
|
||||
): Promise<Result<undefined, GhidraSessionError>> {
|
||||
while (deadline.remainingMs() > 0) {
|
||||
|
|
@ -331,15 +348,15 @@ export class GhidraClient {
|
|||
return err(
|
||||
this.#failure("process", "Ghidra exited before bridge startup"),
|
||||
);
|
||||
try {
|
||||
await access(socketPath);
|
||||
} catch {
|
||||
const observed = await observeGhidraEndpoint(endpoint);
|
||||
if (!observed.ok) return observed;
|
||||
if (observed.value === null) {
|
||||
if ((await deadline.wait(50)) === "aborted")
|
||||
return err(this.#interruptionFailure(deadline));
|
||||
continue;
|
||||
}
|
||||
const connected = await connectGhidraSocketOnce(
|
||||
socketPath,
|
||||
observed.value,
|
||||
deadline.signal,
|
||||
);
|
||||
if (connected.ok) {
|
||||
|
|
@ -565,6 +582,7 @@ export class GhidraClient {
|
|||
return createGhidraDiagnostics({
|
||||
targetPath: this.#options.targetPath,
|
||||
targetSha256: this.#options.targetSha256,
|
||||
transport: this.#options.transport,
|
||||
providerVersion: this.#options.providerVersion,
|
||||
profileDigest: this.#options.profileDigest,
|
||||
...(this.#runtimeRoot === undefined
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Result } from "../domain/result.js";
|
|||
import type { GhidraLauncher } from "./GhidraLauncher.js";
|
||||
import type { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
|
||||
import type { GhidraTransportKind } from "./GhidraTransport.js";
|
||||
|
||||
/** Result of opening and authenticating one headless Ghidra session. */
|
||||
export type GhidraStartResult = Result<GhidraSessionInfo, GhidraSessionError>;
|
||||
|
|
@ -34,6 +35,7 @@ export interface GhidraClientOptions {
|
|||
readonly launcher: GhidraLauncher;
|
||||
readonly targetPath: string;
|
||||
readonly targetSha256: string;
|
||||
readonly transport?: GhidraTransportKind;
|
||||
readonly providerVersion: string;
|
||||
readonly profileDigest: string;
|
||||
readonly requestTimeoutMs?: number;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
|
||||
import type { GhidraLaunch } from "./GhidraLauncher.js";
|
||||
import type { GhidraTransportKind } from "./GhidraTransport.js";
|
||||
|
||||
/** Inputs needed to project bounded diagnostics for one Ghidra runtime. */
|
||||
export interface GhidraDiagnosticsOptions {
|
||||
readonly targetPath: string;
|
||||
readonly targetSha256: string;
|
||||
readonly transport: GhidraTransportKind;
|
||||
readonly providerVersion: string;
|
||||
readonly profileDigest: string;
|
||||
readonly runtimeRoot?: string;
|
||||
|
|
@ -32,13 +34,18 @@ export const createGhidraDiagnostics = (
|
|||
return {
|
||||
target_path: options.targetPath,
|
||||
target_sha256: options.targetSha256,
|
||||
transport: options.transport,
|
||||
provider_version: options.providerVersion,
|
||||
profile_digest: options.profileDigest,
|
||||
...(options.runtimeRoot === undefined
|
||||
? {}
|
||||
: {
|
||||
runtime_root: options.runtimeRoot,
|
||||
socket_path: `${options.runtimeRoot}/bridge.sock`,
|
||||
endpoint_path: `${options.runtimeRoot}/${
|
||||
options.transport === "unix-socket"
|
||||
? "bridge.sock"
|
||||
: "bridge-endpoint.json"
|
||||
}`,
|
||||
}),
|
||||
...(options.launch === undefined
|
||||
? {}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ import {
|
|||
GHIDRA_MAX_HEAP,
|
||||
} from "./GhidraDefaults.js";
|
||||
import { ghidraJavaEnvironment } from "./GhidraInstallation.js";
|
||||
import type { GhidraTransportKind } from "./GhidraTransport.js";
|
||||
|
||||
/** Private paths and identity material for one headless Ghidra import. */
|
||||
export interface GhidraLaunchSession {
|
||||
readonly runtimeRoot: string;
|
||||
readonly socketPath: string;
|
||||
readonly transport: GhidraTransportKind;
|
||||
readonly endpointPath: string;
|
||||
readonly token: string;
|
||||
readonly runId: string;
|
||||
readonly targetPath: string;
|
||||
|
|
@ -86,8 +88,9 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||
await writeFileAtomic(
|
||||
paths.descriptorPath,
|
||||
`${JSON.stringify({
|
||||
schema_version: 1,
|
||||
socket_path: session.socketPath,
|
||||
schema_version: 2,
|
||||
transport: session.transport,
|
||||
endpoint_path: session.endpointPath,
|
||||
token: session.token,
|
||||
run_id: session.runId,
|
||||
target_sha256: session.targetSha256,
|
||||
|
|
|
|||
|
|
@ -357,6 +357,10 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
|||
}),
|
||||
targetPath: target.path,
|
||||
targetSha256: target.sha256,
|
||||
transport:
|
||||
installation.platform === "win32"
|
||||
? "authenticated-loopback-tcp"
|
||||
: "unix-socket",
|
||||
providerVersion: prerequisites.value.providerVersion,
|
||||
profileDigest: committedProfile.digest,
|
||||
logger: this.logger.child({ layer: "ghidra-bridge" }),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createConnection, type Socket } from "node:net";
|
|||
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import type { GhidraConnectTarget } from "./GhidraTransport.js";
|
||||
|
||||
/** Stable listeners attached for the lifetime of one bridge socket. */
|
||||
export interface GhidraSocketHandlers {
|
||||
|
|
@ -26,13 +27,16 @@ export const attachGhidraSocket = (
|
|||
};
|
||||
};
|
||||
|
||||
/** Open one abort-aware Ghidra Unix-socket connection attempt. */
|
||||
/** Open one abort-aware connection to an observed local Ghidra endpoint. */
|
||||
export const connectGhidraSocketOnce = (
|
||||
socketPath: string,
|
||||
target: GhidraConnectTarget,
|
||||
signal: AbortSignal,
|
||||
): Promise<Result<Socket, GhidraSessionError>> =>
|
||||
new Promise((resolve) => {
|
||||
const socket = createConnection(socketPath);
|
||||
const socket =
|
||||
"path" in target
|
||||
? createConnection(target.path)
|
||||
: createConnection({ host: target.host, port: target.port });
|
||||
const detach = (): void => {
|
||||
socket.off("connect", onConnect);
|
||||
socket.off("error", onError);
|
||||
|
|
@ -48,7 +52,7 @@ export const connectGhidraSocketOnce = (
|
|||
resolve(
|
||||
err(
|
||||
new GhidraSessionError("start", "Ghidra bridge connection failed", {
|
||||
socket_path: socketPath,
|
||||
endpoint: target,
|
||||
error: cause.message,
|
||||
}),
|
||||
),
|
||||
|
|
@ -62,7 +66,7 @@ export const connectGhidraSocketOnce = (
|
|||
new GhidraSessionError(
|
||||
"cancelled",
|
||||
"Ghidra bridge connection was cancelled",
|
||||
{ socket_path: socketPath },
|
||||
{ endpoint: target },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
import { access, open } from "node:fs/promises";
|
||||
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
|
||||
/** Local bridge transports admitted by the versioned session descriptor. */
|
||||
export type GhidraTransportKind = "unix-socket" | "authenticated-loopback-tcp";
|
||||
|
||||
/** Private runtime coordinate used to discover one bridge listener. */
|
||||
export interface GhidraEndpoint {
|
||||
readonly transport: GhidraTransportKind;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/** Exact Node connection target after endpoint discovery. */
|
||||
export type GhidraConnectTarget =
|
||||
| { readonly path: string }
|
||||
| { readonly host: "127.0.0.1"; readonly port: number };
|
||||
|
||||
const MAX_ENDPOINT_BYTES = 1024;
|
||||
|
||||
/** Observe a ready endpoint, returning null while its bridge is not listening. */
|
||||
export const observeGhidraEndpoint = async (
|
||||
endpoint: GhidraEndpoint,
|
||||
): Promise<Result<GhidraConnectTarget | null, GhidraSessionError>> => {
|
||||
if (endpoint.transport === "unix-socket") {
|
||||
try {
|
||||
await access(endpoint.path);
|
||||
return ok({ path: endpoint.path });
|
||||
} catch (cause: unknown) {
|
||||
return isMissing(cause)
|
||||
? ok(null)
|
||||
: err(
|
||||
endpointFailure(
|
||||
endpoint,
|
||||
"Ghidra socket observation failed",
|
||||
cause,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(endpoint.path, "r");
|
||||
} catch (cause: unknown) {
|
||||
return isMissing(cause)
|
||||
? ok(null)
|
||||
: err(
|
||||
endpointFailure(
|
||||
endpoint,
|
||||
"Ghidra endpoint observation failed",
|
||||
cause,
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const bytes = Buffer.alloc(MAX_ENDPOINT_BYTES + 1);
|
||||
const observed = await handle.read(bytes, 0, bytes.length, 0);
|
||||
if (observed.bytesRead === 0 || observed.bytesRead > MAX_ENDPOINT_BYTES)
|
||||
return err(endpointFailure(endpoint, "Ghidra TCP endpoint is invalid"));
|
||||
return parseTcpEndpoint(
|
||||
endpoint,
|
||||
bytes.subarray(0, observed.bytesRead).toString("utf8"),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(endpointFailure(endpoint, "Ghidra endpoint read failed", cause));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
const parseTcpEndpoint = (
|
||||
endpoint: GhidraEndpoint,
|
||||
encoded: string,
|
||||
): Result<GhidraConnectTarget, GhidraSessionError> => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(encoded);
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
Array.isArray(value) ||
|
||||
!hasExactKeys(value, ["host", "port", "schema_version"]) ||
|
||||
value.schema_version !== 1 ||
|
||||
value.host !== "127.0.0.1" ||
|
||||
!Number.isSafeInteger(value.port) ||
|
||||
typeof value.port !== "number" ||
|
||||
value.port < 1 ||
|
||||
value.port > 65_535
|
||||
)
|
||||
return err(endpointFailure(endpoint, "Ghidra TCP endpoint is invalid"));
|
||||
return ok({ host: "127.0.0.1", port: value.port });
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
endpointFailure(endpoint, "Ghidra TCP endpoint is invalid", cause),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasExactKeys = (
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
): value is Record<string, unknown> => {
|
||||
const keys = Object.keys(value).sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
return (
|
||||
keys.length === expected.length &&
|
||||
keys.every((key, index) => key === expected[index])
|
||||
);
|
||||
};
|
||||
|
||||
const endpointFailure = (
|
||||
endpoint: GhidraEndpoint,
|
||||
message: string,
|
||||
cause?: unknown,
|
||||
): GhidraSessionError =>
|
||||
new GhidraSessionError(
|
||||
"start",
|
||||
message,
|
||||
{
|
||||
transport: endpoint.transport,
|
||||
endpoint_path: endpoint.path,
|
||||
...(cause instanceof Error ? { error: cause.message } : {}),
|
||||
},
|
||||
cause instanceof Error ? { cause } : undefined,
|
||||
);
|
||||
|
||||
const isMissing = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
|
|
@ -1,23 +1,25 @@
|
|||
import { rm } from "node:fs/promises";
|
||||
import { rename, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
const [
|
||||
socketPath,
|
||||
endpointPath,
|
||||
token,
|
||||
runId,
|
||||
providerVersion,
|
||||
profileDigest,
|
||||
targetSha256,
|
||||
transport,
|
||||
mode = "success",
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (
|
||||
socketPath === undefined ||
|
||||
endpointPath === undefined ||
|
||||
token === undefined ||
|
||||
runId === undefined ||
|
||||
providerVersion === undefined ||
|
||||
profileDigest === undefined ||
|
||||
targetSha256 === undefined
|
||||
targetSha256 === undefined ||
|
||||
!["unix-socket", "authenticated-loopback-tcp"].includes(transport)
|
||||
) {
|
||||
process.exit(64);
|
||||
}
|
||||
|
|
@ -25,7 +27,7 @@ if (
|
|||
if (mode === "exit") process.exit(73);
|
||||
if (mode === "silent") setInterval(() => undefined, 1_000);
|
||||
else {
|
||||
await rm(socketPath, { force: true });
|
||||
await rm(endpointPath, { force: true });
|
||||
const server = createServer((socket) => {
|
||||
socket.setEncoding("utf8");
|
||||
let buffer = "";
|
||||
|
|
@ -128,7 +130,24 @@ else {
|
|||
}
|
||||
});
|
||||
});
|
||||
server.listen(socketPath);
|
||||
if (transport === "unix-socket") server.listen(endpointPath);
|
||||
else {
|
||||
server.listen(0, "127.0.0.1", async () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") process.exit(65);
|
||||
const pending = `${endpointPath}.pending`;
|
||||
await writeFile(
|
||||
pending,
|
||||
`${JSON.stringify({
|
||||
schema_version: 1,
|
||||
host: "127.0.0.1",
|
||||
port: address.port,
|
||||
})}\n`,
|
||||
{ flag: "wx" },
|
||||
);
|
||||
await rename(pending, endpointPath);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sessionInfo = ({
|
||||
|
|
|
|||
|
|
@ -79,6 +79,18 @@ describe("Ghidra bridge truthfulness", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("binds the Windows fallback to authenticated IPv4 loopback", () => {
|
||||
expect(bridgeSource).toContain(
|
||||
'descriptor.transport.equals("unix-socket")',
|
||||
);
|
||||
expect(bridgeSource).toContain(
|
||||
'descriptor.transport.equals("authenticated-loopback-tcp")',
|
||||
);
|
||||
expect(bridgeSource).toContain('InetAddress.getByName("127.0.0.1")');
|
||||
expect(bridgeSource).toContain('endpoint.addProperty("host", "127.0.0.1")');
|
||||
expect(bridgeSource).toContain("StandardCopyOption.ATOMIC_MOVE");
|
||||
});
|
||||
|
||||
it("does not admit mutation methods into the Java dispatch", () => {
|
||||
for (const method of [
|
||||
'case "set_comment"',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
GhidraLaunchSession,
|
||||
GhidraLauncher,
|
||||
} from "../src/ghidra/GhidraLauncher.js";
|
||||
import type { GhidraTransportKind } from "../src/ghidra/GhidraTransport.js";
|
||||
import { GHIDRA_SESSION_CAPABILITIES } from "../src/ghidra/GhidraSessionValues.js";
|
||||
|
||||
const fixturePath = fileURLToPath(
|
||||
|
|
@ -46,7 +47,7 @@ type FixtureMode =
|
|||
|
||||
class FixtureLauncher implements GhidraLauncher {
|
||||
readonly runtimeRoots: string[] = [];
|
||||
readonly socketPaths: string[] = [];
|
||||
readonly endpointPaths: string[] = [];
|
||||
readonly tokens: string[] = [];
|
||||
readonly processes: ChildProcess[] = [];
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ class FixtureLauncher implements GhidraLauncher {
|
|||
|
||||
async launch(session: GhidraLaunchSession) {
|
||||
this.runtimeRoots.push(session.runtimeRoot);
|
||||
this.socketPaths.push(session.socketPath);
|
||||
this.endpointPaths.push(session.endpointPath);
|
||||
this.tokens.push(session.token);
|
||||
const projectRoot = join(session.runtimeRoot, "project");
|
||||
await mkdir(projectRoot, { recursive: true });
|
||||
|
|
@ -62,12 +63,13 @@ class FixtureLauncher implements GhidraLauncher {
|
|||
process.execPath,
|
||||
[
|
||||
fixturePath,
|
||||
session.socketPath,
|
||||
session.endpointPath,
|
||||
session.token,
|
||||
session.runId,
|
||||
session.providerVersion,
|
||||
session.profileDigest,
|
||||
session.targetSha256,
|
||||
session.transport,
|
||||
this.mode,
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
|
|
@ -90,12 +92,16 @@ const clientFor = (
|
|||
readonly startupTimeoutMs?: number;
|
||||
readonly requestTimeoutMs?: number;
|
||||
readonly onDiagnostic?: (event: GhidraDiagnostic) => void;
|
||||
readonly transport?: GhidraTransportKind;
|
||||
} = {},
|
||||
): GhidraClient => {
|
||||
const client = new GhidraClient({
|
||||
launcher,
|
||||
targetPath: fixturePath,
|
||||
targetSha256: TARGET_SHA256,
|
||||
...(options.transport === undefined
|
||||
? {}
|
||||
: { transport: options.transport }),
|
||||
providerVersion: PROVIDER_VERSION,
|
||||
profileDigest: PROFILE_DIGEST,
|
||||
startupTimeoutMs: options.startupTimeoutMs ?? 1_000,
|
||||
|
|
@ -132,7 +138,25 @@ describe("GhidraClient", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
expect(Buffer.byteLength(launcher.socketPaths[0] ?? "")).toBeLessThan(108);
|
||||
expect(Buffer.byteLength(launcher.endpointPaths[0] ?? "")).toBeLessThan(
|
||||
108,
|
||||
);
|
||||
});
|
||||
|
||||
it("completes the same authenticated handshake over loopback TCP", async () => {
|
||||
const launcher = new FixtureLauncher();
|
||||
const client = clientFor(launcher, {
|
||||
transport: "authenticated-loopback-tcp",
|
||||
});
|
||||
|
||||
await expect(client.start()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
bridge_version: 4,
|
||||
target: { sha256: TARGET_SHA256 },
|
||||
},
|
||||
});
|
||||
expect(launcher.endpointPaths[0]).toMatch(/bridge-endpoint\.json$/u);
|
||||
});
|
||||
|
||||
it("correlates an admitted inventory request after startup", async () => {
|
||||
|
|
@ -369,7 +393,8 @@ describe("GhidraClient", () => {
|
|||
|
||||
expect(client.diagnostics()).toMatchObject({
|
||||
runtime_root: launcher.runtimeRoots[0],
|
||||
socket_path: launcher.socketPaths[0],
|
||||
transport: "unix-socket",
|
||||
endpoint_path: launcher.endpointPaths[0],
|
||||
project_root: join(launcher.runtimeRoots[0] ?? "", "project"),
|
||||
process_id: expect.any(Number),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ describe("Ghidra headless launcher", () => {
|
|||
});
|
||||
const launched = await launcher.launch({
|
||||
runtimeRoot,
|
||||
socketPath: join(runtimeRoot, "bridge.sock"),
|
||||
transport: "unix-socket",
|
||||
endpointPath: join(runtimeRoot, "bridge.sock"),
|
||||
token,
|
||||
runId: "d6fcbb66-e829-4ff6-a535-0035aec63139",
|
||||
targetPath: "/tmp/fixture",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { observeGhidraEndpoint } from "../src/ghidra/GhidraTransport.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra local transport", () => {
|
||||
it("observes Unix readiness without interpreting endpoint content", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ghidra-transport-"));
|
||||
roots.push(root);
|
||||
const path = join(root, "bridge.sock");
|
||||
|
||||
await expect(
|
||||
observeGhidraEndpoint({ transport: "unix-socket", path }),
|
||||
).resolves.toEqual({ ok: true, value: null });
|
||||
await writeFile(path, "fixture");
|
||||
await expect(
|
||||
observeGhidraEndpoint({ transport: "unix-socket", path }),
|
||||
).resolves.toEqual({ ok: true, value: { path } });
|
||||
});
|
||||
|
||||
it("accepts only an exact IPv4 loopback endpoint record", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ghidra-transport-"));
|
||||
roots.push(root);
|
||||
const path = join(root, "bridge-endpoint.json");
|
||||
await writeFile(
|
||||
path,
|
||||
`${JSON.stringify({ schema_version: 1, host: "127.0.0.1", port: 49152 })}\n`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
observeGhidraEndpoint({
|
||||
transport: "authenticated-loopback-tcp",
|
||||
path,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { host: "127.0.0.1", port: 49152 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ schema_version: 1, host: "0.0.0.0", port: 49152 },
|
||||
{ schema_version: 1, host: "127.0.0.1", port: 0 },
|
||||
{ schema_version: 1, host: "127.0.0.1", port: 49152, token: "leak" },
|
||||
])(
|
||||
"rejects an invalid or expanded TCP endpoint: $host:$port",
|
||||
async (value) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ghidra-transport-"));
|
||||
roots.push(root);
|
||||
const path = join(root, "bridge-endpoint.json");
|
||||
await writeFile(path, JSON.stringify(value));
|
||||
|
||||
await expect(
|
||||
observeGhidraEndpoint({
|
||||
transport: "authenticated-loopback-tcp",
|
||||
path,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "start", message: "Ghidra TCP endpoint is invalid" },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue