414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { parseConfig } from "../src/config.js";
|
|
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
|
|
import {
|
|
GHIDRA_PROVIDER_IDENTITY,
|
|
GHIDRA_PROVIDER_TOOL_CONTRACTS,
|
|
GhidraProvider,
|
|
type GhidraProviderClientFactory,
|
|
} from "../src/ghidra/GhidraProvider.js";
|
|
import type { GhidraInstallationHost } from "../src/ghidra/GhidraInstallation.js";
|
|
import { GHIDRA_SESSION_CAPABILITIES } from "../src/ghidra/GhidraSessionValues.js";
|
|
import { err, ok } from "../src/domain/result.js";
|
|
import { GhidraSessionError } from "../src/ghidra/GhidraSessionError.js";
|
|
import { silentLogger } from "../src/logger.js";
|
|
|
|
const INSTALL = "/opt/ghidra_12.1.2_PUBLIC";
|
|
const installationHost = (): GhidraInstallationHost => ({
|
|
platform: "linux",
|
|
architecture: "x64",
|
|
readText: () => "application.version=12.1.2\n",
|
|
executable: () => true,
|
|
probeJava: () => ({
|
|
version: "21.0.11",
|
|
major: 21,
|
|
home: "/usr/lib/jvm/jdk-21",
|
|
bits: 64,
|
|
jdk: true,
|
|
}),
|
|
});
|
|
|
|
const provider = (
|
|
host = installationHost(),
|
|
clientFactory?: GhidraProviderClientFactory,
|
|
): GhidraProvider => {
|
|
const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL });
|
|
if (!config.ok) throw config.error;
|
|
return new GhidraProvider(config.value, silentLogger, host, clientFactory);
|
|
};
|
|
|
|
describe("Ghidra provider", () => {
|
|
it("discovers the exact installation once without launching Ghidra", () => {
|
|
const host = installationHost();
|
|
const probe = vi.spyOn(host, "probeJava");
|
|
const ghidra = provider(host);
|
|
|
|
expect(ghidra.identity()).toEqual(GHIDRA_PROVIDER_IDENTITY);
|
|
expect(ghidra.capabilities().map(({ operation }) => operation)).toEqual(
|
|
GHIDRA_PROVIDER_TOOL_CONTRACTS.map(({ name }) => name),
|
|
);
|
|
expect(GHIDRA_PROVIDER_TOOL_CONTRACTS).toHaveLength(18);
|
|
expect(Object.isFrozen(ghidra.capabilities())).toBe(true);
|
|
expect(ghidra.capabilities()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
operation: "list_procedures",
|
|
pagination: "offset",
|
|
effects: expect.objectContaining({
|
|
mutatesArtifact: false,
|
|
mayShowUi: false,
|
|
mayWriteFilesystem: true,
|
|
}),
|
|
limits: {
|
|
maxResults: 500,
|
|
maxPayloadBytes: 1024 * 1024,
|
|
timeoutMs: 10_000,
|
|
},
|
|
}),
|
|
expect.objectContaining({
|
|
operation: "analyze_function",
|
|
pagination: "offset",
|
|
limits: {
|
|
maxResults: 500,
|
|
maxPayloadBytes: 1024 * 1024,
|
|
timeoutMs: 35_000,
|
|
},
|
|
limitations: expect.arrayContaining([
|
|
expect.stringContaining(
|
|
"unresolved targetless flows remain unknown",
|
|
),
|
|
]),
|
|
}),
|
|
]),
|
|
);
|
|
expect(ghidra.inspectAvailability()).toMatchObject({
|
|
status: "available",
|
|
diagnostics: {
|
|
install_dir: INSTALL,
|
|
provider_version: "12.1.2",
|
|
java_version: "21.0.11",
|
|
},
|
|
});
|
|
expect(ghidra.inspectAvailability()).toMatchObject({ status: "available" });
|
|
expect(probe).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("separates target kind, format, and concrete architecture", () => {
|
|
const ghidra = provider();
|
|
expect(
|
|
ghidra.inspectTargetSupport(executableTarget("elf", "x86_64")),
|
|
).toMatchObject({
|
|
status: "supported",
|
|
});
|
|
expect(
|
|
ghidra.inspectTargetSupport(executableTarget("pe", "arm64")),
|
|
).toMatchObject({
|
|
status: "supported",
|
|
});
|
|
expect(
|
|
ghidra.inspectTargetSupport({
|
|
...executableTarget("elf", "x86_64"),
|
|
kind: "archive",
|
|
format: "asar",
|
|
}),
|
|
).toMatchObject({
|
|
status: "unsupported",
|
|
code: "target_kind_unsupported",
|
|
});
|
|
expect(
|
|
ghidra.inspectTargetSupport(executableTarget("javascript", "x86_64")),
|
|
).toMatchObject({
|
|
status: "unsupported",
|
|
code: "target_format_unsupported",
|
|
});
|
|
expect(ghidra.inspectTargetSupport(executableTarget("elf"))).toMatchObject({
|
|
status: "unsupported",
|
|
code: "architecture_unsupported",
|
|
});
|
|
});
|
|
|
|
it("admits only native x86-64 PE applications on Windows P0", () => {
|
|
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
|
const nativeApplication: BinaryTarget = {
|
|
...executableTarget("pe", "x86_64"),
|
|
executableRole: "application",
|
|
managed: false,
|
|
};
|
|
|
|
expect(ghidra.inspectTargetSupport(nativeApplication)).toMatchObject({
|
|
status: "supported",
|
|
diagnostics: {
|
|
host_platform: "win32",
|
|
executable_role: "application",
|
|
managed: false,
|
|
},
|
|
});
|
|
expect(ghidra.capabilities()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
operation: "list_procedures",
|
|
limitations: expect.arrayContaining([
|
|
expect.stringContaining("bounded taskkill termination"),
|
|
expect.stringContaining("private DACL enforcement"),
|
|
]),
|
|
}),
|
|
]),
|
|
);
|
|
expect(
|
|
ghidra.inspectTargetSupport({
|
|
...nativeApplication,
|
|
executableRole: "shared-library",
|
|
}),
|
|
).toMatchObject({
|
|
status: "unsupported",
|
|
code: "target_role_unsupported",
|
|
});
|
|
expect(
|
|
ghidra.inspectTargetSupport({ ...nativeApplication, managed: true }),
|
|
).toMatchObject({
|
|
status: "unsupported",
|
|
code: "managed_target_unsupported",
|
|
});
|
|
expect(
|
|
ghidra.inspectTargetSupport({ ...nativeApplication, format: "elf" }),
|
|
).toMatchObject({
|
|
status: "unsupported",
|
|
code: "target_format_unsupported",
|
|
});
|
|
expect(
|
|
ghidra.inspectTargetSupport({
|
|
...nativeApplication,
|
|
architecture: "arm64",
|
|
}),
|
|
).toMatchObject({
|
|
status: "unsupported",
|
|
code: "architecture_unsupported",
|
|
});
|
|
});
|
|
|
|
it("commits exact provider, isolation, and resource semantics", async () => {
|
|
const callTool = vi.fn().mockResolvedValue(
|
|
ok({
|
|
items: [
|
|
{
|
|
address: "0x1000",
|
|
value: "fixture_main",
|
|
value_truncated: false,
|
|
procedure: {
|
|
external: false,
|
|
thunk: false,
|
|
thunk_target: null,
|
|
},
|
|
},
|
|
],
|
|
offset: 0,
|
|
limit: 100,
|
|
total: 1,
|
|
next_offset: null,
|
|
has_more: false,
|
|
}),
|
|
);
|
|
const start = vi.fn().mockResolvedValue(ok(sessionInfo()));
|
|
const ghidra = provider(installationHost(), () => ({
|
|
start,
|
|
callTool,
|
|
close: () => Promise.resolve(),
|
|
}));
|
|
const resolved = await ghidra.resolveAnalysisProfile(
|
|
executableTarget("elf", "x86_64"),
|
|
);
|
|
|
|
expect(resolved.ok).toBe(true);
|
|
if (!resolved.ok || resolved.value.profile === null) return;
|
|
expect(resolved.value.profile).toMatchObject({
|
|
provider: { id: "ghidra", name: "Ghidra", version: "12.1.2" },
|
|
provider_profile_schema_version: 1,
|
|
parameters: {
|
|
import_mode: "ephemeral-read-only",
|
|
analyzer_preset: "ghidra-default",
|
|
analysis_timeout_seconds: 300,
|
|
max_cpu: 2,
|
|
max_heap: "2G",
|
|
},
|
|
});
|
|
expect(resolved.value.compatibility).toEqual({
|
|
languageId: "auto",
|
|
compilerSpecId: "auto",
|
|
});
|
|
|
|
const result = await ghidra
|
|
.createClient(executableTarget("elf", "x86_64"), resolved.value.profile)
|
|
.execute("list_procedures", {});
|
|
expect(callTool).toHaveBeenCalledWith(
|
|
"list_procedures",
|
|
{ document: null, offset: 0, limit: 100 },
|
|
{},
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
provider: {
|
|
id: "ghidra",
|
|
name: "Ghidra",
|
|
version: "12.1.2",
|
|
},
|
|
analysisProfile: resolved.value.profile,
|
|
result: {
|
|
items: [{ address: "0x1000", value: "fixture_main" }],
|
|
total: 1,
|
|
},
|
|
rawResult: {
|
|
items: [{ procedure: { external: false, thunk: false } }],
|
|
},
|
|
},
|
|
});
|
|
expect(start).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects malformed inventory output before Evidence creation", async () => {
|
|
const ghidra = provider(installationHost(), () => ({
|
|
start: () => Promise.resolve(ok(sessionInfo())),
|
|
callTool: () => Promise.resolve(ok({ items: "not-a-page" })),
|
|
close: () => Promise.resolve(),
|
|
}));
|
|
const resolved = await ghidra.resolveAnalysisProfile(
|
|
executableTarget("elf", "x86_64"),
|
|
);
|
|
if (!resolved.ok || resolved.value.profile === null) return;
|
|
|
|
await expect(
|
|
ghidra
|
|
.createClient(executableTarget("elf", "x86_64"), resolved.value.profile)
|
|
.execute("list_procedures", {}),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisOutputError" },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["invalid_request", "AnalysisInputError"],
|
|
["not_found", "AnalysisInputError"],
|
|
["ambiguous", "AnalysisInputError"],
|
|
["method_unavailable", "AnalysisCapabilityUnavailableError"],
|
|
] as const)(
|
|
"projects remote %s without losing its typed meaning",
|
|
async (code, tag) => {
|
|
const ghidra = provider(installationHost(), () => ({
|
|
start: () => Promise.resolve(ok(sessionInfo())),
|
|
callTool: () =>
|
|
Promise.resolve(
|
|
err(
|
|
new GhidraSessionError(
|
|
"remote",
|
|
"Fixture remote failure",
|
|
{ remote_code: code },
|
|
{ remoteCode: code },
|
|
),
|
|
),
|
|
),
|
|
close: () => Promise.resolve(),
|
|
}));
|
|
const resolved = await ghidra.resolveAnalysisProfile(
|
|
executableTarget("elf", "x86_64"),
|
|
);
|
|
if (!resolved.ok || resolved.value.profile === null) return;
|
|
|
|
await expect(
|
|
ghidra
|
|
.createClient(
|
|
executableTarget("elf", "x86_64"),
|
|
resolved.value.profile,
|
|
)
|
|
.execute("list_procedures", {}),
|
|
).resolves.toMatchObject({ ok: false, error: { _tag: tag } });
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["decompile_timeout", "AnalysisTimeoutError", 30_000],
|
|
["decompile_cancelled", "AnalysisCancelledError", undefined],
|
|
] as const)(
|
|
"projects remote %s as a provider-neutral interruption",
|
|
async (code, tag, timeoutMs) => {
|
|
const ghidra = provider(installationHost(), () => ({
|
|
start: () => Promise.resolve(ok(sessionInfo())),
|
|
callTool: () =>
|
|
Promise.resolve(
|
|
err(
|
|
new GhidraSessionError(
|
|
"remote",
|
|
"Fixture decompiler interruption",
|
|
{ remote_code: code },
|
|
{ remoteCode: code },
|
|
),
|
|
),
|
|
),
|
|
close: () => Promise.resolve(),
|
|
}));
|
|
const resolved = await ghidra.resolveAnalysisProfile(
|
|
executableTarget("elf", "x86_64"),
|
|
);
|
|
if (!resolved.ok || resolved.value.profile === null) return;
|
|
|
|
const result = await ghidra
|
|
.createClient(executableTarget("elf", "x86_64"), resolved.value.profile)
|
|
.execute("procedure_pseudo_code", { procedure: "main" });
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: tag,
|
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it("returns cancellation before profile work", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
await expect(
|
|
provider().resolveAnalysisProfile(executableTarget("elf", "x86_64"), {
|
|
signal: controller.signal,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
|
|
});
|
|
});
|
|
});
|
|
|
|
const sessionInfo = () => ({
|
|
name: "REA Ghidra bridge" as const,
|
|
bridge_version: 4 as const,
|
|
run_id: "11111111-1111-4111-8111-111111111111",
|
|
profile_digest: "a".repeat(64),
|
|
provider: { id: "ghidra" as const, version: "12.1.2" },
|
|
read_only: true as const,
|
|
analysis_complete: true,
|
|
analysis_timed_out: false,
|
|
capabilities: [...GHIDRA_SESSION_CAPABILITIES],
|
|
target: {
|
|
name: "fixture",
|
|
language_id: "x86:LE:64:default",
|
|
compiler_spec_id: "gcc",
|
|
image_base: "0x1000",
|
|
default_address_space: "ram",
|
|
sha256: "a".repeat(64),
|
|
},
|
|
});
|
|
|
|
const executableTarget = (
|
|
format: BinaryTarget["format"],
|
|
architecture?: NonNullable<BinaryTarget["architecture"]>,
|
|
): BinaryTarget => ({
|
|
path: "/tmp/fixture",
|
|
sha256: "a".repeat(64),
|
|
kind: "executable",
|
|
format,
|
|
...(architecture === undefined
|
|
? {}
|
|
: { architecture, availableArchitectures: [architecture] }),
|
|
});
|