mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
refactor(ghidra): consolidate function and inventory admission
This commit is contained in:
1 parent
b45a83368c
commit
d9908fd7a4
10 files changed
+678
-366
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { address, document } from "./toolContractHelpers.js";
|
||||
import {
|
||||
MAX_BYTE_SEARCH_MATCHES,
|
||||
bytePatternFields,
|
||||
validateBytePattern,
|
||||
} from "../domain/native/bytePattern.js";
|
||||
@@ -17,7 +18,7 @@ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [
|
||||
document,
|
||||
start: address.optional(),
|
||||
end: address.optional(),
|
||||
limit: z.int().min(1).max(1000).default(100),
|
||||
limit: z.int().min(1).max(MAX_BYTE_SEARCH_MATCHES).default(100),
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
.superRefine(validateBytePattern),
|
||||
|
||||
+107
-102
@@ -88,87 +88,88 @@ export const analysisBookmarkSchema = z.strictObject({
|
||||
name: z.string().nullable(),
|
||||
});
|
||||
/** Complete observed function-body ranges; every range endpoint is inclusive. */
|
||||
export const functionBodySchema = z.discriminatedUnion("available", [
|
||||
unavailableAnalysisFactSchema,
|
||||
z
|
||||
.strictObject({
|
||||
available: z.literal(true),
|
||||
provenance: z.string().min(1),
|
||||
ranges: z.array(
|
||||
z.strictObject({
|
||||
start: z.string(),
|
||||
end: z
|
||||
.string()
|
||||
.describe("Inclusive last address of this observed body range."),
|
||||
}),
|
||||
),
|
||||
total_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
span_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
|
||||
non_contiguous: z.boolean(),
|
||||
contains_entry: z.boolean(),
|
||||
})
|
||||
.superRefine((body, context) => {
|
||||
let total = 0n;
|
||||
const bounds = new Map<string | null, { start: bigint; end: bigint }>();
|
||||
const closedSpaces = new Set<string | null>();
|
||||
let previousSpace: string | null | undefined;
|
||||
for (const range of body.ranges) {
|
||||
const start = addressCoordinate(range.start);
|
||||
const end = addressCoordinate(range.end);
|
||||
if (
|
||||
start === null ||
|
||||
end === null ||
|
||||
start.space !== end.space ||
|
||||
end.offset < start.offset
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Function body range has invalid inclusive endpoints",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (previousSpace !== undefined && previousSpace !== start.space)
|
||||
closedSpaces.add(previousSpace);
|
||||
const previous = bounds.get(start.space);
|
||||
if (
|
||||
closedSpaces.has(start.space) ||
|
||||
(previous !== undefined && start.offset <= previous.end + 1n)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Function body ranges must be ordered, disjoint and maximal within each address space",
|
||||
});
|
||||
return;
|
||||
}
|
||||
bounds.set(start.space, {
|
||||
start: previous?.start ?? start.offset,
|
||||
end: end.offset,
|
||||
});
|
||||
previousSpace = start.space;
|
||||
total += end.offset - start.offset + 1n;
|
||||
}
|
||||
const onlyBounds =
|
||||
bounds.size === 1 ? [...bounds.values()][0] : undefined;
|
||||
const span =
|
||||
bounds.size === 0
|
||||
? 0n
|
||||
: onlyBounds === undefined
|
||||
? null
|
||||
: onlyBounds.end - onlyBounds.start + 1n;
|
||||
const reportedSpan =
|
||||
body.span_bytes === null ? null : BigInt(body.span_bytes);
|
||||
export const availableFunctionBodySchema = z
|
||||
.strictObject({
|
||||
available: z.literal(true),
|
||||
provenance: z.string().min(1),
|
||||
ranges: z.array(
|
||||
z.strictObject({
|
||||
start: z.string(),
|
||||
end: z
|
||||
.string()
|
||||
.describe("Inclusive last address of this observed body range."),
|
||||
}),
|
||||
),
|
||||
total_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
span_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
|
||||
non_contiguous: z.boolean(),
|
||||
contains_entry: z.boolean(),
|
||||
})
|
||||
.superRefine((body, context) => {
|
||||
let total = 0n;
|
||||
const bounds = new Map<string | null, { start: bigint; end: bigint }>();
|
||||
const closedSpaces = new Set<string | null>();
|
||||
let previousSpace: string | null | undefined;
|
||||
for (const range of body.ranges) {
|
||||
const start = addressCoordinate(range.start);
|
||||
const end = addressCoordinate(range.end);
|
||||
if (
|
||||
total !== BigInt(body.total_bytes) ||
|
||||
span !== reportedSpan ||
|
||||
body.non_contiguous !== body.ranges.length > 1
|
||||
)
|
||||
start === null ||
|
||||
end === null ||
|
||||
start.space !== end.space ||
|
||||
end.offset < start.offset
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Function body range has invalid inclusive endpoints",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (previousSpace !== undefined && previousSpace !== start.space)
|
||||
closedSpaces.add(previousSpace);
|
||||
const previous = bounds.get(start.space);
|
||||
if (
|
||||
closedSpaces.has(start.space) ||
|
||||
(previous !== undefined && start.offset <= previous.end + 1n)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Function body counts, enclosing span or contiguity disagree with inclusive ranges",
|
||||
"Function body ranges must be ordered, disjoint and maximal within each address space",
|
||||
});
|
||||
}),
|
||||
return;
|
||||
}
|
||||
bounds.set(start.space, {
|
||||
start: previous?.start ?? start.offset,
|
||||
end: end.offset,
|
||||
});
|
||||
previousSpace = start.space;
|
||||
total += end.offset - start.offset + 1n;
|
||||
}
|
||||
const onlyBounds = bounds.size === 1 ? [...bounds.values()][0] : undefined;
|
||||
const span =
|
||||
bounds.size === 0
|
||||
? 0n
|
||||
: onlyBounds === undefined
|
||||
? null
|
||||
: onlyBounds.end - onlyBounds.start + 1n;
|
||||
const reportedSpan =
|
||||
body.span_bytes === null ? null : BigInt(body.span_bytes);
|
||||
if (
|
||||
total !== BigInt(body.total_bytes) ||
|
||||
span !== reportedSpan ||
|
||||
body.non_contiguous !== body.ranges.length > 1
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Function body counts, enclosing span or contiguity disagree with inclusive ranges",
|
||||
});
|
||||
});
|
||||
|
||||
export const functionBodySchema = z.discriminatedUnion("available", [
|
||||
unavailableAnalysisFactSchema,
|
||||
availableFunctionBodySchema,
|
||||
]);
|
||||
|
||||
/** Check the declared entry membership against complete, inclusive body ranges. */
|
||||
@@ -325,23 +326,25 @@ const swiftStringLiteralFields = {
|
||||
};
|
||||
|
||||
/** A Swift String literal decoded from the two words of its ABI value, not a data reference. */
|
||||
export const swiftSmallStringLiteralSchema = z.strictObject({
|
||||
...swiftStringLiteralFields,
|
||||
representation: z
|
||||
.literal("small")
|
||||
.describe("The UTF-8 bytes are inside the two words."),
|
||||
address: z.null().describe("A small literal has no storage of its own."),
|
||||
});
|
||||
export const swiftLargeStringLiteralSchema = z.strictObject({
|
||||
...swiftStringLiteralFields,
|
||||
representation: z
|
||||
.literal("large")
|
||||
.describe(
|
||||
"An immortal literal whose words hold its count and a pointer to its bytes.",
|
||||
),
|
||||
address: z.string().describe("Address of a large literal's bytes."),
|
||||
});
|
||||
export const swiftStringLiteralSchema = z.discriminatedUnion("representation", [
|
||||
z.strictObject({
|
||||
...swiftStringLiteralFields,
|
||||
representation: z
|
||||
.literal("small")
|
||||
.describe("The UTF-8 bytes are inside the two words."),
|
||||
address: z.null().describe("A small literal has no storage of its own."),
|
||||
}),
|
||||
z.strictObject({
|
||||
...swiftStringLiteralFields,
|
||||
representation: z
|
||||
.literal("large")
|
||||
.describe(
|
||||
"An immortal literal whose words hold its count and a pointer to its bytes.",
|
||||
),
|
||||
address: z.string().describe("Address of a large literal's bytes."),
|
||||
}),
|
||||
swiftSmallStringLiteralSchema,
|
||||
swiftLargeStringLiteralSchema,
|
||||
]);
|
||||
|
||||
const swiftStringLiteralOmissionFields = {
|
||||
@@ -357,19 +360,21 @@ const swiftStringLiteralOmissionFields = {
|
||||
};
|
||||
|
||||
/** A Swift literal candidate omitted because decoding capacity or control flow left it unknown. */
|
||||
export const swiftSmallStringLiteralOmissionSchema = z.strictObject({
|
||||
...swiftStringLiteralOmissionFields,
|
||||
representation: z.literal("small"),
|
||||
address: z.null(),
|
||||
});
|
||||
export const swiftLargeStringLiteralOmissionSchema = z.strictObject({
|
||||
...swiftStringLiteralOmissionFields,
|
||||
representation: z.literal("large"),
|
||||
address: z.string().describe("Address of the literal's bytes."),
|
||||
});
|
||||
export const swiftStringLiteralOmissionSchema = z.discriminatedUnion(
|
||||
"representation",
|
||||
[
|
||||
z.strictObject({
|
||||
...swiftStringLiteralOmissionFields,
|
||||
representation: z.literal("small"),
|
||||
address: z.null(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...swiftStringLiteralOmissionFields,
|
||||
representation: z.literal("large"),
|
||||
address: z.string().describe("Address of the literal's bytes."),
|
||||
}),
|
||||
swiftSmallStringLiteralOmissionSchema,
|
||||
swiftLargeStringLiteralOmissionSchema,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { addressCoordinate } from "./addressCoordinate.js";
|
||||
import { AnalysisInputError } from "../analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
|
||||
/** Maximum matches delivered by one byte-search page. */
|
||||
export const MAX_BYTE_SEARCH_MATCHES = 1000;
|
||||
|
||||
/** Exact grammar belongs to the object refinement, not a repeating advertised regex. */
|
||||
export const bytePatternFields = {
|
||||
pattern: z
|
||||
@@ -152,7 +155,7 @@ export const byteSearchResultSchema = z
|
||||
.nullable(),
|
||||
}),
|
||||
)
|
||||
.max(1000),
|
||||
.max(MAX_BYTE_SEARCH_MATCHES),
|
||||
searched: z.strictObject({
|
||||
blocks: z.array(coverageBlock),
|
||||
bytes: z.int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
|
||||
@@ -87,7 +87,7 @@ const inferredJumpTableSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const availableNativeApiBoundarySchema = z
|
||||
export const availableNativeApiBoundarySchema = z
|
||||
.object({
|
||||
available: z.literal(true),
|
||||
provenance: z.string().min(1),
|
||||
@@ -115,7 +115,7 @@ const availableNativeApiBoundarySchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const unavailableNativeApiBoundarySchema = z
|
||||
export const unavailableNativeApiBoundarySchema = z
|
||||
.object({
|
||||
available: z.literal(false),
|
||||
reason: z.string().min(1),
|
||||
|
||||
@@ -46,36 +46,38 @@ export const nativePcodeEffectSchema = z.strictObject({
|
||||
});
|
||||
|
||||
/** Bounded data-flow observations; STORE is not necessarily a persistent/global write. */
|
||||
export const availableNativeValueFlowSchema = z.strictObject({
|
||||
available: z.literal(true),
|
||||
provenance: z.literal("ghidra-high-pcode"),
|
||||
operations: z.array(nativePcodeOperationSchema),
|
||||
def_use: z.array(nativePcodeDefUseSchema),
|
||||
effects: z.array(nativePcodeEffectSchema),
|
||||
parameters: z.array(
|
||||
z.strictObject({
|
||||
ordinal: z.int().nonnegative(),
|
||||
name: z.string(),
|
||||
data_type: z.string(),
|
||||
}),
|
||||
),
|
||||
parameter_uses: z.array(
|
||||
z.strictObject({
|
||||
ordinal: z.int().nonnegative(),
|
||||
use: z.string(),
|
||||
input_index: z.int().nonnegative(),
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean(),
|
||||
omitted_operations_lower_bound: z.int().nonnegative(),
|
||||
known_omitted_inputs: z.int().nonnegative(),
|
||||
known_omitted_edges: z.int().nonnegative(),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
});
|
||||
export const unavailableNativeValueFlowSchema = z.strictObject({
|
||||
available: z.literal(false),
|
||||
reason: z.string().min(1),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
});
|
||||
export const nativeValueFlowSchema = z.discriminatedUnion("available", [
|
||||
z.strictObject({
|
||||
available: z.literal(true),
|
||||
provenance: z.literal("ghidra-high-pcode"),
|
||||
operations: z.array(nativePcodeOperationSchema),
|
||||
def_use: z.array(nativePcodeDefUseSchema),
|
||||
effects: z.array(nativePcodeEffectSchema),
|
||||
parameters: z.array(
|
||||
z.strictObject({
|
||||
ordinal: z.int().nonnegative(),
|
||||
name: z.string(),
|
||||
data_type: z.string(),
|
||||
}),
|
||||
),
|
||||
parameter_uses: z.array(
|
||||
z.strictObject({
|
||||
ordinal: z.int().nonnegative(),
|
||||
use: z.string(),
|
||||
input_index: z.int().nonnegative(),
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean(),
|
||||
omitted_operations_lower_bound: z.int().nonnegative(),
|
||||
known_omitted_inputs: z.int().nonnegative(),
|
||||
known_omitted_edges: z.int().nonnegative(),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
}),
|
||||
z.strictObject({
|
||||
available: z.literal(false),
|
||||
reason: z.string().min(1),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
}),
|
||||
availableNativeValueFlowSchema,
|
||||
unavailableNativeValueFlowSchema,
|
||||
]);
|
||||
@@ -1121,3 +1121,208 @@ describe("Ghidra bridge script processor context contract", () => {
|
||||
expect(content).toContain('"ghidra-program-context"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra dossier nested boundary composition", () => {
|
||||
const identityLocations = [
|
||||
"caller",
|
||||
"callee",
|
||||
"incoming source",
|
||||
"incoming target",
|
||||
"outgoing source",
|
||||
"outgoing target",
|
||||
] as const;
|
||||
const withIdentity = (
|
||||
location: (typeof identityLocations)[number],
|
||||
identity: JsonValue,
|
||||
): JsonValue => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
if (location === "caller") return { ...dossier, callers: [identity] };
|
||||
if (location === "callee") return { ...dossier, callees: [identity] };
|
||||
const collection = location.startsWith("incoming")
|
||||
? "incoming_references"
|
||||
: "outgoing_references";
|
||||
const field = location.endsWith("source")
|
||||
? "source_procedure"
|
||||
: "target_procedure";
|
||||
return {
|
||||
...dossier,
|
||||
[collection]: [{ ...ghidraReferenceEdge(), [field]: identity }],
|
||||
};
|
||||
};
|
||||
|
||||
it.each(identityLocations)(
|
||||
"retains the dossier's optional symbol and empty name on a nested %s identity",
|
||||
(location) => {
|
||||
const { symbol: omitted, ...identity } = ghidraFunctionIdentity();
|
||||
expect(omitted).toBeDefined();
|
||||
const value = withIdentity(location, { ...identity, name: "" });
|
||||
expect(
|
||||
parseFunctionValueFixture("analyze_function", value),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(identityLocations)(
|
||||
"rejects noncanonical address and foreign body/classification provenance on a nested %s identity",
|
||||
(location) => {
|
||||
const identity = ghidraFunctionIdentity();
|
||||
for (const change of [
|
||||
{ address: "401000" },
|
||||
{ body: { ...identity.body, provenance: "foreign-provider" } },
|
||||
{ body: { ...identity.body, contains_entry: false } },
|
||||
{
|
||||
classification: {
|
||||
...identity.classification,
|
||||
provenance: "foreign-provider",
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(
|
||||
parseFunctionValueFixture(
|
||||
"analyze_function",
|
||||
withIdentity(location, { ...identity, ...change }),
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["incoming_references", "outgoing_references"] as const)(
|
||||
"requires Ghidra reference-manager provenance inside %s",
|
||||
(collection) => {
|
||||
const edge = ghidraReferenceEdge();
|
||||
expect(
|
||||
parseFunctionValueFixture("analyze_function", {
|
||||
...functionDossierSchema.parse(ghidraFunctionDossier()),
|
||||
[collection]: [
|
||||
{
|
||||
...edge,
|
||||
kind: { ...edge.kind, provenance: "hopper-public-python-api" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "comment address",
|
||||
fixture: (address: string) => ({
|
||||
comments: [{ address, kind: "comment", text: "observed comment" }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "basic-block start",
|
||||
fixture: (address: string) => ({
|
||||
basic_blocks: [{ start: address, end: "0x401006", successors: [] }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "basic-block successor",
|
||||
fixture: (address: string) => ({
|
||||
basic_blocks: [
|
||||
{ start: "0x401000", end: "0x401006", successors: [address] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "referenced name source",
|
||||
fixture: (address: string) => ({
|
||||
referenced_names: [
|
||||
{ address: "0x402000", source_address: address, value: "observed" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "referenced string source",
|
||||
fixture: (address: string) => ({
|
||||
referenced_strings: [
|
||||
{ address: "0x402000", source_address: address, value: "observed" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
])("requires a canonical $name", ({ fixture }) => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
expect(
|
||||
parseFunctionValueFixture("analyze_function", {
|
||||
...dossier,
|
||||
...fixture("0x401000"),
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
parseFunctionValueFixture("analyze_function", {
|
||||
...dossier,
|
||||
...fixture("401000"),
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra native facet coordinate admission", () => {
|
||||
it.each(["dispatch", "data source"] as const)(
|
||||
"requires canonical jump-table %s coordinates",
|
||||
(location) => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const boundary = dossier.native_api;
|
||||
if (boundary?.available !== true)
|
||||
throw new Error("Missing native API fixture");
|
||||
const table = boundary.jump_tables[0];
|
||||
const source = table?.data_sources[0];
|
||||
if (table === undefined || source === undefined)
|
||||
throw new Error("Missing jump-table fixture");
|
||||
expect(parseFunctionValueFixture("analyze_function", dossier).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
parseFunctionValueFixture("analyze_function", {
|
||||
...dossier,
|
||||
native_api: {
|
||||
...boundary,
|
||||
jump_tables: [
|
||||
{
|
||||
...table,
|
||||
...(location === "dispatch"
|
||||
? { dispatch_address: "401010" }
|
||||
: { data_sources: [{ ...source, address: "403000" }] }),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["operation", "input", "output"] as const)(
|
||||
"requires canonical p-code %s coordinates without inventing null locations",
|
||||
(location) => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const flow = dossier.native_value_flow;
|
||||
if (flow?.available !== true) throw new Error("Missing p-code fixture");
|
||||
const operation = flow.operations[0];
|
||||
const input = operation?.inputs[0];
|
||||
const output = operation?.output;
|
||||
if (operation === undefined || input === undefined || output == null)
|
||||
throw new Error("Missing p-code operation fixture");
|
||||
for (const address of ["register:0x0", null, "register:0"] as const) {
|
||||
if (location === "operation" && address === null) continue;
|
||||
const changed = {
|
||||
...operation,
|
||||
...(location === "operation"
|
||||
? { address }
|
||||
: location === "input"
|
||||
? { inputs: [{ ...input, location: address }] }
|
||||
: { output: { ...output, location: address } }),
|
||||
};
|
||||
expect(
|
||||
parseFunctionValueFixture("analyze_function", {
|
||||
...dossier,
|
||||
native_value_flow: { ...flow, operations: [changed] },
|
||||
}).ok,
|
||||
).toBe(address !== "register:0");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js";
|
||||
import { procedureInfoSchema } from "../domain/native/procedureInfo.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ghidraNativeApiBoundary,
|
||||
ghidraNativeValueFlow,
|
||||
} from "./GhidraNativeAnalysisValues.js";
|
||||
import {
|
||||
nativeFunctionAnnotationsInputSchema,
|
||||
nativeFunctionAnnotationsSchema,
|
||||
@@ -20,15 +24,17 @@ import {
|
||||
functionInstructionWindowSchema,
|
||||
functionDossierSchema,
|
||||
availableReferenceKindSchema,
|
||||
type FunctionDossier,
|
||||
unresolvedCallSchema,
|
||||
swiftSmallStringLiteralSchema,
|
||||
swiftLargeStringLiteralSchema,
|
||||
swiftSmallStringLiteralOmissionSchema,
|
||||
swiftLargeStringLiteralOmissionSchema,
|
||||
} from "../domain/hopperValues.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../domain/jsonValue.js";
|
||||
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
|
||||
import { nativeValueFlowSchema } from "../domain/native/nativeValueFlow.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import {
|
||||
relationshipCoverageSchema,
|
||||
@@ -42,6 +48,7 @@ import {
|
||||
ghidraFunctionClassificationSchema as classification,
|
||||
ghidraFunctionBodySchema as ghidraFunctionBody,
|
||||
ghidraProcedureIdentitySchema as procedureIdentity,
|
||||
ghidraFunctionIdentitySchema as dossierProcedureIdentity,
|
||||
} from "./GhidraInventoryValues.js";
|
||||
|
||||
/** Function analysis and annotation operations admitted by the Ghidra adapter. */
|
||||
@@ -190,92 +197,82 @@ const procedureInfo = procedureInfoSchema
|
||||
});
|
||||
});
|
||||
|
||||
const ghidraNativeApiBoundary = nativeApiBoundarySchema.superRefine(
|
||||
(value, context) => {
|
||||
if (!value.available) return;
|
||||
if (value.provenance !== "ghidra-high-function")
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Ghidra native API observations require HighFunction provenance",
|
||||
});
|
||||
const addresses = value.jump_tables.flatMap((table) => [
|
||||
table.dispatch_address,
|
||||
...table.data_sources.map(({ address }) => address),
|
||||
...table.mappings.map(({ target_address }) => target_address),
|
||||
...table.default_targets.map(({ target_address }) => target_address),
|
||||
]);
|
||||
if (
|
||||
addresses.some(
|
||||
(address) => !nativeAddressObservationSchema.safeParse(address).success,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Ghidra native API observation contains a non-canonical address",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const ghidraNativeValueFlow = nativeValueFlowSchema.superRefine(
|
||||
(value, context) => {
|
||||
if (!value.available) return;
|
||||
const operations = value.operations;
|
||||
const addresses = operations.flatMap((operation) => [
|
||||
operation.address,
|
||||
...operation.inputs.flatMap(({ location }) =>
|
||||
location === null ? [] : [location],
|
||||
),
|
||||
...(operation.output?.location == null
|
||||
? []
|
||||
: [operation.output.location]),
|
||||
]);
|
||||
if (
|
||||
addresses.some(
|
||||
(address) => !nativeAddressObservationSchema.safeParse(address).success,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code flow contains a non-canonical address",
|
||||
});
|
||||
const operationIds = new Set(operations.map(({ id }) => id));
|
||||
if (operationIds.size !== operations.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code operation identifiers are not unique",
|
||||
});
|
||||
if (
|
||||
value.def_use.some(
|
||||
({ definition, use }) =>
|
||||
!operationIds.has(definition) || !operationIds.has(use),
|
||||
) ||
|
||||
value.effects.some(({ operation }) => !operationIds.has(operation))
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code relationship references a missing operation",
|
||||
});
|
||||
if (
|
||||
value.truncated !==
|
||||
(value.omitted_operations_lower_bound > 0 ||
|
||||
value.known_omitted_inputs > 0 ||
|
||||
value.known_omitted_edges > 0)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code truncation flag contradicts omitted counts",
|
||||
});
|
||||
},
|
||||
);
|
||||
const dossierReferenceEdge =
|
||||
functionDossierSchema.shape.incoming_references.element.extend({
|
||||
source_address: nativeAddressObservationSchema,
|
||||
target_address: nativeAddressObservationSchema,
|
||||
source_procedure: dossierProcedureIdentity.nullable(),
|
||||
target_procedure: dossierProcedureIdentity.nullable(),
|
||||
kind: referenceKind,
|
||||
});
|
||||
|
||||
const ghidraFunctionDossier = functionDossierSchema
|
||||
.extend({
|
||||
procedure: functionDossierSchema.shape.procedure.safeExtend({
|
||||
classification,
|
||||
body: ghidraFunctionBody,
|
||||
procedure: dossierProcedureIdentity.safeExtend({
|
||||
signature: functionDossierSchema.shape.procedure.shape.signature,
|
||||
locals: z.array(localVariable),
|
||||
}),
|
||||
comments: z.array(
|
||||
functionDossierSchema.shape.comments.element.extend({
|
||||
address: nativeAddressObservationSchema,
|
||||
}),
|
||||
),
|
||||
callers: z.array(dossierProcedureIdentity),
|
||||
callees: z.array(dossierProcedureIdentity),
|
||||
incoming_references: z.array(dossierReferenceEdge),
|
||||
outgoing_references: z.array(dossierReferenceEdge),
|
||||
unresolved_calls: z
|
||||
.array(
|
||||
unresolvedCallSchema.extend({
|
||||
address: nativeAddressObservationSchema,
|
||||
}),
|
||||
)
|
||||
.exactOptional(),
|
||||
referenced_strings: z.array(
|
||||
functionDossierSchema.shape.referenced_strings.element.extend({
|
||||
address: nativeAddressObservationSchema,
|
||||
source_address: nativeAddressObservationSchema,
|
||||
}),
|
||||
),
|
||||
referenced_names: z.array(
|
||||
functionDossierSchema.shape.referenced_names.element.extend({
|
||||
address: nativeAddressObservationSchema,
|
||||
source_address: nativeAddressObservationSchema,
|
||||
}),
|
||||
),
|
||||
swift_string_literals: z
|
||||
.array(
|
||||
z.discriminatedUnion("representation", [
|
||||
swiftSmallStringLiteralSchema.extend({
|
||||
source_address: nativeAddressObservationSchema,
|
||||
}),
|
||||
swiftLargeStringLiteralSchema.extend({
|
||||
source_address: nativeAddressObservationSchema,
|
||||
address: nativeAddressObservationSchema,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.exactOptional(),
|
||||
swift_string_literal_omissions: z
|
||||
.array(
|
||||
z.discriminatedUnion("representation", [
|
||||
swiftSmallStringLiteralOmissionSchema.extend({
|
||||
source_address: nativeAddressObservationSchema,
|
||||
}),
|
||||
swiftLargeStringLiteralOmissionSchema.extend({
|
||||
source_address: nativeAddressObservationSchema,
|
||||
address: nativeAddressObservationSchema,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.exactOptional(),
|
||||
basic_blocks: z.array(
|
||||
functionDossierSchema.shape.basic_blocks.element.extend({
|
||||
start: nativeAddressObservationSchema,
|
||||
end: nativeAddressObservationSchema,
|
||||
successors: z.array(nativeAddressObservationSchema),
|
||||
}),
|
||||
),
|
||||
native_api: ghidraNativeApiBoundary,
|
||||
native_value_flow: ghidraNativeValueFlow,
|
||||
})
|
||||
@@ -290,79 +287,6 @@ const ghidraFunctionDossier = functionDossierSchema
|
||||
message:
|
||||
"Ghidra dossier must record Swift literals and their omissions together",
|
||||
});
|
||||
const addresses = [
|
||||
value.procedure.address,
|
||||
...value.comments.map(({ address }) => address),
|
||||
...value.callers.map(({ address }) => address),
|
||||
...value.callees.map(({ address }) => address),
|
||||
...(value.unresolved_calls ?? []).map(({ address }) => address),
|
||||
...value.incoming_references.flatMap(referenceAddresses),
|
||||
...value.outgoing_references.flatMap(referenceAddresses),
|
||||
...value.referenced_strings.flatMap(({ address, source_address }) => [
|
||||
address,
|
||||
source_address,
|
||||
]),
|
||||
...value.referenced_names.flatMap(({ address, source_address }) => [
|
||||
address,
|
||||
source_address,
|
||||
]),
|
||||
...(value.swift_string_literals ?? []).flatMap(
|
||||
({ address, source_address }) =>
|
||||
address === null ? [source_address] : [address, source_address],
|
||||
),
|
||||
...(value.swift_string_literal_omissions ?? []).flatMap(
|
||||
({ address, source_address }) =>
|
||||
address === null ? [source_address] : [address, source_address],
|
||||
),
|
||||
...value.basic_blocks.flatMap(({ start, end, successors }) => [
|
||||
start,
|
||||
end,
|
||||
...successors,
|
||||
]),
|
||||
];
|
||||
if (
|
||||
addresses.some(
|
||||
(address) => !nativeAddressObservationSchema.safeParse(address).success,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra dossier contains a non-canonical address",
|
||||
});
|
||||
const identities = [
|
||||
value.procedure,
|
||||
...value.callers,
|
||||
...value.callees,
|
||||
...value.incoming_references.flatMap(referenceProcedures),
|
||||
...value.outgoing_references.flatMap(referenceProcedures),
|
||||
];
|
||||
if (
|
||||
identities.some(
|
||||
(identity) =>
|
||||
!classification.safeParse(identity.classification).success ||
|
||||
!ghidraFunctionBody.safeParse(identity.body).success ||
|
||||
!functionBodyEntryAgrees(identity.body, identity.address) ||
|
||||
(identity.body.available &&
|
||||
identity.classification?.external === false &&
|
||||
!identity.body.contains_entry),
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra dossier omitted function classification",
|
||||
});
|
||||
if (
|
||||
value.procedure.locals.some(
|
||||
(variable) => !localVariable.safeParse(variable).success,
|
||||
) ||
|
||||
[...value.incoming_references, ...value.outgoing_references].some(
|
||||
({ kind }) => !referenceKind.safeParse(kind).success,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra dossier contains invalid provenance",
|
||||
});
|
||||
if (
|
||||
!value.limitations.some((item) => /indirect|computed/u.test(item)) ||
|
||||
!value.limitations.some((item) => /provider|Ghidra|Hopper/u.test(item))
|
||||
@@ -507,21 +431,3 @@ export const parseGhidraFunctionResult = (
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const referenceAddresses = (
|
||||
edge: FunctionDossier["incoming_references"][number],
|
||||
): readonly string[] => [
|
||||
edge.source_address,
|
||||
edge.target_address,
|
||||
...(edge.source_procedure === null ? [] : [edge.source_procedure.address]),
|
||||
...(edge.target_procedure === null ? [] : [edge.target_procedure.address]),
|
||||
];
|
||||
|
||||
const referenceProcedures = (
|
||||
edge: FunctionDossier["incoming_references"][number],
|
||||
): readonly NonNullable<
|
||||
FunctionDossier["incoming_references"][number]["source_procedure"]
|
||||
>[] => [
|
||||
...(edge.source_procedure === null ? [] : [edge.source_procedure]),
|
||||
...(edge.target_procedure === null ? [] : [edge.target_procedure]),
|
||||
];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
parseGhidraInventoryInput,
|
||||
@@ -256,3 +257,113 @@ describe("containing-procedure complete body evidence", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const byteSearch = (space = "") => ({
|
||||
matches: [
|
||||
{
|
||||
address: `${space}0x000a`,
|
||||
segment: "text",
|
||||
file_offset: 10,
|
||||
procedure: { address: `${space}0x0008`, name: "entry" },
|
||||
},
|
||||
],
|
||||
searched: {
|
||||
blocks: [
|
||||
{
|
||||
segment: "text",
|
||||
start: `${space}0x0000`,
|
||||
end: `${space}0x000f`,
|
||||
bytes: 16,
|
||||
},
|
||||
],
|
||||
bytes: 16,
|
||||
skipped_uninitialized: [
|
||||
{
|
||||
segment: "bss",
|
||||
start: `${space}0x0020`,
|
||||
end: `${space}0x002f`,
|
||||
bytes: 16,
|
||||
},
|
||||
],
|
||||
skipped_not_loaded: [
|
||||
{
|
||||
segment: "unloaded",
|
||||
start: `${space}0x0030`,
|
||||
end: `${space}0x003f`,
|
||||
bytes: 16,
|
||||
},
|
||||
],
|
||||
},
|
||||
complete: true,
|
||||
next_cursor: null as string | null,
|
||||
});
|
||||
|
||||
it.each(["", "ram%3Aoverlay:"])(
|
||||
"preserves padded byte-search coordinates in space %j",
|
||||
(space) => {
|
||||
const value = byteSearch(space);
|
||||
expect(parseGhidraInventoryResult("search_bytes", value)).toEqual({
|
||||
ok: true,
|
||||
value: { value },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["match", ["matches", 0, "address"]],
|
||||
["procedure", ["matches", 0, "procedure", "address"]],
|
||||
["block start", ["searched", "blocks", 0, "start"]],
|
||||
["block end", ["searched", "blocks", 0, "end"]],
|
||||
["uninitialized start", ["searched", "skipped_uninitialized", 0, "start"]],
|
||||
["uninitialized end", ["searched", "skipped_uninitialized", 0, "end"]],
|
||||
["unloaded start", ["searched", "skipped_not_loaded", 0, "start"]],
|
||||
["unloaded end", ["searched", "skipped_not_loaded", 0, "end"]],
|
||||
] as const)(
|
||||
"rejects noncanonical %s at its owning address field",
|
||||
(_label, path) => {
|
||||
const value = byteSearch("ram%3Aoverlay:");
|
||||
let object: unknown = value;
|
||||
for (const part of path.slice(0, -1))
|
||||
object = Reflect.get(object as object, part);
|
||||
const field = path.at(-1)!;
|
||||
const address: unknown = Reflect.get(object as object, field);
|
||||
if (typeof address !== "string")
|
||||
throw new Error("Expected an address fixture");
|
||||
Reflect.set(object as object, field, address.replace("%3A", "%3a"));
|
||||
const result = parseGhidraInventoryResult("search_bytes", value);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected canonical-address refusal");
|
||||
expect(result.error.cause).toBeInstanceOf(z.ZodError);
|
||||
if (!(result.error.cause instanceof z.ZodError))
|
||||
throw new Error("Expected field schema issues");
|
||||
expect(result.error.cause.issues).toContainEqual(
|
||||
expect.objectContaining({ path: [...path] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains byte-search match capacity and coverage/continuation rules", () => {
|
||||
const overCapacity = byteSearch();
|
||||
overCapacity.matches = Array.from(
|
||||
{ length: 1001 },
|
||||
() => overCapacity.matches[0]!,
|
||||
);
|
||||
expect(parseGhidraInventoryResult("search_bytes", overCapacity).ok).toBe(
|
||||
false,
|
||||
);
|
||||
overCapacity.matches.pop();
|
||||
expect(parseGhidraInventoryResult("search_bytes", overCapacity).ok).toBe(
|
||||
true,
|
||||
);
|
||||
const continuation = byteSearch();
|
||||
continuation.next_cursor = "next";
|
||||
expect(parseGhidraInventoryResult("search_bytes", continuation).ok).toBe(
|
||||
false,
|
||||
);
|
||||
const outside = byteSearch();
|
||||
outside.matches[0]!.address = "0x0010";
|
||||
expect(parseGhidraInventoryResult("search_bytes", outside).ok).toBe(false);
|
||||
const range = byteSearch();
|
||||
range.searched.skipped_not_loaded[0]!.bytes = 15;
|
||||
expect(parseGhidraInventoryResult("search_bytes", range).ok).toBe(false);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js";
|
||||
import { z } from "zod";
|
||||
import { jsonRecordSchema } from "../domain/jsonRecord.js";
|
||||
import {
|
||||
MAX_BYTE_SEARCH_MATCHES,
|
||||
bytePatternFields,
|
||||
validateBytePattern,
|
||||
byteSearchResultSchema,
|
||||
@@ -18,7 +19,8 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
functionBodySchema,
|
||||
availableFunctionBodySchema,
|
||||
procedureIdentitySchema,
|
||||
functionBodyEntryAgrees,
|
||||
projectBinaryOverview,
|
||||
} from "../domain/hopperValues.js";
|
||||
@@ -94,7 +96,7 @@ const inputSchemas = {
|
||||
document,
|
||||
start: explicitAddress.optional(),
|
||||
end: explicitAddress.optional(),
|
||||
limit: z.int().min(1).max(1000).default(100),
|
||||
limit: z.int().min(1).max(MAX_BYTE_SEARCH_MATCHES).default(100),
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
.superRefine(validateBytePattern),
|
||||
@@ -171,48 +173,38 @@ export const ghidraFunctionClassificationSchema = procedureFacts
|
||||
.extend({ provenance: z.literal("ghidra-function-manager") })
|
||||
.strict();
|
||||
/** Require complete canonical AddressSet evidence on every Ghidra function identity. */
|
||||
export const ghidraFunctionBodySchema = functionBodySchema.superRefine(
|
||||
(body, context) => {
|
||||
if (
|
||||
!body.available ||
|
||||
body.provenance !== "ghidra-function-body-address-set" ||
|
||||
body.ranges.some(
|
||||
({ start, end }) =>
|
||||
!nativeAddressObservationSchema.safeParse(start).success ||
|
||||
!nativeAddressObservationSchema.safeParse(end).success,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Ghidra omitted complete canonical function-body AddressSet evidence",
|
||||
});
|
||||
},
|
||||
);
|
||||
export const ghidraFunctionBodySchema = availableFunctionBodySchema.safeExtend({
|
||||
provenance: z.literal("ghidra-function-body-address-set"),
|
||||
ranges: z.array(
|
||||
availableFunctionBodySchema.shape.ranges.element.extend({
|
||||
start: nativeAddressObservationSchema,
|
||||
end: nativeAddressObservationSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** Strict provider identity shared by inventory and function-analysis results. */
|
||||
export const ghidraProcedureIdentitySchema = z
|
||||
.object({
|
||||
/** Canonical Ghidra identity, retaining the generic dossier's name and symbol admission. */
|
||||
export const ghidraFunctionIdentitySchema = procedureIdentitySchema
|
||||
.safeExtend({
|
||||
address: nativeAddressObservationSchema,
|
||||
name: z.string().min(1),
|
||||
symbol: nativeSymbolMetadataSchema.nullable(),
|
||||
classification: ghidraFunctionClassificationSchema,
|
||||
body: ghidraFunctionBodySchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
if (
|
||||
!functionBodyEntryAgrees(value.body, value.address) ||
|
||||
(value.body.available &&
|
||||
!value.classification.external &&
|
||||
!value.body.contains_entry)
|
||||
)
|
||||
if (!value.classification.external && !value.body.contains_entry)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra function body disagrees with its entry point",
|
||||
});
|
||||
});
|
||||
|
||||
/** Inventory identities require a nonempty name and an explicit symbol observation. */
|
||||
export const ghidraProcedureIdentitySchema =
|
||||
ghidraFunctionIdentitySchema.safeExtend({
|
||||
name: z.string().min(1),
|
||||
symbol: nativeSymbolMetadataSchema.nullable(),
|
||||
});
|
||||
|
||||
const stringFacts = z
|
||||
.object({
|
||||
encoding: z.string().min(1),
|
||||
@@ -293,6 +285,20 @@ const containingProcedure = z.discriminatedUnion("found", [
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
const byteMatch = byteSearchResultSchema.shape.matches.element;
|
||||
const canonicalByteMatch = byteMatch.safeExtend({
|
||||
address: nativeAddressObservationSchema,
|
||||
procedure: byteMatch.shape.procedure
|
||||
.unwrap()
|
||||
.safeExtend({ address: nativeAddressObservationSchema })
|
||||
.nullable(),
|
||||
});
|
||||
const byteCoverage = byteSearchResultSchema.shape.searched.shape;
|
||||
const canonicalByteCoverage = byteCoverage.blocks.element.safeExtend({
|
||||
start: nativeAddressObservationSchema,
|
||||
end: nativeAddressObservationSchema,
|
||||
});
|
||||
|
||||
const resultSchemas = {
|
||||
read_binary_inventory_summary: z
|
||||
.strictObject({
|
||||
@@ -302,27 +308,13 @@ const resultSchemas = {
|
||||
string_count: z.int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
})
|
||||
.transform(projectBinaryOverview),
|
||||
search_bytes: byteSearchResultSchema.superRefine((value, context) => {
|
||||
const addresses = [
|
||||
...value.matches.flatMap((match) => [
|
||||
match.address,
|
||||
...(match.procedure === null ? [] : [match.procedure.address]),
|
||||
]),
|
||||
...[
|
||||
...value.searched.blocks,
|
||||
...value.searched.skipped_uninitialized,
|
||||
...value.searched.skipped_not_loaded,
|
||||
].flatMap((block) => [block.start, block.end]),
|
||||
];
|
||||
if (
|
||||
addresses.some(
|
||||
(value) => !nativeAddressObservationSchema.safeParse(value).success,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Byte search addresses must be canonical",
|
||||
});
|
||||
search_bytes: byteSearchResultSchema.safeExtend({
|
||||
matches: z.array(canonicalByteMatch).max(MAX_BYTE_SEARCH_MATCHES),
|
||||
searched: byteSearchResultSchema.shape.searched.safeExtend({
|
||||
blocks: z.array(canonicalByteCoverage),
|
||||
skipped_uninitialized: z.array(canonicalByteCoverage),
|
||||
skipped_not_loaded: z.array(canonicalByteCoverage),
|
||||
}),
|
||||
}),
|
||||
inspect_native_load_image: nativeLoadImageObservationSchema,
|
||||
read_bytes: nativeByteReadSchema
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { z } from "zod";
|
||||
import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js";
|
||||
import {
|
||||
availableNativeApiBoundarySchema,
|
||||
unavailableNativeApiBoundarySchema,
|
||||
} from "../domain/native/nativeApiBoundary.js";
|
||||
import {
|
||||
availableNativeValueFlowSchema,
|
||||
unavailableNativeValueFlowSchema,
|
||||
nativePcodeOperationSchema,
|
||||
nativeVarnodeSchema,
|
||||
} from "../domain/native/nativeValueFlow.js";
|
||||
|
||||
const jumpTable = availableNativeApiBoundarySchema.shape.jump_tables.element;
|
||||
const ghidraJumpTable = jumpTable.extend({
|
||||
dispatch_address: nativeAddressObservationSchema,
|
||||
data_sources: z.array(
|
||||
jumpTable.shape.data_sources.element.extend({
|
||||
address: nativeAddressObservationSchema,
|
||||
}),
|
||||
),
|
||||
mappings: z.array(
|
||||
jumpTable.shape.mappings.element.extend({
|
||||
target_address: nativeAddressObservationSchema,
|
||||
}),
|
||||
),
|
||||
default_targets: z.array(
|
||||
jumpTable.shape.default_targets.element.extend({
|
||||
target_address: nativeAddressObservationSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** Ghidra API facts carry canonical coordinates and HighFunction provenance. */
|
||||
export const ghidraNativeApiBoundary = z.discriminatedUnion("available", [
|
||||
unavailableNativeApiBoundarySchema,
|
||||
availableNativeApiBoundarySchema.extend({
|
||||
provenance: z.literal("ghidra-high-function"),
|
||||
jump_tables: z.array(ghidraJumpTable),
|
||||
}),
|
||||
]);
|
||||
|
||||
const ghidraVarnode = nativeVarnodeSchema.extend({
|
||||
location: nativeAddressObservationSchema.nullable(),
|
||||
});
|
||||
const ghidraPcodeOperation = nativePcodeOperationSchema.extend({
|
||||
address: nativeAddressObservationSchema,
|
||||
inputs: z.array(ghidraVarnode),
|
||||
output: ghidraVarnode.nullable(),
|
||||
});
|
||||
|
||||
/** Validate relationships only after each p-code observation has been admitted. */
|
||||
export const ghidraNativeValueFlow = z.discriminatedUnion("available", [
|
||||
unavailableNativeValueFlowSchema,
|
||||
availableNativeValueFlowSchema
|
||||
.extend({ operations: z.array(ghidraPcodeOperation) })
|
||||
.superRefine((value, context) => {
|
||||
const { operations } = value;
|
||||
const operationIds = new Set(operations.map(({ id }) => id));
|
||||
if (operationIds.size !== operations.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code operation identifiers are not unique",
|
||||
});
|
||||
if (
|
||||
value.def_use.some(
|
||||
({ definition, use }) =>
|
||||
!operationIds.has(definition) || !operationIds.has(use),
|
||||
) ||
|
||||
value.effects.some(({ operation }) => !operationIds.has(operation))
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code relationship references a missing operation",
|
||||
});
|
||||
if (
|
||||
value.truncated !==
|
||||
(value.omitted_operations_lower_bound > 0 ||
|
||||
value.known_omitted_inputs > 0 ||
|
||||
value.known_omitted_edges > 0)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code truncation flag contradicts omitted counts",
|
||||
});
|
||||
}),
|
||||
]);
|
||||
Reference in new issue
Block a user