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
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",
});
});
});