fix(ghidra): disclose missing PDB symbol coverage (#2275)
* fix(ghidra): disclose missing PDB symbol coverage * style: format reviewed source changes * fix(ghidra): bound PDB handshake and retain coverage uncertainty --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: morluto <17176810+morluto@users.noreply.github.com>
This commit is contained in:
parent
1016c96e6b
commit
865c35e25a
|
|
@ -56,6 +56,8 @@ import ghidra.app.decompiler.DecompiledFunction;
|
|||
import ghidra.app.decompiler.ClangCaseToken;
|
||||
import ghidra.app.decompiler.ClangNode;
|
||||
import ghidra.framework.Application;
|
||||
import ghidra.app.util.bin.format.pdb.PdbParserConstants;
|
||||
import ghidra.framework.options.Options;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.address.AddressIterator;
|
||||
import ghidra.program.model.address.AddressSet;
|
||||
|
|
@ -110,6 +112,8 @@ import ghidra.program.util.SymbolicPropogator;
|
|||
import ghidra.program.util.VarnodeContext;
|
||||
|
||||
public final class ReaGhidraBridge extends HeadlessScript {
|
||||
// At most 24 KiB after worst-case JSON escaping (six bytes per UTF-16 unit).
|
||||
private static final int PDB_ATTRIBUTE_UTF16_BUDGET = 4096;
|
||||
private static final int MAX_HIGH_PCODE_OPS = 3000;
|
||||
private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64;
|
||||
private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000;
|
||||
|
|
@ -454,6 +458,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
.toArray(String[]::new)
|
||||
));
|
||||
result.add("target", target);
|
||||
result.add("pdb", pdbObservation());
|
||||
JsonArray extensions = analysisExtensions.reports();
|
||||
if (extensions.size() > 0) result.add("analysis_extensions", extensions);
|
||||
// Written by ReaGhidraApplySeeds before auto-analysis; REA checks it
|
||||
|
|
@ -469,6 +474,41 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
return result;
|
||||
}
|
||||
|
||||
/** Read loader/analyzer-owned facts without searching for or applying companions. */
|
||||
private JsonObject pdbObservation() {
|
||||
Options info = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO);
|
||||
JsonObject pdb = new JsonObject();
|
||||
String[] names = {"file", "guid", "age", "signature", "version"};
|
||||
String[] attributes = {
|
||||
PdbParserConstants.PDB_FILE, PdbParserConstants.PDB_GUID,
|
||||
PdbParserConstants.PDB_AGE, PdbParserConstants.PDB_SIGNATURE,
|
||||
PdbParserConstants.PDB_VERSION
|
||||
};
|
||||
int remaining = PDB_ATTRIBUTE_UTF16_BUDGET;
|
||||
JsonArray omitted = new JsonArray();
|
||||
for (int index = 0; index < names.length; index++) {
|
||||
String value = info.getString(attributes[index], null);
|
||||
if (value != null && value.length() > remaining) {
|
||||
omitted.add(names[index]);
|
||||
pdb.add(names[index], JsonNull.INSTANCE);
|
||||
} else {
|
||||
pdb.addProperty(names[index], value);
|
||||
if (value != null) remaining -= value.length();
|
||||
}
|
||||
}
|
||||
if (omitted.size() > 0) {
|
||||
JsonObject limit = new JsonObject();
|
||||
limit.addProperty("utf16_budget", PDB_ATTRIBUTE_UTF16_BUDGET);
|
||||
limit.add("omitted_attributes", omitted);
|
||||
pdb.add("resource_limit", limit);
|
||||
}
|
||||
if (info.contains(PdbParserConstants.PDB_LOADED))
|
||||
pdb.addProperty("loaded", info.getBoolean(PdbParserConstants.PDB_LOADED, false));
|
||||
else
|
||||
pdb.add("loaded", JsonNull.INSTANCE);
|
||||
return pdb;
|
||||
}
|
||||
|
||||
private static String bytesHex(byte[] bytes) {
|
||||
return java.util.HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,26 @@ does not prove text is absent. Both list and search Evidence carry this
|
|||
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
|
||||
bytes and references.
|
||||
|
||||
## PE debug-symbol coverage
|
||||
|
||||
The post-analysis handshake reports Ghidra's PDB Program Information attributes:
|
||||
the referenced file, GUID, hexadecimal age, legacy signature, version, and
|
||||
`PDB Loaded` state. Absent attributes remain null. PE Evidence carries a
|
||||
limitation when a PDB reference is present but Ghidra reports it as not loaded,
|
||||
or its load state is unknown. A bridge without this report leaves PDB coverage
|
||||
unknown. A reported loaded state is a Ghidra observation, not an independent
|
||||
PDB identity or complete symbol-coverage attestation, and retains an inline
|
||||
coverage caveat. Handshake PDB strings share a 4096 UTF-16-unit serialization
|
||||
budget (at most 24 KiB after JSON escaping); whole attributes that do not fit
|
||||
are omitted with an explicit resource limitation, rather than truncated.
|
||||
|
||||
Original names and types may be missing even when auto-analysis completes.
|
||||
An empty name search does not establish that a function is absent; use
|
||||
`list_procedures` and address-based analysis to inspect recovered functions.
|
||||
REA imports a private executable copy and does not admit a caller-selected
|
||||
PDB companion. This reporting does not search symbol servers, copy a sibling
|
||||
PDB, or change analyzer settings.
|
||||
|
||||
## Loaded addresses and file offsets
|
||||
|
||||
`rea analyze <path> --provider ghidra --json` and MCP `binary_overview` report
|
||||
|
|
|
|||
|
|
@ -206,6 +206,9 @@ export const resolveGhidraAnalysisProfile = async (
|
|||
...(target.availableArchitectures ?? []),
|
||||
].sort(),
|
||||
import_mode: "ephemeral-source-immutable",
|
||||
...(target.format === "pe"
|
||||
? { pdb_observations: "program-info-v1" }
|
||||
: {}),
|
||||
annotation_policy: "atomic-function-entry-metadata-v1",
|
||||
load_image_observations: "source-mappings-entry-context-v2",
|
||||
function_body_evidence: "complete-inclusive-ranges-v1",
|
||||
|
|
|
|||
|
|
@ -1541,6 +1541,113 @@ const sessionInfo = () => ({
|
|||
},
|
||||
});
|
||||
|
||||
describe("Ghidra PDB coverage", () => {
|
||||
it.each([
|
||||
{ name: "missing bridge report", pdb: undefined, warns: true },
|
||||
{
|
||||
name: "report without a PDB reference",
|
||||
pdb: {
|
||||
file: null,
|
||||
guid: null,
|
||||
age: null,
|
||||
signature: null,
|
||||
version: null,
|
||||
loaded: null,
|
||||
},
|
||||
warns: false,
|
||||
},
|
||||
])("distinguishes $name in direct PE searches", async ({ pdb, warns }) => {
|
||||
const ghidra = provider(installationHost(), () => ({
|
||||
start: () =>
|
||||
Promise.resolve(
|
||||
ok(pdb === undefined ? sessionInfo() : { ...sessionInfo(), pdb }),
|
||||
),
|
||||
callTool: () => Promise.resolve(ok([])),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}));
|
||||
const target = executableTarget("pe", "x86_64");
|
||||
const resolved = await ghidra.resolveAnalysisProfile(target);
|
||||
if (!resolved.ok || resolved.value.profile === null)
|
||||
throw new Error("Expected a PE profile");
|
||||
const client = ghidra.createClient(target, resolved.value.profile);
|
||||
try {
|
||||
const searched = await client.execute("search_procedures", {
|
||||
pattern: "add_numbers",
|
||||
});
|
||||
if (!searched.ok) throw searched.error;
|
||||
expect(searched.value.result).toEqual([]);
|
||||
const warnings = searched.value.limitations.filter((limitation) =>
|
||||
limitation.includes("PDB"),
|
||||
);
|
||||
expect(warnings).toEqual(
|
||||
warns
|
||||
? [
|
||||
"Ghidra did not report PDB attributes; debug-symbol coverage is unknown.",
|
||||
]
|
||||
: [],
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
const pdb = {
|
||||
file: "C:\\build\\fixture.pdb",
|
||||
guid: "01234567-89AB-CDEF-0123-456789ABCDEF",
|
||||
age: "a",
|
||||
signature: null,
|
||||
version: "RSDS",
|
||||
loaded: false as boolean | null,
|
||||
};
|
||||
it.each([false, null, true])(
|
||||
"carries PDB load state %s into direct search and health Evidence",
|
||||
async (loaded) => {
|
||||
let starts = 0;
|
||||
const ghidra = provider(installationHost(), () => ({
|
||||
start: () => {
|
||||
starts += 1;
|
||||
return Promise.resolve(
|
||||
ok({ ...sessionInfo(), pdb: { ...pdb, loaded } }),
|
||||
);
|
||||
},
|
||||
callTool: () => Promise.resolve(ok([])),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}));
|
||||
const target = executableTarget("pe", "x86_64");
|
||||
const resolved = await ghidra.resolveAnalysisProfile(target);
|
||||
if (!resolved.ok || resolved.value.profile === null)
|
||||
throw new Error("Expected a PE profile");
|
||||
const client = ghidra.createClient(target, resolved.value.profile);
|
||||
// No preceding health call: a name search must establish symbol coverage itself.
|
||||
const searched = await client.execute("search_procedures", {
|
||||
pattern: "add_numbers",
|
||||
});
|
||||
expect(starts).toBe(1);
|
||||
if (!searched.ok) throw searched.error;
|
||||
expect(searched.value.result).toEqual([]);
|
||||
const health = await client.execute("health", {});
|
||||
if (!health.ok) throw health.error;
|
||||
expect(health.value.result).toMatchObject({ pdb: { ...pdb, loaded } });
|
||||
for (const observation of [searched.value, health.value]) {
|
||||
const limitations = observation.limitations.join("\n");
|
||||
if (loaded === true) {
|
||||
expect(limitations).toContain("loaded PDB");
|
||||
expect(limitations).toContain("not an independent PDB identity");
|
||||
expect(limitations).toContain("empty name search does not prove");
|
||||
} else {
|
||||
expect(limitations).toContain(pdb.file);
|
||||
expect(limitations).toContain(pdb.guid);
|
||||
expect(limitations).toContain("age (hex) a");
|
||||
expect(limitations).toContain("list_procedures");
|
||||
expect(limitations).toContain(
|
||||
loaded === null ? "unknown" : "not loaded",
|
||||
);
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const executableTarget = (
|
||||
format: "mach-o" | "elf" | "pe",
|
||||
architecture: Extract<BinaryTarget, { format: "pe" }>["architecture"],
|
||||
|
|
|
|||
|
|
@ -88,7 +88,10 @@ import {
|
|||
relationshipCoverageFor,
|
||||
} from "./GhidraProviderCapabilities.js";
|
||||
import type { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
|
||||
import {
|
||||
ghidraPdbLimitations,
|
||||
type GhidraSessionInfo,
|
||||
} from "./GhidraSessionValues.js";
|
||||
import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js";
|
||||
import {
|
||||
ghidraExtensionSchema,
|
||||
|
|
@ -526,6 +529,7 @@ export const createGhidraProviderClient = (input: {
|
|||
let extensionFailure: AnalysisError | undefined;
|
||||
// Replaced by the reported outcome once the session handshake is checked.
|
||||
const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)];
|
||||
const pdbLimitations: string[] = [];
|
||||
// Replaced by the reported storage outcome once the handshake is checked.
|
||||
const softFloatLimitations: string[] = [
|
||||
...ghidraMipsSoftFloatLimitations(softFloat),
|
||||
|
|
@ -646,10 +650,16 @@ export const createGhidraProviderClient = (input: {
|
|||
...(context === undefined ? {} : { runId: context.runId }),
|
||||
logger: logger.child({ layer: "ghidra-bridge" }),
|
||||
});
|
||||
const checkExtensions = async (
|
||||
const checkSessionObservations = async (
|
||||
operation: AnalysisOperation,
|
||||
info: GhidraSessionInfo,
|
||||
): Promise<AnalysisError | undefined> => {
|
||||
if (target.format === "pe")
|
||||
pdbLimitations.splice(
|
||||
0,
|
||||
pdbLimitations.length,
|
||||
...ghidraPdbLimitations(info.pdb),
|
||||
);
|
||||
const softFloatReason = ghidraMipsSoftFloatFailure(
|
||||
softFloat,
|
||||
info.mips_soft_float_storage,
|
||||
|
|
@ -701,6 +711,7 @@ export const createGhidraProviderClient = (input: {
|
|||
...softFloatLimitations,
|
||||
...ghidraExtensionLimitations(extensions),
|
||||
...seedLimitations,
|
||||
...pdbLimitations,
|
||||
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
|
||||
];
|
||||
const nativeAotOwner =
|
||||
|
|
@ -772,7 +783,7 @@ export const createGhidraProviderClient = (input: {
|
|||
config.ghidraStartupTimeoutMs,
|
||||
),
|
||||
);
|
||||
const failed = await checkExtensions(operation, started.value);
|
||||
const failed = await checkSessionObservations(operation, started.value);
|
||||
if (failed !== undefined) return err(failed);
|
||||
return ok(
|
||||
createAnalysisExecution(started.value, committedProfile.provider, {
|
||||
|
|
@ -785,7 +796,12 @@ export const createGhidraProviderClient = (input: {
|
|||
? parseGhidraFunctionInput(operation, parameters)
|
||||
: parseGhidraInventoryInput(operation, parameters);
|
||||
if (!input.ok) return input;
|
||||
if (extensions.length > 0 || seeds !== undefined || softFloat) {
|
||||
if (
|
||||
target.format === "pe" ||
|
||||
extensions.length > 0 ||
|
||||
seeds !== undefined ||
|
||||
softFloat
|
||||
) {
|
||||
const started = await client.start(options?.signal, options?.progress);
|
||||
if (!started.ok)
|
||||
return err(
|
||||
|
|
@ -795,7 +811,7 @@ export const createGhidraProviderClient = (input: {
|
|||
config.ghidraStartupTimeoutMs,
|
||||
),
|
||||
);
|
||||
const failed = await checkExtensions(operation, started.value);
|
||||
const failed = await checkSessionObservations(operation, started.value);
|
||||
if (failed !== undefined) return err(failed);
|
||||
}
|
||||
const called = await client.callTool(operation, input.value, {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,49 @@ describe("Ghidra DOS import commitment", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Ghidra PDB observations", () => {
|
||||
const pdb = {
|
||||
file: "C:\\build\\fixture.pdb",
|
||||
guid: "01234567-89AB-CDEF-0123-456789ABCDEF",
|
||||
age: "a",
|
||||
signature: null,
|
||||
version: "RSDS",
|
||||
loaded: false,
|
||||
};
|
||||
it("retains the referenced identity and observed load state", () => {
|
||||
const parsed = parseGhidraSessionInfo({ ...session(), pdb }, expected);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value.pdb).toEqual(pdb);
|
||||
});
|
||||
it("preserves missing attributes as unknown", () => {
|
||||
const unknown = Object.fromEntries(
|
||||
Object.keys(pdb).map((key) => [key, null]),
|
||||
);
|
||||
const parsed = parseGhidraSessionInfo(
|
||||
{ ...session(), pdb: unknown },
|
||||
expected,
|
||||
);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value.pdb).toEqual(unknown);
|
||||
});
|
||||
it.each([
|
||||
{ ...pdb, loaded: "false" },
|
||||
{ ...pdb, age: 1 },
|
||||
{
|
||||
guid: pdb.guid,
|
||||
age: pdb.age,
|
||||
signature: null,
|
||||
version: "RSDS",
|
||||
loaded: false,
|
||||
},
|
||||
{ ...pdb, matched: true },
|
||||
])("rejects malformed PDB reports", (report) => {
|
||||
expect(
|
||||
parseGhidraSessionInfo({ ...session(), pdb: report }, expected).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra MIPS soft-float storage report", () => {
|
||||
const valid = {
|
||||
policy: "o32-soft-float-prototype-v1",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ export const GHIDRA_MUTATING_OPERATIONS: ReadonlySet<string> = new Set([
|
|||
...GHIDRA_NAMING_OPERATIONS,
|
||||
]);
|
||||
|
||||
// Shared wire budget: Java bounds the aggregate before serializing the handshake.
|
||||
const PDB_ATTRIBUTE_UTF16_BUDGET = 4096;
|
||||
const pdbAttributeSchema = z
|
||||
.string()
|
||||
.max(PDB_ATTRIBUTE_UTF16_BUDGET)
|
||||
.nullable();
|
||||
|
||||
const capabilitySchema = z.enum(GHIDRA_SESSION_CAPABILITIES);
|
||||
|
||||
const sessionInfoSchema = z
|
||||
|
|
@ -41,6 +48,39 @@ const sessionInfoSchema = z
|
|||
read_only: z.boolean(),
|
||||
analysis_complete: z.boolean(),
|
||||
analysis_timed_out: z.boolean(),
|
||||
/** Program Information attributes; absent load state remains unknown. */
|
||||
pdb: z
|
||||
.strictObject({
|
||||
file: pdbAttributeSchema,
|
||||
guid: pdbAttributeSchema,
|
||||
/** Ghidra stores CodeView age as a hexadecimal string. */
|
||||
age: pdbAttributeSchema,
|
||||
signature: pdbAttributeSchema,
|
||||
version: pdbAttributeSchema,
|
||||
loaded: z.boolean().nullable(),
|
||||
resource_limit: z
|
||||
.strictObject({
|
||||
utf16_budget: z.literal(PDB_ATTRIBUTE_UTF16_BUDGET),
|
||||
omitted_attributes: z
|
||||
.array(z.enum(["file", "guid", "age", "signature", "version"]))
|
||||
.min(1)
|
||||
.max(5),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(pdb) =>
|
||||
[
|
||||
pdb.file,
|
||||
pdb.guid,
|
||||
pdb.age,
|
||||
pdb.signature,
|
||||
pdb.version,
|
||||
].reduce<number>((sum, value) => sum + (value?.length ?? 0), 0) <=
|
||||
PDB_ATTRIBUTE_UTF16_BUDGET,
|
||||
"PDB attributes exceed the aggregate wire budget",
|
||||
)
|
||||
.optional(),
|
||||
analysis_extensions: z.array(ghidraExtensionResultSchema).optional(),
|
||||
analysis_seeds: ghidraSeedReportSchema.optional(),
|
||||
mips_soft_float_storage: z
|
||||
|
|
@ -68,6 +108,37 @@ const sessionInfoSchema = z
|
|||
/** Authenticated metadata produced after Ghidra finishes auto-analysis. */
|
||||
export type GhidraSessionInfo = z.infer<typeof sessionInfoSchema>;
|
||||
|
||||
/** Describe missing PDB authority without claiming all names are generated. */
|
||||
export const ghidraPdbLimitations = (
|
||||
pdb: GhidraSessionInfo["pdb"],
|
||||
): readonly string[] => {
|
||||
if (pdb === undefined)
|
||||
return [
|
||||
"Ghidra did not report PDB attributes; debug-symbol coverage is unknown.",
|
||||
];
|
||||
const limitations: string[] = [];
|
||||
if (pdb.resource_limit !== undefined)
|
||||
limitations.push(
|
||||
`PDB attributes ${pdb.resource_limit.omitted_attributes.join(", ")} were omitted because the handshake's ${pdb.resource_limit.utf16_budget}-UTF-16-unit PDB attribute budget was exhausted. Omitted values remain unknown; they were not truncated or reported as absent.`,
|
||||
);
|
||||
if (pdb.loaded === true)
|
||||
return [
|
||||
...limitations,
|
||||
"Ghidra reports a loaded PDB; this is not an independent PDB identity or complete symbol/type-coverage attestation. Original names and types may still be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions.",
|
||||
];
|
||||
if (
|
||||
[pdb.file, pdb.guid, pdb.age, pdb.signature, pdb.version].every(
|
||||
(value) => value === null,
|
||||
)
|
||||
)
|
||||
return limitations;
|
||||
const state = pdb.loaded === false ? "not loaded" : "load state unknown";
|
||||
return [
|
||||
...limitations,
|
||||
`The target references PDB ${pdb.file ?? "(file unknown)"} (GUID ${pdb.guid ?? "unknown"}, age (hex) ${pdb.age ?? "unknown"}, signature ${pdb.signature ?? "unknown"}, version ${pdb.version ?? "unknown"}); Ghidra reports ${state}. Original names and types may be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions. REA does not admit a caller-selected PDB companion.`,
|
||||
];
|
||||
};
|
||||
|
||||
/** Require exact run, provider, and profile identity from the Java bridge. */
|
||||
export const parseGhidraSessionInfo = (
|
||||
value: JsonValue,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,86 @@
|
|||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { 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 { ok } from "../../../src/domain/result.js";
|
||||
import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js";
|
||||
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
|
||||
|
||||
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", () =>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { GhidraOperation } from "../../../src/ghidra/GhidraClient.js";
|
|||
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
|
||||
import type { GhidraProviderClientFactory } from "../../../src/ghidra/GhidraProviderClient.js";
|
||||
import { GHIDRA_SESSION_CAPABILITIES } from "../../../src/ghidra/GhidraSessionValues.js";
|
||||
import type { GhidraSessionInfo } from "../../../src/ghidra/GhidraSessionValues.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
|
|
@ -26,12 +27,19 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
|
|||
export const connectGhidraMcp = async (
|
||||
name: string,
|
||||
execute?: ReturnType<GhidraProviderClientFactory>["callTool"],
|
||||
observations: {
|
||||
readonly targetPath?: string;
|
||||
readonly pdb?: GhidraSessionInfo["pdb"];
|
||||
} = {},
|
||||
) => {
|
||||
const calls: GhidraOperation[] = [];
|
||||
const factory: GhidraProviderClientFactory = (options) => ({
|
||||
start: () =>
|
||||
Promise.resolve(
|
||||
ok(sessionInfo(options.profileDigest, options.targetSha256)),
|
||||
ok({
|
||||
...sessionInfo(options.profileDigest, options.targetSha256),
|
||||
...(observations.pdb === undefined ? {} : { pdb: observations.pdb }),
|
||||
}),
|
||||
),
|
||||
callTool: (operation, input, options) => {
|
||||
calls.push(operation);
|
||||
|
|
@ -68,7 +76,7 @@ export const connectGhidraMcp = async (
|
|||
await mcp.connect(clientTransport);
|
||||
const opened = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: process.execPath },
|
||||
arguments: { path: observations.targetPath ?? process.execPath },
|
||||
});
|
||||
if (opened.isError === true)
|
||||
throw new Error(
|
||||
|
|
|
|||
Loading…
Reference in New Issue