fix(ghidra): preserve nullable PDB coverage observations

Use reported Program observations once and preserve declared PE references only when needed. Keep missing load state unknown, retain omission coverage, and bound escaped inline locators without claiming every procedure name is generated.
This commit is contained in:
N0zoM1z0 2026-10-14 03:22:25 +08:00
parent 7b327544f3
commit 7570215ad9
4 changed files with 227 additions and 142 deletions

View File

@ -1,95 +1,145 @@
import { describe, expect, it } from "vitest"; import { expect, it } from "vitest";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; 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", () => { const target: BinaryTarget = {
it("returns empty limitations for non-PE targets", () => { path: "/fixture/public.exe",
const elfTarget: BinaryTarget = { sha256: "a".repeat(64),
path: "/test/file", kind: "executable",
sha256: "abc", format: "pe",
kind: "executable", architecture: "x86_64",
format: "elf", availableArchitectures: ["x86_64"],
architecture: "x86_64", executableRole: "application",
availableArchitectures: ["x86_64"], 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([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: "reported.pdb",
guid: "reported-guid",
age: "a",
signature: null,
version: "RSDS",
loaded,
}; };
expect(ghidraPeCodeViewLimitations(elfTarget)).toEqual([]); const caveats = ghidraPePdbLimitations(target, pdb);
}); expect(caveats).toEqual(ghidraPdbLimitations(pdb));
expect(caveats).toHaveLength(1);
expect(caveats.join(" ")).not.toContain("public.pdb");
},
);
it("does not claim CodeView symbols were unapplied when the session loaded a PDB", () => { it("retains explicit failure to load without asserting all names are generated", () => {
const peTarget: BinaryTarget = { const caveats = ghidraPePdbLimitations(target, {
path: "/test/file.exe", ...absentAttributes,
sha256: "abc", loaded: false,
kind: "executable", });
architecture: "x86_64", expect(caveats[0]).toContain("reports not loaded");
availableArchitectures: ["x86_64"], expect(caveats[0]).not.toContain("names are analysis-generated");
executableRole: "application", });
managed: false,
format: "pe", it("retains a loaded-state attestation caveat even when reference attributes are absent", () => {
const caveats = ghidraPePdbLimitations(target, {
...absentAttributes,
loaded: true,
});
expect(caveats).toEqual(
ghidraPdbLimitations({ ...absentAttributes, loaded: true }),
);
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: 2048 },
};
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: { codeView: {
pdbPath: "C:\\symbols.pdb", pdbPath: "legacy.pdb",
pdbFileName: "symbols.pdb",
guid: "12345678-1234-1234-1234-123456789abc",
age: 1,
},
};
expect(ghidraPeCodeViewLimitations(peTarget, true)).toEqual([]);
});
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,
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",
pdbFileName: "legacy.pdb", pdbFileName: "legacy.pdb",
guid: null, guid: null,
age: 2, age: 2,
}, },
}; },
expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([ undefined,
'The target references debug symbols "legacy.pdb" (age 2) that were not applied; procedure names are analysis-generated.', );
]); expect(caveats[0]).toContain('PDB "legacy.pdb" (age (decimal) 2)');
}); expect(caveats[0]).not.toContain("GUID");
});
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);
}); });

View File

