mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(ghidra): reject JDK_JAVA_OPTIONS argument-file platform overrides
Fixes #6212. Parse effective argfile syntax once, admit bounded regular files with retained cleanup/cancellation ownership, freeze validated bytes for subsequent helpers and analysis launch, and share existing private-runtime writing. Resolves the continuation, FIFO and mutable-source review findings. Verified 97 related tests (3 explicit skips), full static/docs checks, two independent local Codex reviews and 12 real installed-JDK17 grammar/property controls. Real Ghidra, JDK25 and native Windows qualification remain unavailable. Used the explicitly authorized maintainer bypass: current GitHub CI remained queued and Greptile rerun was still in progress; author-readiness metadata was not fabricated. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
+11
-1
@@ -923,7 +923,17 @@ It also checks that the reported JVM `os.arch` and `os.name` map to the same nat
|
||||
directory validated for the host; unrecognized reported architectures or a
|
||||
non-host operating system are refused. LaunchSupport overrides of either
|
||||
property, including through helper-environment JVM option variables, are
|
||||
rejected at launch.
|
||||
rejected at launch. `JDK_JAVA_OPTIONS` argument files (`@file`) are expanded
|
||||
and inspected with that same check using the Java launcher's argfile quoting,
|
||||
comments and continuation rules. The reader admits regular UTF-8 files, retains
|
||||
failed descriptor cleanup, and bounds all admitted file contents and the prepared
|
||||
environment to 64 KiB per launch. Unreadable, oversized or nested `@` references
|
||||
are refused before the analysis JVM starts. Validated bytes are snapshotted under
|
||||
the owned runtime root, so later helpers and the analysis JVM do not reopen mutable
|
||||
caller files. Repeated references share that launch's snapshot; no live state is
|
||||
cached across launches. `JAVA_TOOL_OPTIONS`
|
||||
and `_JAVA_OPTIONS` do not expand `@file` tokens, so they remain
|
||||
literal-token checks.
|
||||
When Java is found through `PATH`, setup records its observed JDK home so GUI
|
||||
MCP clients do not depend on an incidental shell path. Setup shows every exact
|
||||
environment entry in its plan, writes only after approval, and never downloads,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
jvmEnvironmentArguments,
|
||||
prepareJdkJavaOptions,
|
||||
rejectJvmPlatformOverrides,
|
||||
} from "./GhidraJavaOptions.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
ExecFileOutputCancelledError,
|
||||
@@ -8,7 +15,7 @@ import {
|
||||
} from "../process/ExecFileOutput.js";
|
||||
|
||||
export const GHIDRA_LAUNCH_SUPPORT_CAPTURE_BYTES = 64 * 1024;
|
||||
type LaunchSupportPhase = "-envvars" | "-vmargs";
|
||||
type LaunchSupportPhase = "-envvars" | "-vmargs" | "JDK_JAVA_OPTIONS";
|
||||
type LaunchSupportOutcome = Omit<
|
||||
ExecFileOutputFailure,
|
||||
"killed" | "outputTruncated"
|
||||
@@ -27,7 +34,9 @@ export class GhidraLaunchSupportError extends Error {
|
||||
this.cancellation =
|
||||
cause instanceof ExecFileOutputCancelledError
|
||||
? { reason: cause.cause }
|
||||
: undefined;
|
||||
: cause instanceof ArtifactReaderFailure
|
||||
? cause.cancellation
|
||||
: undefined;
|
||||
const outcome = execFileOutputFailure(cause);
|
||||
this.outcome =
|
||||
outcome === undefined
|
||||
@@ -41,38 +50,6 @@ export class GhidraLaunchSupportError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** JVM option environments remove quotes, including quotes inside an option. */
|
||||
const jvmEnvironmentArguments = (value: string): readonly string[] => {
|
||||
const arguments_: string[] = [];
|
||||
let argument = "";
|
||||
let quote: "'" | '"' | undefined;
|
||||
for (const character of value) {
|
||||
if (quote !== undefined) {
|
||||
if (character === quote) quote = undefined;
|
||||
else argument += character;
|
||||
} else if (character === "'" || character === '"') quote = character;
|
||||
else if (/\s/u.test(character)) {
|
||||
if (argument !== "") arguments_.push(argument);
|
||||
argument = "";
|
||||
} else argument += character;
|
||||
}
|
||||
if (quote !== undefined)
|
||||
throw new Error(
|
||||
"Invalid quoted Ghidra LaunchSupport JVM environment option",
|
||||
);
|
||||
if (argument !== "") arguments_.push(argument);
|
||||
return arguments_;
|
||||
};
|
||||
|
||||
const rejectJvmPlatformOverrides = (arguments_: readonly string[]): void => {
|
||||
if (
|
||||
arguments_.some((argument) => /^-Dos\.(?:arch|name)(?:=|$)/u.test(argument))
|
||||
)
|
||||
throw new Error(
|
||||
"Ghidra LaunchSupport may not override os.arch or os.name; the native decompiler was validated for the inspected JVM platform.",
|
||||
);
|
||||
};
|
||||
|
||||
/** Launch coordinates obtained from Ghidra's own release configuration helper. */
|
||||
export const ghidraJavaLaunch = async (options: {
|
||||
readonly platform: NodeJS.Platform;
|
||||
@@ -84,6 +61,8 @@ export const ghidraJavaLaunch = async (options: {
|
||||
readonly arguments: readonly string[];
|
||||
readonly environment: NodeJS.ProcessEnv;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly resourceScope?: ArtifactResourceScope;
|
||||
readonly argumentFileRoot?: string;
|
||||
readonly onStderr: (mode: "-envvars" | "-vmargs", stderr: string) => void;
|
||||
}): Promise<{
|
||||
readonly command: string;
|
||||
@@ -91,6 +70,8 @@ export const ghidraJavaLaunch = async (options: {
|
||||
readonly environment: NodeJS.ProcessEnv;
|
||||
/** REA-configured -Xmx value, not effective or observed JVM memory. */
|
||||
readonly configuredMaxHeap?: string;
|
||||
/** Frozen argfile references for wrappers which launch their own JVM. */
|
||||
readonly preparedJdkJavaOptions?: string;
|
||||
}> => {
|
||||
const supportRoot = dirname(options.analyzeHeadlessPath);
|
||||
const installRoot = dirname(supportRoot);
|
||||
@@ -126,7 +107,7 @@ export const ghidraJavaLaunch = async (options: {
|
||||
const environment = {
|
||||
...snapshotEnvironment(options.environment, options.platform),
|
||||
};
|
||||
const supportOutput = async (phase: LaunchSupportPhase) => {
|
||||
const supportOutput = async (phase: "-envvars" | "-vmargs") => {
|
||||
try {
|
||||
return await execFileOutput(command, [...supportArguments, phase], {
|
||||
env: environment,
|
||||
@@ -160,13 +141,31 @@ export const ghidraJavaLaunch = async (options: {
|
||||
// JVM applies these options variables automatically. An injected
|
||||
// -Dos.arch/-Dos.name there selects an unvalidated native directory without
|
||||
// appearing in vmArguments, so reject the effective values as well.
|
||||
for (const key of [
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"_JAVA_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
] as const) {
|
||||
// JDK_JAVA_OPTIONS is the only helper variable the launched JVM expands as
|
||||
// argument files. Inspect those effective tokens with the same
|
||||
// platform-override policy. JAVA_TOOL_OPTIONS and _JAVA_OPTIONS treat @file
|
||||
// as an unrecognized option rather than expanding it.
|
||||
for (const key of ["JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS"] as const)
|
||||
rejectJvmPlatformOverrides(jvmEnvironmentArguments(environment[key] ?? ""));
|
||||
}
|
||||
const preparedJdkJavaOptions = await prepareJdkJavaOptions(
|
||||
environment.JDK_JAVA_OPTIONS ?? "",
|
||||
{
|
||||
...(options.resourceScope === undefined
|
||||
? {}
|
||||
: { resources: options.resourceScope }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.argumentFileRoot === undefined
|
||||
? {}
|
||||
: { directory: options.argumentFileRoot }),
|
||||
platform: options.platform,
|
||||
},
|
||||
).catch((cause: unknown) => {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
throw new GhidraLaunchSupportError("JDK_JAVA_OPTIONS", cause);
|
||||
throw cause;
|
||||
});
|
||||
if (preparedJdkJavaOptions !== undefined)
|
||||
environment.JDK_JAVA_OPTIONS = preparedJdkJavaOptions;
|
||||
const configuredArguments = await supportOutput("-vmargs");
|
||||
options.onStderr("-vmargs", configuredArguments.stderr);
|
||||
const vmArguments = configuredArguments.stdout
|
||||
@@ -183,6 +182,7 @@ export const ghidraJavaLaunch = async (options: {
|
||||
command,
|
||||
environment,
|
||||
configuredMaxHeap,
|
||||
...(preparedJdkJavaOptions === undefined ? {} : { preparedJdkJavaOptions }),
|
||||
arguments: [
|
||||
...vmArguments,
|
||||
...(options.platform === "darwin" ? ["-Xdock:name=Ghidra-Headless"] : []),
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { withArtifactRegularFile } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
import { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js";
|
||||
|
||||
/** One launch's admitted argument-file contents and expanded environment stay within the helper capture budget. */
|
||||
const OPTIONS_BYTES = 64 * 1024;
|
||||
|
||||
/** Environment quoting does not have the escape/comment rules of an argument file. */
|
||||
export const jvmEnvironmentArguments = (value: string): readonly string[] => {
|
||||
const args: string[] = [];
|
||||
let token = "";
|
||||
let active = false;
|
||||
let quote: "'" | '"' | undefined;
|
||||
for (const character of value) {
|
||||
if (quote !== undefined) {
|
||||
if (character === quote) quote = undefined;
|
||||
else token += character;
|
||||
} else if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
active = true;
|
||||
} else if (/[ \t\n\r\f\v]/u.test(character)) {
|
||||
if (active) args.push(token);
|
||||
token = "";
|
||||
active = false;
|
||||
} else {
|
||||
token += character;
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
if (quote !== undefined)
|
||||
throw new Error(
|
||||
"Invalid quoted Ghidra LaunchSupport JVM environment option",
|
||||
);
|
||||
if (active) args.push(token);
|
||||
return args;
|
||||
};
|
||||
|
||||
export const rejectJvmPlatformOverrides = (args: readonly string[]): void => {
|
||||
if (args.some((argument) => /^-Dos\.(?:arch|name)(?:=|$)/u.test(argument)))
|
||||
throw new Error(
|
||||
"Ghidra LaunchSupport may not override os.arch or os.name; the native decompiler was validated for the inspected JVM platform.",
|
||||
);
|
||||
};
|
||||
|
||||
/** Java launcher argfile grammar: quotes, escapes inside quotes, comments and line continuation are one parse. */
|
||||
const argumentFileTokens = (value: string): readonly string[] => {
|
||||
const args: string[] = [];
|
||||
let prefix = "",
|
||||
segment = "";
|
||||
let quote: "'" | '"' | undefined;
|
||||
let comment = false,
|
||||
active = false;
|
||||
const finish = (): void => {
|
||||
if (active) args.push(prefix + segment);
|
||||
prefix = "";
|
||||
segment = "";
|
||||
active = false;
|
||||
quote = undefined;
|
||||
};
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index]!;
|
||||
if (comment) {
|
||||
if (character === "\r" || character === "\n") comment = false;
|
||||
continue;
|
||||
}
|
||||
if (quote !== undefined && character === "\\") {
|
||||
const next = value[++index];
|
||||
if (next === undefined) {
|
||||
active = false;
|
||||
break;
|
||||
}
|
||||
prefix += segment;
|
||||
segment = "";
|
||||
if (next === "\n" || next === "\r") {
|
||||
while (/[ \t\n\r\f]/u.test(value[index + 1] ?? "")) index += 1;
|
||||
if (index + 1 === value.length) {
|
||||
active = false;
|
||||
break;
|
||||
}
|
||||
} else
|
||||
prefix +=
|
||||
(
|
||||
{ n: "\n", r: "\r", t: "\t", f: "\f" } as Readonly<
|
||||
Record<string, string>
|
||||
>
|
||||
)[next] ?? next;
|
||||
} else if (
|
||||
character === "\r" ||
|
||||
character === "\n" ||
|
||||
(quote === undefined && /[ \t\f]/u.test(character))
|
||||
) {
|
||||
if (active) finish();
|
||||
} else if (quote === undefined && character === "#") {
|
||||
// Unquoted text adjoining a comment is discarded by the launcher; completed quoted fragments remain pending.
|
||||
segment = "";
|
||||
active = false;
|
||||
comment = true;
|
||||
} else if (
|
||||
(character === "'" || character === '"') &&
|
||||
(quote === undefined || quote === character)
|
||||
) {
|
||||
prefix += segment;
|
||||
segment = "";
|
||||
active = true;
|
||||
quote = quote === undefined ? character : undefined;
|
||||
} else {
|
||||
segment += character;
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
finish();
|
||||
return args;
|
||||
};
|
||||
|
||||
const fileReference = (value: string): boolean =>
|
||||
value.startsWith("@") && !value.startsWith("@@");
|
||||
/** JDK environment quotes concatenate; protect literal quote characters without shell interpretation. */
|
||||
const environmentToken = (value: string): string =>
|
||||
value
|
||||
.split("'")
|
||||
.map((part) => `'${part}'`)
|
||||
.join('"\'"');
|
||||
|
||||
/** The file owner retains read/parse failure independently of final-close failure. */
|
||||
const readArgumentFile = (
|
||||
path: string,
|
||||
resources: ArtifactResourceScope,
|
||||
signal: AbortSignal | undefined,
|
||||
remainingBytes: number,
|
||||
): Promise<{
|
||||
readonly text: string;
|
||||
readonly bytes: number;
|
||||
readonly disablesExpansion: boolean;
|
||||
}> =>
|
||||
withArtifactRegularFile(
|
||||
path,
|
||||
resources,
|
||||
signal,
|
||||
async (file) => {
|
||||
const data = await readBoundedFileBytes(file, remainingBytes, {
|
||||
signal,
|
||||
cancellationFailure: (reason) =>
|
||||
new RegularFileOperationCancelled(reason),
|
||||
});
|
||||
if (data === undefined)
|
||||
throw new Error(
|
||||
`JDK_JAVA_OPTIONS argument files exceed the ${String(OPTIONS_BYTES)}-byte cumulative admission bound.`,
|
||||
);
|
||||
const text = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
ignoreBOM: true,
|
||||
}).decode(data);
|
||||
const tokens = argumentFileTokens(text);
|
||||
if (tokens.some(fileReference))
|
||||
throw new Error(
|
||||
"Ghidra LaunchSupport JDK_JAVA_OPTIONS argument files may not nest @ references.",
|
||||
);
|
||||
rejectJvmPlatformOverrides(tokens);
|
||||
return {
|
||||
text,
|
||||
bytes: data.length,
|
||||
disablesExpansion: tokens.includes("--disable-@files"),
|
||||
};
|
||||
},
|
||||
"follow",
|
||||
).catch((cause: unknown) => {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||
throw new Error(
|
||||
`Ghidra LaunchSupport JDK_JAVA_OPTIONS argument file is unreadable: ${path}`,
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
});
|
||||
|
||||
/** Freeze validated bytes under the already owned runtime root; the JVM must not reopen a mutable caller file. */
|
||||
export const prepareJdkJavaOptions = async (
|
||||
value: string,
|
||||
options: {
|
||||
readonly resources?: ArtifactResourceScope;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly directory?: string;
|
||||
readonly platform: NodeJS.Platform;
|
||||
},
|
||||
): Promise<string | undefined> => {
|
||||
const assertActive = (): void => {
|
||||
if (options.signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"JDK_JAVA_OPTIONS preparation cancelled",
|
||||
{ cause: options.signal.reason },
|
||||
);
|
||||
};
|
||||
assertActive();
|
||||
const args = jvmEnvironmentArguments(value);
|
||||
rejectJvmPlatformOverrides(args);
|
||||
const disabledAt = args.indexOf("--disable-@files");
|
||||
if (
|
||||
!args.some(
|
||||
(argument, index) =>
|
||||
fileReference(argument) && (disabledAt < 0 || index < disabledAt),
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
const resources = options.resources;
|
||||
const directory = options.directory;
|
||||
if (resources === undefined || directory === undefined)
|
||||
throw new Error(
|
||||
"JDK_JAVA_OPTIONS argument files require a caller-owned resource scope and runtime root.",
|
||||
);
|
||||
const snapshots = new Map<
|
||||
string,
|
||||
{
|
||||
readonly path: string;
|
||||
readonly bytes: number;
|
||||
readonly disablesExpansion: boolean;
|
||||
}
|
||||
>();
|
||||
const prepared: string[] = [];
|
||||
let expansionEnabled = true;
|
||||
let bytes = 0,
|
||||
encodedBytes = 0;
|
||||
for (const argument of args) {
|
||||
assertActive();
|
||||
if (argument === "--disable-@files") expansionEnabled = false;
|
||||
let replacement = argument;
|
||||
if (expansionEnabled && fileReference(argument)) {
|
||||
const path = argument.slice(1);
|
||||
let snapshot = snapshots.get(path);
|
||||
if (snapshot === undefined) {
|
||||
if (path === "" || path.includes("\0"))
|
||||
throw new Error(
|
||||
"Ghidra LaunchSupport JDK_JAVA_OPTIONS argument file is unreadable.",
|
||||
);
|
||||
const content = await readArgumentFile(
|
||||
path,
|
||||
resources,
|
||||
options.signal,
|
||||
OPTIONS_BYTES - bytes,
|
||||
);
|
||||
bytes += content.bytes;
|
||||
snapshot = {
|
||||
path: join(directory, `rea-jdk-options-${randomUUID()}.args`),
|
||||
bytes: content.bytes,
|
||||
disablesExpansion: content.disablesExpansion,
|
||||
};
|
||||
await writeGhidraRuntimeFile(
|
||||
snapshot.path,
|
||||
content.text,
|
||||
options.platform,
|
||||
);
|
||||
snapshots.set(path, snapshot);
|
||||
} else {
|
||||
bytes += snapshot.bytes;
|
||||
if (bytes > OPTIONS_BYTES)
|
||||
throw new Error(
|
||||
`JDK_JAVA_OPTIONS argument files exceed the ${String(OPTIONS_BYTES)}-byte cumulative admission bound.`,
|
||||
);
|
||||
}
|
||||
replacement = `@${snapshot.path}`;
|
||||
if (snapshot.disablesExpansion) expansionEnabled = false;
|
||||
}
|
||||
const encoded = environmentToken(replacement);
|
||||
encodedBytes +=
|
||||
Buffer.byteLength(encoded) + (prepared.length === 0 ? 0 : 1);
|
||||
if (encodedBytes > OPTIONS_BYTES)
|
||||
throw new Error(
|
||||
`Prepared JDK_JAVA_OPTIONS exceeds the ${String(OPTIONS_BYTES)}-byte environment admission bound.`,
|
||||
);
|
||||
prepared.push(encoded);
|
||||
}
|
||||
assertActive();
|
||||
return prepared.join(" ");
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { mkdir, realpath } from "node:fs/promises";
|
||||
import { basename, dirname, join, posix, win32 } from "node:path";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js";
|
||||
|
||||
import { windowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js";
|
||||
import {
|
||||
@@ -423,6 +423,10 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
: { javaVmName: this.options.javaVmName }),
|
||||
homeRoot: paths.homeRoot,
|
||||
tempRoot: paths.tempRoot,
|
||||
argumentFileRoot: session.runtimeRoot,
|
||||
...(options.resourceScope === undefined
|
||||
? {}
|
||||
: { resourceScope: options.resourceScope }),
|
||||
arguments: headlessArguments,
|
||||
environment,
|
||||
onStderr: (mode, stderr) => {
|
||||
@@ -453,7 +457,18 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
: inspectedJavaLaunch.configuredMaxHeap;
|
||||
const command =
|
||||
platform === "win32"
|
||||
? { ...scriptCommand, environment }
|
||||
? {
|
||||
...scriptCommand,
|
||||
environment: {
|
||||
...environment,
|
||||
...(inspectedJavaLaunch.preparedJdkJavaOptions === undefined
|
||||
? {}
|
||||
: {
|
||||
JDK_JAVA_OPTIONS:
|
||||
inspectedJavaLaunch.preparedJdkJavaOptions,
|
||||
}),
|
||||
},
|
||||
}
|
||||
: inspectedJavaLaunch;
|
||||
started = await (this.options.spawnProcess ?? spawnOwnedProviderProcess)({
|
||||
command: command.command,
|
||||
@@ -922,18 +937,6 @@ const createGhidraRuntimeDirectories = async (
|
||||
);
|
||||
};
|
||||
|
||||
const writeGhidraRuntimeFile = async (
|
||||
path: string,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<void> => {
|
||||
if (platform === "win32") {
|
||||
windowsPrivateRuntime(dirname(path)).writeFile(basename(path), content);
|
||||
return;
|
||||
}
|
||||
await writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 });
|
||||
};
|
||||
|
||||
const ghidraLaunchEnvironment = (
|
||||
paths: ReturnType<typeof ghidraRuntimePaths>,
|
||||
javaHome: string | undefined,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { basename, dirname } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { windowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js";
|
||||
|
||||
/** Runtime-owned configuration text: private native handle on Windows, private atomic write on POSIX. */
|
||||
export const writeGhidraRuntimeFile = async (
|
||||
path: string,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<void> => {
|
||||
if (platform === "win32") {
|
||||
windowsPrivateRuntime(dirname(path)).writeFile(basename(path), content);
|
||||
return;
|
||||
}
|
||||
await writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 });
|
||||
};
|
||||
@@ -1,14 +1,20 @@
|
||||
import { ok as javaProbeOk } from "../../../../src/domain/result.js";
|
||||
import type { GhidraJavaObservation } from "../../../../src/ghidra/GhidraJavaProbe.js";
|
||||
import { mkdir, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, stat, writeFile, readFile, readdir } from "node:fs/promises";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactFileCleanupFailure } from "../../../../src/artifacts/ArtifactFileOperation.js";
|
||||
import { GhidraLaunchSupportError } from "../../../../src/ghidra/GhidraJavaLaunch.js";
|
||||
import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js";
|
||||
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
|
||||
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { createGhidraDiagnostics } from "../../../../src/ghidra/GhidraDiagnostics.js";
|
||||
import {
|
||||
GhidraHeadlessLauncher,
|
||||
GhidraLaunchError,
|
||||
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
|
||||
type GhidraLaunchSession,
|
||||
} from "../../../../src/ghidra/GhidraLauncher.js";
|
||||
@@ -16,6 +22,7 @@ import { bindGhidraSessionFailure } from "../../../../src/ghidra/GhidraSessionEr
|
||||
import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js";
|
||||
import { waitForAbortableDelay } from "../../../../src/process/ProviderDeadline.js";
|
||||
import { execFileOutput } from "../../../../src/process/ExecFileOutput.js";
|
||||
import { GhidraProvider } from "../../../../src/ghidra/GhidraProvider.js";
|
||||
import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js";
|
||||
import { parseConfig } from "../../../../src/config/parseConfig.js";
|
||||
@@ -52,8 +59,9 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
|
||||
process.stderr.write("failed " + probe + "\\n");
|
||||
process.exitCode = 3;
|
||||
} else if (probe === "-envvars") {
|
||||
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : mode.startsWith("env-injection:") ? mode.slice("env-injection:".length) + "\\n" : "GHIDRA_MAXMEM=1G\\n");
|
||||
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : mode.startsWith("env-injection:") ? mode.slice("env-injection:".length) + "\\n" : mode.startsWith("env-race:") ? "JDK_JAVA_OPTIONS=@" + mode.slice("env-race:".length) + "\\n" : "GHIDRA_MAXMEM=1G\\n");
|
||||
} else {
|
||||
if (mode.startsWith("env-race:")) require("node:fs").writeFileSync(mode.slice("env-race:".length), "-Dos.arch=changed_after_admission\\n");
|
||||
const vmArguments = mode === "vm-malformed"
|
||||
? "invalid JVM argument\\n"
|
||||
: mode === "os-arch-override"
|
||||
@@ -80,6 +88,9 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
|
||||
profileDigest: "test",
|
||||
};
|
||||
const analysisArguments: (readonly string[])[] = [];
|
||||
const analysisEnvironments: NodeJS.ProcessEnv[] = [];
|
||||
const resources = new ArtifactResourceScope();
|
||||
onTestFinished(() => resources.close());
|
||||
const controller = new AbortController();
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
environment: { PATH: process.env.PATH ?? "" },
|
||||
@@ -91,6 +102,7 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
|
||||
bridgeScriptPath: join(root, "ReaGhidraBridge.java"),
|
||||
spawnProcess: async (options) => {
|
||||
analysisArguments.push(options.arguments);
|
||||
analysisEnvironments.push(options.env ?? {});
|
||||
const spawned = await spawnOwnedProviderProcess({
|
||||
...options,
|
||||
command: process.execPath,
|
||||
@@ -110,7 +122,14 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
|
||||
return spawned;
|
||||
},
|
||||
});
|
||||
return { session, launcher, analysisArguments, controller };
|
||||
return {
|
||||
session,
|
||||
launcher,
|
||||
analysisArguments,
|
||||
analysisEnvironments,
|
||||
controller,
|
||||
resources,
|
||||
};
|
||||
};
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
@@ -411,6 +430,326 @@ describe.skipIf(process.platform === "win32")(
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"Ghidra LaunchSupport JDK_JAVA_OPTIONS argument files",
|
||||
() => {
|
||||
it.each([
|
||||
'-Dnote="prefix\\"#suffix" -Dos.arch=rea_argfile_probe\n',
|
||||
'"-Dos.ar\\\n ch=rea_argfile_probe"\n',
|
||||
'"-Dos.ar"discard#comment\n ch=rea_argfile_probe\n',
|
||||
])(
|
||||
"rejects effective platform overrides after argfile quoting and continuation: %s",
|
||||
async (text) => {
|
||||
const root = await createTestTempDirectory("rea-ghidra-jdk-grammar-");
|
||||
const optionsFile = join(root, "platform.options");
|
||||
await writeFile(optionsFile, text);
|
||||
const { session, launcher, analysisArguments, resources } =
|
||||
await fixture(`env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`);
|
||||
const launched = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
expect(launched.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
if (launched.ok)
|
||||
throw new Error("Expected an effective platform override refusal");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects argument-file platform overrides before starting analysis", async () => {
|
||||
const root = await createTestTempDirectory("rea-ghidra-jdk-argfile-");
|
||||
const optionsFile = join(root, "platform.options");
|
||||
await writeFile(optionsFile, "-Dos.arch=rea_argfile_probe\n");
|
||||
const { session, launcher, analysisArguments, resources } = await fixture(
|
||||
`env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`,
|
||||
);
|
||||
const launched = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
expect(launched.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a JVM platform override failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining("may not override os.arch or os.name"),
|
||||
});
|
||||
});
|
||||
|
||||
it("admits an argument file that does not override the platform", async () => {
|
||||
const root = await createTestTempDirectory("rea-ghidra-jdk-argfile-ok-");
|
||||
const optionsFile = join(root, "platform.options");
|
||||
await writeFile(optionsFile, "-Dfile.encoding=UTF8\n");
|
||||
const { session, launcher, analysisArguments, resources } = await fixture(
|
||||
`env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`,
|
||||
);
|
||||
const launched = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
if (!launched.ok) throw launched.error;
|
||||
expect(analysisArguments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects an unreadable argument file before starting analysis", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-ghidra-jdk-argfile-missing-",
|
||||
);
|
||||
const optionsFile = join(root, "missing.options");
|
||||
const { session, launcher, analysisArguments, resources } = await fixture(
|
||||
`env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`,
|
||||
);
|
||||
const launched = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
expect(launched.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
if (launched.ok)
|
||||
throw new Error("Expected an unreadable argument-file failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining("argument file is unreadable"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects nested argument-file references before starting analysis", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-ghidra-jdk-argfile-nested-",
|
||||
);
|
||||
const nested = join(root, "nested.options");
|
||||
const optionsFile = join(root, "platform.options");
|
||||
await writeFile(nested, "-Dfile.encoding=UTF8\n");
|
||||
await writeFile(optionsFile, `@${nested}\n`);
|
||||
const { session, launcher, analysisArguments, resources } = await fixture(
|
||||
`env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`,
|
||||
);
|
||||
const launched = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
expect(launched.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a nested argument-file failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining("may not nest @ references"),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"Ghidra argument-file admission",
|
||||
() => {
|
||||
it.each([false, true])(
|
||||
"keeps @ values literal after expansion was disabled by a file: %s",
|
||||
async (fromFile) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-ghidra-options-disabled-",
|
||||
);
|
||||
const path = join(root, "options.args"),
|
||||
missing = join(root, "literal-classpath");
|
||||
await writeFile(path, "--disable-@files\n");
|
||||
const value = `${fromFile ? `@${path}` : "--disable-@files"} --class-path @${missing}`;
|
||||
const { session, launcher, resources, analysisEnvironments } =
|
||||
await fixture(`env-injection:JDK_JAVA_OPTIONS=${value}`);
|
||||
const result = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(analysisEnvironments[0]?.JDK_JAVA_OPTIONS).toContain(missing);
|
||||
const snapshots = (await readdir(session.runtimeRoot)).filter((name) =>
|
||||
name.startsWith("rea-jdk-options-"),
|
||||
);
|
||||
expect(snapshots).toHaveLength(fromFile ? 1 : 0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([100, 40000])(
|
||||
"counts repeated references within the cumulative byte budget: %s",
|
||||
async (size) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-ghidra-options-repeat-",
|
||||
);
|
||||
const path = join(root, "options.args");
|
||||
await writeFile(path, " ".repeat(size));
|
||||
const { session, launcher, resources, analysisArguments } =
|
||||
await fixture(`env-injection:JDK_JAVA_OPTIONS=@${path} @${path}`);
|
||||
const result = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
expect(result.ok).toBe(size * 2 <= 65536);
|
||||
expect(analysisArguments).toHaveLength(result.ok ? 1 : 0);
|
||||
const snapshots = (await readdir(session.runtimeRoot)).filter((name) =>
|
||||
name.startsWith("rea-jdk-options-"),
|
||||
);
|
||||
expect(snapshots).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "oversized", content: Buffer.alloc(65537, 32) },
|
||||
{ label: "invalid UTF-8", content: Buffer.from([255]) },
|
||||
])(
|
||||
"refuses $label input without starting analysis",
|
||||
async ({ content }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-ghidra-options-admission-",
|
||||
);
|
||||
const path = join(root, "options.args");
|
||||
await writeFile(path, content);
|
||||
const { session, launcher, resources, analysisArguments } =
|
||||
await fixture(`env-injection:JDK_JAVA_OPTIONS=@${path}`);
|
||||
const result = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
expect(resources.hasPending).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a named pipe without blocking an owned file read", async () => {
|
||||
const root = await createTestTempDirectory("rea-ghidra-options-pipe-");
|
||||
const path = join(root, "options.args");
|
||||
await execFileOutput("mkfifo", [path], { maxBuffer: 1024 });
|
||||
const { session, launcher, resources, analysisArguments } = await fixture(
|
||||
`env-injection:JDK_JAVA_OPTIONS=@${path}`,
|
||||
);
|
||||
const result = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
expect(resources.hasPending).toBe(false);
|
||||
}, 5000);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"Ghidra argument-file ownership",
|
||||
() => {
|
||||
it("launches with admitted bytes rather than reopening a file changed by the next helper", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-ghidra-options-snapshot-",
|
||||
);
|
||||
const path = join(root, "options.args");
|
||||
const content = '-Dnote="retained\\"#literal"\n';
|
||||
await writeFile(path, content);
|
||||
const { session, launcher, resources, analysisEnvironments } =
|
||||
await fixture(`env-race:${path}`);
|
||||
const result = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(await readFile(path, "utf8")).toContain("changed_after_admission");
|
||||
expect(analysisEnvironments[0]?.JDK_JAVA_OPTIONS).not.toContain(path);
|
||||
const snapshots = (await readdir(session.runtimeRoot)).filter((name) =>
|
||||
name.startsWith("rea-jdk-options-"),
|
||||
);
|
||||
expect(snapshots).toHaveLength(1);
|
||||
expect(
|
||||
await readFile(join(session.runtimeRoot, snapshots[0]!), "utf8"),
|
||||
).toBe(content);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"retains the exact failed close owner alongside an invalid-read fact: %s",
|
||||
async (invalid) => {
|
||||
const root = await createTestTempDirectory("rea-ghidra-options-close-");
|
||||
const path = join(root, "options.args");
|
||||
await writeFile(
|
||||
path,
|
||||
invalid ? "-Dos.arch=invalid\n" : "-Dnote=valid\n",
|
||||
);
|
||||
const { session, launcher, resources, analysisArguments } =
|
||||
await fixture(`env-injection:JDK_JAVA_OPTIONS=@${path}`);
|
||||
const close = OwnedFileHandle.prototype.close;
|
||||
let denied: OwnedFileHandle | undefined;
|
||||
const calls: OwnedFileHandle[] = [];
|
||||
const spy = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
calls.push(this);
|
||||
if (denied === undefined) {
|
||||
denied = this;
|
||||
throw new Error("controlled file close denial");
|
||||
}
|
||||
await close.call(this);
|
||||
});
|
||||
try {
|
||||
const result = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
expect(resources.hasPending).toBe(true);
|
||||
if (
|
||||
result.ok ||
|
||||
!(result.error.cause instanceof GhidraLaunchSupportError)
|
||||
)
|
||||
throw new Error("Expected a retained argument-file failure");
|
||||
const cleanup = result.error.cause.cause;
|
||||
if (!(cleanup instanceof ArtifactFileCleanupFailure))
|
||||
throw new Error("Expected the exact artifact file owner");
|
||||
expect(cleanup.fileCleanupFailure.owner).toBe(denied);
|
||||
expect(cleanup.fileCleanupFailure.outcome.kind).toBe(
|
||||
invalid ? "failed" : "completed",
|
||||
);
|
||||
if (cleanup.fileCleanupFailure.outcome.kind === "failed")
|
||||
expect(cleanup.fileCleanupFailure.outcome.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
});
|
||||
await resources.retryCleanup();
|
||||
expect(resources.hasPending).toBe(false);
|
||||
expect(calls).toEqual([denied, denied]);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves an abort observed during final descriptor close", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-ghidra-options-late-abort-",
|
||||
);
|
||||
const path = join(root, "options.args");
|
||||
await writeFile(path, "-Dnote=valid\n");
|
||||
const { session, launcher, resources, analysisArguments, controller } =
|
||||
await fixture(`env-injection:JDK_JAVA_OPTIONS=@${path}`);
|
||||
const reason = { phase: "argument-file close" };
|
||||
const close = OwnedFileHandle.prototype.close;
|
||||
const spy = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
await close.call(this);
|
||||
controller.abort(reason);
|
||||
});
|
||||
try {
|
||||
const result = await launcher.launch(session, {
|
||||
resourceScope: resources,
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok || !(result.error instanceof GhidraLaunchError))
|
||||
throw new Error("Expected a typed launch cancellation");
|
||||
expect(result.error.primaryFailure).toBeInstanceOf(
|
||||
AnalysisCancelledError,
|
||||
);
|
||||
expect(result.error.primaryFailure?.cause).toBe(reason);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
expect(resources.hasPending).toBe(false);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"Ghidra startup interruption diagnostics",
|
||||
() => {
|
||||
|
||||
Reference in New Issue
Block a user