diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 17b37d8fb4..eb035cfda5 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -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. diff --git a/src/contracts/managed/managedWorkflowToolContracts.ts b/src/contracts/managed/managedWorkflowToolContracts.ts index 28406c42dd..6d0aa4be5e 100644 --- a/src/contracts/managed/managedWorkflowToolContracts.ts +++ b/src/contracts/managed/managedWorkflowToolContracts.ts @@ -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, diff --git a/src/domain/managed/managedNativeVerificationMatch.ts b/src/domain/managed/managedNativeVerificationMatch.ts index 1b9754c407..961ba33a61 100644 --- a/src/domain/managed/managedNativeVerificationMatch.ts +++ b/src/domain/managed/managedNativeVerificationMatch.ts @@ -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[] => + 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.", diff --git a/src/domain/managed/managedNativeVerificationSchemas.ts b/src/domain/managed/managedNativeVerificationSchemas.ts index 134ed7f47d..2c5a0276e3 100644 --- a/src/domain/managed/managedNativeVerificationSchemas.ts +++ b/src/domain/managed/managedNativeVerificationSchemas.ts @@ -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; @@ -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, diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index 6f5bcb0603..4132f5897c 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -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, + ) => + 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", + }); + }); +});