Compare commits
8 Commits
30128a03cf
...
1941d343b8
| Author | SHA1 | Date |
|---|---|---|
|
|
1941d343b8 | |
|
|
c8ba745c92 | |
|
|
c121db7a47 | |
|
|
2e86757063 | |
|
|
6629c5c945 | |
|
|
cf7efda456 | |
|
|
7570215ad9 | |
|
|
7b327544f3 |
|
|
@ -42,6 +42,7 @@ import {
|
|||
workflowAnalysisProfile,
|
||||
} from "./InvestigationProviders.js";
|
||||
import {
|
||||
executeComposedWorkflow,
|
||||
createWorkflowEvidence,
|
||||
workflowSnapshotRecord,
|
||||
recordWorkflowUnknowns,
|
||||
|
|
@ -401,18 +402,17 @@ const executeAnalysisTool = async (input: {
|
|||
tool === "trace_native_ui_action"
|
||||
) {
|
||||
const { EnhancedTools } = await import("./EnhancedTools.js");
|
||||
const result = await new EnhancedTools(session).execute(
|
||||
tool,
|
||||
input.arguments,
|
||||
signal,
|
||||
const result = await executeComposedWorkflow(session, (observed) =>
|
||||
new EnhancedTools(observed).execute(tool, input.arguments, signal),
|
||||
);
|
||||
if (!result.ok) return { output: cliError(result.error) };
|
||||
const evidence = createWorkflowEvidence({
|
||||
target: input.openedTarget,
|
||||
operation: tool,
|
||||
parameters: input.arguments,
|
||||
result: result.value,
|
||||
result: result.value.result,
|
||||
upstreamProfile: evidenceProfile,
|
||||
upstreamLimitations: result.value.limitations,
|
||||
});
|
||||
return { output: evidence, evidence };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ export const workflowAnalysisProfile = (
|
|||
createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
|
||||
upstream_analysis_profile: upstream,
|
||||
...(operation === "binary_overview"
|
||||
? { workflow_revision: 3 }
|
||||
? { workflow_revision: 4 }
|
||||
: operation === "batch_decompile" ||
|
||||
operation === "get_call_graph" ||
|
||||
operation === "trace_feature"
|
||||
? { workflow_revision: 2 }
|
||||
: {}),
|
||||
? { workflow_revision: 3 }
|
||||
: { workflow_revision: 1 }),
|
||||
});
|
||||
|
||||
/** Provider identity for deterministic artifact inventories. */
|
||||
|
|
|
|||
|
|
@ -3,15 +3,145 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
recordWorkflowUnknowns,
|
||||
createWorkflowEvidence,
|
||||
executeComposedWorkflow,
|
||||
} from "./WorkflowEvidence.js";
|
||||
import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
|
||||
import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisOperationPort,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
|
||||
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
|
||||
import { functionDossierSchema } from "../domain/hopperValues.js";
|
||||
import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
|
||||
it("keeps Evidence identity stable when dependent branches finish in opposite orders", async () => {
|
||||
const run = async (order: readonly number[]) => {
|
||||
const gate = () => {
|
||||
let resolve: () => void = () => {
|
||||
throw new Error("gate not initialized");
|
||||
};
|
||||
const promise = new Promise<void>((release) => {
|
||||
resolve = release;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
const parents = [gate(), gate()];
|
||||
const children = [gate(), gate()];
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: async (operation, parameters) => {
|
||||
const index = Number(parameters.index);
|
||||
if (operation === "procedure_address") {
|
||||
await parents[index]!.promise;
|
||||
return ok(createAnalysisExecution(String(index), provider));
|
||||
}
|
||||
children[index]!.resolve();
|
||||
return ok(
|
||||
createAnalysisExecution(String(index), provider, {
|
||||
limitations: [
|
||||
index === 0 ? "first child caveat" : "second child caveat",
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
const composed = await executeComposedWorkflow(
|
||||
analysis,
|
||||
async (observed) => {
|
||||
const branches = Promise.all(
|
||||
[0, 1].map(async (index) => {
|
||||
await observed.execute("procedure_address", { index });
|
||||
const child = await observed.execute("address_name", { index });
|
||||
if (!child.ok) throw child.error;
|
||||
return child.value.result;
|
||||
}),
|
||||
);
|
||||
for (const index of order) {
|
||||
parents[index]!.resolve();
|
||||
await children[index]!.promise;
|
||||
}
|
||||
return ok(await branches);
|
||||
},
|
||||
);
|
||||
if (!composed.ok) throw composed.error;
|
||||
return createWorkflowEvidence({
|
||||
target: undefined,
|
||||
operation: "batch_decompile",
|
||||
parameters: {},
|
||||
result: composed.value.result,
|
||||
upstreamProfile: undefined,
|
||||
upstreamLimitations: composed.value.limitations,
|
||||
});
|
||||
};
|
||||
const left = await run([0, 1]);
|
||||
const right = await run([1, 0]);
|
||||
expect(left.normalized_result).toEqual(right.normalized_result);
|
||||
expect(left.limitations).toEqual(right.limitations);
|
||||
expect(left.evidence_id).toBe(right.evidence_id);
|
||||
});
|
||||
|
||||
it("retains unique upstream limits in canonical order across concurrent observations", async () => {
|
||||
let release: () => void = () => {
|
||||
throw new Error("gate not initialized");
|
||||
};
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
const options = { signal: new AbortController().signal };
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: async (_, parameters, forwarded) => {
|
||||
expect(parameters).toEqual({ marker: true });
|
||||
expect(forwarded).toBe(options);
|
||||
const call = calls++;
|
||||
if (call === 0) await gate;
|
||||
else release();
|
||||
return ok(
|
||||
createAnalysisExecution([], provider, {
|
||||
limitations: call === 0 ? ["first", "shared"] : ["second", "shared"],
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
const result = await executeComposedWorkflow(analysis, async (observed) => {
|
||||
await Promise.all([
|
||||
observed.execute("list_strings", { marker: true }, options),
|
||||
observed.execute("list_procedures", { marker: true }, options),
|
||||
]);
|
||||
return ok({ count: 0 });
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
expect(result).toEqual(
|
||||
ok({ result: { count: 0 }, limitations: ["first", "second", "shared"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a failed composed operation without an extra provider read", async () => {
|
||||
const failure = err(new AnalysisCancelledError("list_strings"));
|
||||
let calls = 0;
|
||||
const result = await executeComposedWorkflow(
|
||||
{
|
||||
execute: async () => {
|
||||
calls++;
|
||||
return failure;
|
||||
},
|
||||
},
|
||||
async (observed) => {
|
||||
const observedResult = await observed.execute("list_strings", {});
|
||||
return observedResult.ok
|
||||
? ok(observedResult.value.result)
|
||||
: observedResult;
|
||||
},
|
||||
);
|
||||
expect(result).toBe(failure);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
describe("workflow residual question retention", () => {
|
||||
it("keeps questions linked to workflow Evidence and records repeats idempotently", () => {
|
||||
const records = new InvestigationRecords();
|
||||
|
|
@ -24,6 +154,7 @@ describe("workflow residual question retention", () => {
|
|||
parameters: { procedure: "0x1000" },
|
||||
result,
|
||||
upstreamProfile: undefined,
|
||||
upstreamLimitations: [],
|
||||
});
|
||||
expect(records.recordEvidence(evidence).ok).toBe(true);
|
||||
const input = {
|
||||
|
|
@ -61,6 +192,7 @@ describe("workflow residual question retention", () => {
|
|||
parameters: {},
|
||||
result,
|
||||
upstreamProfile: undefined,
|
||||
upstreamLimitations: [],
|
||||
});
|
||||
|
||||
expect(records.recordEvidence(evidence).ok).toBe(true);
|
||||
|
|
@ -88,6 +220,7 @@ describe("workflow residual question retention", () => {
|
|||
parameters: {},
|
||||
result,
|
||||
upstreamProfile: undefined,
|
||||
upstreamLimitations: [],
|
||||
});
|
||||
|
||||
expect(records.recordEvidence(evidence).ok).toBe(true);
|
||||
|
|
@ -139,6 +272,7 @@ it.each([
|
|||
parameters: { procedure: "0x1000" },
|
||||
result,
|
||||
upstreamProfile: undefined,
|
||||
upstreamLimitations: [],
|
||||
});
|
||||
const records = new InvestigationRecords();
|
||||
expect(records.recordEvidence(evidence).ok).toBe(true);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import {
|
|||
REA_WORKFLOW_PROVIDER,
|
||||
workflowAnalysisProfile,
|
||||
} from "./InvestigationProviders.js";
|
||||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
} from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { WorkflowSnapshotRecordInput } from "./binary/BinarySessionRecords.js";
|
||||
import {
|
||||
|
|
@ -18,6 +23,37 @@ import {
|
|||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { UnknownRegistryPort } from "./investigation/InvestigationRecordPort.js";
|
||||
import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Retain the limits of exactly the observations used by one composed call. */
|
||||
export const executeComposedWorkflow = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
execute: (
|
||||
observed: AnalysisOperationPort,
|
||||
) => Promise<Result<JsonValue, AnalysisError>>,
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly result: JsonValue; readonly limitations: readonly string[] },
|
||||
AnalysisError
|
||||
>
|
||||
> => {
|
||||
const limitations = new Set<string>();
|
||||
const result = await execute({
|
||||
execute: async (operation, parameters, options) => {
|
||||
const execution = await analysis.execute(operation, parameters, options);
|
||||
if (execution.ok)
|
||||
execution.value.limitations.forEach((limitation) =>
|
||||
limitations.add(limitation),
|
||||
);
|
||||
return execution;
|
||||
},
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
return ok({
|
||||
result: result.value,
|
||||
limitations: [...limitations].sort(compareUnicodeCodePoints),
|
||||
});
|
||||
};
|
||||
|
||||
/** Build the shared Evidence record for a successful composed binary workflow. */
|
||||
export const createWorkflowEvidence = (input: {
|
||||
|
|
@ -26,6 +62,7 @@ export const createWorkflowEvidence = (input: {
|
|||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly result: JsonValue;
|
||||
readonly upstreamProfile: AnalysisProfileCommitment | undefined;
|
||||
readonly upstreamLimitations: readonly string[];
|
||||
}): Evidence =>
|
||||
createEvidence(input.target, REA_WORKFLOW_PROVIDER, {
|
||||
operation: input.operation,
|
||||
|
|
@ -40,7 +77,10 @@ export const createWorkflowEvidence = (input: {
|
|||
),
|
||||
}),
|
||||
confidence: "derived",
|
||||
limitations: ["Derived by an REA composed workflow."],
|
||||
limitations: [
|
||||
"Derived by an REA composed workflow.",
|
||||
...input.upstreamLimitations,
|
||||
],
|
||||
...(input.operation === "batch_decompile"
|
||||
? { locations: batchProcedureLocations(input.result) }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ export const resolveGhidraAnalysisProfile = async (
|
|||
].sort(),
|
||||
import_mode: "ephemeral-source-immutable",
|
||||
...(target.format === "pe"
|
||||
? { pdb_observations: "program-info-v1" }
|
||||
? { pdb_observations: "program-info-v5" }
|
||||
: {}),
|
||||
annotation_policy: "atomic-function-entry-metadata-v1",
|
||||
load_image_observations: "source-mappings-entry-context-v2",
|
||||
|
|
|
|||
|
|
@ -1,95 +1,297 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { expect, it } from "vitest";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { ghidraPeCodeViewLimitations } from "./GhidraPeProfile.js";
|
||||
import { ghidraPePdbLimitations } from "./GhidraPeProfile.js";
|
||||
import {
|
||||
ghidraPdbLimitations,
|
||||
type GhidraSessionInfo,
|
||||
} from "./GhidraSessionValues.js";
|
||||
|
||||
describe("ghidraPeCodeViewLimitations", () => {
|
||||
it("returns empty limitations for non-PE targets", () => {
|
||||
const elfTarget: BinaryTarget = {
|
||||
path: "/test/file",
|
||||
sha256: "abc",
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
const target: BinaryTarget = {
|
||||
path: "/fixture/public.exe",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: "pe",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
executableRole: "application",
|
||||
managed: false,
|
||||
codeView: {
|
||||
pdbPath: "public.pdb",
|
||||
pdbFileName: "public.pdb",
|
||||
guid: "01234567-89ab-cdef-0123-456789abcdef",
|
||||
age: 10,
|
||||
},
|
||||
};
|
||||
const absentAttributes = {
|
||||
file: null,
|
||||
guid: null,
|
||||
age: null,
|
||||
signature: null,
|
||||
version: null,
|
||||
loaded: null,
|
||||
};
|
||||
|
||||
it.each(["file", "guid", "age"] as const)(
|
||||
"retains declared identity when only reported %s is omitted",
|
||||
(attribute) => {
|
||||
const pdb: GhidraSessionInfo["pdb"] = {
|
||||
file: "program.pdb",
|
||||
guid: "reported-guid",
|
||||
age: "a",
|
||||
signature: null,
|
||||
version: "RSDS",
|
||||
loaded: null,
|
||||
resource_limit: { omitted_attributes: [attribute], utf16_budget: 4096 },
|
||||
[attribute]: null,
|
||||
};
|
||||
expect(ghidraPeCodeViewLimitations(elfTarget)).toEqual([]);
|
||||
});
|
||||
const caveats = ghidraPePdbLimitations(target, pdb).join(" ");
|
||||
expect(caveats).toContain("omitted");
|
||||
expect(caveats).toContain('PDB "public.pdb"');
|
||||
expect(caveats).toContain("01234567-89ab-cdef-0123-456789abcdef");
|
||||
expect(caveats).toContain("load state unknown");
|
||||
},
|
||||
);
|
||||
|
||||
it("does not claim CodeView symbols were unapplied when the session loaded a PDB", () => {
|
||||
const peTarget: BinaryTarget = {
|
||||
path: "/test/file.exe",
|
||||
sha256: "abc",
|
||||
kind: "executable",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
executableRole: "application",
|
||||
managed: false,
|
||||
format: "pe",
|
||||
it.each([undefined, absentAttributes])(
|
||||
"retains declared identity and unknown load state when reported attributes are %j",
|
||||
(pdb) => {
|
||||
const caveats = ghidraPePdbLimitations(target, pdb);
|
||||
expect(caveats).toHaveLength(1);
|
||||
expect(caveats[0]).toContain(
|
||||
'PDB "public.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age (decimal) 10)',
|
||||
);
|
||||
expect(caveats[0]).toContain("load state unknown");
|
||||
expect(caveats[0]).not.toContain("were not applied");
|
||||
expect(caveats[0]).not.toContain("names are analysis-generated");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([true, false, null])(
|
||||
"uses reported PDB observations once with loaded = %s",
|
||||
(loaded) => {
|
||||
const pdb = {
|
||||
file: "public.pdb",
|
||||
guid: "{01234567-89AB-CDEF-0123-456789ABCDEF}",
|
||||
age: "0X000A",
|
||||
signature: null,
|
||||
version: "RSDS",
|
||||
loaded,
|
||||
};
|
||||
const caveats = ghidraPePdbLimitations(target, pdb);
|
||||
expect(caveats).toEqual(ghidraPdbLimitations(pdb));
|
||||
expect(caveats).toHaveLength(1);
|
||||
expect(caveats.join(" ")).not.toContain('PDB "public.pdb"');
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ file: "different.pdb", guid: "0123456789abcdef0123456789abcdef", age: "a" },
|
||||
{ file: "public.pdb", guid: "1123456789abcdef0123456789abcdef", age: "a" },
|
||||
{ file: "public.pdb", guid: "0123456789abcdef0123456789abcdef", age: "10" },
|
||||
{ file: "public.pdb", guid: "unrecognized-guid", age: "a" },
|
||||
{ file: "public.pdb", guid: "0123456789abcdef0123456789abcdef", age: "ten" },
|
||||
])("retains non-equivalent declared PDB identity for %j", (identity) => {
|
||||
for (const loaded of [false, null, true]) {
|
||||
const pdb = { ...absentAttributes, ...identity, version: "RSDS", loaded };
|
||||
const caveats = ghidraPePdbLimitations(target, pdb);
|
||||
expect(caveats[0]).toBe(ghidraPdbLimitations(pdb)[0]);
|
||||
expect(caveats[0]).toContain(identity.file);
|
||||
expect(caveats[0]).toContain(identity.guid);
|
||||
expect(caveats[0]).toContain(`age (hex) ${identity.age}`);
|
||||
expect(caveats).toHaveLength(2);
|
||||
expect(caveats[1]).toContain('PDB "public.pdb"');
|
||||
expect(caveats[1]).toContain("01234567-89ab-cdef-0123-456789abcdef");
|
||||
expect(caveats[1]).toContain("age (decimal) 10");
|
||||
expect(caveats[1]).toContain("equivalence is not established");
|
||||
expect(pdb).toMatchObject({ ...identity, loaded });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"0123456789ABCDEF0123456789ABCDEF",
|
||||
"01234567-89ab-cdef-0123-456789abcdef",
|
||||
])("avoids duplicate identity for normalized matching GUID %s", (guid) => {
|
||||
const pdb = { ...absentAttributes, file: "public.pdb", guid, age: "a" };
|
||||
expect(ghidraPePdbLimitations(target, pdb)).toEqual(
|
||||
ghidraPdbLimitations(pdb),
|
||||
);
|
||||
});
|
||||
|
||||
it("retains explicit failure to load without asserting all names are generated", () => {
|
||||
const caveats = ghidraPePdbLimitations(target, {
|
||||
...absentAttributes,
|
||||
loaded: false,
|
||||
});
|
||||
expect(caveats[0]).toContain("reports not loaded");
|
||||
expect(caveats[0]).not.toContain("names are analysis-generated");
|
||||
});
|
||||
|
||||
it("retains a loaded-state attestation caveat even when reference attributes are absent", () => {
|
||||
const caveats = ghidraPePdbLimitations(target, {
|
||||
...absentAttributes,
|
||||
loaded: true,
|
||||
});
|
||||
expect(caveats[0]).toEqual(
|
||||
ghidraPdbLimitations({ ...absentAttributes, loaded: true })[0],
|
||||
);
|
||||
expect(caveats).toHaveLength(2);
|
||||
expect(caveats[1]).toContain('PDB "public.pdb"');
|
||||
expect(caveats[0]).toContain("not an independent PDB identity");
|
||||
expect(caveats[0]).not.toContain("reports not loaded");
|
||||
});
|
||||
|
||||
it("preserves omitted-attribute resource coverage with declared fallback identity", () => {
|
||||
const pdb: GhidraSessionInfo["pdb"] = {
|
||||
...absentAttributes,
|
||||
resource_limit: { omitted_attributes: ["file"], utf16_budget: 4096 },
|
||||
};
|
||||
const caveats = ghidraPePdbLimitations(target, pdb);
|
||||
expect(caveats).toHaveLength(2);
|
||||
expect(caveats[0]).toContain("omitted");
|
||||
expect(caveats[1]).toContain("public.pdb");
|
||||
});
|
||||
|
||||
it("reports NB10 declared age without inventing a GUID", () => {
|
||||
const caveats = ghidraPePdbLimitations(
|
||||
{
|
||||
...target,
|
||||
codeView: {
|
||||
pdbPath: "C:\\symbols.pdb",
|
||||
pdbFileName: "symbols.pdb",
|
||||
guid: "12345678-1234-1234-1234-123456789abc",
|
||||
age: 1,
|
||||
pdbPath: "legacy.pdb",
|
||||
pdbFileName: "legacy.pdb",
|
||||
guid: null,
|
||||
age: 2,
|
||||
},
|
||||
};
|
||||
expect(ghidraPeCodeViewLimitations(peTarget, true)).toEqual([]);
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(caveats[0]).toContain('PDB "legacy.pdb" (age (decimal) 2)');
|
||||
expect(caveats[0]).not.toContain("GUID");
|
||||
});
|
||||
|
||||
it("returns empty limitations for PE target without codeView", () => {
|
||||
const peTarget: BinaryTarget = {
|
||||
path: "/test/file.exe",
|
||||
sha256: "def",
|
||||
kind: "executable",
|
||||
format: "pe",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
executableRole: "application",
|
||||
managed: false,
|
||||
};
|
||||
expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns limitation with GUID and age for RSDS CodeView records", () => {
|
||||
const peTarget: BinaryTarget = {
|
||||
path: "/test/file.exe",
|
||||
sha256: "def",
|
||||
kind: "executable",
|
||||
format: "pe",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
executableRole: "application",
|
||||
managed: false,
|
||||
it.each([false, null, true])(
|
||||
"keeps an omitted NB10 GUID unknown with loaded = %s",
|
||||
(loaded) => {
|
||||
const pdbPath = "a".repeat(4070) + "\\legacy.pdb";
|
||||
const legacy = {
|
||||
...target,
|
||||
codeView: {
|
||||
pdbPath: "C:\\build\\app.pdb",
|
||||
pdbFileName: "app.pdb",
|
||||
guid: "01234567-89ab-cdef-0123-456789abcdef",
|
||||
age: 1,
|
||||
},
|
||||
};
|
||||
expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([
|
||||
'The target references debug symbols "app.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age 1) that were not applied; procedure names are analysis-generated.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns limitation without GUID for NB10 CodeView records", () => {
|
||||
const peTarget: BinaryTarget = {
|
||||
path: "/test/file.exe",
|
||||
sha256: "def",
|
||||
kind: "executable",
|
||||
format: "pe",
|
||||
architecture: "x86",
|
||||
availableArchitectures: ["x86"],
|
||||
executableRole: "application",
|
||||
managed: false,
|
||||
codeView: {
|
||||
pdbPath: "D:\\test\\legacy.pdb",
|
||||
pdbPath,
|
||||
pdbFileName: "legacy.pdb",
|
||||
guid: null,
|
||||
age: 2,
|
||||
},
|
||||
};
|
||||
expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([
|
||||
'The target references debug symbols "legacy.pdb" (age 2) that were not applied; procedure names are analysis-generated.',
|
||||
const pdb = {
|
||||
...absentAttributes,
|
||||
file: pdbPath,
|
||||
age: "2",
|
||||
signature: "01234567",
|
||||
version: "NB10",
|
||||
loaded,
|
||||
};
|
||||
expect(ghidraPePdbLimitations(legacy, pdb)).toEqual(
|
||||
ghidraPdbLimitations(pdb),
|
||||
);
|
||||
const omitted: GhidraSessionInfo["pdb"] = {
|
||||
...pdb,
|
||||
resource_limit: { omitted_attributes: ["guid"], utf16_budget: 4096 },
|
||||
};
|
||||
const caveats = ghidraPePdbLimitations(legacy, omitted);
|
||||
expect(caveats).toEqual([
|
||||
...ghidraPdbLimitations(omitted),
|
||||
expect.stringContaining('PDB "legacy.pdb" (age (decimal) 2)'),
|
||||
]);
|
||||
});
|
||||
expect(caveats.at(-1)).toContain("equivalence is not established");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["signature", "version"] as const)(
|
||||
"keeps matching NB10 identity when only %s is omitted",
|
||||
(attribute) => {
|
||||
const legacy = {
|
||||
...target,
|
||||
codeView: {
|
||||
pdbPath: "legacy.pdb",
|
||||
pdbFileName: "legacy.pdb",
|
||||
guid: null,
|
||||
age: 2,
|
||||
},
|
||||
};
|
||||
const pdb: GhidraSessionInfo["pdb"] = {
|
||||
...absentAttributes,
|
||||
file: "legacy.pdb",
|
||||
age: "2",
|
||||
resource_limit: { omitted_attributes: [attribute], utf16_budget: 4096 },
|
||||
};
|
||||
expect(ghidraPePdbLimitations(legacy, pdb)).toEqual(
|
||||
ghidraPdbLimitations(pdb),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains missing-report uncertainty without a declared CodeView record", () => {
|
||||
const { codeView: _, ...withoutReference } = target;
|
||||
expect(ghidraPePdbLimitations(withoutReference, undefined)).toEqual(
|
||||
ghidraPdbLimitations(undefined),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds no PDB warning for a non-PE target", () => {
|
||||
const elf: BinaryTarget = {
|
||||
path: "/fixture/program",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
};
|
||||
expect(ghidraPePdbLimitations(elf, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("bounds an escaped inline locator while preserving its declared source value", () => {
|
||||
const name = '"'.repeat(5000) + ".pdb";
|
||||
const selected = {
|
||||
...target,
|
||||
codeView: { ...target.codeView!, pdbFileName: name },
|
||||
};
|
||||
const caveats = ghidraPePdbLimitations(selected, undefined).join(" ");
|
||||
expect(Buffer.byteLength(caveats)).toBeLessThan(1024);
|
||||
expect(caveats).toContain("locator omitted");
|
||||
expect(caveats).toContain("01234567-89ab-cdef-0123-456789abcdef");
|
||||
expect(selected.codeView.pdbFileName).toBe(name);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["long ASCII", "a".repeat(171)],
|
||||
["ASCII at the byte budget", "a".repeat(1022)],
|
||||
["ASCII beyond the byte budget", "a".repeat(1023)],
|
||||
["quotes at the byte budget", '"'.repeat(511)],
|
||||
["quotes beyond the byte budget", '"'.repeat(512)],
|
||||
["short control escapes", "\n".repeat(511)],
|
||||
["short control escapes beyond the byte budget", "\n".repeat(512)],
|
||||
["six-byte control escapes", "\u0001".repeat(170)],
|
||||
["six-byte control escapes beyond the byte budget", "\u0001".repeat(171)],
|
||||
["UTF-8 BMP text", "中".repeat(340)],
|
||||
["UTF-8 BMP text beyond the byte budget", "中".repeat(341)],
|
||||
["surrogate pairs", "😀".repeat(255)],
|
||||
["surrogate pairs beyond the byte budget", "😀".repeat(256)],
|
||||
["unpaired high surrogates", "\ud800".repeat(170)],
|
||||
["unpaired high surrogates beyond the byte budget", "\ud800".repeat(171)],
|
||||
["unpaired low surrogates", "\udfff".repeat(170)],
|
||||
["unpaired low surrogates beyond the byte budget", "\udfff".repeat(171)],
|
||||
])("uses actual JSON UTF-8 size for %s", (_, name) => {
|
||||
const selected = {
|
||||
...target,
|
||||
codeView: { ...target.codeView!, pdbFileName: name },
|
||||
};
|
||||
const caveat = ghidraPePdbLimitations(selected, undefined).join(" ");
|
||||
const encoded = JSON.stringify(name);
|
||||
if (Buffer.byteLength(encoded) <= 1024) {
|
||||
expect(caveat).toContain(`PDB ${encoded} (`);
|
||||
expect(caveat).not.toContain("locator omitted");
|
||||
} else {
|
||||
expect(caveat).toContain("locator omitted");
|
||||
expect(caveat).not.toContain(encoded);
|
||||
}
|
||||
expect(selected.codeView.pdbFileName).toBe(name);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,24 +1,113 @@
|
|||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type {
|
||||
BinaryTarget,
|
||||
PeCodeViewRecord,
|
||||
} from "../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
ghidraPdbLimitations,
|
||||
type GhidraSessionInfo,
|
||||
} from "./GhidraSessionValues.js";
|
||||
|
||||
/**
|
||||
* Report unapplied PE companion PDB symbols when CodeView debug information is present.
|
||||
*
|
||||
* Ghidra does not automatically locate or apply sidecar PDBs in REA's isolated
|
||||
* runtime directories without companion admission. When the PE target header
|
||||
* references debug symbols, surface this limitation so callers understand that
|
||||
* procedure names remain analysis-generated.
|
||||
*/
|
||||
export const ghidraPeCodeViewLimitations = (
|
||||
target: BinaryTarget,
|
||||
pdbLoaded = false,
|
||||
): readonly string[] => {
|
||||
if (target.format !== "pe" || target.codeView === undefined || pdbLoaded) {
|
||||
return [];
|
||||
}
|
||||
const { pdbFileName, guid, age } = target.codeView;
|
||||
return [
|
||||
guid !== null
|
||||
? `The target references debug symbols "${pdbFileName}" (GUID ${guid}, age ${age}) that were not applied; procedure names are analysis-generated.`
|
||||
: `The target references debug symbols "${pdbFileName}" (age ${age}) that were not applied; procedure names are analysis-generated.`,
|
||||
];
|
||||
const PDB_LOCATOR_INLINE_BYTE_BUDGET = 1024;
|
||||
|
||||
/** Establish equivalence only for the declared fields and recognized formats. */
|
||||
const reportedPdbMatchesDeclaration = (
|
||||
declared: PeCodeViewRecord,
|
||||
reported: NonNullable<GhidraSessionInfo["pdb"]>,
|
||||
): boolean => {
|
||||
if (
|
||||
reported.resource_limit?.omitted_attributes.some(
|
||||
(attribute) =>
|
||||
attribute === "file" || attribute === "guid" || attribute === "age",
|
||||
) ||
|
||||
(reported.file !== declared.pdbPath &&
|
||||
reported.file !== declared.pdbFileName) ||
|
||||
reported.age === null ||
|
||||
!/^(?:0x)?[0-9a-f]+$/iu.test(reported.age) ||
|
||||
Number.parseInt(reported.age, 16) !== declared.age
|
||||
)
|
||||
return false;
|
||||
if (declared.guid === null) return reported.guid === null;
|
||||
if (reported.guid === null) return false;
|
||||
let guid = reported.guid;
|
||||
if (guid.startsWith("{") && guid.endsWith("}")) guid = guid.slice(1, -1);
|
||||
if (
|
||||
!/^(?:[0-9a-f]{32}|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/iu.test(
|
||||
guid,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
guid.replaceAll("-", "").toLowerCase() ===
|
||||
declared.guid.replaceAll("-", "").toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
/** Measure JSON quotes, escapes and UTF-8 without allocating the encoded string. */
|
||||
const pdbLocatorFitsInlineBudget = (value: string): boolean => {
|
||||
if (value.length * 6 + 2 <= PDB_LOCATOR_INLINE_BYTE_BUDGET) return true;
|
||||
if (value.length + 2 > PDB_LOCATOR_INLINE_BYTE_BUDGET) return false;
|
||||
let bytes = 2;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const point = value.codePointAt(index) ?? 0;
|
||||
if (point > 0xffff) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else if (point >= 0xd800 && point <= 0xdfff) {
|
||||
// JSON.stringify escapes unpaired UTF-16 surrogates as six ASCII bytes.
|
||||
bytes += 6;
|
||||
} else if (point < 0x20) {
|
||||
bytes +=
|
||||
point === 8 ||
|
||||
point === 9 ||
|
||||
point === 10 ||
|
||||
point === 12 ||
|
||||
point === 13
|
||||
? 2
|
||||
: 6;
|
||||
} else if (point === 0x22 || point === 0x5c) {
|
||||
bytes += 2;
|
||||
} else {
|
||||
bytes += point < 0x80 ? 1 : point < 0x800 ? 2 : 3;
|
||||
}
|
||||
if (bytes > PDB_LOCATOR_INLINE_BYTE_BUDGET) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Use reported Program facts first, retaining declared PE facts as a fallback. */
|
||||
export const ghidraPePdbLimitations = (
|
||||
target: BinaryTarget,
|
||||
pdb: GhidraSessionInfo["pdb"],
|
||||
): readonly string[] => {
|
||||
if (target.format !== "pe") return [];
|
||||
const reported = ghidraPdbLimitations(pdb);
|
||||
if (
|
||||
target.codeView === undefined ||
|
||||
(pdb !== undefined && reportedPdbMatchesDeclaration(target.codeView, pdb))
|
||||
)
|
||||
return reported;
|
||||
const { pdbFileName, guid, age } = target.codeView;
|
||||
// Include JSON escaping in the inline budget before allocating that representation.
|
||||
const locator = pdbLocatorFitsInlineBudget(pdbFileName)
|
||||
? JSON.stringify(pdbFileName)
|
||||
: `(declared locator omitted from this inline caveat: ${PDB_LOCATOR_INLINE_BYTE_BUDGET}-byte resource budget)`;
|
||||
const identity =
|
||||
guid === null
|
||||
? `age (decimal) ${age}`
|
||||
: `GUID ${guid}, age (decimal) ${age}`;
|
||||
const state =
|
||||
pdb === undefined
|
||||
? "Ghidra did not report PDB attributes; load state unknown"
|
||||
: pdb.loaded === false
|
||||
? "Ghidra reports not loaded"
|
||||
: pdb.loaded === true
|
||||
? "Ghidra reports a loaded PDB; this declared identity is not an independent matching-PDB attestation"
|
||||
: "Ghidra reports load state unknown";
|
||||
const comparison =
|
||||
pdb !== undefined &&
|
||||
[pdb.file, pdb.guid, pdb.age].some((value) => value !== null)
|
||||
? " Reported and declared PDB observations are retained separately; equivalence is not established."
|
||||
: "";
|
||||
const declared = `The PE CodeView record references PDB ${locator} (${identity}); ${state}.${comparison} Original PDB names and types may be missing. Use list_procedures and address-based analysis to inspect recovered functions. REA does not admit a caller-selected PDB companion.`;
|
||||
return pdb === undefined ? [declared] : [...reported, declared];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsA
|
|||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
||||
import { createAnalysisProfile } from "../domain/analysisProfile.js";
|
||||
import { createAnalysisExecution } from "../application/AnalysisProvider.js";
|
||||
import { AnalysisSnapshotCache } from "../application/binary/AnalysisSnapshotCache.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
|
||||
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
const installationHost = (
|
||||
|
|
@ -80,6 +85,69 @@ const createElfClient = async (ghidra: GhidraProvider) => {
|
|||
return ghidra.createClient(target, resolved.value.profile);
|
||||
};
|
||||
|
||||
it.each([
|
||||
"program-info-v1",
|
||||
"program-info-v2",
|
||||
"program-info-v3",
|
||||
"program-info-v4",
|
||||
])(
|
||||
"retains historical PE PDB Evidence for %s without replaying its old projection",
|
||||
async (pdbObservations) => {
|
||||
const target = peTarget("x86_64");
|
||||
const resolved = await provider().resolveAnalysisProfile(target);
|
||||
if (!resolved.ok || resolved.value.profile === null)
|
||||
throw new Error("Expected admitted PE profile");
|
||||
const current = resolved.value.profile;
|
||||
const prior = createAnalysisProfile(current.provider, {
|
||||
...current.parameters,
|
||||
pdb_observations: pdbObservations,
|
||||
});
|
||||
const operation = "search_procedures";
|
||||
const parameters = { pattern: "wmain" };
|
||||
const limitations = [
|
||||
pdbObservations === "program-info-v4"
|
||||
? "Legacy projection discarded NB10 declaration when the reported GUID was omitted."
|
||||
: "Legacy projection collapsed unknown PDB load state.",
|
||||
];
|
||||
const cache = new AnalysisSnapshotCache();
|
||||
cache.record({
|
||||
target,
|
||||
profile: prior,
|
||||
operation,
|
||||
parameters,
|
||||
execution: createAnalysisExecution([], prior.provider, {
|
||||
subject: target,
|
||||
analysisProfile: prior,
|
||||
limitations,
|
||||
}),
|
||||
});
|
||||
const evidence = createEvidence(target, prior.provider, {
|
||||
operation,
|
||||
parameters,
|
||||
result: [],
|
||||
analysisProfile: prior,
|
||||
limitations,
|
||||
});
|
||||
const exported = cache.export(
|
||||
target,
|
||||
prior,
|
||||
createEvidenceBundle([evidence]),
|
||||
);
|
||||
if (!exported.ok) throw exported.error;
|
||||
const imported = new AnalysisSnapshotCache();
|
||||
expect(imported.import(exported.value, undefined, () => ok(0)).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
imported.lookup(target, current, operation, parameters),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
imported.lookup(target, prior, operation, parameters)?.limitations,
|
||||
).toEqual(expect.arrayContaining(limitations));
|
||||
expect(exported.value.evidence_bundle.records).toContainEqual(evidence);
|
||||
},
|
||||
);
|
||||
|
||||
describe("Ghidra provider", () => {
|
||||
it("commits DOS loader and real-mode language while retaining the CPU family", async () => {
|
||||
const ghidra = provider();
|
||||
|
|
@ -830,36 +898,60 @@ describe("Ghidra result projection", () => {
|
|||
});
|
||||
|
||||
describe("Ghidra PE CodeView debug symbol limitations", () => {
|
||||
it("projects unapplied companion PDB limitation for PE target with CodeView record", async () => {
|
||||
const ghidra = provider(installationHost(), () => ({
|
||||
start: () => Promise.resolve(ok(sessionInfo())),
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}));
|
||||
const target = {
|
||||
...peTarget("x86_64"),
|
||||
codeView: {
|
||||
pdbPath: "C:\\projects\\app\\build\\test.pdb",
|
||||
pdbFileName: "test.pdb",
|
||||
guid: "01234567-89ab-cdef-0123-456789abcdef",
|
||||
age: 1,
|
||||
},
|
||||
};
|
||||
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||
if (!profile.ok || profile.value.profile === null)
|
||||
throw new Error("expected PE profile");
|
||||
const client = ghidra.createClient(target, profile.value.profile);
|
||||
const health = await client.execute("health", {});
|
||||
expect(health).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
limitations: expect.arrayContaining([
|
||||
'The target references debug symbols "test.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age 1) that were not applied; procedure names are analysis-generated.',
|
||||
]),
|
||||
},
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
it.each([
|
||||
["absent report", undefined, "load state unknown"],
|
||||
["unknown load", null, "load state unknown"],
|
||||
["not loaded", false, "reports not loaded"],
|
||||
["loaded", true, "reports a loaded PDB"],
|
||||
] as const)(
|
||||
"preserves %s with one PDB caveat for a declared CodeView record",
|
||||
async (_, loaded, expected) => {
|
||||
const ghidra = provider(installationHost(), () => ({
|
||||
start: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
...sessionInfo(),
|
||||
...(loaded === undefined
|
||||
? {}
|
||||
: {
|
||||
pdb: {
|
||||
file: "test.pdb",
|
||||
guid: "01234567-89ab-cdef-0123-456789abcdef",
|
||||
age: "1",
|
||||
signature: null,
|
||||
version: "RSDS",
|
||||
loaded,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}));
|
||||
const target = {
|
||||
...peTarget("x86_64"),
|
||||
codeView: {
|
||||
pdbPath: "C:\\projects\\app\\build\\test.pdb",
|
||||
pdbFileName: "test.pdb",
|
||||
guid: "01234567-89ab-cdef-0123-456789abcdef",
|
||||
age: 1,
|
||||
},
|
||||
};
|
||||
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||
if (!profile.ok || profile.value.profile === null)
|
||||
throw new Error("expected PE profile");
|
||||
const client = ghidra.createClient(target, profile.value.profile);
|
||||
const health = await client.execute("health", {});
|
||||
if (!health.ok) throw health.error;
|
||||
const pdbCaveats = health.value.limitations.filter((limit) =>
|
||||
limit.includes("PDB"),
|
||||
);
|
||||
expect(pdbCaveats).toHaveLength(1);
|
||||
expect(pdbCaveats[0]).toContain(expected);
|
||||
expect(pdbCaveats[0]).not.toContain("names are analysis-generated");
|
||||
await client.close();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra relationship coverage", () => {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ import {
|
|||
isGhidraMipsSoftFloatTarget,
|
||||
} from "./GhidraMipsProfile.js";
|
||||
import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js";
|
||||
import { ghidraPeCodeViewLimitations } from "./GhidraPeProfile.js";
|
||||
import { ghidraPePdbLimitations } from "./GhidraPeProfile.js";
|
||||
import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js";
|
||||
import { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
|
||||
import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js";
|
||||
|
|
@ -89,10 +89,7 @@ import {
|
|||
limitationsFor,
|
||||
} from "./GhidraProviderCapabilities.js";
|
||||
import type { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import {
|
||||
ghidraPdbLimitations,
|
||||
type GhidraSessionInfo,
|
||||
} from "./GhidraSessionValues.js";
|
||||
import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
|
||||
import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js";
|
||||
import {
|
||||
ghidraExtensionSchema,
|
||||
|
|
@ -531,7 +528,6 @@ export const createGhidraProviderClient = (input: {
|
|||
// Replaced by the reported outcome once the session handshake is checked.
|
||||
const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)];
|
||||
const pdbLimitations: string[] = [];
|
||||
let pdbLoaded = false;
|
||||
// Replaced by the reported storage outcome once the handshake is checked.
|
||||
const softFloatLimitations: string[] = [
|
||||
...ghidraMipsSoftFloatLimitations(softFloat),
|
||||
|
|
@ -657,11 +653,10 @@ export const createGhidraProviderClient = (input: {
|
|||
info: GhidraSessionInfo,
|
||||
): Promise<AnalysisError | undefined> => {
|
||||
if (target.format === "pe") {
|
||||
pdbLoaded = info.pdb?.loaded === true;
|
||||
pdbLimitations.splice(
|
||||
0,
|
||||
pdbLimitations.length,
|
||||
...ghidraPdbLimitations(info.pdb),
|
||||
...ghidraPePdbLimitations(target, info.pdb),
|
||||
);
|
||||
}
|
||||
const softFloatReason = ghidraMipsSoftFloatFailure(
|
||||
|
|
@ -716,7 +711,6 @@ export const createGhidraProviderClient = (input: {
|
|||
...ghidraExtensionLimitations(extensions),
|
||||
...seedLimitations,
|
||||
...pdbLimitations,
|
||||
...ghidraPeCodeViewLimitations(target, pdbLoaded),
|
||||
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
|
||||
];
|
||||
const nativeAotOwner =
|
||||
|
|
|
|||
|
|
@ -121,21 +121,26 @@ export const ghidraPdbLimitations = (
|
|||
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.`,
|
||||
);
|
||||
const hasReference = [
|
||||
pdb.file,
|
||||
pdb.guid,
|
||||
pdb.age,
|
||||
pdb.signature,
|
||||
pdb.version,
|
||||
].some((value) => value !== null);
|
||||
const reference = hasReference
|
||||
? `The Ghidra Program references PDB ${pdb.file ?? "(file unknown)"} (GUID ${pdb.guid ?? "unknown"}, age (hex) ${pdb.age ?? "unknown"}, signature ${pdb.signature ?? "unknown"}, version ${pdb.version ?? "unknown"}); `
|
||||
: "";
|
||||
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.",
|
||||
`${reference}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;
|
||||
if (!hasReference) 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.`,
|
||||
`${reference}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.`,
|
||||
];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { BinarySessionPort } from "../application/binary/BinarySessionPort.
|
|||
import { EnhancedTools } from "../application/EnhancedTools.js";
|
||||
import type { ValidatedEnhancedCall } from "../application/EnhancedToolTypes.js";
|
||||
import {
|
||||
executeComposedWorkflow,
|
||||
createWorkflowEvidence,
|
||||
workflowSnapshotRecord,
|
||||
recordWorkflowUnknowns,
|
||||
|
|
@ -231,15 +232,20 @@ const executeEnhancedTool = async (
|
|||
context,
|
||||
parameters,
|
||||
});
|
||||
const services = new EnhancedTools({
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: (operation, operationParameters, executionOptions) =>
|
||||
admittedAnalysis.execute(operation, operationParameters, {
|
||||
...executionOptions,
|
||||
progress,
|
||||
}),
|
||||
});
|
||||
};
|
||||
const result = await logToolExecution(registration.logger, name, () =>
|
||||
services.executeValidated(validatedCall, context.mcpReq.signal),
|
||||
executeComposedWorkflow(analysis, (observed) =>
|
||||
new EnhancedTools(observed).executeValidated(
|
||||
validatedCall,
|
||||
context.mcpReq.signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
await progress.report({
|
||||
phase: name,
|
||||
|
|
@ -253,8 +259,9 @@ const executeEnhancedTool = async (
|
|||
target: registration.activeTarget?.(),
|
||||
operation: name,
|
||||
parameters,
|
||||
result: result.value,
|
||||
result: result.value.result,
|
||||
upstreamProfile: registration.analysisProfile?.(),
|
||||
upstreamLimitations: result.value.limitations,
|
||||
});
|
||||
const recorded = registration.recordEvidence?.(evidence);
|
||||
if (recorded !== undefined && !recorded.ok)
|
||||
|
|
@ -273,7 +280,7 @@ const executeEnhancedTool = async (
|
|||
}
|
||||
const unknowns = recordWorkflowUnknowns({
|
||||
name,
|
||||
result: result.value,
|
||||
result: result.value.result,
|
||||
evidenceId: evidence.evidence_id,
|
||||
recordUnknown: registration.recordUnknown,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ interface SnapshotProviderOptions {
|
|||
readonly profile?: ReturnType<typeof createAnalysisProfile>;
|
||||
readonly identity?: ProviderIdentity;
|
||||
readonly supportedOperations?: readonly CapabilityDescriptor["operation"][];
|
||||
readonly limitations?: readonly string[];
|
||||
}
|
||||
|
||||
const makeProvider = (
|
||||
|
|
@ -209,6 +210,7 @@ const makeProvider = (
|
|||
createAnalysisExecution(result, profile.provider, {
|
||||
analysisProfile: profile,
|
||||
rawResult: result,
|
||||
limitations: options.limitations ?? [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
@ -253,11 +255,11 @@ const withAlternateWorkflowProfile = (
|
|||
snapshot: AnalysisSnapshot,
|
||||
path: string,
|
||||
current: Evidence,
|
||||
): AnalysisSnapshot => {
|
||||
const alternateProfile = createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
|
||||
alternateProfile = createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
|
||||
workflow: "binary_overview",
|
||||
fixture: "alternate-profile",
|
||||
});
|
||||
}),
|
||||
): AnalysisSnapshot => {
|
||||
if (
|
||||
typeof current.normalized_result !== "object" ||
|
||||
current.normalized_result === null ||
|
||||
|
|
@ -466,6 +468,78 @@ const verifyWindowsSnapshotAdmission = async (
|
|||
).toHaveLength(1);
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ tool: "binary_overview", arguments: {}, priorRevision: 3 },
|
||||
{
|
||||
tool: "inspect_native_api",
|
||||
arguments: { procedure: "0x1000" },
|
||||
priorRevision: undefined,
|
||||
},
|
||||
] as const)(
|
||||
"refreshes pre-coverage $tool Evidence and replays the current revision",
|
||||
async (scenario) => {
|
||||
const fixture = await createSnapshotReplayFixture("rea-prior-coverage-");
|
||||
const limitation = "Original PDB names may be missing.";
|
||||
const provider = fixture.createProvider({
|
||||
supportedOperations: workflowProviderOperations,
|
||||
limitations: [limitation],
|
||||
});
|
||||
const dependencies = fixture.dependencies(provider);
|
||||
const options = { snapshotPath: fixture.snapshotPath };
|
||||
const first = parseEvidence(
|
||||
await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
scenario.tool,
|
||||
scenario.arguments,
|
||||
options,
|
||||
),
|
||||
);
|
||||
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
|
||||
if (!saved.ok) throw saved.error;
|
||||
const priorProfile = createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
|
||||
upstream_analysis_profile: saved.value.binding.analysis_profile,
|
||||
...(scenario.priorRevision === undefined
|
||||
? {}
|
||||
: { workflow_revision: scenario.priorRevision }),
|
||||
});
|
||||
const prior = withAlternateWorkflowProfile(
|
||||
saved.value,
|
||||
fixture.path,
|
||||
{ ...first, limitations: ["Derived by an REA composed workflow."] },
|
||||
priorProfile,
|
||||
);
|
||||
const written = await withArtifactResourceScope((resources) =>
|
||||
writeAnalysisSnapshot(prior, fixture.snapshotPath, true, resources),
|
||||
);
|
||||
if (!written.ok) throw written.error;
|
||||
const refreshed = parseEvidence(
|
||||
await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
scenario.tool,
|
||||
scenario.arguments,
|
||||
options,
|
||||
),
|
||||
);
|
||||
expect(refreshed.limitations).toContain(limitation);
|
||||
expect(refreshed.analysis_profile?.digest).not.toBe(priorProfile.digest);
|
||||
expect(refreshed.normalized_result).toEqual(first.normalized_result);
|
||||
const starts = [...fixture.starts];
|
||||
const calls = [...fixture.calls];
|
||||
const replayed = await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
scenario.tool,
|
||||
scenario.arguments,
|
||||
options,
|
||||
);
|
||||
expect(replayed).toEqual(refreshed);
|
||||
expect(fixture.starts).toEqual(starts);
|
||||
expect(fixture.calls).toEqual(calls);
|
||||
},
|
||||
);
|
||||
|
||||
describe("direct analysis composed snapshot replay", () => {
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"does not replay a CLI snapshot through a Windows junction",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
AnalysisOutputError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { err, ok as resultOk } from "../../../src/domain/result.js";
|
||||
|
||||
import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js";
|
||||
|
|
@ -125,9 +126,13 @@ describe("enhanced MCP tools", () => {
|
|||
limitations: [caveat],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(graph.structuredContent)?.split(caveat)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
const evidence = parseEvidence(graph.structuredContent);
|
||||
expect(
|
||||
JSON.stringify(evidence.normalized_result).split(caveat),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
evidence.limitations.filter((limit) => limit === caveat),
|
||||
).toHaveLength(1);
|
||||
|
||||
const trace = await client.callTool({
|
||||
name: "trace_call_path",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { afterEach, expect, it } from "vitest";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { connect, closeEnhancedToolResources } from "./enhancedToolsHarness.js";
|
||||
|
||||
afterEach(closeEnhancedToolResources);
|
||||
|
||||
it("retains provider coverage caveats in a composed MCP overview without repeated reads", async () => {
|
||||
const calls: string[] = [];
|
||||
const limitation = "Companion PDB symbol coverage is unverified.";
|
||||
const client = await connect({
|
||||
execute: async (operation) => {
|
||||
calls.push(operation);
|
||||
const value = operation === "current_document" ? "fixture" : [];
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
value,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ limitations: [limitation] },
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
const response = await client.callTool({
|
||||
name: "binary_overview",
|
||||
arguments: {},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
expect(evidence.limitations).toEqual([
|
||||
"Artifact identity is unavailable for this observation.",
|
||||
"Derived by an REA composed workflow.",
|
||||
limitation,
|
||||
]);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
document: "fixture",
|
||||
procedure_count: 0,
|
||||
string_count: 0,
|
||||
});
|
||||
expect(calls.length).toBe(new Set(calls).size);
|
||||
});
|
||||
|
|
@ -315,7 +315,7 @@ it("retains missing PDB coverage in empty PE name-search Evidence and snapshot r
|
|||
const evidence = sessionEvidence(harness.session, reply.structuredContent);
|
||||
expect(evidence.normalized_result).toEqual([]);
|
||||
expect(evidence.analysis_profile?.parameters.pdb_observations).toBe(
|
||||
"program-info-v1",
|
||||
"program-info-v5",
|
||||
);
|
||||
const limitation = evidence.limitations.find((value) =>
|
||||
value.includes(pdb.file),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { parseConfig } from "../../src/config/parseConfig.js";
|
||||
import { runDirectAnalysis } from "../../src/application/DirectAnalysis.js";
|
||||
import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js";
|
||||
import { ok } from "../../src/domain/result.js";
|
||||
import {
|
||||
createBinarySessionTargets,
|
||||
createTestBinarySession,
|
||||
} from "../fixtures/binarySession.js";
|
||||
|
||||
it("retains provider coverage caveats in a composed CLI result and closes the session", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const limitation = "Companion PDB symbol coverage is unverified.";
|
||||
let reads = 0;
|
||||
let closed = 0;
|
||||
const createSession = () =>
|
||||
createTestBinarySession(() => ({
|
||||
execute: async (operation) => {
|
||||
reads++;
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
operation === "current_document" ? "fixture" : [],
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ limitations: [limitation] },
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => {
|
||||
closed++;
|
||||
return ok(null);
|
||||
},
|
||||
}));
|
||||
const output = await runDirectAnalysis(
|
||||
{
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: createSession,
|
||||
createManagedBinarySession: createSession,
|
||||
},
|
||||
path,
|
||||
"binary_overview",
|
||||
{},
|
||||
);
|
||||
expect(output).toMatchObject({
|
||||
limitations: ["Derived by an REA composed workflow.", limitation],
|
||||
normalized_result: {
|
||||
document: "fixture",
|
||||
segment_count: 0,
|
||||
procedure_count: 0,
|
||||
string_count: 0,
|
||||
},
|
||||
});
|
||||
expect(reads).toBe(5);
|
||||
expect(closed).toBe(1);
|
||||
});
|
||||
Loading…
Reference in New Issue