fix(native): preserve compact reference provenance (#1438)
Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
This commit is contained in:
parent
2ec93ba166
commit
24d9337649
|
|
@ -841,14 +841,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
|
||||
private JsonObject instructionReference(Reference reference) {
|
||||
JsonObject item = new JsonObject();
|
||||
RefType type = reference.getReferenceType();
|
||||
JsonObject kind = referenceKind(reference);
|
||||
item.addProperty("source_address", canonicalAddress(reference.getFromAddress()));
|
||||
item.addProperty("target_address", canonicalAddress(reference.getToAddress()));
|
||||
item.addProperty("type", type.getName());
|
||||
item.addProperty("call", type.isCall());
|
||||
item.addProperty("jump", type.isJump());
|
||||
item.addProperty("indirect", type.isIndirect());
|
||||
item.addProperty("computed", type.isComputed());
|
||||
item.addProperty("operand_index", reference.getOperandIndex());
|
||||
for (String field : List.of("type", "call", "jump", "indirect", "computed",
|
||||
"operand_index", "data", "read", "write", "primary", "provenance"))
|
||||
item.add(field, kind.get(field));
|
||||
item.addProperty("source", normalizedSource(reference.getSource()));
|
||||
return item;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -195,6 +195,14 @@ outside-memory and undecodable addresses have separate outcomes. Effective
|
|||
memory base/index/displacement roles and per-instruction context mode remain
|
||||
unavailable; `mode` is the program language variant.
|
||||
|
||||
Compact instruction references and call-target references preserve the source
|
||||
address, operand index, DATA/READ/WRITE classification, primary status, provider
|
||||
`provenance`, and reference `source` (for Ghidra: `analysis`, `default`, `imported`
|
||||
or `user_defined`). Primary status reports the provider's chosen reference; it
|
||||
does not establish literal addressing. Analyzer-inferred indexed targets remain
|
||||
static references, not observed runtime reads or writes. Older evidence may omit
|
||||
the additive fields, and null or omitted facts mean unavailable rather than false.
|
||||
|
||||
Ghidra distinguishes a return using decoded instruction p-code's `RETURN`
|
||||
operation. `flow.classification_evidence` preserves the Listing flow type and
|
||||
the observed return evidence; mnemonic spelling alone is not the classifier.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ for (const architecture of ["x86_64", "x86"]) {
|
|||
"fixtures",
|
||||
`rea-ghidra-windows${x86 ? "-x86" : ""}.${dll ? "dll" : "exe"}`,
|
||||
);
|
||||
const bytes = Buffer.alloc(1024);
|
||||
const bytes = Buffer.alloc(dll ? 1024 : 1536);
|
||||
const peOffset = 0x80;
|
||||
const optionalHeader = peOffset + 24;
|
||||
const optionalHeaderSize = x86 ? 0xe0 : 0xf0;
|
||||
|
|
@ -24,7 +24,7 @@ for (const architecture of ["x86_64", "x86"]) {
|
|||
bytes.writeUInt32LE(peOffset, 0x3c);
|
||||
bytes.write("PE\0\0", peOffset, "binary");
|
||||
bytes.writeUInt16LE(x86 ? 0x14c : 0x8664, peOffset + 4);
|
||||
bytes.writeUInt16LE(1, peOffset + 6);
|
||||
bytes.writeUInt16LE(dll ? 1 : 2, peOffset + 6);
|
||||
bytes.writeUInt16LE(optionalHeaderSize, peOffset + 20);
|
||||
bytes.writeUInt16LE(
|
||||
(x86 ? 0x102 : 0x22) | (dll ? 0x2000 : 0),
|
||||
|
|
@ -33,6 +33,7 @@ for (const architecture of ["x86_64", "x86"]) {
|
|||
|
||||
bytes.writeUInt16LE(x86 ? 0x10b : 0x20b, optionalHeader);
|
||||
bytes.writeUInt32LE(0x200, optionalHeader + 4);
|
||||
if (!dll) bytes.writeUInt32LE(0x200, optionalHeader + 8);
|
||||
bytes.writeUInt32LE(dll ? 0 : 0x1000, optionalHeader + 16);
|
||||
bytes.writeUInt32LE(0x1000, optionalHeader + 20);
|
||||
if (x86) bytes.writeUInt32LE(0x400000, optionalHeader + 28);
|
||||
|
|
@ -41,7 +42,7 @@ for (const architecture of ["x86_64", "x86"]) {
|
|||
bytes.writeUInt32LE(0x200, optionalHeader + 36);
|
||||
bytes.writeUInt16LE(6, optionalHeader + 40);
|
||||
bytes.writeUInt16LE(6, optionalHeader + 48);
|
||||
bytes.writeUInt32LE(0x2000, optionalHeader + 56);
|
||||
bytes.writeUInt32LE(dll ? 0x2000 : 0x3000, optionalHeader + 56);
|
||||
bytes.writeUInt32LE(0x200, optionalHeader + 60);
|
||||
bytes.writeUInt16LE(3, optionalHeader + 68);
|
||||
bytes.writeUInt16LE(x86 ? 0x8140 : 0x8160, optionalHeader + 70);
|
||||
|
|
@ -60,7 +61,7 @@ for (const architecture of ["x86_64", "x86"]) {
|
|||
}
|
||||
|
||||
bytes.write(".text", sectionTable, "ascii");
|
||||
bytes.writeUInt32LE(dll ? 0x180 : 0x16, sectionTable + 8);
|
||||
bytes.writeUInt32LE(dll ? 0x180 : 0x50, sectionTable + 8);
|
||||
bytes.writeUInt32LE(0x1000, sectionTable + 12);
|
||||
bytes.writeUInt32LE(0x200, sectionTable + 16);
|
||||
bytes.writeUInt32LE(0x200, sectionTable + 20);
|
||||
|
|
@ -70,11 +71,48 @@ for (const architecture of ["x86_64", "x86"]) {
|
|||
bytes.set(
|
||||
dll
|
||||
? [0xe8, 0x0b, 0, 0, 0, 0x83, 0xc0, 1, 0xc3]
|
||||
: [0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3],
|
||||
: [0xe8, 0x0b, 0, 0, 0, 0xe8, 0x16, 0, 0, 0, 0x31, 0xc0, 0xc3],
|
||||
0x200,
|
||||
);
|
||||
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
|
||||
|
||||
if (!dll) {
|
||||
const dataSection = sectionTable + 40;
|
||||
bytes.write(".data", dataSection, "ascii");
|
||||
bytes.writeUInt32LE(0x20, dataSection + 8);
|
||||
bytes.writeUInt32LE(0x2000, dataSection + 12);
|
||||
bytes.writeUInt32LE(0x200, dataSection + 16);
|
||||
bytes.writeUInt32LE(0x400, dataSection + 20);
|
||||
bytes.writeUInt32LE(0xc000_0040, dataSection + 36);
|
||||
bytes.writeUInt32LE(0x1122_3344, 0x400);
|
||||
const referenceInstructions = x86
|
||||
? [
|
||||
"ba00204000", // MOV EDX, address: DATA only.
|
||||
"a100204000", // MOV EAX, [address]: READ.
|
||||
"a304204000", // MOV [address], EAX: WRITE.
|
||||
"ba10204000", // Indexed base, unknown ECX.
|
||||
"85db", // TEST EBX, EBX: two candidate bases.
|
||||
"7405", // JZ over the alternate base.
|
||||
"ba18204000", // MOV EDX, alternate base.
|
||||
"c6040a01", // MOV byte [EDX + ECX], 1.
|
||||
"ffd7", // CALL EDI: unknown target.
|
||||
"c3",
|
||||
]
|
||||
: [
|
||||
"488d15d90f0000", // LEA RDX, [RIP + displacement]: DATA.
|
||||
"8b05d30f0000", // MOV EAX, [RIP + displacement]: READ.
|
||||
"8905d10f0000", // MOV [RIP + displacement], EAX: WRITE.
|
||||
"488d15d60f0000", // Indexed base, unknown RCX.
|
||||
"85db", // TEST EBX, EBX: two candidate bases.
|
||||
"7407", // JZ over the alternate base.
|
||||
"488d15d30f0000", // LEA RDX, alternate base.
|
||||
"c6040a01", // MOV byte [RDX + RCX], 1.
|
||||
"41ffd0", // CALL R8: unknown target.
|
||||
"c3",
|
||||
];
|
||||
bytes.set(Buffer.from(referenceInstructions.join(""), "hex"), 0x220);
|
||||
}
|
||||
|
||||
if (dll) {
|
||||
// Export both functions; an entry-point-free library is discovered by exports.
|
||||
bytes.writeUInt32LE(0x1040, directories);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
/** Verify actual reference facts on the source-owned Windows PE fixture. */
|
||||
export async function verifyGhidraCompactReferences(call, x86, cli) {
|
||||
const base = x86 ? 0x400000n : 0x1_4000_0000n;
|
||||
const address = (offset) => `0x${(base + BigInt(offset)).toString(16)}`;
|
||||
const cases = [
|
||||
["data", 0x1020, 0x2000],
|
||||
["read", x86 ? 0x1025 : 0x1027, 0x2000],
|
||||
["write", x86 ? 0x102a : 0x102d, 0x2004],
|
||||
];
|
||||
const full = await call("procedure_references", {
|
||||
procedure: address(0x1020),
|
||||
});
|
||||
const inspected = {};
|
||||
for (const [kind, offset, target] of cases) {
|
||||
const instruction = await call("inspect_native_instruction", {
|
||||
address: address(offset),
|
||||
});
|
||||
inspected[kind] = instruction;
|
||||
const reference = instruction.references.find(
|
||||
(item) => item.target_address === address(target),
|
||||
);
|
||||
assert.ok(reference, `Missing ${kind} fixture reference`);
|
||||
assertReference(reference, instruction.address, full.references);
|
||||
assert.equal(reference.data, true);
|
||||
assert.equal(reference.read, kind === "read");
|
||||
assert.equal(reference.write, kind === "write");
|
||||
}
|
||||
const indexed = await call("inspect_native_instruction", {
|
||||
address: address(x86 ? 0x103d : 0x1045),
|
||||
});
|
||||
for (const reference of indexed.references)
|
||||
assertReference(reference, indexed.address, full.references);
|
||||
assert.ok(
|
||||
indexed.references.some(
|
||||
(reference) =>
|
||||
reference.primary === false && reference.source === "analysis",
|
||||
),
|
||||
"Indexed fixture lacks a non-primary analyzer reference",
|
||||
);
|
||||
|
||||
const { direct, targets } = await verifyDirectCall(
|
||||
call,
|
||||
address(0x1000),
|
||||
address(0x1010),
|
||||
);
|
||||
|
||||
const indirectAddress = address(x86 ? 0x1041 : 0x1049);
|
||||
const indirect = await call("inspect_native_instruction", {
|
||||
address: indirectAddress,
|
||||
});
|
||||
const unresolved = await call("resolve_native_call_targets", {
|
||||
address: indirectAddress,
|
||||
});
|
||||
assert.equal(indirect.flow.kind, "call");
|
||||
assert.deepEqual(indirect.references, []);
|
||||
assert.equal(unresolved.status, "unresolved");
|
||||
assert.deepEqual(unresolved.targets, []);
|
||||
|
||||
const returned = await verifyEmptyReferences(
|
||||
call,
|
||||
address(x86 ? 0x1043 : 0x104c),
|
||||
);
|
||||
|
||||
if (cli !== undefined) {
|
||||
for (const instruction of [inspected.write, indexed, direct, returned])
|
||||
assert.deepEqual(
|
||||
await cli("inspect-native-instruction", instruction.address),
|
||||
instruction,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await cli("resolve-native-call-targets", direct.address),
|
||||
targets,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await cli("resolve-native-call-targets", indirectAddress),
|
||||
unresolved,
|
||||
);
|
||||
}
|
||||
return {
|
||||
data_read_write: true,
|
||||
source_primary_provenance: true,
|
||||
full_reference_parity: true,
|
||||
direct_call_reference_parity: true,
|
||||
indexed_reference_count: indexed.references.length,
|
||||
indexed_non_primary_count: indexed.references.filter(
|
||||
(reference) => reference.primary === false,
|
||||
).length,
|
||||
indexed_non_primary_analyzer_reference: true,
|
||||
indexed_reference_kinds: indexed.references.map(
|
||||
({ type, read, write, primary, source }) => ({
|
||||
type,
|
||||
read,
|
||||
write,
|
||||
primary,
|
||||
source,
|
||||
}),
|
||||
),
|
||||
targetless_indirect: true,
|
||||
empty_and_unavailable: true,
|
||||
cli_mcp_parity: cli !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyDirectCall(call, entry, callee) {
|
||||
const direct = await call("inspect_native_instruction", { address: entry });
|
||||
const targets = await call("resolve_native_call_targets", { address: entry });
|
||||
assert.equal(targets.status, "direct");
|
||||
assert.equal(targets.targets[0].address, callee);
|
||||
assert.deepEqual(
|
||||
targets.targets[0].references,
|
||||
direct.references.filter((item) => item.call),
|
||||
);
|
||||
const full = await call("procedure_references", { procedure: entry });
|
||||
for (const reference of direct.references)
|
||||
assertReference(reference, entry, full.references);
|
||||
return { direct, targets };
|
||||
}
|
||||
|
||||
async function verifyEmptyReferences(call, address) {
|
||||
const returned = await call("inspect_native_instruction", { address });
|
||||
assert.equal(returned.flow.kind, "return");
|
||||
assert.deepEqual(returned.references, []);
|
||||
assert.equal(
|
||||
(await call("resolve_native_call_targets", { address })).status,
|
||||
"not-call",
|
||||
);
|
||||
const outside = await call("inspect_native_instruction", { address: "0x0" });
|
||||
assert.equal(outside.status, "outside-memory");
|
||||
assert.deepEqual(outside.references, []);
|
||||
assert.equal(
|
||||
(await call("resolve_native_call_targets", { address: "0x0" })).status,
|
||||
"unavailable",
|
||||
);
|
||||
return returned;
|
||||
}
|
||||
|
||||
function assertReference(reference, source, full) {
|
||||
assert.equal(reference.source_address, source);
|
||||
assert.equal(reference.provenance, "ghidra-reference-manager");
|
||||
assert.equal(typeof reference.primary, "boolean");
|
||||
assert.equal(typeof reference.source, "string");
|
||||
assert.ok(reference.source.length > 0);
|
||||
const edge = full.find(
|
||||
(item) =>
|
||||
item.source_address === source &&
|
||||
item.target_address === reference.target_address &&
|
||||
item.kind.operand_index === reference.operand_index,
|
||||
);
|
||||
assert.ok(edge, "Compact reference lacks its matching full reference");
|
||||
for (const field of [
|
||||
"type",
|
||||
"call",
|
||||
"jump",
|
||||
"indirect",
|
||||
"computed",
|
||||
"operand_index",
|
||||
"data",
|
||||
"read",
|
||||
"write",
|
||||
"primary",
|
||||
"provenance",
|
||||
])
|
||||
assert.equal(
|
||||
reference[field],
|
||||
edge.kind[field],
|
||||
`Reference ${field} differs from its full representation`,
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import assert from "node:assert/strict";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
|
|
@ -358,6 +359,36 @@ try {
|
|||
await call("inspect_native_data_type", { type: "/undefined8" });
|
||||
await call("inspect_native_instruction", { address: procedure.address });
|
||||
await call("resolve_native_call_targets", { address: procedure.address });
|
||||
// Explicit caller-selected targets need not contain our fixture instructions.
|
||||
const controlledTargets = [
|
||||
"rea-ghidra-windows.exe",
|
||||
"rea-ghidra-windows-x86.exe",
|
||||
].map((name) => resolve("build", "fixtures", name));
|
||||
if (controlledTargets.includes(target))
|
||||
report.compactReferences = await verifyGhidraCompactReferences(
|
||||
call,
|
||||
target === controlledTargets[1],
|
||||
async (command, address) => {
|
||||
const result = await exec(
|
||||
process.execPath,
|
||||
[entry, command, target, address, "--provider", "ghidra", "--json"],
|
||||
{
|
||||
env: environment,
|
||||
cwd: callerDirectory,
|
||||
timeout: 360_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const evidence = JSON.parse(result.stdout);
|
||||
assert.ok(evidence.error === undefined, `CLI ${command} failed`);
|
||||
const contract = TOOL_CONTRACTS.find(
|
||||
({ name }) => name === command.replaceAll("-", "_"),
|
||||
);
|
||||
assert.ok(contract, `Missing CLI ${command} contract`);
|
||||
contract.outputSchema.parse(evidence);
|
||||
return evidence.normalized_result;
|
||||
},
|
||||
);
|
||||
const expected = (
|
||||
await import(
|
||||
pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js"))
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { windowsP0Capabilities } from "../dist/ghidra/GhidraProviderCapabilities
|
|||
import { GHIDRA_SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js";
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
|
||||
|
||||
const verifierRun = createVerifierRun();
|
||||
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
|
||||
|
|
@ -247,6 +248,14 @@ try {
|
|||
`Windows Ghidra operation proof was incomplete: ${JSON.stringify([...observed])}`,
|
||||
);
|
||||
|
||||
// The reference controls occupy the EXE fixture's additional function.
|
||||
// DLL fixtures retain their independent exported-caller/return controls.
|
||||
const compactReferences = dllFixture
|
||||
? null
|
||||
: await verifyGhidraCompactReferences(
|
||||
functionOperation,
|
||||
architecture === "x86",
|
||||
);
|
||||
report = {
|
||||
ok: true,
|
||||
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
|
||||
|
|
@ -261,6 +270,7 @@ try {
|
|||
transport: "authenticated-loopback-tcp",
|
||||
operations: [...observed].sort((left, right) => left.localeCompare(right)),
|
||||
cleanup: "complete",
|
||||
compact_references: compactReferences,
|
||||
native_controls: {
|
||||
job_object: true,
|
||||
protected_dacl: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import type { JsonValue } from "../jsonValue.js";
|
||||
|
||||
/** Wire-boundary controls; these do not establish real Ghidra behavior. */
|
||||
export const compactReference = () => ({
|
||||
source_address: "0x401000",
|
||||
target_address: "0x402000",
|
||||
type: "COMPUTED_CALL",
|
||||
call: true,
|
||||
jump: false,
|
||||
indirect: false,
|
||||
computed: true,
|
||||
operand_index: 1,
|
||||
data: false,
|
||||
read: false,
|
||||
write: false,
|
||||
primary: false,
|
||||
provenance: "ghidra-reference-manager",
|
||||
source: "analysis",
|
||||
});
|
||||
|
||||
export const compactInstruction = (references: JsonValue[] = []) => ({
|
||||
address: "0x401000",
|
||||
status: "decoded",
|
||||
procedure: "0x401000",
|
||||
architecture: "x86:LE:32:default",
|
||||
mode: "default",
|
||||
bytes: "ffd0",
|
||||
length: 2,
|
||||
mnemonic: "CALL",
|
||||
raw_disassembly: "CALL EAX",
|
||||
operands: [],
|
||||
flow: {
|
||||
kind: "call",
|
||||
conditional: false,
|
||||
computed: true,
|
||||
direct_destinations: [],
|
||||
},
|
||||
references,
|
||||
limitations: ["Static references do not establish runtime targets."],
|
||||
});
|
||||
|
||||
export const compactCallTargets = (references: JsonValue[] = []) => ({
|
||||
call_site: "0x401000",
|
||||
procedure: "0x401000",
|
||||
status: references.length === 0 ? "unresolved" : "resolved-indirect",
|
||||
mechanism: "computed",
|
||||
targets:
|
||||
references.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
address: "0x402000",
|
||||
procedure: null,
|
||||
status: "resolved-indirect",
|
||||
basis: "provider-reference",
|
||||
references,
|
||||
},
|
||||
],
|
||||
limitations: ["Static references do not establish runtime targets."],
|
||||
});
|
||||
|
|
@ -13,6 +13,33 @@ const reference = z.strictObject({
|
|||
indirect: z.boolean(),
|
||||
computed: z.boolean(),
|
||||
operand_index: z.number().int(),
|
||||
source_address: z.string().min(1).nullable().optional(),
|
||||
data: z.boolean().nullable().optional(),
|
||||
read: z.boolean().nullable().optional(),
|
||||
write: z.boolean().nullable().optional(),
|
||||
primary: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Provider primary-reference status, not proof of literal addressing or a runtime access. Null or omission means unavailable.",
|
||||
),
|
||||
provenance: z
|
||||
.string()
|
||||
.min(1)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Provider subsystem reporting this reference; null or omission means unavailable.",
|
||||
),
|
||||
source: z
|
||||
.string()
|
||||
.min(1)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Provider reference origin, such as analysis, default, imported or user_defined; null or omission means unavailable.",
|
||||
),
|
||||
});
|
||||
/** Provider-decoded facts for one instruction; operand tokens preserve their native ordering. */
|
||||
export const nativeInstructionSchema = z.strictObject({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest";
|
|||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { functionDossierSchema } from "../domain/hopperValues.js";
|
||||
import {
|
||||
compactReference,
|
||||
compactInstruction,
|
||||
compactCallTargets,
|
||||
} from "../domain/native/nativeInstruction.fixture.js";
|
||||
import {
|
||||
parseGhidraFunctionResult,
|
||||
type GhidraFunctionOperation,
|
||||
|
|
@ -118,6 +123,75 @@ describe("Ghidra function-analysis result values", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Ghidra compact reference provenance", () => {
|
||||
const operations = [
|
||||
["inspect_native_instruction", compactInstruction],
|
||||
["resolve_native_call_targets", compactCallTargets],
|
||||
] as const;
|
||||
|
||||
it.each(operations)(
|
||||
"preserves complete, legacy and unknown facts in %s",
|
||||
(operation, output) => {
|
||||
const complete = compactReference();
|
||||
const legacy = Object.fromEntries(
|
||||
Object.entries(complete).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"source_address",
|
||||
"data",
|
||||
"read",
|
||||
"write",
|
||||
"primary",
|
||||
"provenance",
|
||||
"source",
|
||||
].includes(key),
|
||||
),
|
||||
);
|
||||
const unknown = {
|
||||
...legacy,
|
||||
source_address: null,
|
||||
data: null,
|
||||
read: null,
|
||||
write: null,
|
||||
primary: null,
|
||||
provenance: null,
|
||||
source: null,
|
||||
};
|
||||
for (const reference of [complete, legacy, unknown]) {
|
||||
const expected = output([reference]);
|
||||
const parsed = parseGhidraFunctionResult(operation, expected);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value).toEqual(expected);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(operations)(
|
||||
"rejects malformed present provenance in %s",
|
||||
(operation, output) => {
|
||||
for (const [field, value] of [
|
||||
["source_address", 1],
|
||||
["data", "true"],
|
||||
["read", 1],
|
||||
["write", 0],
|
||||
["primary", "false"],
|
||||
["provenance", ""],
|
||||
["source", false],
|
||||
] as const) {
|
||||
expect(
|
||||
parseGhidraFunctionResult(
|
||||
operation,
|
||||
output([{ ...compactReference(), [field]: value }]),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra jump-table mapping contract", () => {
|
||||
it("preserves evidenced defaults separately from cases and unresolved targets", () => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import {
|
||||
compactReference,
|
||||
compactInstruction,
|
||||
compactCallTargets,
|
||||
} from "../../../src/domain/native/nativeInstruction.fixture.js";
|
||||
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
|
||||
|
||||
describe("compact Ghidra reference MCP evidence", () => {
|
||||
it("retains access kind, non-primary analyzer references and call-target provenance", async () => {
|
||||
const base = {
|
||||
...compactReference(),
|
||||
call: false,
|
||||
computed: false,
|
||||
data: true,
|
||||
};
|
||||
const references = [
|
||||
{ ...base, type: "DATA", primary: true, operand_index: 1 },
|
||||
{ ...base, type: "READ", primary: true, read: true, operand_index: 1 },
|
||||
{ ...base, type: "WRITE", primary: false, write: true, operand_index: 0 },
|
||||
];
|
||||
const outputs = {
|
||||
inspect_native_instruction: compactInstruction(references),
|
||||
resolve_native_call_targets: compactCallTargets([compactReference()]),
|
||||
};
|
||||
const harness = await connectGhidraMcp(
|
||||
"ghidra-compact-provenance",
|
||||
(operation) => {
|
||||
if (
|
||||
operation !== "inspect_native_instruction" &&
|
||||
operation !== "resolve_native_call_targets"
|
||||
)
|
||||
throw new TypeError(`Unexpected operation: ${operation}`);
|
||||
return Promise.resolve(ok(outputs[operation]));
|
||||
},
|
||||
);
|
||||
try {
|
||||
for (const name of [
|
||||
"inspect_native_instruction",
|
||||
"resolve_native_call_targets",
|
||||
] as const) {
|
||||
const result = await harness.mcp.callTool({
|
||||
name,
|
||||
arguments: { address: "0x401000" },
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
const evidence = sessionEvidence(
|
||||
harness.session,
|
||||
result.structuredContent,
|
||||
);
|
||||
expect(evidence.normalized_result).toEqual(outputs[name]);
|
||||
}
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("preserves empty references and unresolved targets without manufacturing provenance", async () => {
|
||||
const outputs = {
|
||||
inspect_native_instruction: compactInstruction(),
|
||||
resolve_native_call_targets: compactCallTargets(),
|
||||
};
|
||||
const harness = await connectGhidraMcp(
|
||||
"ghidra-compact-unknown",
|
||||
(operation) => {
|
||||
if (
|
||||
operation !== "inspect_native_instruction" &&
|
||||
operation !== "resolve_native_call_targets"
|
||||
)
|
||||
throw new TypeError(`Unexpected operation: ${operation}`);
|
||||
return Promise.resolve(ok(outputs[operation]));
|
||||
},
|
||||
);
|
||||
try {
|
||||
for (const name of [
|
||||
"inspect_native_instruction",
|
||||
"resolve_native_call_targets",
|
||||
] as const) {
|
||||
const result = await harness.mcp.callTool({
|
||||
name,
|
||||
arguments: { address: "0x401000" },
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(
|
||||
sessionEvidence(harness.session, result.structuredContent)
|
||||
.normalized_result,
|
||||
).toEqual(outputs[name]);
|
||||
}
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
Loading…
Reference in New Issue