fix(native): consolidate observed address validation

This commit is contained in:
morluto
2026-10-23 19:11:26 +08:00
parent 5a5bcb6b54
commit 99a5ffae3b
8 changed files with 242 additions and 46 deletions
+1 -1
View File
@@ -498,7 +498,7 @@ function hexBytes(bytes) {
function assertProfile(profile) {
assert.equal(
profile.parameters.load_image_evidence,
"independent-mz-mapping-relocations-v1",
"independent-mz-mapping-relocations-v2",
);
assert.equal(
profile.parameters.function_body_evidence,
+5
View File
@@ -1,5 +1,10 @@
import { z } from "zod";
/** Observed hexadecimal coordinate; preserve padding and encoded space spelling. */
export const nativeAddressObservationSchema = z
.string()
.regex(/^(?:(?:[A-Za-z0-9._~\x2d]|%[0-9A-F]{2})+:)?0x[0-9a-f]+$/u);
/** Canonical hexadecimal coordinate, retaining its optional address space. */
export const nativeAddressSchema = z
.string()
+11 -11
View File
@@ -2,11 +2,11 @@ import { z } from "zod";
import { nativeMetadataRecoverySummarySchema } from "./nativeMetadataRecovery.js";
import { jsonValueSchema } from "../jsonValue.js";
import { binaryArchitectureSchema } from "../rawBinary.js";
import { nativeAddressObservationSchema } from "./nativeAddress.js";
const integer = z.int().min(0).max(Number.MAX_SAFE_INTEGER);
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
const hexBytes = z.string().regex(/^(?:[a-f0-9]{2})*$/u);
const address = z.string().min(1);
/** Measured provider image, without exporting the complete executable bytes. */
export const nativeLoadImageObservationSchema = z.strictObject({
@@ -14,7 +14,7 @@ export const nativeLoadImageObservationSchema = z.strictObject({
executable_format: z.string().min(1),
language_id: z.string().min(1),
compiler_spec_id: z.string().min(1),
image_base: address,
image_base: nativeAddressObservationSchema,
default_address_space: z.string().min(1),
source_files: z.array(
z.strictObject({
@@ -27,8 +27,8 @@ export const nativeLoadImageObservationSchema = z.strictObject({
mappings: z.array(
z.strictObject({
block: z.string(),
start: address,
end: address,
start: nativeAddressObservationSchema,
end: nativeAddressObservationSchema,
address_space: z.string().min(1),
initialized: z.boolean(),
loaded: z.boolean(),
@@ -41,7 +41,7 @@ export const nativeLoadImageObservationSchema = z.strictObject({
),
relocations: z.array(
z.strictObject({
address,
address: nativeAddressObservationSchema,
segment: integer.max(0xffff).nullable(),
offset: integer.max(0xffff).nullable(),
status: z.string().min(1),
@@ -53,10 +53,10 @@ export const nativeLoadImageObservationSchema = z.strictObject({
memory_bytes_hex: hexBytes.nullable(),
}),
),
entry_points: z.array(address),
entry_points: z.array(nativeAddressObservationSchema),
entry_context: z.array(
z.strictObject({
address,
address: nativeAddressObservationSchema,
registers: z.array(
z.strictObject({
name: z.string().min(1),
@@ -77,7 +77,7 @@ export const nativeLoadImageCheckSchema = z.strictObject({
expected: jsonValueSchema,
observed: jsonValueSchema,
file_offset: integer.nullable(),
address: address.nullable(),
address: nativeAddressObservationSchema.nullable(),
});
/** Read-only native image verification; verified applies only to declared coverage. */
@@ -99,7 +99,7 @@ export const nativeLoadImageSchema = z.union([
entry: z.strictObject({
relative_segment: integer.max(0xffff),
offset: integer.max(0xffff),
linear_address: address,
linear_address: nativeAddressObservationSchema,
}),
observations: nativeLoadImageObservationSchema,
checks: z.array(nativeLoadImageCheckSchema),
@@ -112,10 +112,10 @@ export const nativeLoadImageSchema = z.union([
architecture: binaryArchitectureSchema,
language_id: z.string().min(1),
compiler_spec_id: z.string().min(1),
base_address: address,
base_address: nativeAddressObservationSchema,
file_bytes: integer.min(1),
entry: z.strictObject({
address: address,
address: nativeAddressObservationSchema,
file_offset: z.literal(0),
}),
observations: nativeLoadImageObservationSchema,
+13 -13
View File
@@ -1,3 +1,4 @@
import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js";
import { procedureInfoSchema } from "../domain/native/procedureInfo.js";
import { z } from "zod";
import {
@@ -38,7 +39,6 @@ import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"
import {
ghidraIdentifierSchema,
ghidraInputAddressSchema,
ghidraCanonicalAddressSchema,
ghidraFunctionClassificationSchema as classification,
ghidraFunctionBodySchema as ghidraFunctionBody,
ghidraProcedureIdentitySchema as procedureIdentity,
@@ -146,8 +146,8 @@ const referenceKind = availableReferenceKindSchema.safeExtend({
});
const referenceEdge = z
.object({
source_address: ghidraCanonicalAddressSchema,
target_address: ghidraCanonicalAddressSchema,
source_address: nativeAddressObservationSchema,
target_address: nativeAddressObservationSchema,
source_procedure: procedureIdentity.nullable(),
target_procedure: procedureIdentity.nullable(),
kind: referenceKind,
@@ -161,7 +161,7 @@ const procedureReferences = z
reference_kinds_available: z.boolean(),
unresolved_calls: z.array(
z.strictObject({
address: ghidraCanonicalAddressSchema,
address: nativeAddressObservationSchema,
reason: z.string(),
}),
),
@@ -170,7 +170,7 @@ const procedureReferences = z
const procedureInfo = procedureInfoSchema
.safeExtend({
name: z.string().min(1),
entrypoint: ghidraCanonicalAddressSchema,
entrypoint: nativeAddressObservationSchema,
length: z.int().min(0),
locals: z.array(localVariable),
classification,
@@ -207,7 +207,7 @@ const ghidraNativeApiBoundary = nativeApiBoundarySchema.superRefine(
]);
if (
addresses.some(
(address) => !ghidraCanonicalAddressSchema.safeParse(address).success,
(address) => !nativeAddressObservationSchema.safeParse(address).success,
)
)
context.addIssue({
@@ -233,7 +233,7 @@ const ghidraNativeValueFlow = nativeValueFlowSchema.superRefine(
]);
if (
addresses.some(
(address) => !ghidraCanonicalAddressSchema.safeParse(address).success,
(address) => !nativeAddressObservationSchema.safeParse(address).success,
)
)
context.addIssue({
@@ -322,7 +322,7 @@ const ghidraFunctionDossier = functionDossierSchema
];
if (
addresses.some(
(address) => !ghidraCanonicalAddressSchema.safeParse(address).success,
(address) => !nativeAddressObservationSchema.safeParse(address).success,
)
)
context.addIssue({
@@ -382,8 +382,8 @@ const resultSchemas = {
}),
analyze_function: ghidraFunctionDossier,
procedure_assembly: z.string(),
procedure_callees: z.array(ghidraCanonicalAddressSchema),
procedure_callers: z.array(ghidraCanonicalAddressSchema),
procedure_callees: z.array(nativeAddressObservationSchema),
procedure_callers: z.array(nativeAddressObservationSchema),
procedure_info: procedureInfo,
procedure_pseudo_code: z.string().nullable(),
read_function_instructions: ghidraFunctionInstructionWindow,
@@ -391,7 +391,7 @@ const resultSchemas = {
inspect_native_data_type: nativeDataTypeSchema,
resolve_native_call_targets: nativeCallTargetsSchema,
procedure_references: procedureReferences,
xrefs: z.array(ghidraCanonicalAddressSchema),
xrefs: z.array(nativeAddressObservationSchema),
} satisfies Readonly<Record<GhidraFunctionOperation, z.ZodType>>;
const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation> =
@@ -414,8 +414,8 @@ const functionObservationEnvelope = z.strictObject({
selection: z
.strictObject({
file_offset: z.int().min(0).max(Number.MAX_SAFE_INTEGER),
mapped_address: ghidraCanonicalAddressSchema,
procedure_address: ghidraCanonicalAddressSchema,
mapped_address: nativeAddressObservationSchema,
procedure_address: nativeAddressObservationSchema,
})
.exactOptional(),
});
+19 -18
View File
@@ -1,3 +1,4 @@
import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js";
import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js";
import { z } from "zod";
import {
@@ -147,10 +148,6 @@ export const parseGhidraInventoryInput = (
return ok(parsed.data);
};
export const ghidraCanonicalAddressSchema = z
.string()
.regex(/^(?:0x[0-9a-f]+|(?:[A-Za-z0-9._~-]|%[0-9A-F]{2})+:0x[0-9a-f]+)$/u);
const canonicalAddress = ghidraCanonicalAddressSchema;
const symbolFacts = z
.object({
primary: z.boolean(),
@@ -164,7 +161,7 @@ const procedureFacts = z
.object({
external: z.boolean(),
thunk: z.boolean(),
thunk_target: canonicalAddress.nullable(),
thunk_target: nativeAddressObservationSchema.nullable(),
})
.strict();
/** Ghidra FunctionManager classification, independent of body ownership inference. */
@@ -179,8 +176,8 @@ export const ghidraFunctionBodySchema = functionBodySchema.superRefine(
body.provenance !== "ghidra-function-body-address-set" ||
body.ranges.some(
({ start, end }) =>
!ghidraCanonicalAddressSchema.safeParse(start).success ||
!ghidraCanonicalAddressSchema.safeParse(end).success,
!nativeAddressObservationSchema.safeParse(start).success ||
!nativeAddressObservationSchema.safeParse(end).success,
)
)
context.addIssue({
@@ -194,7 +191,7 @@ export const ghidraFunctionBodySchema = functionBodySchema.superRefine(
/** Strict provider identity shared by inventory and function-analysis results. */
export const ghidraProcedureIdentitySchema = z
.object({
address: ghidraCanonicalAddressSchema,
address: nativeAddressObservationSchema,
name: z.string().min(1),
classification: ghidraFunctionClassificationSchema,
body: ghidraFunctionBodySchema,
@@ -221,7 +218,7 @@ const stringFacts = z
})
.strict();
const baseItem = {
address: canonicalAddress,
address: nativeAddressObservationSchema,
value: z.string(),
};
const symbolItem = z.object({ ...baseItem, symbol: symbolFacts }).strict();
@@ -240,15 +237,15 @@ const availablePermissions = z
const memoryRegion = z
.object({
name: z.string(),
start: canonicalAddress,
end: canonicalAddress,
start: nativeAddressObservationSchema,
end: nativeAddressObservationSchema,
readable: z.boolean(),
writable: z.boolean(),
executable: z.boolean(),
permissions: availablePermissions,
provenance: z.literal("ghidra-memory-block"),
address_space: z.string().min(1),
image_base: canonicalAddress,
image_base: nativeAddressObservationSchema,
initialized: z.boolean(),
overlay: z.boolean(),
})
@@ -259,7 +256,7 @@ const segment = memoryRegion
const containingProcedure = z.discriminatedUnion("found", [
z
.object({
query_address: canonicalAddress,
query_address: nativeAddressObservationSchema,
found: z.literal(true),
procedure: ghidraProcedureIdentitySchema,
})
@@ -285,7 +282,7 @@ const containingProcedure = z.discriminatedUnion("found", [
}),
z
.object({
query_address: canonicalAddress,
query_address: nativeAddressObservationSchema,
found: z.literal(false),
procedure: z.null(),
reason: z.enum(["outside_segments", "not_in_procedure"]),
@@ -314,7 +311,11 @@ const resultSchemas = {
...value.searched.skipped_not_loaded,
].flatMap((block) => [block.start, block.end]),
];
if (addresses.some((value) => !canonicalAddress.safeParse(value).success))
if (
addresses.some(
(value) => !nativeAddressObservationSchema.safeParse(value).success,
)
)
context.addIssue({
code: "custom",
message: "Byte search addresses must be canonical",
@@ -322,10 +323,10 @@ const resultSchemas = {
}),
inspect_native_load_image: nativeLoadImageObservationSchema,
read_bytes: nativeByteReadSchema
.safeExtend({ address: canonicalAddress })
.safeExtend({ address: nativeAddressObservationSchema })
.strict(),
address_to_file_offset: z.strictObject({
address: canonicalAddress,
address: nativeAddressObservationSchema,
file_offset: z.int().min(0).max(Number.MAX_SAFE_INTEGER),
}),
address_name: z.string().nullable(),
@@ -334,7 +335,7 @@ const resultSchemas = {
list_procedures: z.array(procedureItem),
list_segments: z.array(segment),
list_strings: z.array(stringItem),
procedure_address: canonicalAddress,
procedure_address: nativeAddressObservationSchema,
resolve_containing_procedure: containingProcedure,
search_procedures: z.array(searchItem),
search_strings: z.array(searchItem),
+103 -1
View File
@@ -4,8 +4,13 @@ import {
compareDosFixture,
fixtureDosLoadImage,
} from "./GhidraLoadImage.fixture.js";
import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
nativeLoadImageObservationSchema,
nativeLoadImageSchema,
} from "../domain/native/nativeLoadImage.js";
import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js";
import { parseGhidraInventoryResult } from "./GhidraInventoryValues.js";
import { prepareGhidraDosLoadImage } from "./GhidraLoadImageValues.js";
it("does not interpret an ambiguous Windows-header candidate as a DOS image", () => {
@@ -229,6 +234,103 @@ const damage: ReadonlyArray<
},
],
];
const malformedCoordinates: ReadonlyArray<
readonly [string, (observation: NativeLoadImageObservation) => void]
> = [
["image base", (value) => (value.image_base = "not-an-address")],
[
"mapping start",
(value) => {
const mapping = value.mappings[0];
if (mapping) mapping.start = "not-an-address";
},
],
[
"mapping end",
(value) => {
const mapping = value.mappings[0];
if (mapping) mapping.end = "not-an-address";
},
],
[
"relocation address",
(value) => {
const relocation = value.relocations[0];
if (relocation) relocation.address = "not-an-address";
},
],
["entry point", (value) => (value.entry_points[0] = "not-an-address")],
[
"entry context address",
(value) => {
value.entry_context.push({ address: "not-an-address", registers: [] });
},
],
];
it.each(malformedCoordinates)(
"rejects a malformed nonempty %s at both native load-image boundaries",
(_name, mutate) => {
const observation = structuredClone(fixtureDosLoadImage().observation);
mutate(observation);
expect({
shared: nativeLoadImageObservationSchema.safeParse(observation).success,
provider: parseGhidraInventoryResult(
"inspect_native_load_image",
jsonValueSchema.parse(observation),
).ok,
}).toEqual({ shared: false, provider: false });
},
);
it("preserves zero-padded, qualified, and large observation addresses", () => {
const observation = structuredClone(fixtureDosLoadImage().observation);
observation.image_base = "0x0000";
const mapping = observation.mappings[0];
const relocation = observation.relocations[0];
if (!mapping || !relocation) throw new Error("Incomplete load-image fixture");
mapping.start = "ram%2Fbank:0x0000000000000001";
mapping.end = "ram%2Fbank:0x10000000000000001";
relocation.address = "0x10000000000000009";
observation.entry_points[0] = "ram:0x10000000000000001";
const parsed = parseGhidraInventoryResult(
"inspect_native_load_image",
jsonValueSchema.parse(observation),
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual(observation);
});
it("rejects malformed coordinates in completed load-image checks and entries", () => {
const fixture = fixtureDosLoadImage();
const compared = compareDosFixture(
fixture.bytes,
fixture.sha256,
fixture.observation,
);
if (!compared.ok || compared.value.status === "unsupported")
throw new Error("Expected a completed DOS load-image result");
const malformedResults = [
{
...compared.value,
entry: { ...compared.value.entry, linear_address: "not-an-address" },
},
{
...compared.value,
checks: compared.value.checks.map((check, index) =>
index === 0 ? { ...check, address: "not-an-address" } : check,
),
},
];
expect(
malformedResults.map(
(result) => nativeLoadImageSchema.safeParse(result).success,
),
).toEqual([false, false]);
});
describe("independent DOS MZ import verification", () => {
it("verifies every initialized source byte while retaining overlay and BSS scope", () => {
const fixture = fixtureDosLoadImage();
+9 -2
View File
@@ -1478,10 +1478,13 @@ describe("Ghidra measured raw load-image projection", () => {
"mapping-mismatch",
"changed-snapshot",
"cancelled",
"malformed-coordinate",
] as const)(
"streams raw snapshot identity and preserves %s outcomes",
async (state) => {
const fixture = fixtureRawBinaryLoadImage();
if (state === "malformed-coordinate")
fixture.observation.image_base = "not-an-address";
if (state === "mapping-mismatch") {
const mapping = fixture.observation.mappings[0];
if (mapping === undefined)
@@ -1542,7 +1545,9 @@ describe("Ghidra measured raw load-image projection", () => {
{},
{ signal: controller.signal },
);
expect(signals).toEqual([controller.signal]);
expect(signals).toEqual(
state === "malformed-coordinate" ? [] : [controller.signal],
);
if (state === "verified" || state === "mapping-mismatch") {
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
@@ -1567,7 +1572,9 @@ describe("Ghidra measured raw load-image projection", () => {
_tag:
state === "cancelled"
? "AnalysisCancelledError"
: "ProviderAdapterError",
: state === "malformed-coordinate"
? "AnalysisOutputError"
: "ProviderAdapterError",
},
});
}
@@ -14,6 +14,10 @@ import {
managedNativeVerificationResultSchema,
} from "../../../src/domain/managed/managedNativeVerificationSchemas.js";
import { nativeUiResultSchema } from "../../../src/domain/native/nativeUiObservation.js";
import { nativeLoadImageSchema } from "../../../src/domain/native/nativeLoadImage.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
import { verifyManagedNativeBoundaries } from "../../../src/domain/managed/managedNativeVerification.js";
@@ -186,3 +190,80 @@ it("advertises semantic output invariants and keeps producers aligned with Zod",
await Promise.allSettled([client.close(), server.close()]);
}
});
mcpTest(
"advertises and enforces the observed load-image coordinate grammar",
async ({ mcp }) => {
const contract = toolContract("inspect_native_load_image");
const server = new McpServer({
name: "load-image-coordinates",
version: "1",
});
const image = {
status: "unsupported",
reason: "No format-specific comparison was performed",
observations: {
executable_format: "ELF",
language_id: "AARCH64:LE:64:v8A",
compiler_spec_id: "default",
image_base: "ram%2Fbank:0x0000",
default_address_space: "ram",
source_files: [],
mappings: [],
relocations: [],
entry_points: ["0x10000000000000001"],
entry_context: [],
},
limitations: [],
};
const evidence = (result: JsonValue) =>
createEvidence(
undefined,
{ id: "ghidra", name: "Controlled bridge result", version: "12.1.4" },
{ operation: contract.name, parameters: {}, result },
);
let reply = evidence(image);
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({
content: [],
structuredContent: reply,
}),
);
const client = await mcp.connect(server);
const advertised = (await client.listTools()).tools.find(
({ name }) => name === contract.name,
);
if (advertised?.outputSchema === undefined)
throw new Error("Load-image output schema missing");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const outputSchema = z
.record(z.string(), z.unknown())
.parse(advertised.outputSchema);
expect(ajv.validateSchema(outputSchema)).toBe(true);
const validate = ajv.compile(outputSchema);
expect(nativeLoadImageSchema.parse(image)).toEqual(image);
expect(validate(reply)).toBe(true);
const accepted = await client.callTool({
name: contract.name,
arguments: {},
});
expect(accepted.isError).not.toBe(true);
expect(accepted.structuredContent).toEqual(reply);
const malformed = {
...image,
observations: { ...image.observations, image_base: "not-an-address" },
};
reply = evidence(malformed);
expect(nativeLoadImageSchema.safeParse(malformed).success).toBe(false);
expect(validate(reply)).toBe(false);
const rejected = await client.callTool({
name: contract.name,
arguments: {},
});
expect(rejected.isError).toBe(true);
expect(rejected.structuredContent).toBeUndefined();
},
);