mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(managed): verify P/Invokes against portable native export Evidence
verify_managed_native_boundaries accepted only inspect_macho exports and one analyze_function dossier per function, so checking a library's P/Invokes on a non-macOS host took one engine call per declaration. It now also accepts inspect_native_exports Evidence: one record supplies a whole PE export directory or ELF dynamic symbol table. An exact name verifies only when a by-name lookup binds it; a PE forwarder or an ELF symbol with only a hidden or unreadable version is inferred with basis qualified-export-candidate and a limitation naming the reason. Ordinal-only PE exports match no declaration. Fixes #4057 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
65a3128873
commit
6be1c9e272
5 files changed
+263
-10
No files matched your search
@@ -415,6 +415,15 @@ function implements a specific managed method.
|
||||
`verify_managed_native_boundaries` compares declared P/Invoke module and entry
|
||||
point identities with supplied native export or function evidence. Name/module
|
||||
matches establish declaration links; they do not establish that a call executed.
|
||||
|
||||
Native observations may be `inspect_macho`, `analyze_function`, or
|
||||
`inspect_native_exports` Evidence. One `inspect_native_exports` record supplies
|
||||
a whole PE export directory or ELF dynamic symbol table, on any host. An exact
|
||||
name verifies only when it is what a by-name lookup binds: a PE export that
|
||||
forwards to another DLL, or an ELF symbol whose only version is hidden or whose
|
||||
version could not be read, is `inferred` with basis
|
||||
`qualified-export-candidate` and a limitation naming the reason. Ordinal-only
|
||||
PE exports have no name and match no declaration.
|
||||
When either side lacks a usable module identity, a matching name remains
|
||||
unresolved and retains the candidate evidence; REA reports a module mismatch
|
||||
only when both identities are available and differ.
|
||||
|
||||
@@ -99,7 +99,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [
|
||||
name: "verify_managed_native_boundaries",
|
||||
...toolContractMetadata("verify_managed_native_boundaries"),
|
||||
description:
|
||||
"Verify managed P/Invoke/native-boundary declarations against authenticated native export or function Evidence without executing managed code or translating managed metadata tokens into native addresses. The workflow preserves declaration-only, verified, inferred, contradicted, and unresolved states; a name candidate with missing managed or native module identity remains unresolved with its candidate evidence retained.",
|
||||
"Verify managed P/Invoke/native-boundary declarations against authenticated native export or function Evidence (inspect_macho, inspect_native_exports or analyze_function) without executing managed code or translating managed metadata tokens into native addresses. The workflow preserves declaration-only, verified, inferred, contradicted, and unresolved states; a name candidate with missing managed or native module identity remains unresolved with its candidate evidence retained.",
|
||||
kind: "application",
|
||||
inputSchema: managedNativeVerificationInputSchema,
|
||||
outputSchema: nativeVerificationOutputSchema,
|
||||
|
||||
@@ -4,6 +4,10 @@ import { parseEvidence } from "../evidence.js";
|
||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
||||
import { functionDossierSchema } from "../hopperValues.js";
|
||||
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
||||
import {
|
||||
nativeExportsSchema,
|
||||
type NativeExports,
|
||||
} from "../native/nativeExports.js";
|
||||
import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js";
|
||||
import {
|
||||
pinvokeVerificationSchema,
|
||||
@@ -47,6 +51,19 @@ const symbolsForEvidence = (
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (evidence.operation === "inspect_native_exports")
|
||||
return {
|
||||
supported: true,
|
||||
symbols: nativeExportSymbols(
|
||||
nativeExportsSchema.parse(evidence.normalized_result),
|
||||
).map((symbol) => ({
|
||||
evidence_id: evidence.evidence_id,
|
||||
operation: evidence.operation,
|
||||
module_name: moduleName(evidence),
|
||||
module_path: modulePath(evidence),
|
||||
...symbol,
|
||||
})),
|
||||
};
|
||||
if (evidence.operation === "analyze_function") {
|
||||
const dossier = functionDossierSchema.parse(evidence.normalized_result);
|
||||
return {
|
||||
@@ -67,6 +84,36 @@ const symbolsForEvidence = (
|
||||
return { supported: false, symbols: [] };
|
||||
};
|
||||
|
||||
/** Named export rows; ordinal-only PE rows have no name to match. */
|
||||
const nativeExportSymbols = (
|
||||
inventory: NativeExports,
|
||||
): Pick<NativeSymbol, "name" | "address" | "source" | "export_qualifier">[] =>
|
||||
inventory.format === "pe"
|
||||
? inventory.exports.flatMap((row) =>
|
||||
row.name === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
source: "pe-export" as const,
|
||||
...(row.forwarder === null
|
||||
? {}
|
||||
: { export_qualifier: "forwarded" as const }),
|
||||
},
|
||||
],
|
||||
)
|
||||
: inventory.exports.map((row) => ({
|
||||
name: row.name,
|
||||
address: row.value,
|
||||
source: "elf-export" as const,
|
||||
...(row.version === "unknown"
|
||||
? { export_qualifier: "unknown-version" as const }
|
||||
: row.version?.hidden === true
|
||||
? { export_qualifier: "non-default-version" as const }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
export const collectNativeSymbols = (
|
||||
observations: readonly ManagedNativeVerificationInput["native_observations"][number][],
|
||||
): {
|
||||
@@ -199,16 +246,21 @@ const matchPinvoke = (
|
||||
|
||||
const candidates: [NativeSymbol, ...NativeSymbol[]] = [first, ...remaining];
|
||||
const scopeKnown = declaredModuleIdentity(managed.import_scope_name) !== null;
|
||||
const exactName = (symbol: NativeSymbol) =>
|
||||
managed.import_name === normalizedSymbolName(symbol) &&
|
||||
moduleCompatible(managed.import_scope_name, symbol);
|
||||
const exact = candidates.find(
|
||||
(symbol) =>
|
||||
managed.import_name === normalizedSymbolName(symbol) &&
|
||||
moduleCompatible(managed.import_scope_name, symbol),
|
||||
(symbol) => exactName(symbol) && symbol.export_qualifier === undefined,
|
||||
);
|
||||
const qualified = candidates.find(
|
||||
(symbol) => exactName(symbol) && symbol.export_qualifier !== undefined,
|
||||
);
|
||||
const decorated = candidates.find((symbol) =>
|
||||
moduleCompatible(managed.import_scope_name, symbol),
|
||||
);
|
||||
const selected =
|
||||
exact ??
|
||||
qualified ??
|
||||
decorated ??
|
||||
(scopeKnown
|
||||
? candidates.find((symbol) => !hasModuleIdentity(symbol))
|
||||
@@ -219,14 +271,24 @@ const matchPinvoke = (
|
||||
match: {
|
||||
status: "verified",
|
||||
basis:
|
||||
selected.source === "macho-export"
|
||||
? "exact-export-name"
|
||||
: "exact-function-name",
|
||||
selected.source === "function-dossier"
|
||||
? "exact-function-name"
|
||||
: "exact-export-name",
|
||||
confidence: "observed",
|
||||
matched_native: selected,
|
||||
candidates,
|
||||
},
|
||||
};
|
||||
if (qualified !== undefined)
|
||||
return {
|
||||
match: {
|
||||
basis: "qualified-export-candidate",
|
||||
status: "inferred",
|
||||
confidence: "inferred",
|
||||
matched_native: selected,
|
||||
candidates,
|
||||
},
|
||||
};
|
||||
if (decorated !== undefined)
|
||||
return {
|
||||
match: {
|
||||
@@ -258,6 +320,15 @@ const matchPinvoke = (
|
||||
};
|
||||
};
|
||||
|
||||
const QUALIFIED_EXPORT_LIMITATIONS = {
|
||||
forwarded:
|
||||
"The exactly named PE export forwards to another DLL, which was not inspected.",
|
||||
"non-default-version":
|
||||
"The exactly named ELF symbol has only a hidden version, which a by-name lookup does not select.",
|
||||
"unknown-version":
|
||||
"The exactly named ELF symbol's version could not be read, so whether a by-name lookup selects it is unknown.",
|
||||
} as const;
|
||||
|
||||
const pinvokeLimitations = (
|
||||
match: PinvokeMatch,
|
||||
managed: PinvokeImport,
|
||||
@@ -266,11 +337,15 @@ const pinvokeLimitations = (
|
||||
managed.import_scope_name.trim().length === 0
|
||||
? ["Managed declaration does not name an import scope/module."]
|
||||
: []),
|
||||
...(match.status === "inferred"
|
||||
...(match.status === "inferred" && match.basis === "decorated-name-candidate"
|
||||
? [
|
||||
"Native symbol matched a decorated candidate rather than the exact declared import name.",
|
||||
]
|
||||
: []),
|
||||
...(match.status === "inferred" &&
|
||||
match.basis === "qualified-export-candidate"
|
||||
? [QUALIFIED_EXPORT_LIMITATIONS[match.matched_native.export_qualifier!]]
|
||||
: []),
|
||||
...(match.status === "contradicted"
|
||||
? [
|
||||
"A symbol name candidate exists, but the supplied native Evidence module identity does not match the managed import scope.",
|
||||
|
||||
@@ -19,7 +19,20 @@ const nativeSymbolSchema = z.strictObject({
|
||||
address: z.string().nullable(),
|
||||
module_name: z.string().nullable(),
|
||||
module_path: z.string().nullable(),
|
||||
source: z.enum(["macho-export", "function-dossier"]),
|
||||
source: z.enum([
|
||||
"macho-export",
|
||||
"pe-export",
|
||||
"elf-export",
|
||||
"function-dossier",
|
||||
]),
|
||||
/**
|
||||
* Present when an exact export name does not establish what a by-name
|
||||
* lookup binds: a PE forwarder, or an ELF symbol whose version is hidden
|
||||
* or unreadable.
|
||||
*/
|
||||
export_qualifier: z
|
||||
.enum(["forwarded", "non-default-version", "unknown-version"])
|
||||
.exactOptional(),
|
||||
});
|
||||
|
||||
export type NativeSymbol = z.infer<typeof nativeSymbolSchema>;
|
||||
@@ -64,7 +77,7 @@ export const pinvokeVerificationSchema = z.union([
|
||||
z.strictObject({
|
||||
...pinvokeVerificationContextShape,
|
||||
status: z.literal("inferred"),
|
||||
basis: z.literal("decorated-name-candidate"),
|
||||
basis: z.enum(["decorated-name-candidate", "qualified-export-candidate"]),
|
||||
confidence: z.literal("inferred"),
|
||||
matched_native: nativeSymbolSchema,
|
||||
candidates: observedCandidatesSchema,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { verifyManagedNativeBoundariesEvidence } from "../../../../src/application/managed/ManagedNativeVerificationService.js";
|
||||
@@ -12,6 +13,12 @@ import { managedNativeBoundaryInspectionSchema } from "../../../../src/domain/ma
|
||||
import { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js";
|
||||
|
||||
import { verifyManagedNativeBoundaries } from "../../../../src/domain/managed/managedNativeVerification.js";
|
||||
import { NATIVE_EXPORTS_PROVIDER } from "../../../../src/application/InvestigationProviders.js";
|
||||
import { parseNativeExports } from "../../../../src/native/exports/NativeExportParser.js";
|
||||
import {
|
||||
elfExportImage,
|
||||
peExportImage,
|
||||
} from "../../../fixtures/binaryDiagnostics/nativeExports.js";
|
||||
const exampleInput = () =>
|
||||
managedNativeVerificationInputSchema.parse(
|
||||
MANAGED_NATIVE_VERIFICATION_EXAMPLE,
|
||||
@@ -477,3 +484,152 @@ it("does not treat a raw Mach-O ABI name as an exact underscored C identifier",
|
||||
});
|
||||
expect(result.pinvoke_imports[0]?.candidates).toEqual([]);
|
||||
});
|
||||
|
||||
describe("portable native export inventories", () => {
|
||||
/** Managed Evidence declaring each name from `scope`, cloned from the example. */
|
||||
const managedImports = (scope: string, names: readonly string[]) => {
|
||||
const managed = exampleInput().managed_boundaries;
|
||||
const normalized = managedNativeBoundaryInspectionSchema.parse(
|
||||
managed.normalized_result,
|
||||
);
|
||||
const [template] = normalized.pinvoke_imports;
|
||||
if (template === undefined) throw new Error("missing P/Invoke example");
|
||||
return createEvidence(undefined, managed.provider, {
|
||||
operation: managed.operation,
|
||||
parameters: managed.parameters,
|
||||
result: {
|
||||
...normalized,
|
||||
pinvoke_imports: names.map((import_name, index) => ({
|
||||
...template,
|
||||
token: `0x1c00000${String(index + 1)}`,
|
||||
import_name,
|
||||
import_scope_name: scope,
|
||||
})),
|
||||
},
|
||||
rawResult: null,
|
||||
});
|
||||
};
|
||||
|
||||
const exportEvidence = (path: string, bytes: Buffer) => {
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const result = parseNativeExports(bytes, {
|
||||
path,
|
||||
sha256,
|
||||
bytes: bytes.length,
|
||||
});
|
||||
return createEvidence(
|
||||
{ path, sha256, format: result.format },
|
||||
NATIVE_EXPORTS_PROVIDER,
|
||||
{
|
||||
operation: "inspect_native_exports",
|
||||
parameters: { path, max_file_bytes: 268435456 },
|
||||
result,
|
||||
confidence: "observed",
|
||||
limitations: result.limitations,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const verify = (
|
||||
scope: string,
|
||||
names: readonly string[],
|
||||
native: ReturnType<typeof exportEvidence>,
|
||||
) =>
|
||||
new Map(
|
||||
verifyManagedNativeBoundaries({
|
||||
managed_boundaries: managedImports(scope, names),
|
||||
native_observations: [native],
|
||||
}).pinvoke_imports.map((item) => [item.managed.import_name, item]),
|
||||
);
|
||||
|
||||
it("verifies exact ELF exports and keeps hidden or unread versions inferred", () => {
|
||||
const elf = exportEvidence(
|
||||
"/native/libfixture.so",
|
||||
elfExportImage({
|
||||
symbols: [
|
||||
{ name: "plain_fn" },
|
||||
{ name: "current_fn", version: 2 },
|
||||
{ name: "old_fn", version: 0x8003 },
|
||||
],
|
||||
versionDefinitions: ["libfixture.so", "V2", "V1"],
|
||||
}),
|
||||
);
|
||||
const results = verify(
|
||||
"fixture",
|
||||
["plain_fn", "current_fn", "old_fn", "absent_fn"],
|
||||
elf,
|
||||
);
|
||||
|
||||
for (const name of ["plain_fn", "current_fn"])
|
||||
expect(results.get(name)).toMatchObject({
|
||||
status: "verified",
|
||||
basis: "exact-export-name",
|
||||
matched_native: {
|
||||
name,
|
||||
source: "elf-export",
|
||||
evidence_id: elf.evidence_id,
|
||||
module_path: "/native/libfixture.so",
|
||||
},
|
||||
});
|
||||
expect(results.get("old_fn")).toMatchObject({
|
||||
status: "inferred",
|
||||
basis: "qualified-export-candidate",
|
||||
matched_native: { export_qualifier: "non-default-version" },
|
||||
limitations: [expect.stringContaining("hidden version")],
|
||||
});
|
||||
expect(results.get("absent_fn")).toMatchObject({
|
||||
status: "unresolved",
|
||||
basis: "no-native-candidate",
|
||||
});
|
||||
|
||||
const unread = exportEvidence(
|
||||
"/native/libfixture.so",
|
||||
elfExportImage({
|
||||
symbols: [{ name: "plain_fn" }],
|
||||
versionDefinitions: ["libfixture.so"],
|
||||
truncatedVersions: true,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
verify("fixture", ["plain_fn"], unread).get("plain_fn"),
|
||||
).toMatchObject({
|
||||
status: "inferred",
|
||||
matched_native: { export_qualifier: "unknown-version" },
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies named PE exports and keeps forwarders inferred", () => {
|
||||
const pe = exportEvidence(
|
||||
"/native/fixture.dll",
|
||||
peExportImage({
|
||||
functions: [
|
||||
{ rva: 0x2000 },
|
||||
{ forwarder: "OTHER.Target" },
|
||||
{ rva: 0x2010 },
|
||||
],
|
||||
names: [
|
||||
{ name: "Primary", index: 0 },
|
||||
{ name: "Forwarded", index: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const results = verify("fixture.dll", ["Primary", "Forwarded"], pe);
|
||||
|
||||
expect(results.get("Primary")).toMatchObject({
|
||||
status: "verified",
|
||||
basis: "exact-export-name",
|
||||
matched_native: { source: "pe-export", address: "0x180002000" },
|
||||
});
|
||||
expect(results.get("Forwarded")).toMatchObject({
|
||||
status: "inferred",
|
||||
basis: "qualified-export-candidate",
|
||||
matched_native: { export_qualifier: "forwarded", address: null },
|
||||
limitations: [expect.stringContaining("forwards to another DLL")],
|
||||
});
|
||||
// A matching name under another module is a contradiction, not a match.
|
||||
expect(verify("other.dll", ["Primary"], pe).get("Primary")).toMatchObject({
|
||||
status: "contradicted",
|
||||
basis: "module-mismatch",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user