fix(ghidra): commit analysis profiles to the bridge script sources (#10562)

* fix(ghidra): commit analysis profiles to the bridge script sources

Snapshot reuse for Ghidra depends on the analysis profile digest, but the
profile committed only to hand-bumped semantic markers. A bridge change that
forgot to bump its marker left earlier snapshots admissible (#3664).

Add ghidraBridgeSourcesDigest, a SHA-256 over every packaged bridge source by
name, and commit it as bridge_sources_sha256 in every Ghidra profile, for new
imports and existing projects alike. A changed script now changes the profile
digest without anyone remembering a marker. Existing snapshots are not reused
once, after upgrading.

GhidraRawProfileDigest.test.ts pinned literal digests that every bridge or
marker change had to re-record; it now checks the committed parameters and
that the digest is the profile owner's digest of them. A new test checks that
the source list covers every packaged .java file.

* fix(ghidra): frame bridge digest metadata as a tuple
This commit is contained in:
morluto authored and GitHub committed 2026-11-14 14:51:19 -05:00
1 parent e68e548a3d
commit 3fbdf0057b
4 files changed
+78 -18

No files matched your search

+10 -4
View File
@@ -17,6 +17,7 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import { ghidraDisabledAnalyzerPreset } from "./GhidraDisabledAnalyzers.js";
import { ghidraBridgeSourcesDigest } from "./GhidraBridgeSources.js";
import type { GhidraInstallationInspection } from "./GhidraInstallation.js";
import {
isLegacyMipsAdmission,
@@ -54,7 +55,7 @@ import {
} from "../domain/native/nativeAotPe.js";
/** Shared analysis commitments for raw and header-selected imports. */
const sharedAnalysisSemantics = {
const sharedAnalysisSemanticMarkers = {
annotation_policy: "atomic-function-entry-metadata-v1",
byte_search_boundary: "contiguous-eligible-initialized-blocks-v1",
load_image_observations: "source-mappings-entry-context-relocation-symbol-v3",
@@ -77,6 +78,11 @@ const sharedAnalysisSemantics = {
byte_search_coverage: "separate-loaded-overlays-v1",
function_boundary_observations: "ghidra-function-boundary-limitations-v5",
} as const;
/** Marker revisions plus the bridge sources that produce every Ghidra result. */
const sharedAnalysisSemantics = () => ({
...sharedAnalysisSemanticMarkers,
bridge_sources_sha256: ghidraBridgeSourcesDigest(),
});
const loaderRelocationSemantics = "volatile-unapplied-relocation-blocks-v1";
// Read-only sessions record relocation limitations without marking blocks volatile.
const readOnlyLoaderRelocationSemantics = "read-only-relocation-limitations-v1";
@@ -124,7 +130,7 @@ const binaryLoaderPolicy = (
architecture: target.architecture,
available_architectures: [...target.availableArchitectures].sort(),
import_mode: "ephemeral-source-immutable",
...sharedAnalysisSemantics,
...sharedAnalysisSemantics(),
no_return_repair:
installation.platform === "win32"
? "disabled-windows-p0"
@@ -329,7 +335,7 @@ export const resolveGhidraAnalysisProfile = async (
windows_arm_pe_machine: target.peMachine ?? null,
}
: {}),
...sharedAnalysisSemantics,
...sharedAnalysisSemantics(),
// Windows P0 has no database-mutation authority: limitations only.
loader_relocation_semantics:
installation.platform === "win32"
@@ -499,7 +505,7 @@ export const existingGhidraAnalysisProfile = (
architecture: target.architecture ?? null,
...(mipsElf === undefined ? {} : { mips_elf: mipsElf }),
import_mode: "existing-project-private-copy-v1",
...sharedAnalysisSemantics,
...sharedAnalysisSemantics(),
selected_project_relationship_coverage: "prior-analysis-unknown-v1",
annotation_policy: "read-only-existing-project-v1",
loader_relocation_semantics: readOnlyLoaderRelocationSemantics,
+26
View File
@@ -0,0 +1,26 @@
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { expect, it } from "vitest";
import {
GHIDRA_BRIDGE_SOURCES,
ghidraBridgeSourcesDigest,
} from "./GhidraBridgeSources.js";
const directory = new URL("../../bridge/ghidra/", import.meta.url);
it("lists every packaged Ghidra bridge source", async () => {
const java = (await readdir(directory)).filter((name) =>
name.endsWith(".java"),
);
expect(Object.values(GHIDRA_BRIDGE_SOURCES).sort()).toEqual(java.sort());
});
it("commits to the name and bytes of each bridge source", async () => {
const hash = createHash("sha256");
for (const name of Object.values(GHIDRA_BRIDGE_SOURCES).sort()) {
const bytes = await readFile(new URL(name, directory));
hash.update(JSON.stringify([name, bytes.byteLength])).update(bytes);
}
expect(ghidraBridgeSourcesDigest()).toBe(hash.digest("hex"));
});
+23
View File
@@ -1,3 +1,6 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
/** Required packaged sources, selected by their runtime role rather than order. */
export const GHIDRA_BRIDGE_SOURCES = Object.freeze({
bridge: "ReaGhidraBridge.java",
@@ -12,3 +15,23 @@ export const GHIDRA_BRIDGE_SOURCES = Object.freeze({
mipsSoftFloat: "ReaGhidraMipsSoftFloat.java",
riscvCallingConvention: "ReaGhidraRiscvCallingConvention.java",
} as const);
let bridgeSourcesDigest: string | undefined;
/**
* SHA-256 over the packaged bridge sources, keyed by file name. Ghidra results
* depend on these scripts, so the analysis profile commits to them: a changed
* script yields a new profile digest and earlier snapshots are not reused,
* without a hand-bumped semantics marker.
*/
export const ghidraBridgeSourcesDigest = (): string => {
if (bridgeSourcesDigest !== undefined) return bridgeSourcesDigest;
const directory = new URL("../../bridge/ghidra/", import.meta.url);
const hash = createHash("sha256");
for (const name of Object.values(GHIDRA_BRIDGE_SOURCES).sort()) {
const bytes = readFileSync(new URL(name, directory));
hash.update(JSON.stringify([name, bytes.byteLength])).update(bytes);
}
bridgeSourcesDigest = hash.digest("hex");
return bridgeSourcesDigest;
};
+19 -14
View File
@@ -5,16 +5,23 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { silentLogger } from "../logger.js";
import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js";
import { GhidraProvider } from "./GhidraProvider.js";
import { ghidraBridgeSourcesDigest } from "./GhidraBridgeSources.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
// Digests commit to the current shared policy, including the instruction
// status, load-image relocation-symbol, procedure search, byte-search and
// bit-field base-type, runtime value-flow budgets, function-boundary and inventory-search revisions that prevent reuse of snapshots with earlier
// results.
const RAW_DIGESTS = {
"6502": "6c4e88435526e8f0907b072600e3b121577962a36554e023dde4cf85113dd9f6",
"x86-disabled-analyzer":
"b2dc33d1932cbe71d72819428634927f3a2d8a2bbd54b6893f823ccb6b795d0b",
} as const;
// A profile digest is the digest of its committed parameters; these tests pin
// the parameters (semantic revisions and the bridge sources) rather than a
// literal hash that every bridge or marker change would have to re-record.
const expectCommittedProfile = (
profile: ReturnType<typeof createAnalysisProfile> | null | undefined,
): void => {
if (profile == null) throw new Error("Expected an analysis profile");
expect(profile.parameters).toMatchObject({
bridge_sources_sha256: ghidraBridgeSourcesDigest(),
});
expect(profile.digest).toBe(
createAnalysisProfile(profile.provider, profile.parameters).digest,
);
};
const configFor = (environment: NodeJS.ProcessEnv) => {
const parsed = parseConfig({ GHIDRA_INSTALL_DIR: "/ghidra", ...environment });
@@ -34,12 +41,12 @@ const raw = (architecture: "6502" | "x86", entryPoint: string): BinaryTarget =>
entryPointSource: entryPoint,
}) as unknown as BinaryTarget;
describe("raw-binary analysis profile digest", () => {
describe("raw-binary analysis profile commitments", () => {
it("commits the 6502 raw profile to the current shared policy", async () => {
const ghidra = new GhidraProvider(configFor({}), silentLogger, {}, host);
const resolved = await ghidra.resolveAnalysisProfile(raw("6502", "0x8001"));
if (!resolved.ok) throw resolved.error;
expect(resolved.value.profile?.digest).toBe(RAW_DIGESTS["6502"]);
expectCommittedProfile(resolved.value.profile);
expect(resolved.value.profile?.parameters).toMatchObject({
target_format: "raw-binary",
loader: "BinaryLoader",
@@ -72,9 +79,7 @@ describe("raw-binary analysis profile digest", () => {
);
const resolved = await ghidra.resolveAnalysisProfile(raw("x86", "0x1000"));
if (!resolved.ok) throw resolved.error;
expect(resolved.value.profile?.digest).toBe(
RAW_DIGESTS["x86-disabled-analyzer"],
);
expectCommittedProfile(resolved.value.profile);
expect(resolved.value.profile?.parameters).toMatchObject({
inventory_search_projection: "provider-inventory-facts-v1",
value_flow_effect_scope: "pcode-load-store-explicit-v1",