From d3eb3f64781592c7bb9eceb44c162213eb3eaaeb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:24:20 +0800 Subject: [PATCH] fix(ghidra): validate seed outcome conservation (#9969) --- src/ghidra/GhidraAnalysisSeeds.test.ts | 78 ++++++++++++++++++++++++++ src/ghidra/GhidraAnalysisSeeds.ts | 50 ++++++++++++----- 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/ghidra/GhidraAnalysisSeeds.test.ts b/src/ghidra/GhidraAnalysisSeeds.test.ts index 7cf12e02a4..8a927b4e41 100644 --- a/src/ghidra/GhidraAnalysisSeeds.test.ts +++ b/src/ghidra/GhidraAnalysisSeeds.test.ts @@ -17,8 +17,13 @@ import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { silentLogger } from "../logger.js"; +import { + GHIDRA_SESSION_CAPABILITIES, + parseGhidraSessionInfo, +} from "./GhidraSessionValues.js"; import { ghidraSeedFailure, + ghidraSeedReportSchema, ghidraSeedLimitations, parseGhidraSeeds, resolveGhidraAnalysisSeeds, @@ -252,6 +257,79 @@ describe("Ghidra seed session verification", () => { unmapped: 0, }; + it.each([100, 0])( + "rejects a handshake whose outcome counters report %i created functions", + (function_created) => { + const expected = { + runId: "d6fcbb66-e829-4ff6-a535-0035aec63139", + providerVersion: "12.1.4", + profileDigest: "a".repeat(64), + targetSha256: "b".repeat(64), + }; + const parsed = parseGhidraSessionInfo( + { + name: "REA Ghidra bridge", + run_id: expected.runId, + profile_digest: expected.profileDigest, + provider: { id: "ghidra", version: expected.providerVersion }, + read_only: false, + analysis_complete: true, + analysis_timed_out: false, + capabilities: [...GHIDRA_SESSION_CAPABILITIES], + target: { + name: "fixture", + language_id: "x86:LE:64:default", + compiler_spec_id: "gcc", + image_base: "0x0", + default_address_space: "ram", + sha256: expected.targetSha256, + }, + analysis_seeds: { ...report, function_created }, + }, + expected, + ); + expect(parsed).toMatchObject({ + ok: false, + error: { code: "handshake_identity" }, + }); + }, + ); + + it.each([ + { function_created: 3, function_failed: 0, code_decoded: 0 }, + { function_created: 0, function_failed: 0, code_decoded: 3 }, + { function_created: 0, function_failed: 0, label_applied: 3 }, + ])( + "rejects outcomes attributed to more seeds of a kind than committed: %j", + (changed) => { + const misattributed = { ...report, ...changed }; + expect(ghidraSeedReportSchema.safeParse(misattributed).success).toBe( + true, + ); + expect( + ghidraSeedFailure(commitment, misattributed, "xrefs"), + ).toBeDefined(); + }, + ); + + it("admits producer outcomes including aggregate unmapped seeds without inventing their kinds", () => { + for (const outcome of [ + report, + { + ...report, + function_created: 0, + function_failed: 0, + code_decoded: 0, + label_applied: 0, + unmapped: 4, + }, + { ...report, function_created: 0, code_decoded: 0, unmapped: 2 }, + ]) { + expect(ghidraSeedReportSchema.safeParse(outcome).success).toBe(true); + expect(ghidraSeedFailure(commitment, outcome, "xrefs")).toBeUndefined(); + } + }); + it("accepts exactly the committed seeds and reports their outcome", () => { expect(ghidraSeedFailure(commitment, report, "xrefs")).toBeUndefined(); expect(ghidraSeedFailure(undefined, undefined, "xrefs")).toBeUndefined(); diff --git a/src/ghidra/GhidraAnalysisSeeds.ts b/src/ghidra/GhidraAnalysisSeeds.ts index 511bf1aaba..78291b40ab 100644 --- a/src/ghidra/GhidraAnalysisSeeds.ts +++ b/src/ghidra/GhidraAnalysisSeeds.ts @@ -37,19 +37,33 @@ export const ghidraSeedCommitmentSchema = z.strictObject({ export type GhidraSeedCommitment = z.infer; /** What the pre-analysis script applied, as reported by the bridge handshake. */ -export const ghidraSeedReportSchema = z.strictObject({ - format: z.literal(GHIDRA_SEED_FORMAT), - sha256: digest, - entries: count, - function_created: count, - function_existing: count, - function_failed: count, - code_decoded: count, - code_failed: count, - label_applied: count, - label_failed: count, - unmapped: count, -}); +export const ghidraSeedReportSchema = z + .strictObject({ + format: z.literal(GHIDRA_SEED_FORMAT), + sha256: digest, + entries: count, + function_created: count, + function_existing: count, + function_failed: count, + code_decoded: count, + code_failed: count, + label_applied: count, + label_failed: count, + unmapped: count, + }) + .refine( + (report) => + report.function_created + + report.function_existing + + report.function_failed + + report.code_decoded + + report.code_failed + + report.label_applied + + report.label_failed + + report.unmapped === + report.entries, + { message: "Seed outcomes must account for exactly the reported entries." }, + ); export type GhidraSeedReport = z.infer; type SeedKind = "function" | "code" | "label"; @@ -267,7 +281,15 @@ export const ghidraSeedFailure = ( : report === undefined ? "The Ghidra session did not report the committed analysis seeds." : report.sha256 !== commitment.sha256 || - report.entries !== totalEntries(commitment) + report.entries !== totalEntries(commitment) || + report.function_created + + report.function_existing + + report.function_failed > + commitment.entries.function || + report.code_decoded + report.code_failed > + commitment.entries.code || + report.label_applied + report.label_failed > + commitment.entries.label ? "The Ghidra session applied analysis seeds that differ from the committed profile." : undefined; return reason === undefined