mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(managed): respect native module case semantics
Preserve the observed native format when comparing module identities so ELF case-only differences remain mismatches while unknown platform behavior stays unresolved. Fixes #6652
This commit is contained in:
1 parent
9200adea61
commit
72dd48a44c
3 files changed
+203
-30
No files matched your search
@@ -443,7 +443,10 @@ point identities with supplied native export or function evidence. Name/module
|
||||
matches establish declaration links; they do not establish that a call executed.
|
||||
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.
|
||||
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.
|
||||
|
||||
Metadata tokens are not native addresses. Native body mapping for C++/CLI,
|
||||
ReadyToRun, NativeAOT, and IL2CPP requires separately verified format and provider
|
||||
|
||||
@@ -21,11 +21,17 @@ export const collectNativeSymbols = (
|
||||
readonly symbols: readonly NativeSymbol[];
|
||||
readonly accepted: number;
|
||||
readonly unsupported: number;
|
||||
readonly moduleFormatsByEvidenceId: ReadonlyMap<string, string | null>;
|
||||
} => {
|
||||
const symbols: NativeSymbol[] = [];
|
||||
const moduleFormatsByEvidenceId = new Map<string, string | null>();
|
||||
let accepted = 0;
|
||||
let unsupported = 0;
|
||||
for (const observation of observations) {
|
||||
moduleFormatsByEvidenceId.set(
|
||||
observation.evidence.evidence_id,
|
||||
observation.evidence.subject?.format ?? null,
|
||||
);
|
||||
if (!observation.supported) {
|
||||
unsupported += 1;
|
||||
} else {
|
||||
@@ -33,7 +39,7 @@ export const collectNativeSymbols = (
|
||||
symbols.push(...observation.symbols);
|
||||
}
|
||||
}
|
||||
return { symbols, accepted, unsupported };
|
||||
return { symbols, accepted, unsupported, moduleFormatsByEvidenceId };
|
||||
};
|
||||
|
||||
const normalizedSymbolName = (native: NativeSymbol): string =>
|
||||
@@ -44,21 +50,27 @@ const normalizedSymbolName = (native: NativeSymbol): string =>
|
||||
const sameSymbolName = (expected: string, native: NativeSymbol): boolean =>
|
||||
expected === normalizedSymbolName(native);
|
||||
|
||||
const normalizeModule = (value: string): string =>
|
||||
basename(value)
|
||||
.toLowerCase()
|
||||
.replace(/\.(dll|dylib|so|node|exe)$/u, "");
|
||||
type ModuleCompatibility = "match" | "mismatch" | "unknown";
|
||||
|
||||
/** A declared module identity, or null when normalization leaves no name. */
|
||||
const declaredModuleIdentity = (scope: string | null): string | null => {
|
||||
if (scope === null || scope.trim().length === 0) return null;
|
||||
const identity = normalizeModule(scope);
|
||||
return identity.length > 0 ? identity : null;
|
||||
const declaredModuleIdentity = (scope: string | null): boolean =>
|
||||
scope !== null &&
|
||||
scope.trim().length > 0 &&
|
||||
normalizeModule(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 nativeModuleIdentities = (value: string): ReadonlySet<string> => {
|
||||
const filename = basename(value).toLowerCase();
|
||||
const stem = normalizeModule(filename);
|
||||
const moduleIdentities = (
|
||||
value: string,
|
||||
caseSensitive: boolean,
|
||||
): ReadonlySet<string> => {
|
||||
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));
|
||||
@@ -66,23 +78,52 @@ const nativeModuleIdentities = (value: string): ReadonlySet<string> => {
|
||||
return identities;
|
||||
};
|
||||
|
||||
const moduleCompatible = (
|
||||
const moduleValueCompatibility = (
|
||||
expected: string,
|
||||
observed: string,
|
||||
format: string | null,
|
||||
): ModuleCompatibility => {
|
||||
if (
|
||||
[...moduleIdentities(expected, true)].some((identity) =>
|
||||
moduleIdentities(observed, true).has(identity),
|
||||
)
|
||||
)
|
||||
return "match";
|
||||
const caseFoldedMatch = [...moduleIdentities(expected, false)].some(
|
||||
(identity) => moduleIdentities(observed, false).has(identity),
|
||||
);
|
||||
if (!caseFoldedMatch) return "mismatch";
|
||||
if (format === "elf") return "mismatch";
|
||||
if (format === "pe") return "match";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const moduleCompatibility = (
|
||||
scope: string | null,
|
||||
symbol: NativeSymbol,
|
||||
): boolean => {
|
||||
const expected = declaredModuleIdentity(scope);
|
||||
if (expected === null) return false;
|
||||
return [symbol.module_name, symbol.module_path]
|
||||
.filter(
|
||||
(value): value is string =>
|
||||
value !== null && normalizeModule(value).length > 0,
|
||||
)
|
||||
.some((value) => nativeModuleIdentities(value).has(expected));
|
||||
format: string | null,
|
||||
): ModuleCompatibility => {
|
||||
if (
|
||||
scope === null ||
|
||||
scope.trim().length === 0 ||
|
||||
normalizeModule(scope, false).length === 0
|
||||
)
|
||||
return "unknown";
|
||||
const values = [symbol.module_name, symbol.module_path].filter(
|
||||
(value): value is string => value !== null && value.length > 0,
|
||||
);
|
||||
if (values.length === 0) return "unknown";
|
||||
const results = values.map((value) =>
|
||||
moduleValueCompatibility(scope, value, format),
|
||||
);
|
||||
if (results.includes("match")) return "match";
|
||||
if (results.includes("unknown")) return "unknown";
|
||||
return "mismatch";
|
||||
};
|
||||
|
||||
const hasModuleIdentity = (symbol: NativeSymbol): boolean =>
|
||||
[symbol.module_name, symbol.module_path].some(
|
||||
(value) => value !== null && normalizeModule(value).length > 0,
|
||||
(value) => value !== null && normalizeModule(value, false).length > 0,
|
||||
);
|
||||
|
||||
const candidateNames = (
|
||||
@@ -123,6 +164,7 @@ const matchPinvoke = (
|
||||
managed: PinvokeImport,
|
||||
symbols: readonly NativeSymbol[],
|
||||
supportedNativeEvidence: number,
|
||||
moduleFormatsByEvidenceId: ReadonlyMap<string, string | null>,
|
||||
): PinvokeCandidateMatch => {
|
||||
const names = candidateNames(managed);
|
||||
const allCandidates = symbols.filter((symbol) =>
|
||||
@@ -144,14 +186,23 @@ const matchPinvoke = (
|
||||
};
|
||||
|
||||
const candidates: [NativeSymbol, ...NativeSymbol[]] = [first, ...remaining];
|
||||
const scopeKnown = declaredModuleIdentity(managed.import_scope_name) !== null;
|
||||
const scopeKnown = declaredModuleIdentity(managed.import_scope_name);
|
||||
const moduleRelation = (symbol: NativeSymbol) =>
|
||||
moduleCompatibility(
|
||||
managed.import_scope_name,
|
||||
symbol,
|
||||
moduleFormatsByEvidenceId.get(symbol.evidence_id) ?? null,
|
||||
);
|
||||
const exact = candidates.find(
|
||||
(symbol) =>
|
||||
managed.import_name === normalizedSymbolName(symbol) &&
|
||||
moduleCompatible(managed.import_scope_name, symbol),
|
||||
moduleRelation(symbol) === "match",
|
||||
);
|
||||
const decorated = candidates.find((symbol) =>
|
||||
moduleCompatible(managed.import_scope_name, symbol),
|
||||
const unknownModule = candidates.find(
|
||||
(symbol) => moduleRelation(symbol) === "unknown",
|
||||
);
|
||||
const decorated = candidates.find(
|
||||
(symbol) => moduleRelation(symbol) === "match",
|
||||
);
|
||||
const selected =
|
||||
exact ??
|
||||
@@ -173,6 +224,16 @@ const matchPinvoke = (
|
||||
candidates,
|
||||
},
|
||||
};
|
||||
if (unknownModule !== undefined)
|
||||
return {
|
||||
match: {
|
||||
status: "unresolved",
|
||||
basis: "module-identity-unknown",
|
||||
confidence: "unknown",
|
||||
matched_native: unknownModule,
|
||||
candidates,
|
||||
},
|
||||
};
|
||||
if (decorated !== undefined)
|
||||
return {
|
||||
match: {
|
||||
@@ -239,6 +300,7 @@ interface VerifyPinvokeContext {
|
||||
readonly native: {
|
||||
readonly symbols: readonly NativeSymbol[];
|
||||
readonly accepted: number;
|
||||
readonly moduleFormatsByEvidenceId: ReadonlyMap<string, string | null>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,7 +325,12 @@ export const verifyPinvoke = ({
|
||||
call_convention: item.call_convention,
|
||||
declaration_verification: item.verification,
|
||||
};
|
||||
const selection = matchPinvoke(item, native.symbols, native.accepted);
|
||||
const selection = matchPinvoke(
|
||||
item,
|
||||
native.symbols,
|
||||
native.accepted,
|
||||
native.moduleFormatsByEvidenceId,
|
||||
);
|
||||
const limitations = pinvokeLimitations(selection.match, item);
|
||||
const context: PinvokeVerificationContext = {
|
||||
item_id: `mnv_pinvoke_${digestCanonicalValue(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../../../src/domain/managed/managedNativeVerificationSchemas.js";
|
||||
import { managedNativeBoundaryInspectionSchema } from "../../../../src/domain/managed/managedArtifact.js";
|
||||
import { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js";
|
||||
import { functionDossierSchema } from "../../../../src/domain/hopperValues.js";
|
||||
|
||||
import { verifyManagedNativeBoundaries } from "../../../../src/domain/managed/managedNativeVerification.js";
|
||||
const exampleInput = () =>
|
||||
@@ -67,6 +68,52 @@ const nativeEvidenceWithExports = (
|
||||
);
|
||||
};
|
||||
|
||||
const nativeFunctionEvidence = (
|
||||
modulePath: string,
|
||||
format: "elf" | "mach-o" | "pe" | "file",
|
||||
) => {
|
||||
const native = exampleInput().native_observations[0];
|
||||
if (native === undefined) throw new Error("missing native provider");
|
||||
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 fixture" },
|
||||
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: modulePath, sha256: "8".repeat(64), format },
|
||||
native.provider,
|
||||
{
|
||||
operation: "analyze_function",
|
||||
parameters: {},
|
||||
result: dossier,
|
||||
rawResult: null,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const inputWithImportScope = (scope: string | null) => {
|
||||
const input = exampleInput();
|
||||
const managed = input.managed_boundaries;
|
||||
@@ -246,6 +293,62 @@ it("reports module mismatch as a contradiction within supplied Evidence", () =>
|
||||
});
|
||||
});
|
||||
|
||||
it("matches ELF module names case-sensitively and leaves unknown platform equivalence unresolved", () => {
|
||||
const linuxInput = inputWithImportScope("caseprobe");
|
||||
const elfExact = verifyManagedNativeBoundaries({
|
||||
...linuxInput,
|
||||
native_observations: [
|
||||
nativeFunctionEvidence("/examples/libcaseprobe.so", "elf"),
|
||||
],
|
||||
});
|
||||
expect(elfExact.pinvoke_imports[0]).toMatchObject({
|
||||
status: "verified",
|
||||
basis: "exact-function-name",
|
||||
matched_native: { module_path: "/examples/libcaseprobe.so" },
|
||||
});
|
||||
|
||||
const elfCaseMismatch = verifyManagedNativeBoundaries({
|
||||
...linuxInput,
|
||||
native_observations: [
|
||||
nativeFunctionEvidence("/examples/libCaseProbe.so", "elf"),
|
||||
],
|
||||
});
|
||||
expect(elfCaseMismatch.pinvoke_imports[0]).toMatchObject({
|
||||
status: "contradicted",
|
||||
basis: "module-mismatch",
|
||||
matched_native: { module_path: "/examples/libCaseProbe.so" },
|
||||
});
|
||||
const workflow = verifyManagedNativeBoundariesEvidence({
|
||||
...linuxInput,
|
||||
native_observations: [
|
||||
nativeFunctionEvidence("/examples/libCaseProbe.so", "elf"),
|
||||
],
|
||||
});
|
||||
expect(workflow.ok).toBe(true);
|
||||
if (!workflow.ok) throw workflow.error;
|
||||
expect(workflow.value.normalized_result).toMatchObject({
|
||||
summary: { contradicted: 1, verified: 0 },
|
||||
pinvoke_imports: [
|
||||
expect.objectContaining({
|
||||
basis: "module-mismatch",
|
||||
confidence: "unknown",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const uncertainPlatform = verifyManagedNativeBoundaries({
|
||||
...inputWithImportScope("foo"),
|
||||
native_observations: [
|
||||
nativeEvidenceWithExports(["open_native"], "/examples/libFoo.dylib"),
|
||||
],
|
||||
});
|
||||
expect(uncertainPlatform.pinvoke_imports[0]).toMatchObject({
|
||||
status: "unresolved",
|
||||
basis: "module-identity-unknown",
|
||||
matched_native: { module_path: "/examples/libFoo.dylib" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps literal module names distinct and accepts the shared-library lib alias", () => {
|
||||
const literalName = verifyManagedNativeBoundaries({
|
||||
...inputWithImportScope("library"),
|
||||
|
||||
Reference in new issue
Block a user