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:
Kaoru0822andClaude Opus 5.5 committed 2026-10-22 07:31:34 +00:00
1 parent 65a3128873
commit 6be1c9e272
5 files changed
+263 -10

No files matched your search

+9
View File
@@ -415,6 +415,15 @@ function implements a specific managed method.
`verify_managed_native_boundaries` compares declared P/Invoke module and entry `verify_managed_native_boundaries` compares declared P/Invoke module and entry
point identities with supplied native export or function evidence. Name/module point identities with supplied native export or function evidence. Name/module
matches establish declaration links; they do not establish that a call executed. 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 When either side lacks a usable module identity, a matching name remains
unresolved and retains the candidate evidence; REA reports a module mismatch unresolved and retains the candidate evidence; REA reports a module mismatch
only when both identities are available and differ. only when both identities are available and differ.
@@ -99,7 +99,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [
name: "verify_managed_native_boundaries", name: "verify_managed_native_boundaries",
...toolContractMetadata("verify_managed_native_boundaries"), ...toolContractMetadata("verify_managed_native_boundaries"),
description: 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", kind: "application",
inputSchema: managedNativeVerificationInputSchema, inputSchema: managedNativeVerificationInputSchema,
outputSchema: nativeVerificationOutputSchema, outputSchema: nativeVerificationOutputSchema,
@@ -4,6 +4,10 @@ import { parseEvidence } from "../evidence.js";
import { digestCanonicalValue } from "../canonicalDigest.js"; import { digestCanonicalValue } from "../canonicalDigest.js";
import { functionDossierSchema } from "../hopperValues.js"; import { functionDossierSchema } from "../hopperValues.js";
import { inspectMachoSchema } from "../native/nativeInspection.js"; import { inspectMachoSchema } from "../native/nativeInspection.js";
import {
nativeExportsSchema,
type NativeExports,
} from "../native/nativeExports.js";
import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js"; import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js";
import { import {
pinvokeVerificationSchema, 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") { if (evidence.operation === "analyze_function") {
const dossier = functionDossierSchema.parse(evidence.normalized_result); const dossier = functionDossierSchema.parse(evidence.normalized_result);
return { return {
@@ -67,6 +84,36 @@ const symbolsForEvidence = (
return { supported: false, symbols: [] }; 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 = ( export const collectNativeSymbols = (
observations: readonly ManagedNativeVerificationInput["native_observations"][number][], observations: readonly ManagedNativeVerificationInput["native_observations"][number][],
): { ): {
@@ -199,16 +246,21 @@ const matchPinvoke = (
const candidates: [NativeSymbol, ...NativeSymbol[]] = [first, ...remaining]; const candidates: [NativeSymbol, ...NativeSymbol[]] = [first, ...remaining];
const scopeKnown = declaredModuleIdentity(managed.import_scope_name) !== null; 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( const exact = candidates.find(
(symbol) => (symbol) => exactName(symbol) && symbol.export_qualifier === undefined,
managed.import_name === normalizedSymbolName(symbol) && );
moduleCompatible(managed.import_scope_name, symbol), const qualified = candidates.find(
(symbol) => exactName(symbol) && symbol.export_qualifier !== undefined,
); );
const decorated = candidates.find((symbol) => const decorated = candidates.find((symbol) =>
moduleCompatible(managed.import_scope_name, symbol), moduleCompatible(managed.import_scope_name, symbol),
); );
const selected = const selected =
exact ?? exact ??
qualified ??
decorated ?? decorated ??
(scopeKnown (scopeKnown
? candidates.find((symbol) => !hasModuleIdentity(symbol)) ? candidates.find((symbol) => !hasModuleIdentity(symbol))
@@ -219,14 +271,24 @@ const matchPinvoke = (
match: { match: {
status: "verified", status: "verified",
basis: basis:
selected.source === "macho-export" selected.source === "function-dossier"
? "exact-export-name" ? "exact-function-name"
: "exact-function-name", : "exact-export-name",
confidence: "observed", confidence: "observed",
matched_native: selected, matched_native: selected,
candidates, candidates,
}, },
}; };
if (qualified !== undefined)
return {
match: {
basis: "qualified-export-candidate",
status: "inferred",
confidence: "inferred",
matched_native: selected,
candidates,
},
};
if (decorated !== undefined) if (decorated !== undefined)
return { return {
match: { 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 = ( const pinvokeLimitations = (
match: PinvokeMatch, match: PinvokeMatch,
managed: PinvokeImport, managed: PinvokeImport,
@@ -266,11 +337,15 @@ const pinvokeLimitations = (
managed.import_scope_name.trim().length === 0 managed.import_scope_name.trim().length === 0
? ["Managed declaration does not name an import scope/module."] ? ["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.", "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" ...(match.status === "contradicted"
? [ ? [
"A symbol name candidate exists, but the supplied native Evidence module identity does not match the managed import scope.", "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(), address: z.string().nullable(),
module_name: z.string().nullable(), module_name: z.string().nullable(),
module_path: 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>; export type NativeSymbol = z.infer<typeof nativeSymbolSchema>;
@@ -64,7 +77,7 @@ export const pinvokeVerificationSchema = z.union([
z.strictObject({ z.strictObject({
...pinvokeVerificationContextShape, ...pinvokeVerificationContextShape,
status: z.literal("inferred"), status: z.literal("inferred"),
basis: z.literal("decorated-name-candidate"), basis: z.enum(["decorated-name-candidate", "qualified-export-candidate"]),
confidence: z.literal("inferred"), confidence: z.literal("inferred"),
matched_native: nativeSymbolSchema, matched_native: nativeSymbolSchema,
candidates: observedCandidatesSchema, candidates: observedCandidatesSchema,
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { verifyManagedNativeBoundariesEvidence } from "../../../../src/application/managed/ManagedNativeVerificationService.js"; 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 { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js";
import { verifyManagedNativeBoundaries } from "../../../../src/domain/managed/managedNativeVerification.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 = () => const exampleInput = () =>
managedNativeVerificationInputSchema.parse( managedNativeVerificationInputSchema.parse(
MANAGED_NATIVE_VERIFICATION_EXAMPLE, 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([]); 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",
});
});
});