diff --git a/docs/guides/managed/managed-code-analysis.md b/docs/guides/managed/managed-code-analysis.md index 15d9337bb3..3b259a1b5a 100644 --- a/docs/guides/managed/managed-code-analysis.md +++ b/docs/guides/managed/managed-code-analysis.md @@ -651,6 +651,9 @@ only when both identities are available and their format-specific comparison establishes a difference. ELF module names retain case, PE module names compare case-insensitively, and a case-only match for Mach-O or an unknown format stays unresolved because the available Evidence does not establish case behavior. +The authenticated subject format establishes the native platform: thin and +universal Mach-O use the same lookup rules; missing or unrecognized formats +remain unknown. Retained source Evidence keeps its original subject format. For PE imports, `.dll`, `.exe`, a trailing period, and absolute paths suppress appending `.dll`; other names may also be tried with `.dll`. Unix loaders use the platform suffix (`.so` or `.dylib`) and `lib` prefix variations. A suffix diff --git a/src/application/managed/ManagedNativeVerificationService.platform.test.ts b/src/application/managed/ManagedNativeVerificationService.platform.test.ts new file mode 100644 index 0000000000..49e6520c61 --- /dev/null +++ b/src/application/managed/ManagedNativeVerificationService.platform.test.ts @@ -0,0 +1,201 @@ +import { expect, it } from "vitest"; + +import { MANAGED_NATIVE_VERIFICATION_EXAMPLE } from "../../contracts/managed/managedWorkflowExamples.js"; +import { createEvidence } from "../../domain/evidence.js"; +import { managedNativeBoundaryInspectionSchema } from "../../domain/managed/managedArtifact.js"; +import { managedNativeVerificationResultSchema } from "../../domain/managed/managedNativeVerificationSchemas.js"; +import { functionDossierSchema } from "../../domain/native/functionDossier.js"; +import { inspectMachoSchema } from "../../domain/native/nativeInspection.js"; +import { executeManagedNativeBoundaries } from "./ManagedNativeVerificationService.js"; + +type NativeFormat = "mach-o" | "mach-o-universal" | "pe" | "elf" | "file"; +const native = (format: NativeFormat, path: string, decorated: boolean) => { + const original = MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations[0]!; + const name = decorated ? "open_nativeW" : "open_native"; + const symbolTableName = `_${name}`; + const macho = inspectMachoSchema.parse(original.normalized_result); + const isMacho = format === "mach-o" || format === "mach-o-universal"; + const result = isMacho + ? { + ...macho, + format, + exports: { + ...macho.exports, + total: 1, + items: [ + { + name: symbolTableName, + address: "0x1000", + weak: false, + reexport: false, + source: "nm", + }, + ], + }, + } + : functionDossierSchema.parse({ + procedure: { + address: "0x1000", + name, + classification: { + external: false, + thunk: false, + thunk_target: null, + provenance: "synthetic-provider", + }, + body: { available: false, reason: "Inert authored observation" }, + 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: [], + }); + return createEvidence( + { path, sha256: "a".repeat(64), format }, + original.provider, + { + operation: isMacho ? "inspect_macho" : "analyze_function", + parameters: {}, + result, + }, + ); +}; + +const verify = ( + format: NativeFormat, + path: string, + scope: string, + decorated = false, +) => { + const original = MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries; + const managed = managedNativeBoundaryInspectionSchema.parse( + original.normalized_result, + ); + const managedEvidence = createEvidence(undefined, original.provider, { + operation: original.operation, + parameters: {}, + result: { + ...managed, + pinvoke_imports: managed.pinvoke_imports.map((item) => ({ + ...item, + import_scope_name: scope, + no_mangle: !decorated, + char_set: decorated ? "unicode" : "not-specified", + call_convention: "cdecl", + })), + }, + }); + const nativeEvidence = native(format, path, decorated); + const execution = executeManagedNativeBoundaries({ + managed_boundaries: managedEvidence, + native_observations: [nativeEvidence], + }); + if (!execution.ok) throw execution.error; + expect(execution.value.sourceEvidence).toEqual([ + managedEvidence, + nativeEvidence, + ]); + expect(execution.value.sourceEvidence[1]?.subject?.format).toBe(format); + const result = managedNativeVerificationResultSchema.parse( + execution.value.evidence.normalized_result, + ); + expect(result.evidence_links).toEqual([ + managedEvidence.evidence_id, + nativeEvidence.evidence_id, + ]); + const item = result.pinvoke_imports[0]!; + expect(item.managed.import_scope_name).toBe(scope); + return { item, execution: execution.value, nativeEvidence }; +}; + +it.each([ + { + format: "mach-o", + path: "/fixtures/libnativehelper.dylib", + status: "verified", + }, + { + format: "mach-o-universal", + path: "/fixtures/libnativehelper.dylib", + status: "verified", + }, + { format: "elf", path: "/fixtures/libnativehelper.so", status: "verified" }, + { format: "pe", path: "C:\\fixtures\\nativehelper.dll", status: "verified" }, + { + format: "file", + path: "/fixtures/libnativehelper.dylib", + status: "unresolved", + }, +] as const)( + "uses authenticated $format subject platform for module aliases", + ({ format, path, status }) => { + const { item, execution, nativeEvidence } = verify( + format, + path, + "nativehelper", + ); + expect(item.status).toBe(status); + expect(execution.hasUnknowns).toBe(format === "file"); + expect(item.matched_native?.evidence_id).toBe(nativeEvidence.evidence_id); + expect(item.matched_native?.name).toBe( + format === "mach-o" || format === "mach-o-universal" + ? "_open_native" + : "open_native", + ); + if (format === "file") expect(item.basis).toBe("module-identity-unknown"); + }, +); + +it.each([ + { + format: "mach-o", + path: "/fixtures/libnativehelper.dylib", + scope: "libnativehelper.dylib", + status: "unresolved", + }, + { + format: "mach-o-universal", + path: "/fixtures/libnativehelper.dylib", + scope: "libnativehelper.dylib", + status: "unresolved", + }, + { + format: "elf", + path: "/fixtures/libnativehelper.so", + scope: "libnativehelper.so", + status: "unresolved", + }, + { + format: "pe", + path: "C:\\fixtures\\nativehelper.dll", + scope: "nativehelper.dll", + status: "inferred", + }, + { + format: "file", + path: "/fixtures/nativehelper", + scope: "nativehelper", + status: "inferred", + }, +] as const)( + "keeps Windows decorated-name candidates within $format platform authority", + ({ format, path, scope, status }) => { + const { item } = verify(format, path, scope, true); + expect(item.status).toBe(status); + if (status === "unresolved") { + expect(item.basis).toBe("no-native-candidate"); + expect(item.candidates).toEqual([]); + } else expect(item.basis).toBe("decorated-name-candidate"); + }, +); diff --git a/src/domain/managed/managedNativeVerificationMatch.ts b/src/domain/managed/managedNativeVerificationMatch.ts index 9b7fc672b2..1e5dd83724 100644 --- a/src/domain/managed/managedNativeVerificationMatch.ts +++ b/src/domain/managed/managedNativeVerificationMatch.ts @@ -1,7 +1,10 @@ import { digestCanonicalValue } from "../canonicalDigest.js"; import type { Evidence } from "../evidence.js"; import { machoRuntimeSymbolName } from "../native/machoSymbolName.js"; -import type { ManagedNativeObservationFacts } from "./managedNativeVerificationSource.js"; +import type { + ManagedNativeObservationFacts, + ManagedNativePlatform, +} from "./managedNativeVerificationSource.js"; import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js"; import type { ManagedNativeVerificationResult, @@ -22,18 +25,21 @@ export const collectNativeSymbols = ( readonly unsupported: number; readonly partialExports: boolean; readonly exportLimitations: readonly string[]; - readonly moduleFormatsByEvidenceId: ReadonlyMap; + readonly modulePlatformsByEvidenceId: ReadonlyMap< + string, + ManagedNativePlatform + >; } => { const symbols: NativeSymbol[] = []; - const moduleFormatsByEvidenceId = new Map(); + const modulePlatformsByEvidenceId = new Map(); let accepted = 0; let unsupported = 0; let partialExports = false; const exportLimitations: string[] = []; for (const observation of observations) { - moduleFormatsByEvidenceId.set( + modulePlatformsByEvidenceId.set( observation.evidence.evidence_id, - observation.evidence.subject?.format ?? null, + observation.platform, ); if (!observation.supported) { unsupported += 1; @@ -55,7 +61,7 @@ export const collectNativeSymbols = ( unsupported, partialExports, exportLimitations, - moduleFormatsByEvidenceId, + modulePlatformsByEvidenceId, }; }; @@ -73,22 +79,26 @@ type ModuleCompatibility = "match" | "mismatch" | "unknown"; const moduleFilename = ( value: string, caseSensitive: boolean, - format?: string | null, + platform: ManagedNativePlatform, ): string => { - const separators = format === "elf" || format === "mach-o" ? /\//u : /[\\/]/u; + const separators = + platform === "elf" || platform === "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 => +const isAbsoluteModulePath = ( + value: string, + platform: ManagedNativePlatform, +): boolean => value.startsWith("/") || - (format === "pe" && + (platform === "pe" && (/^[a-z]:[\\/]/iu.test(value) || value.startsWith("\\\\"))); const hasModulePathSeparator = ( value: string, - format: string | null, -): boolean => (format === "pe" ? /[\\/]/u.test(value) : value.includes("/")); + platform: ManagedNativePlatform, +): boolean => (platform === "pe" ? /[\\/]/u.test(value) : value.includes("/")); const unixModuleCandidates = ( value: string, @@ -123,15 +133,15 @@ const unixModuleCandidates = ( /** Candidate module names documented for each native loader platform. */ const moduleCandidates = ( value: string, - format: string | null, + platform: ManagedNativePlatform, ): readonly string[] => { - if (isAbsoluteModulePath(value, format)) return [value]; - if (format === "pe") + if (isAbsoluteModulePath(value, platform)) return [value]; + if (platform === "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"); + if (platform === "elf") return unixModuleCandidates(value, ".so"); + if (platform === "mach-o") return unixModuleCandidates(value, ".dylib"); return [value]; }; @@ -148,29 +158,29 @@ const plausiblePlatformCandidates = (value: string): readonly string[] => { const moduleValueCompatibility = ( expected: string, observed: string, - format: string | null, + platform: ManagedNativePlatform, ): ModuleCompatibility => { - const exactCandidates = moduleCandidates(expected, format); - const expectedIsAbsolutePath = isAbsoluteModulePath(expected, format); + const exactCandidates = moduleCandidates(expected, platform); + const expectedIsAbsolutePath = isAbsoluteModulePath(expected, platform); if (expectedIsAbsolutePath) { - const observedIsAbsolutePath = isAbsoluteModulePath(observed, format); + const observedIsAbsolutePath = isAbsoluteModulePath(observed, platform); if (!observedIsAbsolutePath) { const hasMatchingFilename = exactCandidates.some( (candidate) => - moduleFilename(candidate, format !== "pe", format) === - moduleFilename(observed, format !== "pe", format), + moduleFilename(candidate, platform !== "pe", platform) === + moduleFilename(observed, platform !== "pe", platform), ); return hasMatchingFilename ? "unknown" : "mismatch"; } - const fold = format === "pe"; + const fold = platform === "pe"; const normalize = (path: string) => fold ? path.replaceAll("\\", "/").toLowerCase() : path; return exactCandidates.some( (candidate) => normalize(candidate) === normalize(observed), ) ? "match" - : format !== "pe" && - format !== "elf" && + : platform !== "pe" && + platform !== "elf" && exactCandidates.some( (candidate) => normalize(candidate).toLowerCase() === @@ -179,15 +189,15 @@ const moduleValueCompatibility = ( ? "unknown" : "mismatch"; } - if (hasModulePathSeparator(expected, format)) { - const isObservedAbsolute = isAbsoluteModulePath(observed, format); + if (hasModulePathSeparator(expected, platform)) { + const isObservedAbsolute = isAbsoluteModulePath(observed, platform); if (!isObservedAbsolute && exactCandidates.includes(observed)) return "match"; const normalizePath = (path: string) => - format === "pe" ? path.replaceAll("\\", "/").toLowerCase() : path; + platform === "pe" ? path.replaceAll("\\", "/").toLowerCase() : path; if ( !isObservedAbsolute && - format === "pe" && + platform === "pe" && exactCandidates.some( (candidate) => normalizePath(candidate) === normalizePath(observed), ) @@ -203,55 +213,53 @@ const moduleValueCompatibility = ( } const sameFilename = exactCandidates.some( (candidate) => - moduleFilename(candidate, false, format) === - moduleFilename(observed, false, format), + moduleFilename(candidate, false, platform) === + moduleFilename(observed, false, platform), ); return sameFilename ? "unknown" : "mismatch"; } - const observedFilename = moduleFilename(observed, true, format); + const observedFilename = moduleFilename(observed, true, platform); if (exactCandidates.includes(observedFilename)) return "match"; const caseFoldedCandidates = exactCandidates.map((candidate) => - moduleFilename(candidate, false, format), + moduleFilename(candidate, false, platform), ); - const observedFolded = moduleFilename(observed, false, format); + const observedFolded = moduleFilename(observed, false, platform); if (!caseFoldedCandidates.includes(observedFolded)) { if ( - format !== "pe" && - format !== "elf" && - format !== "mach-o" && + platform === null && plausiblePlatformCandidates(expected) - .map((candidate) => moduleFilename(candidate, false, format)) + .map((candidate) => moduleFilename(candidate, false, platform)) .includes(observedFolded) ) return "unknown"; return "mismatch"; } - if (format === "elf") return "mismatch"; - if (format === "pe") return "match"; + if (platform === "elf") return "mismatch"; + if (platform === "pe") return "match"; return "unknown"; }; const moduleCompatibility = ( scope: string | null, symbol: NativeSymbol, - format: string | null, + platform: ManagedNativePlatform, ): ModuleCompatibility => { if ( scope === null || scope.trim().length === 0 || - moduleFilename(scope, false, format).length === 0 + moduleFilename(scope, false, platform).length === 0 ) return "unknown"; - const observedValues = isAbsoluteModulePath(scope, format) + const observedValues = isAbsoluteModulePath(scope, platform) ? [symbol.module_path] : [symbol.module_name, symbol.module_path]; const values = observedValues.filter( (value): value is string => - value !== null && moduleFilename(value, false, format).length > 0, + value !== null && moduleFilename(value, false, platform).length > 0, ); if (values.length === 0) return "unknown"; const results = values.map((value) => - moduleValueCompatibility(scope, value, format), + moduleValueCompatibility(scope, value, platform), ); if (results.includes("match")) return "match"; if (results.includes("unknown")) return "unknown"; @@ -263,26 +271,22 @@ const candidateNames = ( PinvokeImport, "import_name" | "char_set" | "call_convention" | "no_mangle" >, - nativeFormat: string | null, + platform: ManagedNativePlatform, ): readonly string[] => { if (managed.import_name.length === 0) return []; const names = new Set([managed.import_name]); // Unix P/Invoke lookup uses the declared entry point directly, regardless of // marshalling charset or calling convention. Keep Windows decorations local // to PE or an unresolved native platform; metadata flags remain unchanged. - if ( - !managed.no_mangle && - nativeFormat !== "elf" && - nativeFormat !== "mach-o" - ) { + if (!managed.no_mangle && (platform === "pe" || platform === null)) { if ( managed.char_set === "unicode" || - (nativeFormat === "pe" && managed.char_set === "auto") + (platform === "pe" && managed.char_set === "auto") ) names.add(`${managed.import_name}W`); if ( managed.char_set === "ansi" || - (nativeFormat === "pe" && managed.char_set === "not-specified") + (platform === "pe" && managed.char_set === "not-specified") ) names.add(`${managed.import_name}A`); if (managed.call_convention === "stdcall") { @@ -312,15 +316,16 @@ interface PinvokeCandidateMatch { const pinvokeCandidates = ( managed: PinvokeImport, symbols: readonly NativeSymbol[], - moduleFormatsByEvidenceId: ReadonlyMap, + modulePlatformsByEvidenceId: ReadonlyMap, ): readonly NativeSymbol[] => { - const namesByFormat = new Map(); + const namesByPlatform = new Map(); return symbols.filter((symbol) => { - const format = moduleFormatsByEvidenceId.get(symbol.evidence_id) ?? null; - let names = namesByFormat.get(format); + const platform = + modulePlatformsByEvidenceId.get(symbol.evidence_id) ?? null; + let names = namesByPlatform.get(platform); if (names === undefined) { - names = candidateNames(managed, format); - namesByFormat.set(format, names); + names = candidateNames(managed, platform); + namesByPlatform.set(platform, names); } return names.some((name) => sameSymbolName(name, symbol)); }); @@ -330,12 +335,12 @@ const matchPinvoke = ( managed: PinvokeImport, symbols: readonly NativeSymbol[], supportedNativeEvidence: number, - moduleFormatsByEvidenceId: ReadonlyMap, + modulePlatformsByEvidenceId: ReadonlyMap, ): PinvokeCandidateMatch => { const allCandidates = pinvokeCandidates( managed, symbols, - moduleFormatsByEvidenceId, + modulePlatformsByEvidenceId, ); const [first, ...remaining] = allCandidates; if (first === undefined) @@ -357,7 +362,7 @@ const matchPinvoke = ( moduleCompatibility( managed.import_scope_name, symbol, - moduleFormatsByEvidenceId.get(symbol.evidence_id) ?? null, + modulePlatformsByEvidenceId.get(symbol.evidence_id) ?? null, ); const exact = candidates.find( (symbol) => @@ -460,7 +465,10 @@ interface VerifyPinvokeContext { readonly native: { readonly symbols: readonly NativeSymbol[]; readonly accepted: number; - readonly moduleFormatsByEvidenceId: ReadonlyMap; + readonly modulePlatformsByEvidenceId: ReadonlyMap< + string, + ManagedNativePlatform + >; }; } @@ -489,7 +497,7 @@ export const verifyPinvoke = ({ item, native.symbols, native.accepted, - native.moduleFormatsByEvidenceId, + native.modulePlatformsByEvidenceId, ); const limitations = pinvokeLimitations(selection.match, item); const context: PinvokeVerificationContext = { diff --git a/src/domain/managed/managedNativeVerificationSource.test.ts b/src/domain/managed/managedNativeVerificationSource.test.ts index d68c228d85..003aedc97c 100644 --- a/src/domain/managed/managedNativeVerificationSource.test.ts +++ b/src/domain/managed/managedNativeVerificationSource.test.ts @@ -30,6 +30,7 @@ describe("managed/native verification source", () => { if (!source.ok) throw source.error; const [observation] = source.value.nativeObservations; expect(observation?.supported).toBe(true); + expect(observation?.platform).toBeNull(); expect(observation?.symbols.map(({ name }) => name)).toEqual([ "open_native", ]); diff --git a/src/domain/managed/managedNativeVerificationSource.ts b/src/domain/managed/managedNativeVerificationSource.ts index 5164e5945a..a9c5414e9c 100644 --- a/src/domain/managed/managedNativeVerificationSource.ts +++ b/src/domain/managed/managedNativeVerificationSource.ts @@ -1,3 +1,4 @@ +import { isMachOFormat } from "../binaryTargetTypes.js"; import { getEvidenceInputContract } from "../evidenceInputContract.js"; import { managedNativeVerificationRequestSchema } from "./managedNativeVerificationSchemas.js"; @@ -30,10 +31,14 @@ export interface ManagedNativeVerificationSource { readonly nativeObservations: readonly ManagedNativeObservationFacts[]; } +/** Module lookup semantics proved by the native subject's format. */ +export type ManagedNativePlatform = "pe" | "elf" | "mach-o" | null; + export interface ManagedNativeObservationFacts { readonly evidence: Evidence; readonly symbols: readonly NativeSymbol[]; readonly supported: boolean; + readonly platform: ManagedNativePlatform; /** Coverage of an export inventory; function observations are targeted. */ readonly exportCoverage?: { readonly exhaustive: boolean; @@ -58,6 +63,12 @@ const readNativeObservation = ( const read = readEvidence(raw); if (!read.ok) return read; const evidence = read.value; + const format = evidence.subject?.format; + const platform: ManagedNativePlatform = isMachOFormat(format) + ? "mach-o" + : format === "pe" || format === "elf" + ? format + : null; if (evidence.operation === "inspect_macho") { const payload = readEvidencePayload( retainedInspectMachoSchema, @@ -66,6 +77,7 @@ const readNativeObservation = ( if (!payload.ok) return payload; return ok({ evidence, + platform, supported: true, exportCoverage: { exhaustive: payload.value.exports.exhaustive, @@ -90,6 +102,7 @@ const readNativeObservation = ( if (!payload.ok) return payload; return ok({ evidence, + platform, supported: true, symbols: [ { @@ -104,7 +117,12 @@ const readNativeObservation = ( ], }); } - return ok({ evidence, supported: false, symbols: [] }); + return ok({ + evidence, + platform, + supported: false, + symbols: [], + }); }; /** Authenticate and parse source Evidence once, retaining it for session recording. */