diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 192a650f9..405f93945 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -344,7 +344,8 @@ reassignments, dynamic selections, and visibly overwritten namespace members are unresolved. Known Object/Reflect mutators remain recognizable through literal destructuring of intrinsic objects and stable bound-function aliases. Calls retain bound -argument prefixes through `call`, `apply`, and `Reflect.apply`; unexamined +argument prefixes and the original bound receiver through `call`, `apply`, and +`Reflect.apply`, including generic `Function.prototype` invocation; 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 1ececa18a..2fa971b83 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -546,6 +546,12 @@ describe("Electron module interop bound mutators", () => { 'const change = Object.defineProperty.bind(null,...args); change(Object,"create",{value:customCreate});', 'const metadata = {}; Object.defineProperty.call(...args,metadata,"x",{value:1});', 'const metadata = {}; Object.defineProperty.apply(...args,[metadata,"x",{value:1}]);', + 'const change = Function.prototype.call.bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + 'const first = Function.prototype.call.bind(Object.defineProperty,null); const change = first.bind(customReceiver,Object,"create"); change({value:customCreate});', + 'const change = Function.prototype.apply.bind(Object.defineProperty,null); change([Object,"create",{value:customCreate}]);', + '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 = 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}]);', @@ -562,6 +568,8 @@ describe("Electron module interop bound mutators", () => { 'const change = Object.defineProperty.bind(null); change(metadata,"x",{value:1});', 'const change = Object.defineProperty.bind(null,metadata,"x"); change({value:1});', '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 = 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 ef3e7f22a..90f79bf7c 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -43,6 +43,8 @@ export const electronMemberPath = ( /** A known intrinsic callable and its examined positional arguments. */ export interface JavaScriptIntrinsicInvocation { + /** The original bound receiver; rebinding cannot replace it. */ + readonly receiver?: t.Node; readonly path: readonly string[]; readonly arguments: | readonly t.CallExpression["arguments"][number][] diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index f8eb6d2fd..22b1eddfc 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -5,11 +5,6 @@ import { semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { - resolveSemanticBindingState, - semanticStableInitializer, - type JavaScriptSemanticAnalysisState, -} from "./javascriptSemanticState.js"; const arrayArguments = (array: t.Node | undefined) => t.isArrayExpression(array) && @@ -35,62 +30,79 @@ const invocationArguments = ( */ export const resolveJavaScriptIntrinsicInvocation = ( node: t.CallExpression | t.OptionalCallExpression, - state: JavaScriptSemanticAnalysisState, intrinsicPath: (node: t.Node) => readonly string[] | undefined, boundIntrinsic: (node: t.Node) => JavaScriptIntrinsicInvocation | undefined, ): JavaScriptIntrinsicInvocation | undefined => { - const visited = new Set(); + const visited = new Set(); let callee: t.Node = node.callee; let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments; + let receiver: t.Node | undefined; for (;;) { callee = unwrapJavaScriptExpression(callee).node; + if (visited.has(callee)) return undefined; + visited.add(callee); let path = intrinsicPath(callee); - // Static projections can capture call/apply themselves as aliases. - while (path !== undefined && path.length > 3) { + if (path === undefined) { + if ( + t.isMemberExpression(callee) || + t.isOptionalMemberExpression(callee) + ) { + const method = semanticStaticPropertyKey( + callee.property, + callee.computed, + ); + if (method === "call" || method === "apply") { + receiver = arguments_?.[0]; + callee = callee.object; + arguments_ = invocationArguments(method, arguments_); + continue; + } + } + const bound = boundIntrinsic(callee); + if (bound === undefined) return undefined; + path = bound.path; + receiver = bound.receiver; + arguments_ = + bound.arguments === undefined + ? undefined + : [...bound.arguments, ...(arguments_ ?? [])]; + } + // A projected .call/.apply invokes the preceding callable. The actual + // Function.prototype methods instead invoke their receiver, retained by bind. + while (path.length > 3 && !genericInvocationMethod(path)) { const method = path.at(-1); if (method !== "call" && method !== "apply") break; + receiver = arguments_?.[0]; arguments_ = invocationArguments(method, arguments_); path = path.slice(0, -1); } - if (path?.join(".") === "intrinsic.Reflect.apply") { + const genericMethod = genericInvocationMethod(path); + if (genericMethod !== undefined) { + if (!t.isExpression(receiver)) return undefined; + callee = receiver; + receiver = arguments_?.[0]; + arguments_ = invocationArguments(genericMethod, arguments_); + continue; + } + if (path.join(".") === "intrinsic.Reflect.apply") { const callable = arguments_?.[0]; if (!t.isExpression(callable)) return undefined; callee = callable; + receiver = arguments_?.[1]; arguments_ = arrayArguments(arguments_?.[2]); continue; } - if (path !== undefined) return { path, arguments: arguments_ }; - if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) { - const method = semanticStaticPropertyKey( - callee.property, - callee.computed, - ); - if (method === "call" || method === "apply") { - callee = callee.object; - arguments_ = invocationArguments(method, arguments_); - continue; - } - } - const bound = boundIntrinsic(callee); - if (bound !== undefined) - return { - path: bound.path, - arguments: - bound.arguments === undefined - ? undefined - : [...bound.arguments, ...(arguments_ ?? [])], - }; - if (!t.isIdentifier(callee)) return undefined; - const binding = resolveSemanticBindingState(state, callee, callee.name); - const initializer = semanticStableInitializer(binding, state); - if ( - binding === undefined || - initializer === undefined || - initializer.projection.length !== 0 || - visited.has(binding.bindingId) - ) - return undefined; - visited.add(binding.bindingId); - callee = initializer.node; + return { path, arguments: arguments_ }; } }; + +const genericInvocationMethod = ( + path: readonly string[], +): "call" | "apply" | undefined => + path.length === 4 && + path[0] === "intrinsic" && + path[1] === "Function" && + path[2] === "prototype" && + (path[3] === "call" || path[3] === "apply") + ? path[3] + : undefined; diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index bee2f4318..c55527f99 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -212,6 +212,7 @@ export const createSemanticModuleInterop = ( const argumentGroups: t.CallExpression["arguments"][] = []; const bindReads: t.Node[] = []; let opaqueArguments = false; + let receiver: t.Node | undefined; let current = node; for (;;) { current = unwrapJavaScriptExpression(current).node; @@ -243,11 +244,9 @@ export const createSemanticModuleInterop = ( // intervening members would require a separate proof of their identity. if (!terminal && members.length !== 1) return undefined; bindReads.push(captured?.capturedAt ?? current.callee); - const thisArgument = current.arguments[0]; + receiver = current.arguments[0]; const ownArguments = current.arguments.slice(1); - opaqueArguments ||= - thisArgument !== undefined && - !readExactJavaScriptLiteral(thisArgument).found; + opaqueArguments ||= t.isSpreadElement(receiver); opaqueArguments ||= ownArguments.some((argument) => t.isSpreadElement(argument), ); @@ -271,6 +270,7 @@ export const createSemanticModuleInterop = ( }); return { path, + ...(receiver === undefined ? {} : { receiver }), // Rebinding appends arguments to the earlier bound argument list. arguments: opaqueArguments ? undefined @@ -549,12 +549,7 @@ export const createSemanticModuleInterop = ( callOrigin, intrinsicPath, intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) => - resolveJavaScriptIntrinsicInvocation( - node, - state, - intrinsicPath, - boundIntrinsic, - ), + resolveJavaScriptIntrinsicInvocation(node, intrinsicPath, boundIntrinsic), boundIntrinsic, staticString, isFreshObject, diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index ed988df0f..d16051c1f 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -10,6 +10,26 @@ import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +const INTRINSIC_MUTATIONS = [ + ["poisoned.cjs", "({Object} = replacements);"], + [ + "projected.cjs", + 'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});', + ], + [ + "decorated.cjs", + '@(() => Object) class metadata {}; Object.defineProperty(metadata,"create",{value:customCreate});', + ], + [ + "uncurried.cjs", + 'const change = Function.prototype.call.bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + ], + [ + "bound.cjs", + 'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});', + ], +] as const; + cliTest.for(["CLI", "stdio MCP"] as const)( "retains converted Electron windows and preload evidence through %s", async (surface, { cli }) => { @@ -22,21 +42,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( join(root, "preload.js"), 'throw new Error("must stay inert");', ); - for (const [name, mutation] of [ - ["poisoned.cjs", "({Object} = replacements);"], - [ - "projected.cjs", - 'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});', - ], - [ - "decorated.cjs", - '@(() => Object) class metadata {}; Object.defineProperty(metadata,"create",{value:customCreate});', - ], - [ - "bound.cjs", - 'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});', - ], - ] as const) + for (const [name, mutation] of INTRINSIC_MUTATIONS) await writeFile( join(root, name), `${mutation} @@ -86,6 +92,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( require("./projected.cjs"); require("./decorated.cjs"); require("./bound.cjs"); + require("./uncurried.cjs"); throw new Error("must not execute main"); `; await writeFile(join(root, "main.cjs"), source); @@ -112,7 +119,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(6); + expect(result.statistics.parsed_javascript_files).toBe(7); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", );