mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(ghidra): report restricted-host temp write denial on Windows (#8889)
* fix(ghidra): report restricted-host temp write denial on Windows Add a Windows doctor check that creates and deletes a file under os.tmpdir(), and classify a denied java_home.save in Ghidra startup output with its path, Win32 error 5, and TMP/TEMP remediation. The probe covers REA's own process only; a host that restricts only child processes is reported from the child's output at startup. Fixes #7075 * fix(ghidra): probe the session root and retain failed probe cleanup The Windows doctor temp_directory probe now writes under ghidraSessionRoot(), the same root Ghidra session runtimes use, instead of os.tmpdir(), which can differ when the inherited temp path has a dot-prefixed element. A probe file whose removal fails is retained on the doctor resource owner so doctor shutdown retries the removal; the result still reports its path.
This commit is contained in:
7 files changed
+507
-16
No files matched your search
@@ -224,6 +224,33 @@ launch paths are unsupported. JVM home and temporary properties use quoted
|
||||
`JDK_JAVA_OPTIONS`, preserving spaces. `APPDATA` and `LOCALAPPDATA` point into
|
||||
the private runtime, isolating Ghidra preferences, logs, and caches.
|
||||
|
||||
### Restricted hosts
|
||||
|
||||
Each session's private runtime root is created under the Ghidra session root:
|
||||
`os.tmpdir()`, which follows `TMP`/`TEMP` on Windows, or, when that path has a
|
||||
dot-prefixed element that Ghidra rejects, its nearest ancestor without one. Some
|
||||
sandboxed agent hosts deny writes by child processes such as the JVM outside an
|
||||
allowed tree even when REA itself can write there. Ghidra's LaunchSupport then
|
||||
cannot create `java_home.save` and the provider exits at startup with
|
||||
`java.io.FileNotFoundException: ...\java_home.save (Access is denied)`.
|
||||
|
||||
When startup output names `java_home.save` with that denial, REA reports the
|
||||
path, Win32 error 5, and this remediation without attributing the denial to
|
||||
antivirus or REA's directory ACL: set `TMP` and `TEMP` to a directory inside the
|
||||
host's allowed tree, with no dot-prefixed path element, in the environment that
|
||||
starts REA (your shell or the MCP client's registration), restart REA, and
|
||||
retry. Creating `java_home.save` in advance does not help because LaunchSupport
|
||||
rewrites it.
|
||||
|
||||
On Windows, `rea doctor` also reports a `temp_directory` check under the Ghidra
|
||||
provider. It creates and deletes one file under that same session root from the
|
||||
REA process and fails the provider when either step fails. If the file cannot
|
||||
be removed, doctor reports its path and retries removal when it finishes. A
|
||||
passing result does not test writes by Ghidra's child JVM, so a host that
|
||||
restricts only child processes can still pass doctor and fail at startup with
|
||||
the message above.
|
||||
The startup-output match is limited to the English `Access is denied` text.
|
||||
|
||||
Process creation assigns a suspended child atomically to an owned Job Object.
|
||||
Membership and kill-on-owner-close policy are verified before resume.
|
||||
Breakaway is disabled. Only the child's default file owner is normalized;
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
type GhidraInstallationHost,
|
||||
type GhidraInstallationInspection,
|
||||
} from "./GhidraInstallation.js";
|
||||
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
|
||||
import {
|
||||
inspectSystemGhidraProvider,
|
||||
projectGhidraDoctorInspection,
|
||||
} from "./GhidraDoctor.js";
|
||||
import { GHIDRA_RESTRICTED_TEMP_REMEDIATION } from "./GhidraTemporaryDirectory.js";
|
||||
|
||||
let inspectionResources = createInspectionResources();
|
||||
afterEach(async () => {
|
||||
@@ -396,3 +400,88 @@ describe("Windows native doctor failure diagnostics", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows Ghidra temporary directory check", () => {
|
||||
const WINDOWS_INSTALL = "C:\\tools\\ghidra_12.1.4_PUBLIC";
|
||||
|
||||
it("fails the provider closed and keeps restricted-host remediation when the probe fails", async () => {
|
||||
const projected = projectGhidraDoctorInspection(
|
||||
await inspection({ installDir: WINDOWS_INSTALL, platform: "win32" }),
|
||||
{
|
||||
ok: false,
|
||||
directory: "C:\\sandbox\\tmp",
|
||||
stage: "write",
|
||||
code: "EPERM",
|
||||
message: "EPERM: operation not permitted",
|
||||
},
|
||||
);
|
||||
expect(projected.available).toBe(false);
|
||||
const check = projected.checks.find(
|
||||
({ name }) => name === "temp_directory",
|
||||
);
|
||||
expect(check).toMatchObject({
|
||||
ok: false,
|
||||
code: "temp_directory_unwritable",
|
||||
classification: "runtime_failure",
|
||||
remediation: GHIDRA_RESTRICTED_TEMP_REMEDIATION,
|
||||
details: { directory: "C:\\sandbox\\tmp", system_code: "EPERM" },
|
||||
});
|
||||
expect(check?.detail).toContain("C:\\sandbox\\tmp");
|
||||
expect(check?.detail).not.toMatch(/antivirus|DACL/iu);
|
||||
});
|
||||
|
||||
it("reports a retained probe file whose cleanup doctor retries", async () => {
|
||||
const projected = projectGhidraDoctorInspection(
|
||||
await inspection({ installDir: WINDOWS_INSTALL, platform: "win32" }),
|
||||
{
|
||||
ok: false,
|
||||
directory: "C:\\work",
|
||||
stage: "cleanup",
|
||||
code: "EPERM",
|
||||
message: "EPERM: operation not permitted",
|
||||
retainedPath: "C:\\work\\rea-doctor-probe-1",
|
||||
},
|
||||
);
|
||||
const check = projected.checks.find(
|
||||
({ name }) => name === "temp_directory",
|
||||
);
|
||||
expect(check).toMatchObject({
|
||||
details: {
|
||||
directory: "C:\\work",
|
||||
stage: "cleanup",
|
||||
retained_path: "C:\\work\\rea-doctor-probe-1",
|
||||
},
|
||||
});
|
||||
expect(check?.detail).toContain("retries removal when doctor finishes");
|
||||
});
|
||||
|
||||
it("reports a passing probe without claiming child-process access", async () => {
|
||||
const projected = projectGhidraDoctorInspection(
|
||||
await inspection({ installDir: WINDOWS_INSTALL, platform: "win32" }),
|
||||
{ ok: true, directory: "C:\\Temp" },
|
||||
);
|
||||
expect(projected.checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "temp_directory",
|
||||
ok: true,
|
||||
detail: expect.stringContaining(
|
||||
"does not test writes by Ghidra's child JVM",
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not probe when no Windows installation was selected", async () => {
|
||||
const probe = vi.fn(() => Promise.reject(new Error("unexpected probe")));
|
||||
const projected = await inspectSystemGhidraProvider(
|
||||
{},
|
||||
inspectionResources.owner,
|
||||
undefined,
|
||||
probe,
|
||||
);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(projected.checks.map(({ name }) => name)).not.toContain(
|
||||
"temp_directory",
|
||||
);
|
||||
});
|
||||
});
|
||||
+63
-14
@@ -11,21 +11,30 @@ import {
|
||||
type GhidraInstallationCheck,
|
||||
type GhidraInstallationInspection,
|
||||
} from "./GhidraInstallation.js";
|
||||
import {
|
||||
GHIDRA_RESTRICTED_TEMP_REMEDIATION,
|
||||
probeGhidraTemporaryDirectory,
|
||||
type GhidraTemporaryDirectoryProbe,
|
||||
} from "./GhidraTemporaryDirectory.js";
|
||||
|
||||
/** Project installation and runtime-authority facts into the doctor contract. */
|
||||
export const projectGhidraDoctorInspection = (
|
||||
inspection: GhidraInstallationInspection,
|
||||
temporaryDirectory?: GhidraTemporaryDirectoryProbe,
|
||||
): DoctorProviderInspection => {
|
||||
const readiness =
|
||||
inspection.status === "available" && inspection.platform === "win32"
|
||||
? inspectWindowsNativeReadiness(inspection.platform)
|
||||
: undefined;
|
||||
const windowsAuthorityUnavailable = readiness?.available === false;
|
||||
const temporaryDirectoryUnavailable = temporaryDirectory?.ok === false;
|
||||
return {
|
||||
id: "ghidra",
|
||||
configured: inspection.installDir !== null,
|
||||
available:
|
||||
inspection.status === "available" && !windowsAuthorityUnavailable,
|
||||
inspection.status === "available" &&
|
||||
!windowsAuthorityUnavailable &&
|
||||
!temporaryDirectoryUnavailable,
|
||||
providerVersion: inspection.providerVersion,
|
||||
registrationEnvironment:
|
||||
inspection.status === "available"
|
||||
@@ -62,30 +71,70 @@ export const projectGhidraDoctorInspection = (
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(temporaryDirectory === undefined
|
||||
? []
|
||||
: [temporaryDirectoryCheck(temporaryDirectory)]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/** Report the REA-process write probe without claiming child-process access. */
|
||||
const temporaryDirectoryCheck = (
|
||||
probe: GhidraTemporaryDirectoryProbe,
|
||||
): DoctorProviderCheck =>
|
||||
probe.ok
|
||||
? {
|
||||
name: "temp_directory",
|
||||
ok: true,
|
||||
code: null,
|
||||
detail: `REA created and deleted a file under the Ghidra session root ${probe.directory}, where session runtimes are created. This does not test writes by Ghidra's child JVM.`,
|
||||
remediation: null,
|
||||
classification: "runtime_failure",
|
||||
}
|
||||
: {
|
||||
name: "temp_directory",
|
||||
ok: false,
|
||||
code: "temp_directory_unwritable",
|
||||
detail: `REA could not ${probe.stage === "write" ? "write" : "delete"} a probe file under the Ghidra session root ${probe.directory}, where session runtimes are created${probe.code === undefined ? "" : ` (${probe.code})`}: ${probe.message}${probe.retainedPath === undefined ? "" : `. The probe file ${probe.retainedPath} remains; REA retains its cleanup and retries removal when doctor finishes.`}`,
|
||||
remediation: GHIDRA_RESTRICTED_TEMP_REMEDIATION,
|
||||
classification: "runtime_failure",
|
||||
details: {
|
||||
directory: probe.directory,
|
||||
stage: probe.stage,
|
||||
...(probe.code === undefined ? {} : { system_code: probe.code }),
|
||||
...(probe.retainedPath === undefined
|
||||
? {}
|
||||
: { retained_path: probe.retainedPath }),
|
||||
},
|
||||
};
|
||||
|
||||
/** Inspect the process-configured BYO Ghidra installation for `rea doctor`. */
|
||||
export const inspectSystemGhidraProvider = async (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
resourceOwner: RetainedResourceScope,
|
||||
signal?: AbortSignal,
|
||||
probeTemporaryDirectory: (
|
||||
resourceOwner: RetainedResourceScope,
|
||||
) => Promise<GhidraTemporaryDirectoryProbe> = probeGhidraTemporaryDirectory,
|
||||
): Promise<DoctorProviderInspection> => {
|
||||
try {
|
||||
return projectGhidraDoctorInspection(
|
||||
await inspectGhidraInstallation({
|
||||
environment,
|
||||
resourceOwner,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
...(environment.GHIDRA_INSTALL_DIR === undefined
|
||||
? {}
|
||||
: { installDir: environment.GHIDRA_INSTALL_DIR }),
|
||||
...(environment.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { javaHome: environment.JAVA_HOME }),
|
||||
}),
|
||||
);
|
||||
const installation = await inspectGhidraInstallation({
|
||||
environment,
|
||||
resourceOwner,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
...(environment.GHIDRA_INSTALL_DIR === undefined
|
||||
? {}
|
||||
: { installDir: environment.GHIDRA_INSTALL_DIR }),
|
||||
...(environment.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { javaHome: environment.JAVA_HOME }),
|
||||
});
|
||||
// The restricted-host denial reported for #7075 is Windows-specific.
|
||||
const temporaryDirectory =
|
||||
installation.status === "available" && installation.platform === "win32"
|
||||
? await probeTemporaryDirectory(resourceOwner)
|
||||
: undefined;
|
||||
return projectGhidraDoctorInspection(installation, temporaryDirectory);
|
||||
} catch (cause: unknown) {
|
||||
throw analysisClientFailureForOperation(cause, "ghidra", "doctor");
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { parseExecutableHeader } from "../domain/binaryTarget.js";
|
||||
import { elf, thinMach } from "../domain/binaryTarget.fixture.js";
|
||||
import { GhidraProvider } from "./GhidraProvider.js";
|
||||
import { GHIDRA_RESTRICTED_TEMP_REMEDIATION } from "./GhidraTemporaryDirectory.js";
|
||||
import type { GhidraTargetSnapshot } from "./GhidraClient.js";
|
||||
import {
|
||||
GHIDRA_ANNOTATIONS_RETAINED,
|
||||
@@ -4327,6 +4328,7 @@ describe("Ghidra missing project program", () => {
|
||||
const startFailure = async (
|
||||
stdout: string,
|
||||
environment: Readonly<Record<string, string>> = {},
|
||||
stderr = "",
|
||||
) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-missing-program-"));
|
||||
onTestFinished(async () => {
|
||||
@@ -4340,7 +4342,7 @@ describe("Ghidra missing project program", () => {
|
||||
{
|
||||
failure_kind: "start",
|
||||
exit_code: 1,
|
||||
failure_output: failureOutput(stdout),
|
||||
failure_output: failureOutput(stdout, stderr),
|
||||
},
|
||||
);
|
||||
const ghidra = provider(
|
||||
@@ -4416,6 +4418,34 @@ describe("Ghidra missing project program", () => {
|
||||
const projected = await startFailure("ERROR Unable to lock project");
|
||||
expect(projected.code).toBe("execution_failure");
|
||||
});
|
||||
|
||||
it("reports a denied java_home.save with its path and restricted-host remediation", async () => {
|
||||
const path =
|
||||
"C:\\Users\\a\\AppData\\Local\\Temp\\rea-provider-1\\config\\ghidra\\ghidra_12.1.4_PUBLIC\\java_home.save";
|
||||
const projected = await startFailure(
|
||||
"",
|
||||
{},
|
||||
`java.io.FileNotFoundException: ${path} (Access is denied)\n`,
|
||||
);
|
||||
expect(projected.code).toBe("execution_failure");
|
||||
expect(projected.message).toContain(path);
|
||||
expect(projected.message).toContain("Win32 error 5");
|
||||
expect(projected.message).not.toMatch(/antivirus|DACL/iu);
|
||||
expect(projected.remediation.action).toBe(
|
||||
GHIDRA_RESTRICTED_TEMP_REMEDIATION,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the generic start failure for other denied paths", async () => {
|
||||
const projected = await startFailure(
|
||||
"",
|
||||
{},
|
||||
"java.io.FileNotFoundException: C:\\x\\other.txt (Access is denied)\n",
|
||||
);
|
||||
expect(projected.remediation.action).not.toBe(
|
||||
GHIDRA_RESTRICTED_TEMP_REMEDIATION,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra changed project", () => {
|
||||
|
||||
@@ -10,6 +10,10 @@ import { artifactFilePrimaryFacts } from "../artifacts/ArtifactFileOperation.js"
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
|
||||
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
|
||||
import {
|
||||
GHIDRA_RESTRICTED_TEMP_REMEDIATION,
|
||||
ghidraJavaHomeSaveDenial,
|
||||
} from "./GhidraTemporaryDirectory.js";
|
||||
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
@@ -1488,6 +1492,16 @@ const projectPrimarySessionError = (
|
||||
limits.ghidraExistingProject?.programName !== undefined,
|
||||
context,
|
||||
);
|
||||
const javaHomeSave =
|
||||
output === undefined ? undefined : ghidraJavaHomeSaveDenial(output.text);
|
||||
if (javaHomeSave !== undefined)
|
||||
return new ProviderAdapterError("ghidra", operation, {
|
||||
...context,
|
||||
userMessage: `Ghidra's launcher could not open ${javaHomeSave.path} ("Access is denied", Win32 error 5), so Ghidra exited before its bridge started. The output does not establish why the host denied the write.${
|
||||
uncertainty === undefined ? "" : ` ${uncertainty}`
|
||||
}`,
|
||||
remediationAction: GHIDRA_RESTRICTED_TEMP_REMEDIATION,
|
||||
});
|
||||
const killed = projectSigkill(operation, failure, context, {
|
||||
restart: exitedProviderMessage(operation, failure, health),
|
||||
uncertainty,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
|
||||
import {
|
||||
ghidraJavaHomeSaveDenial,
|
||||
probeGhidraTemporaryDirectory,
|
||||
type GhidraTemporaryDirectoryHost,
|
||||
} from "./GhidraTemporaryDirectory.js";
|
||||
import { ghidraSessionRoot } from "./GhidraSessionRoot.js";
|
||||
|
||||
const denied = (code: string): NodeJS.ErrnoException =>
|
||||
Object.assign(new Error(`${code}: operation not permitted`), { code });
|
||||
|
||||
let resources = createInspectionResources();
|
||||
afterEach(async () => {
|
||||
await resources.close().catch(() => undefined);
|
||||
resources = createInspectionResources();
|
||||
});
|
||||
|
||||
const host = (
|
||||
overrides: Partial<GhidraTemporaryDirectoryHost> = {},
|
||||
): GhidraTemporaryDirectoryHost => ({
|
||||
sessionRoot: () => "C:\\sandbox\\tmp",
|
||||
writeProbe: () => Promise.resolve(),
|
||||
removeProbe: () => Promise.resolve(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("Ghidra temporary directory probe", () => {
|
||||
it("reports success only after the probe file is written and removed", async () => {
|
||||
const removeProbe = vi.fn(() => Promise.resolve());
|
||||
await expect(
|
||||
probeGhidraTemporaryDirectory(resources.owner, host({ removeProbe })),
|
||||
).resolves.toEqual({ ok: true, directory: "C:\\sandbox\\tmp" });
|
||||
expect(removeProbe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails closed with the system code when the write is denied", async () => {
|
||||
const removeProbe = vi.fn(() => Promise.reject(denied("ENOENT")));
|
||||
const result = await probeGhidraTemporaryDirectory(
|
||||
resources.owner,
|
||||
host({ writeProbe: () => Promise.reject(denied("EPERM")), removeProbe }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
directory: "C:\\sandbox\\tmp",
|
||||
stage: "write",
|
||||
code: "EPERM",
|
||||
});
|
||||
expect(result).not.toHaveProperty("retainedPath");
|
||||
});
|
||||
|
||||
it("probes the Ghidra session root rather than an unusable inherited temp", async () => {
|
||||
const writeProbe = vi.fn((_path: string) => Promise.resolve());
|
||||
const names = ["TMPDIR", "TMP", "TEMP"] as const;
|
||||
const previous = names.map((name) => process.env[name]);
|
||||
const dotted = resolve("work", ".cache", "tmp");
|
||||
for (const name of names) process.env[name] = dotted;
|
||||
let expected: string;
|
||||
try {
|
||||
expected = ghidraSessionRoot();
|
||||
expect(resolve(tmpdir())).toBe(dotted);
|
||||
expect(expected).not.toBe(dotted);
|
||||
await expect(
|
||||
probeGhidraTemporaryDirectory(resources.owner, {
|
||||
writeProbe,
|
||||
removeProbe: () => Promise.resolve(),
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, directory: expected });
|
||||
} finally {
|
||||
names.forEach((name, index) => {
|
||||
const value = previous[index];
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
});
|
||||
}
|
||||
expect(dirname(writeProbe.mock.calls[0]?.[0] ?? "")).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a failed write", "write", () => Promise.reject(denied("EIO"))],
|
||||
["a written probe", "cleanup", () => Promise.resolve()],
|
||||
] as const)(
|
||||
"retains cleanup ownership of %s whose removal fails and retries it on close",
|
||||
async (_label, stage, writeProbe) => {
|
||||
const removeProbe = vi
|
||||
.fn<(path: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(denied("EPERM"))
|
||||
.mockRejectedValueOnce(denied("EPERM"))
|
||||
.mockResolvedValue(undefined);
|
||||
const result = await probeGhidraTemporaryDirectory(
|
||||
resources.owner,
|
||||
host({ writeProbe, removeProbe }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
stage,
|
||||
retainedPath: expect.stringContaining("rea-doctor-probe-"),
|
||||
});
|
||||
const retainedPath = result.ok ? undefined : result.retainedPath;
|
||||
const callsBeforeClose = removeProbe.mock.calls.length;
|
||||
await expect(resources.close()).rejects.toThrow();
|
||||
await expect(resources.close()).resolves.toBeUndefined();
|
||||
expect(removeProbe.mock.calls.slice(callsBeforeClose)).toEqual([
|
||||
[retainedPath],
|
||||
[retainedPath],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not retain cleanup when the probe was removed", async () => {
|
||||
const removeProbe = vi.fn(() => Promise.resolve());
|
||||
await probeGhidraTemporaryDirectory(resources.owner, host({ removeProbe }));
|
||||
await resources.close();
|
||||
expect(removeProbe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra java_home.save denial", () => {
|
||||
it("extracts the denied path from JVM output", () => {
|
||||
expect(
|
||||
ghidraJavaHomeSaveDenial([
|
||||
"INFO starting\r\njava.io.FileNotFoundException: C:\\Users\\a b\\Temp\\rea-provider-1\\config\\ghidra\\ghidra_12.1.4_PUBLIC\\java_home.save (Access is denied)\r\n\tat java.base/java.io.FileOutputStream.open0(Native Method)",
|
||||
]),
|
||||
).toEqual({
|
||||
path: "C:\\Users\\a b\\Temp\\rea-provider-1\\config\\ghidra\\ghidra_12.1.4_PUBLIC\\java_home.save",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"another denied path",
|
||||
"java.io.FileNotFoundException: C:\\x\\other.txt (Access is denied)",
|
||||
],
|
||||
[
|
||||
"a different failure for the file",
|
||||
"java.io.FileNotFoundException: C:\\x\\java_home.save (The system cannot find the path specified)",
|
||||
],
|
||||
["only a mention", "Reading java_home.save failed"],
|
||||
["no output", ""],
|
||||
])("does not classify %s", (_label, text) => {
|
||||
expect(ghidraJavaHomeSaveDenial([text])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { open, unlink } from "node:fs/promises";
|
||||
|
||||
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
|
||||
import { ghidraSessionRoot } from "./GhidraSessionRoot.js";
|
||||
|
||||
/**
|
||||
* Remediation for a host that denies writes under the session temporary root.
|
||||
* It states only what REA observed; it does not name an antivirus product or
|
||||
* REA's own private-directory ACL as the cause.
|
||||
*/
|
||||
export const GHIDRA_RESTRICTED_TEMP_REMEDIATION =
|
||||
"REA places each Ghidra session's private runtime under the Ghidra session root: os.tmpdir(), which follows TMP/TEMP on Windows, or, when that path has a dot-prefixed element that Ghidra rejects, its nearest ancestor without one. On a restricted or sandboxed host that denies child-process writes outside an allowed tree, set TMP and TEMP to a directory inside that allowed tree whose path has no dot-prefixed element, in the environment that starts REA (your shell or the MCP client's registration), restart REA, and retry. Ghidra's LaunchSupport rewrites java_home.save, so creating that file in advance does not help.";
|
||||
|
||||
/** Outcome of one write-and-delete probe of the session temporary root. */
|
||||
export type GhidraTemporaryDirectoryProbe =
|
||||
| { readonly ok: true; readonly directory: string }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly directory: string;
|
||||
/** Whether creating/writing the file or removing it failed. */
|
||||
readonly stage: "write" | "cleanup";
|
||||
/** Node system code such as EACCES or EPERM, when reported. */
|
||||
readonly code: string | undefined;
|
||||
readonly message: string;
|
||||
/** Probe file whose removal failed; its cleanup owner is retained. */
|
||||
readonly retainedPath?: string;
|
||||
};
|
||||
|
||||
/** Filesystem seam so classification is testable without a restricted host. */
|
||||
export interface GhidraTemporaryDirectoryHost {
|
||||
readonly sessionRoot: () => string;
|
||||
readonly writeProbe: (path: string) => Promise<void>;
|
||||
readonly removeProbe: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const systemHost: GhidraTemporaryDirectoryHost = {
|
||||
sessionRoot: () => ghidraSessionRoot(),
|
||||
writeProbe: async (path) => {
|
||||
const handle = await open(path, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile("rea doctor probe\n");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
removeProbe: unlink,
|
||||
};
|
||||
|
||||
const errorCode = (cause: unknown): string | undefined =>
|
||||
cause instanceof Error && "code" in cause && typeof cause.code === "string"
|
||||
? cause.code
|
||||
: undefined;
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
/**
|
||||
* Create and delete one file under the Ghidra session root from the REA
|
||||
* process. This proves only that REA itself can use the root; a host that
|
||||
* restricts child processes separately is not detected here and is classified
|
||||
* from the child's own failure output instead. A probe file that cannot be
|
||||
* removed stays owned by `resourceOwner`, whose close retries the removal.
|
||||
*/
|
||||
export const probeGhidraTemporaryDirectory = async (
|
||||
resourceOwner: RetainedResourceScope,
|
||||
overrides: Partial<GhidraTemporaryDirectoryHost> = {},
|
||||
): Promise<GhidraTemporaryDirectoryProbe> => {
|
||||
const host = { ...systemHost, ...overrides };
|
||||
const directory = host.sessionRoot();
|
||||
const path = join(directory, `rea-doctor-probe-${randomUUID()}`);
|
||||
const remove = () =>
|
||||
host.removeProbe(path).catch((cause: unknown) => {
|
||||
if (errorCode(cause) !== "ENOENT") throw cause;
|
||||
});
|
||||
const retain = () =>
|
||||
resourceOwner.retain({ close: remove, cleanupResources: [path] }, path);
|
||||
try {
|
||||
await host.writeProbe(path);
|
||||
} catch (cause: unknown) {
|
||||
// A failed write may still have created the entry; try to remove it.
|
||||
const removed = await remove().then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!removed) retain();
|
||||
return {
|
||||
ok: false,
|
||||
directory,
|
||||
stage: "write",
|
||||
code: errorCode(cause),
|
||||
message: errorMessage(cause),
|
||||
...(removed ? {} : { retainedPath: path }),
|
||||
};
|
||||
}
|
||||
try {
|
||||
await host.removeProbe(path);
|
||||
} catch (cause: unknown) {
|
||||
retain();
|
||||
return {
|
||||
ok: false,
|
||||
directory,
|
||||
stage: "cleanup",
|
||||
code: errorCode(cause),
|
||||
message: errorMessage(cause),
|
||||
retainedPath: path,
|
||||
};
|
||||
}
|
||||
return { ok: true, directory };
|
||||
};
|
||||
|
||||
/** A denied `java_home.save` write reported in Ghidra launcher output. */
|
||||
export interface GhidraJavaHomeSaveDenial {
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
const JAVA_HOME_SAVE_DENIED =
|
||||
/FileNotFoundException:\s*(.*?java_home\.save)\s*\(Access is denied\)/iu;
|
||||
|
||||
/**
|
||||
* Recognize the JVM's access-denied refusal to open `java_home.save` (Win32
|
||||
* error 5). Only a line naming that file and the denial counts; other output,
|
||||
* including other denied paths, is not classified.
|
||||
*/
|
||||
export const ghidraJavaHomeSaveDenial = (
|
||||
text: readonly string[],
|
||||
): GhidraJavaHomeSaveDenial | undefined => {
|
||||
for (const block of text)
|
||||
for (const line of block.split(/\r?\n/u)) {
|
||||
const path = JAVA_HOME_SAVE_DENIED.exec(line)?.[1]?.trim();
|
||||
if (path !== undefined && path !== "") return { path };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
Reference in new issue
Block a user