* feat(native): read embedded cargo-auditable Rust dependency lists * fix(native): trim PE section padding and compute dependency cycles iteratively * fix(native): ignore SHT_NOBITS sections and validate dependency edges linearly * fix(native): bound compiler path scanning and retained hashes * fix(rust): pass package arrays as named parameterized test cases * fix(rust): admit graph expansion and preserve resource units * fix(rust): reject malformed packages, false cycles, and hidden compiler paths Fail the first malformed cargo-auditable package before Zod collects an issue per field. Require cyclic dependency indices to match the cycles in package dependencies. Keep read-only ELF ranges when a section name does not fit the bounded name window, so a rustc path in that range is still reported. * fix(ci): repair integrated checks and Hopper test baseline * fix(go): verify Mach-O sections against segments --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
import { expect, it } from "vitest";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js";
|
|
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
|
|
import { err, ok } from "../../../src/domain/result.js";
|
|
import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js";
|
|
import {
|
|
connectGhidraMcp,
|
|
ghidraInstallationHost,
|
|
sessionEvidence,
|
|
} from "./ghidraMcpHarness.js";
|
|
import { spawn } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
|
|
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
|
|
import { parseConfig } from "../../../src/config/parseConfig.js";
|
|
import { silentLogger } from "../../../src/logger.js";
|
|
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
|
|
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
|
|
import { createServer } from "../../../src/server/createServer.js";
|
|
import { waitForExit } from "../../support/process/processFixture.js";
|
|
import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js";
|
|
|
|
it("retains byte-search coverage and rejects malformed pages before creating Evidence", async () => {
|
|
const page = {
|
|
matches: [
|
|
{
|
|
address: "0x401000",
|
|
segment: ".text",
|
|
file_offset: null,
|
|
procedure: null,
|
|
},
|
|
],
|
|
searched: {
|
|
blocks: [
|
|
{ segment: ".text", start: "0x401000", end: "0x40100f", bytes: 16 },
|
|
],
|
|
bytes: 16,
|
|
skipped_uninitialized: [
|
|
{ segment: ".bss", start: "0x402000", end: "0x40200f", bytes: 16 },
|
|
],
|
|
skipped_not_loaded: [
|
|
{ segment: ".symtab", start: "0x0", end: "0x17f", bytes: 384 },
|
|
],
|
|
},
|
|
complete: false,
|
|
next_cursor: "continuation",
|
|
};
|
|
let output = jsonValueSchema.parse(page);
|
|
const harness = await connectGhidraMcp("byte-search-projection", () =>
|
|
Promise.resolve(ok(output)),
|
|
);
|
|
try {
|
|
const first = await harness.mcp.callTool({
|
|
name: "search_bytes",
|
|
arguments: { pattern: "48 ??", limit: 1 },
|
|
});
|
|
expect(first.isError).not.toBe(true);
|
|
expect(
|
|
sessionEvidence(harness.session, first.structuredContent)
|
|
.normalized_result,
|
|
).toEqual(page);
|
|
output = jsonValueSchema.parse({
|
|
...page,
|
|
matches: [],
|
|
complete: true,
|
|
next_cursor: null,
|
|
});
|
|
const last = await harness.mcp.callTool({
|
|
name: "search_bytes",
|
|
arguments: { pattern: "48 ??", cursor: "continuation" },
|
|
});
|
|
expect(last.isError).not.toBe(true);
|
|
expect(
|
|
sessionEvidence(harness.session, last.structuredContent)
|
|
.normalized_result,
|
|
).toEqual(output);
|
|
output = jsonValueSchema.parse({ ...page, complete: true });
|
|
const malformed = await harness.mcp.callTool({
|
|
name: "search_bytes",
|
|
arguments: { pattern: "49 ??" },
|
|
});
|
|
expect(parseMcpToolError(malformed)).toMatchObject({
|
|
error: { code: "unreadable_output" },
|
|
});
|
|
for (const block of [
|
|
{ segment: ".text", start: "0x1000", end: "0x1009", bytes: 1 },
|
|
{ segment: ".text", start: "0x1009", end: "0x1000", bytes: 1 },
|
|
{ segment: ".text", start: "overlay:0x1000", end: "0x1000", bytes: 1 },
|
|
{ segment: ".text", start: "invalid", end: "0x1000", bytes: 1 },
|
|
]) {
|
|
output = jsonValueSchema.parse({
|
|
...page,
|
|
searched: { ...page.searched, blocks: [block], bytes: block.bytes },
|
|
});
|
|
const invalidCoverage = await harness.mcp.callTool({
|
|
name: "search_bytes",
|
|
arguments: { pattern: "48 ??" },
|
|
});
|
|
expect(parseMcpToolError(invalidCoverage)).toMatchObject({
|
|
error: { code: "unreadable_output" },
|
|
});
|
|
expect(invalidCoverage.structuredContent).toBeUndefined();
|
|
}
|
|
const calls = harness.calls.length;
|
|
for (const args of [{ pattern: "??" }, { pattern: "48", mask: "ffff" }]) {
|
|
const invalid = await harness.mcp.callTool({
|
|
name: "search_bytes",
|
|
arguments: args,
|
|
});
|
|
expect(invalid.isError).toBe(true);
|
|
expect(JSON.stringify(invalid.content)).toContain(
|
|
"Input validation error",
|
|
);
|
|
}
|
|
expect(harness.calls).toHaveLength(calls);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it("projects cancelled byte searches as typed failures without successful partial Evidence", async () => {
|
|
const harness = await connectGhidraMcp("byte-search-cancelled", () =>
|
|
Promise.resolve(
|
|
err(new GhidraSessionError("cancelled", "Search cancelled")),
|
|
),
|
|
);
|
|
try {
|
|
const reply = await harness.mcp.callTool({
|
|
name: "search_bytes",
|
|
arguments: { pattern: "48 8b" },
|
|
});
|
|
expect(reply.isError).toBe(true);
|
|
expect(parseMcpToolError(reply)).toMatchObject({
|
|
error: { code: "cancelled" },
|
|
});
|
|
expect(reply.structuredContent).toBeUndefined();
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it("searches bytes through MCP and the socket bridge with pagination and skipped coverage", async () => {
|
|
const config = parseConfig({ GHIDRA_INSTALL_DIR: "/fixture/ghidra" });
|
|
if (!config.ok) throw config.error;
|
|
const provider = new GhidraProvider(
|
|
config.value,
|
|
silentLogger,
|
|
{},
|
|
ghidraInstallationHost(),
|
|
(options) =>
|
|
new GhidraClient({
|
|
...options,
|
|
platform: process.platform,
|
|
startupTimeoutMs: 5000,
|
|
launcher: {
|
|
launch: async (launch) => {
|
|
const projectRoot = join(launch.runtimeRoot, "project");
|
|
await mkdir(projectRoot);
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
fileURLToPath(
|
|
new URL("../../fixtures/fakeGhidra.mjs", import.meta.url),
|
|
),
|
|
],
|
|
{
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: {
|
|
...process.env,
|
|
REA_GHIDRA_FIXTURE_CONFIG: JSON.stringify({
|
|
endpointPath: launch.endpointPath,
|
|
token: launch.token,
|
|
runId: launch.runId,
|
|
providerVersion: launch.providerVersion,
|
|
profileDigest: launch.profileDigest,
|
|
targetSha256: launch.targetSha256,
|
|
transport: launch.transport,
|
|
}),
|
|
},
|
|
},
|
|
);
|
|
return ok({
|
|
process: child,
|
|
ownsProcessLifetime: true as const,
|
|
projectRoot,
|
|
ghidraLogPath: join(launch.runtimeRoot, "ghidra.log"),
|
|
scriptLogPath: join(launch.runtimeRoot, "script.log"),
|
|
cleanup: async () => {
|
|
const signaled =
|
|
child.exitCode === null && child.signalCode === null;
|
|
if (signaled) child.kill("SIGTERM");
|
|
return (await waitForExit(child, 2000))
|
|
? { cleaned: true as const, signaled }
|
|
: { cleaned: false as const, reason: "Fixture did not exit" };
|
|
},
|
|
});
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const session = composeBinarySession(
|
|
new AnalysisProviderRegistry([provider]),
|
|
);
|
|
const server = createServer(
|
|
{ kind: "session", session },
|
|
{ logger: silentLogger },
|
|
);
|
|
const client = new Client({ name: "byte-search", version: "1" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const opened = await client.callTool({
|
|
name: "open_binary",
|
|
arguments: { path: process.execPath },
|
|
});
|
|
expect(opened.isError).not.toBe(true);
|
|
const first = await client.callTool({
|
|
name: "search_bytes",
|
|
arguments: { pattern: "48 8b ?? ?? 89", limit: 1 },
|
|
});
|
|
expect(first.isError, JSON.stringify(first)).not.toBe(true);
|
|
const firstEvidence = sessionEvidence(session, first.structuredContent);
|
|
expect(firstEvidence.normalized_result).toMatchObject({
|
|
matches: [
|
|
{
|
|
address: "0x1000",
|
|
segment: ".text",
|
|
file_offset: 0,
|
|
procedure: { name: "fixture_main" },
|
|
},
|
|
],
|
|
searched: {
|
|
bytes: 10,
|
|
skipped_uninitialized: [{ segment: ".bss", bytes: 16 }],
|
|
},
|
|
complete: false,
|
|
next_cursor: "fixture-second-page",
|
|
});
|
|
const second = await client.callTool({
|
|
name: "search_bytes",
|
|
arguments: {
|
|
pattern: "48 8b ?? ?? 89",
|
|
limit: 1,
|
|
cursor: "fixture-second-page",
|
|
},
|
|
});
|
|
expect(second.isError).not.toBe(true);
|
|
expect(
|
|
sessionEvidence(session, second.structuredContent).normalized_result,
|
|
).toMatchObject({
|
|
matches: [{ address: "0x1005", file_offset: 5 }],
|
|
complete: true,
|
|
next_cursor: null,
|
|
});
|
|
const invalid = await client.callTool({
|
|
name: "search_bytes",
|
|
arguments: { pattern: "??" },
|
|
});
|
|
expect(invalid.isError).toBe(true);
|
|
expect(JSON.stringify(invalid.content)).toContain(
|
|
"Pattern must constrain at least one bit",
|
|
);
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
const closed = await session.close();
|
|
if (!closed.ok) throw closed.error;
|
|
}
|
|
});
|
|
|
|
it("retains missing PDB coverage in empty PE name-search Evidence and snapshot replay", async () => {
|
|
const root = await createTestTempDirectory("rea-pdb-evidence-");
|
|
const targetPath = join(root, "fixture.exe");
|
|
await writeFile(targetPath, nativeAotPeFixture());
|
|
// Synthetic post-analysis facts at the real provider boundary; no engine is simulated as verified.
|
|
const pdb = {
|
|
file: "C:\\build\\fixture.pdb",
|
|
guid: "01234567-89AB-CDEF-0123-456789ABCDEF",
|
|
age: "a",
|
|
signature: null,
|
|
version: "RSDS",
|
|
loaded: false,
|
|
};
|
|
const harness = await connectGhidraMcp(
|
|
"ghidra-pdb-coverage",
|
|
(operation) =>
|
|
Promise.resolve(
|
|
ok(
|
|
operation === "procedure_pseudo_code"
|
|
? {
|
|
value: "int FUN_140001000(void) { return 0; }",
|
|
limitations: [],
|
|
}
|
|
: [],
|
|
),
|
|
),
|
|
{
|
|
targetPath,
|
|
pdb,
|
|
},
|
|
);
|
|
try {
|
|
const reply = await harness.mcp.callTool({
|
|
name: "search_procedures",
|
|
arguments: { pattern: "add_numbers" },
|
|
});
|
|
expect(reply.isError).not.toBe(true);
|
|
const evidence = sessionEvidence(harness.session, reply.structuredContent);
|
|
expect(evidence.normalized_result).toEqual([]);
|
|
expect(evidence.analysis_profile?.parameters.pdb_observations).toBe(
|
|
"program-info-v1",
|
|
);
|
|
const limitation = evidence.limitations.find((value) =>
|
|
value.includes(pdb.file),
|
|
);
|
|
expect(limitation).toContain(pdb.guid);
|
|
expect(limitation).toContain("age (hex) a");
|
|
expect(limitation).toContain("not loaded");
|
|
const decompiled = await harness.mcp.callTool({
|
|
name: "procedure_pseudo_code",
|
|
arguments: { procedure: "0x140001000", document: "fixture" },
|
|
});
|
|
expect(decompiled.isError).not.toBe(true);
|
|
const functionEvidence = sessionEvidence(
|
|
harness.session,
|
|
decompiled.structuredContent,
|
|
);
|
|
expect(functionEvidence.limitations).toContain(limitation);
|
|
const replay = await harness.session.execute(
|
|
"procedure_pseudo_code",
|
|
functionEvidence.parameters,
|
|
);
|
|
if (!replay.ok) throw replay.error;
|
|
expect(replay.value.limitations).toContain(limitation);
|
|
expect(replay.value.limitations).toContainEqual(
|
|
expect.stringContaining("Loaded from a local REA analysis snapshot"),
|
|
);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it("retains defined-string coverage in empty and matching search Evidence", async () => {
|
|
let output = jsonValueSchema.parse([]);
|
|
const harness = await connectGhidraMcp("ghidra-string-coverage", () =>
|
|
Promise.resolve(ok(output)),
|
|
);
|
|
try {
|
|
for (const request of [
|
|
{ name: "list_strings", arguments: {} },
|
|
{ name: "search_strings", arguments: { pattern: "projection" } },
|
|
{
|
|
name: "search_strings",
|
|
arguments: { pattern: "projection.*", mode: "regex" },
|
|
},
|
|
]) {
|
|
const reply = await harness.mcp.callTool(request);
|
|
expect(reply.isError).not.toBe(true);
|
|
const evidence = sessionEvidence(
|
|
harness.session,
|
|
reply.structuredContent,
|
|
);
|
|
expect(evidence.normalized_result).toEqual([]);
|
|
expect(evidence.analysis_profile?.parameters).toMatchObject({
|
|
string_inventory_evidence: "defined-data-coverage-v1",
|
|
});
|
|
expect(evidence.limitations).toContainEqual(
|
|
expect.stringMatching(
|
|
/Only Ghidra-defined string Data.*empty search does not establish/u,
|
|
),
|
|
);
|
|
}
|
|
output = jsonValueSchema.parse([
|
|
{ address: "0x401000", value: "projection cache ignored" },
|
|
]);
|
|
const matched = await harness.mcp.callTool({
|
|
name: "search_strings",
|
|
arguments: { pattern: "projection" },
|
|
});
|
|
const evidence = sessionEvidence(
|
|
harness.session,
|
|
matched.structuredContent,
|
|
);
|
|
expect(evidence.normalized_result).toEqual(output);
|
|
expect(evidence.limitations).toContainEqual(
|
|
expect.stringContaining("Completeness applies to that inventory"),
|
|
);
|
|
output = jsonValueSchema.parse([]);
|
|
const procedures = await harness.mcp.callTool({
|
|
name: "search_procedures",
|
|
arguments: { pattern: "projection" },
|
|
});
|
|
expect(
|
|
sessionEvidence(harness.session, procedures.structuredContent)
|
|
.limitations,
|
|
).not.toContainEqual(expect.stringContaining("Ghidra-defined string Data"));
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it("rejects contradictory provider output before emitting MCP Evidence", async () => {
|
|
const bytes = {
|
|
address: "0x401000",
|
|
requested_bytes: 4,
|
|
returned_bytes: 2,
|
|
bytes_hex: "0410",
|
|
complete: false,
|
|
};
|
|
let output = jsonValueSchema.parse(bytes);
|
|
const harness = await connectGhidraMcp("ghidra-invalid-inventory", () =>
|
|
Promise.resolve(ok(output)),
|
|
);
|
|
try {
|
|
const accepted = await harness.mcp.callTool({
|
|
name: "read_bytes",
|
|
arguments: { address: bytes.address, length: 4 },
|
|
});
|
|
expect(accepted.isError).not.toBe(true);
|
|
expect(accepted.structuredContent).toMatchObject({
|
|
normalized_result: bytes,
|
|
});
|
|
const cases = [
|
|
...[
|
|
{ complete: true },
|
|
{ returned_bytes: 4 },
|
|
{ bytes_hex: "04" },
|
|
{ requested_bytes: 1 },
|
|
].map((change, index) => ({
|
|
name: "read_bytes",
|
|
arguments: {
|
|
address: `0x${(0x401004 + index * 4).toString(16)}`,
|
|
length: 4,
|
|
},
|
|
output: { ...bytes, ...change },
|
|
})),
|
|
{
|
|
name: "address_to_file_offset",
|
|
arguments: { address: bytes.address },
|
|
output: { address: bytes.address, file_offset: -1 },
|
|
},
|
|
{
|
|
name: "list_procedures",
|
|
arguments: {},
|
|
output: [
|
|
{
|
|
address: "00401000",
|
|
value: "main",
|
|
procedure: { external: false, thunk: false, thunk_target: null },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "search_strings",
|
|
arguments: { pattern: "needle" },
|
|
output: [{ address: "0x401000" }],
|
|
},
|
|
{
|
|
name: "read_function_instructions",
|
|
arguments: { procedure: "fixture_main" },
|
|
output: {
|
|
procedure: { ...ghidraFunctionIdentity(), address: "0X401000" },
|
|
instructions: ["0x401000: push rbp"],
|
|
limitations: ["Ghidra-specific instruction text."],
|
|
},
|
|
},
|
|
{
|
|
name: "resolve_containing_procedure",
|
|
arguments: { address: "EXTERNAL:0x2" },
|
|
output: {
|
|
query_address: "EXTERNAL:0x2",
|
|
found: true,
|
|
procedure: {
|
|
...ghidraFunctionIdentity(),
|
|
address: "EXTERNAL:0x1",
|
|
classification: {
|
|
...ghidraFunctionIdentity().classification,
|
|
external: true,
|
|
},
|
|
body: {
|
|
...ghidraFunctionIdentity().body,
|
|
ranges: [],
|
|
total_bytes: 0,
|
|
span_bytes: 0,
|
|
contains_entry: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
for (const probe of cases) {
|
|
output = jsonValueSchema.parse(probe.output);
|
|
const rejected = await harness.mcp.callTool({
|
|
name: probe.name,
|
|
arguments: probe.arguments,
|
|
});
|
|
expect(rejected.isError, probe.name).toBe(true);
|
|
expect(parseMcpToolError(rejected)).toMatchObject({
|
|
error: { code: "unreadable_output" },
|
|
});
|
|
expect(parseMcpToolError(rejected)).not.toHaveProperty("evidence_id");
|
|
expect(parseMcpToolError(rejected)).not.toHaveProperty("result");
|
|
}
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|