From c121db7a4723dd1546974ced774bc8a6b9925dcc Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:34:00 +0800 Subject: [PATCH] fix(ghidra): retain conflicting PDB identity observations Compare declared CodeView identity with recognized Program GUID and hexadecimal age formats. Preserve both sources when fields conflict or equivalence cannot be established. Keep known Program identity alongside loaded-state attestation caveats at the projection owner, and revise PE snapshot coverage to program-info-v4. Validation: 246 associated tests across 15 suites, runtime build, typecheck and changed-file lint pass; two Windows-only cases skip on Linux. Five conflict/unknown-format cases reproduce before the comparison fix; their loaded-state variants also expose the original Program projection dropping identity fields. --- src/ghidra/GhidraAnalysisProfile.ts | 2 +- src/ghidra/GhidraPeProfile.test.ts | 41 +++++++++++++++++-- src/ghidra/GhidraPeProfile.ts | 46 +++++++++++++++++++--- src/ghidra/GhidraProvider.behavior.test.ts | 2 +- src/ghidra/GhidraSessionValues.ts | 21 ++++++---- 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 5a193ec99..f731b3c62 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -218,7 +218,7 @@ export const resolveGhidraAnalysisProfile = async ( ].sort(), import_mode: "ephemeral-source-immutable", ...(target.format === "pe" - ? { pdb_observations: "program-info-v3" } + ? { pdb_observations: "program-info-v4" } : {}), annotation_policy: "atomic-function-entry-metadata-v1", load_image_observations: "source-mappings-entry-context-v2", diff --git a/src/ghidra/GhidraPeProfile.test.ts b/src/ghidra/GhidraPeProfile.test.ts index 36822377f..4bf441690 100644 --- a/src/ghidra/GhidraPeProfile.test.ts +++ b/src/ghidra/GhidraPeProfile.test.ts @@ -70,9 +70,9 @@ it.each([true, false, null])( "uses reported PDB observations once with loaded = %s", (loaded) => { const pdb = { - file: "reported.pdb", - guid: "reported-guid", - age: "a", + file: "public.pdb", + guid: "{01234567-89AB-CDEF-0123-456789ABCDEF}", + age: "0X000A", signature: null, version: "RSDS", loaded, @@ -80,10 +80,43 @@ it.each([true, false, null])( const caveats = ghidraPePdbLimitations(target, pdb); expect(caveats).toEqual(ghidraPdbLimitations(pdb)); expect(caveats).toHaveLength(1); - expect(caveats.join(" ")).not.toContain("public.pdb"); + 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, diff --git a/src/ghidra/GhidraPeProfile.ts b/src/ghidra/GhidraPeProfile.ts index 482eabd6e..300978c70 100644 --- a/src/ghidra/GhidraPeProfile.ts +++ b/src/ghidra/GhidraPeProfile.ts @@ -1,4 +1,7 @@ -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { + BinaryTarget, + PeCodeViewRecord, +} from "../domain/binaryTargetTypes.js"; import { ghidraPdbLimitations, type GhidraSessionInfo, @@ -6,6 +9,35 @@ import { const PDB_LOCATOR_INLINE_BYTE_BUDGET = 1024; +/** Establish equivalence only for the declared fields and recognized formats. */ +const reportedPdbMatchesDeclaration = ( + declared: PeCodeViewRecord, + reported: NonNullable, +): boolean => { + if ( + (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; @@ -47,10 +79,7 @@ export const ghidraPePdbLimitations = ( const reported = ghidraPdbLimitations(pdb); if ( target.codeView === undefined || - (pdb !== undefined && - pdb.file !== null && - pdb.age !== null && - (target.codeView.guid === null || pdb.guid !== null)) + (pdb !== undefined && reportedPdbMatchesDeclaration(target.codeView, pdb)) ) return reported; const { pdbFileName, guid, age } = target.codeView; @@ -70,6 +99,11 @@ export const ghidraPePdbLimitations = ( : pdb.loaded === true ? "Ghidra reports a loaded PDB; this declared identity is not an independent matching-PDB attestation" : "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.`; + 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]; }; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index c27fb19b7..99e6a330e 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -85,7 +85,7 @@ const createElfClient = async (ghidra: GhidraProvider) => { return ghidra.createClient(target, resolved.value.profile); }; -it.each(["program-info-v1", "program-info-v2"])( +it.each(["program-info-v1", "program-info-v2", "program-info-v3"])( "retains historical PE PDB Evidence for %s without replaying its old projection", async (pdbObservations) => { const target = peTarget("x86_64"); diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index e370892f3..5d7bda0d2 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -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.`, ]; };