From c409a8bbe83e1afc181eb8841af73af4ac40038b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:49:26 +0800 Subject: [PATCH] fix(managed): preserve native module names under platform loader rules Preserve boundary facts and uncertainty through their consumers. Add focused production-boundary regressions and update affected documentation. Fixes #6758 --- .../managed/managedNativeVerificationMatch.ts | 183 +++++++++++++++--- .../managedNativeVerificationAdapters.test.ts | 135 +++++++++++++ .../managed/managedNativeVerification.test.ts | 122 +++++++++++- 3 files changed, 406 insertions(+), 34 deletions(-) diff --git a/src/domain/managed/managedNativeVerificationMatch.ts b/src/domain/managed/managedNativeVerificationMatch.ts index 12ff09a98f..6757720a4d 100644 --- a/src/domain/managed/managedNativeVerificationMatch.ts +++ b/src/domain/managed/managedNativeVerificationMatch.ts @@ -1,5 +1,3 @@ -import { basename } from "node:path"; - import { digestCanonicalValue } from "../canonicalDigest.js"; import type { Evidence } from "../evidence.js"; import type { ManagedNativeObservationFacts } from "./managedNativeVerificationSource.js"; @@ -55,27 +53,81 @@ type ModuleCompatibility = "match" | "mismatch" | "unknown"; const declaredModuleIdentity = (scope: string | null): boolean => scope !== null && scope.trim().length > 0 && - normalizeModule(scope, false).length > 0; + moduleFilename(scope, false).length > 0; -const normalizeModule = (value: string, caseSensitive: boolean): string => { - const basename_ = basename(value); - const comparable = caseSensitive ? basename_ : basename_.toLowerCase(); - return comparable.replace(/\.(dll|dylib|so|node|exe)$/u, ""); -}; - -const moduleIdentities = ( +const moduleFilename = ( value: string, caseSensitive: boolean, -): ReadonlySet => { - const filename = caseSensitive - ? basename(value) - : basename(value).toLowerCase(); - const stem = normalizeModule(value, caseSensitive); - const identities = new Set([stem]); - if (/\.(dylib|so)$/u.test(filename) && stem.startsWith("lib")) - identities.add(stem.slice("lib".length)); - identities.delete(""); - return identities; + format?: string | null, +): string => { + const separators = format === "elf" || format === "mach-o" ? /\//u : /[\\/]/u; + const filename = value.split(separators).at(-1) ?? value; + return caseSensitive ? filename : filename.toLowerCase(); +}; + +const isAbsoluteModulePath = (value: string, format?: string | null): boolean => + value.startsWith("/") || + (format === "pe" && + (/^[a-z]:[\\/]/iu.test(value) || value.startsWith("\\\\"))); + +const hasModulePathSeparator = ( + value: string, + format: string | null, +): boolean => (format === "pe" ? /[\\/]/u.test(value) : value.includes("/")); + +const unixModuleCandidates = ( + value: string, + suffix: ".so" | ".dylib", +): readonly string[] => { + const suffixIndex = value.indexOf(suffix); + const containsSuffix = + suffixIndex >= 0 && + (suffixIndex + suffix.length === value.length || + value[suffixIndex + suffix.length] === "."); + const hasPath = value.includes("/"); + const libPrefix = (name: string): string | null => + hasPath ? null : `lib${name}`; + const suffixedName = `${value}${suffix}`; + const libSuffixedName = libPrefix(suffixedName); + const libName = libPrefix(value); + if (containsSuffix) + return [ + value, + ...(libName === null ? [] : [libName]), + suffixedName, + ...(libSuffixedName === null ? [] : [libSuffixedName]), + ]; + return [ + suffixedName, + ...(libSuffixedName === null ? [] : [libSuffixedName]), + value, + ...(libName === null ? [] : [libName]), + ]; +}; + +/** Candidate module names documented for each native loader platform. */ +const moduleCandidates = ( + value: string, + format: string | null, +): readonly string[] => { + if (isAbsoluteModulePath(value, format)) return [value]; + if (format === "pe") + return /\.(dll|exe)$/iu.test(value) || value.endsWith(".") + ? [value] + : [value, `${value}.dll`]; + if (format === "elf") return unixModuleCandidates(value, ".so"); + if (format === "mach-o") return unixModuleCandidates(value, ".dylib"); + return [value]; +}; + +const plausiblePlatformCandidates = (value: string): readonly string[] => { + return [ + ...new Set([ + ...moduleCandidates(value, "pe"), + ...moduleCandidates(value, "elf"), + ...moduleCandidates(value, "mach-o"), + ]), + ]; }; const moduleValueCompatibility = ( @@ -83,16 +135,82 @@ const moduleValueCompatibility = ( observed: string, format: string | null, ): ModuleCompatibility => { - if ( - [...moduleIdentities(expected, true)].some((identity) => - moduleIdentities(observed, true).has(identity), + const exactCandidates = moduleCandidates(expected, format); + const expectedIsAbsolutePath = isAbsoluteModulePath(expected, format); + if (expectedIsAbsolutePath) { + const observedIsAbsolutePath = isAbsoluteModulePath(observed, format); + if (!observedIsAbsolutePath) { + const hasMatchingFilename = exactCandidates.some( + (candidate) => + moduleFilename(candidate, format !== "pe", format) === + moduleFilename(observed, format !== "pe", format), + ); + return hasMatchingFilename ? "unknown" : "mismatch"; + } + const fold = format === "pe"; + const normalize = (path: string) => + fold ? path.replaceAll("\\", "/").toLowerCase() : path; + return exactCandidates.some( + (candidate) => normalize(candidate) === normalize(observed), ) - ) - return "match"; - const caseFoldedMatch = [...moduleIdentities(expected, false)].some( - (identity) => moduleIdentities(observed, false).has(identity), + ? "match" + : format !== "pe" && + format !== "elf" && + exactCandidates.some( + (candidate) => + normalize(candidate).toLowerCase() === + normalize(observed).toLowerCase(), + ) + ? "unknown" + : "mismatch"; + } + if (hasModulePathSeparator(expected, format)) { + const isObservedAbsolute = isAbsoluteModulePath(observed, format); + if (!isObservedAbsolute && exactCandidates.includes(observed)) + return "match"; + const normalizePath = (path: string) => + format === "pe" ? path.replaceAll("\\", "/").toLowerCase() : path; + if ( + !isObservedAbsolute && + format === "pe" && + exactCandidates.some( + (candidate) => normalizePath(candidate) === normalizePath(observed), + ) + ) + return "match"; + if (isObservedAbsolute) { + if ( + exactCandidates.some((candidate) => + normalizePath(observed).endsWith(`/${normalizePath(candidate)}`), + ) + ) + return "match"; + } + const sameFilename = exactCandidates.some( + (candidate) => + moduleFilename(candidate, false, format) === + moduleFilename(observed, false, format), + ); + return sameFilename ? "unknown" : "mismatch"; + } + const observedFilename = moduleFilename(observed, true, format); + if (exactCandidates.includes(observedFilename)) return "match"; + const caseFoldedCandidates = exactCandidates.map((candidate) => + moduleFilename(candidate, false, format), ); - if (!caseFoldedMatch) return "mismatch"; + const observedFolded = moduleFilename(observed, false, format); + if (!caseFoldedCandidates.includes(observedFolded)) { + if ( + format !== "pe" && + format !== "elf" && + format !== "mach-o" && + plausiblePlatformCandidates(expected) + .map((candidate) => moduleFilename(candidate, false, format)) + .includes(observedFolded) + ) + return "unknown"; + return "mismatch"; + } if (format === "elf") return "mismatch"; if (format === "pe") return "match"; return "unknown"; @@ -106,10 +224,13 @@ const moduleCompatibility = ( if ( scope === null || scope.trim().length === 0 || - normalizeModule(scope, false).length === 0 + moduleFilename(scope, false).length === 0 ) return "unknown"; - const values = [symbol.module_name, symbol.module_path].filter( + const observedValues = isAbsoluteModulePath(scope, format) + ? [symbol.module_path] + : [symbol.module_name, symbol.module_path]; + const values = observedValues.filter( (value): value is string => value !== null && value.length > 0, ); if (values.length === 0) return "unknown"; @@ -123,7 +244,7 @@ const moduleCompatibility = ( const hasModuleIdentity = (symbol: NativeSymbol): boolean => [symbol.module_name, symbol.module_path].some( - (value) => value !== null && normalizeModule(value, false).length > 0, + (value) => value !== null && moduleFilename(value, false).length > 0, ); const candidateNames = ( diff --git a/tests/boundary/mcp/managedNativeVerificationAdapters.test.ts b/tests/boundary/mcp/managedNativeVerificationAdapters.test.ts index 7fb58195e1..b5e9754aac 100644 --- a/tests/boundary/mcp/managedNativeVerificationAdapters.test.ts +++ b/tests/boundary/mcp/managedNativeVerificationAdapters.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { afterAll, beforeAll, expect } from "vitest"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; import { managedNativeVerificationResultSchema } from "../../../src/domain/managed/managedNativeVerificationSchemas.js"; import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; @@ -46,6 +47,140 @@ const signatureEvidence = () => }, ); +cliTest( + "preserves explicit PE module suffixes and matches only the Windows DLL variation through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-managed-native-module-names-", + ); + const nativeProvider = { + id: "native-fixture", + name: "Synthetic native fixture", + version: "1", + }; + const dossier = functionDossierSchema.parse({ + procedure: { + address: "0x1000", + name: "open_native", + classification: { + external: false, + thunk: false, + thunk_target: null, + provenance: "synthetic-provider", + }, + body: { available: false, reason: "Synthetic test Evidence only." }, + signature: null, + locals: [], + }, + pseudocode: "", + assembly: [], + comments: [], + callers: [], + callees: [], + incoming_references: [], + outgoing_references: [], + referenced_strings: [], + referenced_names: [], + basic_blocks: [], + native_api: null, + native_value_flow: null, + limitations: ["No native provider was executed."], + }); + const cases = [ + { + format: "pe", + scope: "nativehelper.dll", + module: "nativehelper.dll", + status: "verified", + }, + { + format: "pe", + scope: "nativehelper.dll", + module: "nativehelper.exe", + status: "contradicted", + }, + { + format: "pe", + scope: "nativehelper.dll", + module: "nativehelper.node", + status: "contradicted", + }, + { + format: "pe", + scope: "nativehelper", + module: "nativehelper.dll", + status: "verified", + }, + { + scope: "nativedep.so.6", + module: "libnativedep.so.6", + format: "elf", + status: "verified", + }, + ] as const; + + for (const [index, item] of cases.entries()) { + const managedPath = join(root, `managed-${String(index)}.dll`); + await writeFile( + managedPath, + buildManagedPeFixture({ + pinvoke: { importName: "open_native", moduleName: item.scope }, + }), + ); + const inspected = await client.callTool({ + name: "inspect_managed_native_boundaries", + arguments: { path: managedPath }, + }); + expect(inspected.isError, JSON.stringify(inspected.content)).not.toBe( + true, + ); + const managed = parseEvidence(inspected.structuredContent); + const native = createEvidence( + { + path: `/synthetic/${item.module}`, + sha256: "8".repeat(64), + format: item.format ?? "pe", + }, + nativeProvider, + { + operation: "analyze_function", + parameters: {}, + result: dossier, + rawResult: null, + limitations: ["Synthetic native Evidence; no provider executed."], + }, + ); + const input = { + managed_boundaries: managed, + native_observations: [native], + }; + const mcp = await client.callTool({ + name: "verify_managed_native_boundaries", + arguments: input, + }); + expect(mcp.isError, JSON.stringify(mcp.content)).not.toBe(true); + const mcpResult = managedNativeVerificationResultSchema.parse( + parseEvidence(mcp.structuredContent).normalized_result, + ); + const request = join(root, `verify-${String(index)}.json`); + await writeFile(request, JSON.stringify(input)); + const cliRun = await cli.run({ + arguments: ["verify-managed-native-boundaries", request, "--json"], + }); + expect(cliRun.exitCode, cliRun.stderr).toBe(0); + const cliResult = managedNativeVerificationResultSchema.parse( + parseEvidence(cliRun.json).normalized_result, + ); + expect(mcpResult).toEqual(cliResult); + expect(mcpResult.pinvoke_imports[0]).toMatchObject({ + status: item.status, + ...(item.status === "contradicted" ? { basis: "module-mismatch" } : {}), + }); + } + }, + 60_000, +); + cliTest( "counts authentic unsupported native Evidence identically through MCP and CLI", async ({ cli }) => { diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index 447fbb78ec..bad0a27b2e 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -375,7 +375,40 @@ it("keeps literal module names distinct and accepts the shared-library lib alias }); }); -it("keeps name candidates unresolved when a required module identity is absent", () => { +it("keeps explicit PE module suffixes distinct and permits the Windows DLL variation", () => { + const verify = (scope: string, modulePath: string) => + verifyManagedNativeBoundaries({ + ...inputWithImportScope(scope), + native_observations: [nativeFunctionEvidence(modulePath, "pe")], + }).pinvoke_imports[0]; + + expect( + verify("nativehelper.dll", "/examples/nativehelper.dll"), + ).toMatchObject({ + status: "verified", + basis: "exact-function-name", + }); + for (const suffix of ["exe", "node"]) { + expect( + verify("nativehelper.dll", `/examples/nativehelper.${suffix}`), + ).toMatchObject({ + status: "contradicted", + basis: "module-mismatch", + }); + } + expect(verify("nativehelper", "/examples/nativehelper.dll")).toMatchObject({ + status: "verified", + basis: "exact-function-name", + }); + expect( + verify("C:\\examples\\nativehelper.dll", "C:\\examples\\nativehelper.dll"), + ).toMatchObject({ status: "verified" }); + expect( + verify("C:\\examples\\nativehelper.dll", "C:\\other\\nativehelper.dll"), + ).toMatchObject({ status: "contradicted", basis: "module-mismatch" }); +}); + +it("keeps empty identities unknown and compares suffix-only identities as data", () => { for (const scope of [null, "", " "]) { const result = verifyManagedNativeBoundaries({ ...inputWithImportScope(scope), @@ -402,8 +435,8 @@ it("keeps name candidates unresolved when a required module identity is absent", native_observations: [nativeEvidenceWithExports(["open_native"], path)], }); expect(extensionOnly.pinvoke_imports[0]).toMatchObject({ - status: "unresolved", - basis: "module-identity-unknown", + status: "contradicted", + basis: "module-mismatch", candidates: [{ name: "open_native", module_path: path }], }); } @@ -426,6 +459,89 @@ it("keeps name candidates unresolved when a required module identity is absent", }); }); +it("matches versioned ELF SONAME variations without dropping their version", () => { + const result = verifyManagedNativeBoundaries({ + ...inputWithImportScope("nativedep.so.6"), + native_observations: [ + nativeFunctionEvidence("/examples/libnativedep.so.6", "elf"), + ], + }); + expect(result.pinvoke_imports[0]).toMatchObject({ + status: "verified", + matched_native: { module_path: "/examples/libnativedep.so.6" }, + }); + + const relativePath = verifyManagedNativeBoundaries({ + ...inputWithImportScope("relative/nativedep.so.6"), + native_observations: [ + nativeFunctionEvidence("/examples/relative/nativedep.so.6", "elf"), + ], + }); + expect(relativePath.pinvoke_imports[0]).toMatchObject({ status: "verified" }); + + const basenameOnly = verifyManagedNativeBoundaries({ + ...inputWithImportScope("relative/nativedep.so.6"), + native_observations: [ + nativeFunctionEvidence("/examples/other/nativedep.so.6", "elf"), + ], + }); + expect(basenameOnly.pinvoke_imports[0]).toMatchObject({ + status: "unresolved", + basis: "module-identity-unknown", + }); +}); + +it("keeps POSIX backslashes literal in absolute module paths", () => { + const path = "/examples/native\\helper.so"; + const exact = verifyManagedNativeBoundaries({ + ...inputWithImportScope(path), + native_observations: [nativeFunctionEvidence(path, "elf")], + }); + expect(exact.pinvoke_imports[0]).toMatchObject({ status: "verified" }); + + const slashEquivalent = verifyManagedNativeBoundaries({ + ...inputWithImportScope(path), + native_observations: [ + nativeFunctionEvidence("/examples/native/helper.so", "elf"), + ], + }); + expect(slashEquivalent.pinvoke_imports[0]).toMatchObject({ + status: "contradicted", + basis: "module-mismatch", + }); +}); + +it("applies Unix suffix variations even when the name already has the suffix", () => { + const result = verifyManagedNativeBoundaries({ + ...inputWithImportScope("nativehelper.dylib"), + native_observations: [ + nativeFunctionEvidence("/examples/nativehelper.dylib.dylib", "mach-o"), + ], + }); + expect(result.pinvoke_imports[0]).toMatchObject({ status: "verified" }); +}); + +it("does not append the Windows DLL suffix after a trailing period", () => { + const exact = verifyManagedNativeBoundaries({ + ...inputWithImportScope("nativehelper."), + native_observations: [ + nativeFunctionEvidence("/examples/nativehelper.", "pe"), + ], + }); + expect(exact.pinvoke_imports[0]).toMatchObject({ status: "verified" }); + + const appended = verifyManagedNativeBoundaries({ + ...inputWithImportScope("nativehelper."), + native_observations: [ + nativeFunctionEvidence("/examples/nativehelper.dll", "pe"), + ], + }); + expect(appended.pinvoke_imports[0]).toMatchObject({ + status: "contradicted", + basis: "module-mismatch", + }); +}); + it("treats supported native Evidence with no symbols as unresolved", () => { const result = verifyManagedNativeBoundaries({ ...exampleInput(),