@ -1,24 +1,41 @@
import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
ghidraPdbLimitations,
type GhidraSessionInfo,
} from "./GhidraSessionValues.js";
/** /** Use reported Program facts first, retaining declared PE facts as a fallback. */
* Report unapplied PE companion PDB symbols when CodeView debug information is present. export const ghidraPePdbLimitations = (
*
* 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, target: BinaryTarget,
pdbLoaded = false, pdb: GhidraSessionInfo["pdb"],
): readonly string[] => { ): readonly string[] => {
if (target.format !== "pe" || target.codeView === undefined || pdbLoaded) { if (target.format !== "pe") return [];
return []; const reported = ghidraPdbLimitations(pdb);
} if (
target.codeView === undefined ||
pdb?.loaded === true ||
(pdb !== undefined &&
[pdb.file, pdb.guid, pdb.age, pdb.signature, pdb.version].some(
(attribute) => attribute !== null,
))
)
return reported;
const { pdbFileName, guid, age } = target.codeView; const { pdbFileName, guid, age } = target.codeView;
return [ // Include JSON escaping in the inline budget before allocating that representation.
guid !== null const locator =
? `The target references debug symbols "${pdbFileName}" (GUID ${guid}, age ${age}) that were not applied; procedure names are analysis-generated.` pdbFileName.length * 6 + 2 <= 1024
: `The target references debug symbols "${pdbFileName}" (age ${age}) that were not applied; procedure names are analysis-generated.`, ? JSON.stringify(pdbFileName)
]; : "(declared locator omitted from this inline caveat: 1024-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"
: "Ghidra reports load state unknown";
const declared = `The PE CodeView record references PDB ${locator} (${identity}); ${state}. 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];
}; };

View File

@ -830,36 +830,60 @@ describe("Ghidra result projection", () => {
}); });
describe("Ghidra PE CodeView debug symbol limitations", () => { describe("Ghidra PE CodeView debug symbol limitations", () => {
it("projects unapplied companion PDB limitation for PE target with CodeView record", async () => { it.each([
const ghidra = provider(installationHost(), () => ({ ["absent report", undefined, "load state unknown"],
start: () => Promise.resolve(ok(sessionInfo())), ["unknown load", null, "load state unknown"],
callTool: () => Promise.resolve(ok(null)), ["not loaded", false, "reports not loaded"],
close: () => Promise.resolve(ok(null)), ["loaded", true, "reports a loaded PDB"],
})); ] as const)(
const target = { "preserves %s with one PDB caveat for a declared CodeView record",
...peTarget("x86_64"), async (_, loaded, expected) => {
codeView: { const ghidra = provider(installationHost(), () => ({
pdbPath: "C:\\projects\\app\\build\\test.pdb", start: () =>
pdbFileName: "test.pdb", Promise.resolve(
guid: "01234567-89ab-cdef-0123-456789abcdef", ok({
age: 1, ...sessionInfo(),
}, ...(loaded === undefined
}; ? {}
const profile = await ghidra.resolveAnalysisProfile(target); : {
if (!profile.ok || profile.value.profile === null) pdb: {
throw new Error("expected PE profile"); file: "test.pdb",
const client = ghidra.createClient(target, profile.value.profile); guid: "01234567-89ab-cdef-0123-456789abcdef",
const health = await client.execute("health", {}); age: "1",
expect(health).toMatchObject({ signature: null,
ok: true, version: "RSDS",
value: { loaded,
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.', }),
]), }),
}, ),
}); callTool: () => Promise.resolve(ok(null)),
await client.close(); 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", () => { describe("Ghidra relationship coverage", () => {

View File

@ -69,7 +69,7 @@ import {
isGhidraMipsSoftFloatTarget, isGhidraMipsSoftFloatTarget,
} from "./GhidraMipsProfile.js"; } from "./GhidraMipsProfile.js";
import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js";
import { ghidraPeCodeViewLimitations } from "./GhidraPeProfile.js"; import { ghidraPePdbLimitations } from "./GhidraPeProfile.js";
import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js";
import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js";
@ -89,10 +89,7 @@ import {
limitationsFor, limitationsFor,
} from "./GhidraProviderCapabilities.js"; } from "./GhidraProviderCapabilities.js";
import type { GhidraSessionError } from "./GhidraSessionError.js"; import type { GhidraSessionError } from "./GhidraSessionError.js";
import { import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
ghidraPdbLimitations,
type GhidraSessionInfo,
} from "./GhidraSessionValues.js";
import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js"; import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js";
import { import {
ghidraExtensionSchema, ghidraExtensionSchema,
@ -531,7 +528,6 @@ export const createGhidraProviderClient = (input: {
// Replaced by the reported outcome once the session handshake is checked. // Replaced by the reported outcome once the session handshake is checked.
const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)]; const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)];
const pdbLimitations: string[] = []; const pdbLimitations: string[] = [];
let pdbLoaded = false;
// Replaced by the reported storage outcome once the handshake is checked. // Replaced by the reported storage outcome once the handshake is checked.
const softFloatLimitations: string[] = [ const softFloatLimitations: string[] = [
...ghidraMipsSoftFloatLimitations(softFloat), ...ghidraMipsSoftFloatLimitations(softFloat),
@ -657,11 +653,10 @@ export const createGhidraProviderClient = (input: {
info: GhidraSessionInfo, info: GhidraSessionInfo,
): Promise<AnalysisError | undefined> => { ): Promise<AnalysisError | undefined> => {
if (target.format === "pe") { if (target.format === "pe") {
pdbLoaded = info.pdb?.loaded === true;
pdbLimitations.splice( pdbLimitations.splice(
0, 0,
pdbLimitations.length, pdbLimitations.length,
...ghidraPdbLimitations(info.pdb), ...ghidraPePdbLimitations(target, info.pdb),
); );
} }
const softFloatReason = ghidraMipsSoftFloatFailure( const softFloatReason = ghidraMipsSoftFloatFailure(
@ -716,7 +711,6 @@ export const createGhidraProviderClient = (input: {
...ghidraExtensionLimitations(extensions), ...ghidraExtensionLimitations(extensions),
...seedLimitations, ...seedLimitations,
...pdbLimitations, ...pdbLimitations,
...ghidraPeCodeViewLimitations(target, pdbLoaded),
...(releaseLimitation === undefined ? [] : [releaseLimitation]), ...(releaseLimitation === undefined ? [] : [releaseLimitation]),
]; ];
const nativeAotOwner = const nativeAotOwner =