diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 405f939456..b8a6e5c6dd 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -345,7 +345,10 @@ are unresolved. Known Object/Reflect mutators remain recognizable through literal destructuring of intrinsic objects and stable bound-function aliases. Calls retain bound argument prefixes and the original bound receiver through `call`, `apply`, and -`Reflect.apply`, including generic `Function.prototype` invocation; unexamined +`Reflect.apply`, including generic `Function.prototype` invocation and binding +creation. Visibly replaced invocation intrinsics produce unknown effects instead +of using their former call semantics; captured function aliases retain their +earlier identity. Unexamined targets remain unresolved. Creating an unused binding does not itself mutate an intrinsic. Variable redeclarations retain a single unconditional initializer when they introduce no assignment or second initializer. diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index 2fa971b839..c06b87ac3d 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -552,6 +552,13 @@ describe("Electron module interop bound mutators", () => { 'Function.prototype.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', 'Function.prototype.apply.call(Object.defineProperty,null,[Object,"create",{value:customCreate}]);', 'const uncurry = Function.prototype.call.bind(Function.prototype.call); uncurry(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'const change = Function.prototype.bind.call(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + 'const bind = Function.prototype.call.bind(Function.prototype.bind); const change = bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + 'const change = Function.prototype.bind.apply(Object.defineProperty,[null,Object,"create"]); change({value:customCreate});', + 'const change = Reflect.apply(Function.prototype.bind,Object.defineProperty,[null,Object,"create"]); change({value:customCreate});', + 'const first = Object.defineProperty.bind(null,Object); const change = Function.prototype.bind.call(first,null,"create"); change({value:customCreate});', + 'Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', + 'const apply = Reflect.apply; Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', 'const change = Reflect.set.bind(null,Object,"create"); change(customCreate);', 'const change = Object.defineProperty.call; change(null,Object,"create",{value:customCreate});', 'const {apply:change} = Object.defineProperty; change(null,[Object,"create",{value:customCreate}]);', @@ -570,6 +577,11 @@ describe("Electron module interop bound mutators", () => { 'const first = Object.defineProperty.bind(null,metadata); const change = first.bind(null,"x"); change({value:1});', 'const change = Function.prototype.call.bind(Object.defineProperty,null); change(metadata,"x",{value:1});', 'const change = Function.prototype.apply.bind(Object.defineProperty,null); change([metadata,"x",{value:1}]);', + 'const change = Function.prototype.bind.call(Object.defineProperty,null,metadata,"x"); change({value:1});', + 'const bind = Function.prototype.call.bind(Function.prototype.bind); const change = bind(Object.defineProperty,null,metadata,"x"); change({value:1});', + 'const change = Function.prototype.bind.apply(Object.defineProperty,[null,metadata,"x"]); change({value:1});', + 'const change = Reflect.apply(Function.prototype.bind,Object.defineProperty,[null,metadata,"x"]); change({value:1});', + 'const apply = Reflect.apply; Reflect.apply = replacement; apply(Object.defineProperty,null,[metadata,"x",{value:1}]);', 'const change = Object.defineProperty.bind(null,Object,"create");', 'Object.defineProperty.call(null,metadata,"x",{value:1});', 'Object.defineProperty.apply(null,[metadata,"x",{value:1}]);', diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 90f79bf7ca..3ee0b50442 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -43,6 +43,7 @@ export const electronMemberPath = ( /** A known intrinsic callable and its examined positional arguments. */ export interface JavaScriptIntrinsicInvocation { + readonly kind: "known" | "opaque"; /** The original bound receiver; rebinding cannot replace it. */ readonly receiver?: t.Node; readonly path: readonly string[]; @@ -82,6 +83,11 @@ export function* collectJavaScriptIdentityWritesSteps( enter: (node) => { if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { const invocation = intrinsicInvocation(node); + if (invocation?.kind === "opaque") { + const scope = origin(node, "opaque-call"); + if (scope !== undefined) retain(node, scope); + return; + } const callee = invocation?.path; const arguments_ = invocation?.arguments; const method = callee?.at(-1); diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index 22b1eddfcb..e34efe04a6 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -32,6 +32,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( node: t.CallExpression | t.OptionalCallExpression, intrinsicPath: (node: t.Node) => readonly string[] | undefined, boundIntrinsic: (node: t.Node) => JavaScriptIntrinsicInvocation | undefined, + intrinsicReadChanged: (node: t.Node, path: readonly string[]) => boolean, ): JavaScriptIntrinsicInvocation | undefined => { const visited = new Set(); let callee: t.Node = node.callee; @@ -42,6 +43,8 @@ export const resolveJavaScriptIntrinsicInvocation = ( if (visited.has(callee)) return undefined; visited.add(callee); let path = intrinsicPath(callee); + if (path !== undefined && intrinsicReadChanged(callee, path)) + return { kind: "opaque", path, arguments: undefined }; if (path === undefined) { if ( t.isMemberExpression(callee) || @@ -52,6 +55,12 @@ export const resolveJavaScriptIntrinsicInvocation = ( callee.computed, ); if (method === "call" || method === "apply") { + const bound = boundIntrinsic(callee.object); + if ( + bound !== undefined && + intrinsicReadChanged(callee, [...bound.path, method]) + ) + return { kind: "opaque", path: bound.path, arguments: undefined }; receiver = arguments_?.[0]; callee = callee.object; arguments_ = invocationArguments(method, arguments_); @@ -60,6 +69,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( } const bound = boundIntrinsic(callee); if (bound === undefined) return undefined; + if (bound.kind === "opaque") return bound; path = bound.path; receiver = bound.receiver; arguments_ = @@ -92,7 +102,12 @@ export const resolveJavaScriptIntrinsicInvocation = ( arguments_ = arrayArguments(arguments_?.[2]); continue; } - return { path, arguments: arguments_ }; + return { + kind: "known", + path, + arguments: arguments_, + ...(receiver === undefined ? {} : { receiver }), + }; } }; diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index c55527f998..35e1c9ef12 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -205,7 +205,31 @@ export const createSemanticModuleInterop = ( } }; - const boundIntrinsic = ( + const intrinsicReadChanged = ( + node: t.Node, + path: readonly string[], + ): boolean => { + const capture = capturedIntrinsic(node)?.capturedAt ?? node; + const scope = opaqueMutationPath(capture).join("."); + const method = path.at(-1); + const invocationMethod = + method === "bind" || method === "call" || method === "apply"; + return writes.some( + (write) => + (write.scope === scope || write.scope === "intrinsic") && + write.offset < (capture.start ?? -1) && + (write.coverage.covers(path) || + (invocationMethod && + write.coverage.covers([ + "intrinsic", + "Function", + "prototype", + method, + ]))), + ); + }; + + const resolveBoundIntrinsic = ( node: t.Node, ): JavaScriptIntrinsicInvocation | undefined => { const visited = new Set(); @@ -236,26 +260,49 @@ export const createSemanticModuleInterop = ( continue; } if (!t.isCallExpression(current)) return undefined; - const { root: bindTarget, members } = electronMemberPath(current.callee); - if (members.at(-1) !== "bind") return undefined; - const captured = capturedIntrinsic(current.callee); - const terminal = captured?.path.at(-1) === "bind"; - // Only a direct .bind can continue a previously bound callable. Keeping - // intervening members would require a separate proof of their identity. - if (!terminal && members.length !== 1) return undefined; - bindReads.push(captured?.capturedAt ?? current.callee); - receiver = current.arguments[0]; - const ownArguments = current.arguments.slice(1); - opaqueArguments ||= t.isSpreadElement(receiver); + const { root, members } = electronMemberPath(current.callee); + let bindTarget = root; + let bindingArguments: JavaScriptIntrinsicInvocation["arguments"] = + current.arguments; + let captured = capturedIntrinsic(current.callee); + let terminal = captured?.path.at(-1) === "bind"; + let path: readonly string[] | undefined = captured?.path.slice(0, -1); + if (members.at(-1) === "bind") { + // A direct .bind may continue a bound callable, without discarding members. + if (!terminal && members.length !== 1) return undefined; + bindReads.push(captured?.capturedAt ?? current.callee); + } else { + // Reuse the invocation owner for generic call/apply/Reflect.apply of bind. + const invocation = resolveJavaScriptIntrinsicInvocation( + current, + intrinsicPath, + boundIntrinsic, + intrinsicReadChanged, + ); + if ( + invocation?.kind !== "known" || + invocation.path.join(".") !== "intrinsic.Function.prototype.bind" || + invocation.receiver === undefined + ) + return undefined; + bindTarget = invocation.receiver; + bindingArguments = invocation.arguments; + captured = capturedIntrinsic(bindTarget); + terminal = captured !== undefined; + path = captured?.path; + } + receiver = bindingArguments?.[0]; + const ownArguments = bindingArguments?.slice(1) ?? []; + opaqueArguments ||= + bindingArguments === undefined || t.isSpreadElement(receiver); opaqueArguments ||= ownArguments.some((argument) => t.isSpreadElement(argument), ); argumentGroups.push(ownArguments); - if (!terminal || captured === undefined) { + if (!terminal || path === undefined) { current = bindTarget; continue; } - const path = captured.path.slice(0, -1); const boundPath = [...path, "bind"]; const functionBind = ["intrinsic", "Function", "prototype", "bind"]; opaqueArguments ||= bindReads.some((capture) => { @@ -269,6 +316,7 @@ export const createSemanticModuleInterop = ( ); }); return { + kind: opaqueArguments ? "opaque" : "known", path, ...(receiver === undefined ? {} : { receiver }), // Rebinding appends arguments to the earlier bound argument list. @@ -279,6 +327,19 @@ export const createSemanticModuleInterop = ( } }; + const activeBoundValues = new WeakSet(); + const boundIntrinsic = ( + node: t.Node, + ): JavaScriptIntrinsicInvocation | undefined => { + if (activeBoundValues.has(node)) return undefined; + activeBoundValues.add(node); + try { + return resolveBoundIntrinsic(node); + } finally { + activeBoundValues.delete(node); + } + }; + const isFreshObject = (node: t.Node): boolean => { const visited = new Set(); let current = node; @@ -549,7 +610,12 @@ export const createSemanticModuleInterop = ( callOrigin, intrinsicPath, intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) => - resolveJavaScriptIntrinsicInvocation(node, intrinsicPath, boundIntrinsic), + resolveJavaScriptIntrinsicInvocation( + node, + intrinsicPath, + boundIntrinsic, + intrinsicReadChanged, + ), boundIntrinsic, staticString, isFreshObject, diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index d16051c1f4..9e6387c980 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -24,6 +24,14 @@ const INTRINSIC_MUTATIONS = [ "uncurried.cjs", 'const change = Function.prototype.call.bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});', ], + [ + "generic-bind.cjs", + 'const change = Function.prototype.bind.call(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + ], + [ + "replaced-apply.cjs", + 'Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', + ], [ "bound.cjs", 'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});', @@ -93,6 +101,8 @@ cliTest.for(["CLI", "stdio MCP"] as const)( require("./decorated.cjs"); require("./bound.cjs"); require("./uncurried.cjs"); + require("./generic-bind.cjs"); + require("./replaced-apply.cjs"); throw new Error("must not execute main"); `; await writeFile(join(root, "main.cjs"), source); @@ -119,7 +129,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( preload_entrypoints: 1, }); expect(result.statistics.parse_failures).toBe(0); - expect(result.statistics.parsed_javascript_files).toBe(7); + expect(result.statistics.parsed_javascript_files).toBe(9); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", );