fix(ghidra): preserve configured seed path traversal

This commit is contained in:
morluto committed 2026-10-27 09:39:43 +08:00
1 parent 7ba1d0161e
commit 01d6a95fd7
2 files changed
+81 -3

No files matched your search

+66
View File
@@ -2,6 +2,8 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { createHash } from "node:crypto";
import {
mkdtemp,
mkdir,
open,
readFile,
realpath,
rm,
@@ -152,6 +154,70 @@ describe("Ghidra seed commitment", () => {
expect(resolved.value.path).toBe(await realpath(path));
});
it.skipIf(process.platform === "win32")(
"rejects an invalid file/.. traversal instead of reading a sibling seed file",
async () => {
const directory = await workspace();
await writeFile(join(directory, "file"), "not a directory");
await writeFile(join(directory, "seeds.tsv"), SEEDS);
const configured = `${directory}/file/../seeds.tsv`;
await expect(open(configured)).rejects.toMatchObject({ code: "ENOTDIR" });
const resolved = await resolveGhidraAnalysisSeeds(configured, resources);
expect(resolved.ok).toBe(false);
},
);
it.skipIf(process.platform === "win32")(
"preserves symlink/.. lookup against a sibling decoy",
async () => {
const directory = await workspace();
const selected = join(directory, "selected");
await mkdir(join(selected, "child"), { recursive: true });
await writeFile(join(selected, "seeds.tsv"), SEEDS);
await writeFile(join(directory, "seeds.tsv"), "0x1234\tcode\n");
const link = join(directory, "link");
await symlink(join(selected, "child"), link);
const configured = `${link}/../seeds.tsv`;
const resolved = await resolveGhidraAnalysisSeeds(configured, resources);
if (!resolved.ok || resolved.value === undefined)
throw new Error("Expected a commitment");
expect(resolved.value.configured_path).toBe(configured);
expect(resolved.value.path).toBe(
await realpath(join(selected, "seeds.tsv")),
);
const snapshot = await snapshotGhidraAnalysisSeeds(
resolved.value,
await workspace(),
resources,
);
expect(await readFile(snapshot, "utf8")).toBe(CANONICAL);
},
);
it.skipIf(process.platform === "win32")(
"rejects a configured traversal that becomes invalid before snapshotting",
async () => {
const directory = await workspace();
const child = join(directory, "child");
await mkdir(child);
await writeFile(join(directory, "seeds.tsv"), SEEDS);
const configured = `${child}/../seeds.tsv`;
const resolved = await resolveGhidraAnalysisSeeds(configured, resources);
if (!resolved.ok || resolved.value === undefined)
throw new Error("Expected a commitment");
await rm(child, { recursive: true });
await writeFile(child, "not a directory");
await expect(open(configured)).rejects.toMatchObject({ code: "ENOTDIR" });
await expect(
snapshotGhidraAnalysisSeeds(
resolved.value,
await workspace(),
resources,
),
).rejects.toMatchObject({ _tag: "ProviderAdapterError" });
},
);
it("reports an unreadable seed file as a provider error", async () => {
const resolved = await resolveGhidraAnalysisSeeds(
join(await workspace(), "absent.tsv"),
+15 -3
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, realpath } from "node:fs/promises";
import { mkdir, realpath, stat } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { z } from "zod";
@@ -13,6 +13,7 @@ import {
writeGhidraProfileArtifact,
ghidraFileFailure,
} from "./GhidraFileArtifacts.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
import { err, ok, type Result } from "../domain/result.js";
/** Seed file format admitted by this adapter. */
@@ -164,6 +165,15 @@ const readSeeds = (
},
);
/** Preserve native lookup semantics while allowing configured symbolic links. */
const resolveSeedPath = async (configured: string): Promise<string> => {
// Darwin realpath may collapse file/.. even though native lookup rejects it.
const selected = await stat(configured);
if (!selected.isFile())
throw new NonRegularFileReadError(configured, selected.isDirectory());
return realpath(configured);
};
/** Resolve the configured seed file into an immutable profile commitment. */
export const resolveGhidraAnalysisSeeds = async (
configured: string | undefined,
@@ -176,7 +186,7 @@ export const resolveGhidraAnalysisSeeds = async (
let entries: readonly SeedEntry[];
let path: string;
try {
path = await realpath(configured);
path = await resolveSeedPath(configured);
entries = await readSeeds(path, resources, signal);
} catch (cause: unknown) {
return err(
@@ -215,7 +225,9 @@ export const snapshotGhidraAnalysisSeeds = async (
resources
.run(async () => {
ghidraSeedCommitmentSchema.parse(commitment);
if ((await realpath(commitment.configured_path)) !== commitment.path)
if (
(await resolveSeedPath(commitment.configured_path)) !== commitment.path
)
throw new Error(
"Ghidra seed file resolved path differs from explicit configuration",
);