diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 192a650f9..1bbbb6af0 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -346,7 +346,9 @@ 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 targets remain unresolved. Creating an unused binding does not itself mutate -an intrinsic. Variable redeclarations retain a single unconditional initializer +an intrinsic. Generic `Function.prototype.call` and `apply` follow the callable +receiver, including a bound receiver; rebinding does not replace an earlier +bound receiver. Variable redeclarations retain a single unconditional initializer when they introduce no assignment or second initializer. Stable identifier chains to fresh object, array, function, undecorated class or RegExp syntax keep those targets separate from intrinsic diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index a9f436a82..8dcb71587 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -506,6 +506,52 @@ describe("Electron module interop capture and scope", () => { }); }); +describe("Electron module interop generic invocations", () => { + it.each([ + 'const change = Function.prototype.call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});', + 'const call = Function.prototype.call; const change = call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});', + 'const change = Function.prototype.call.bind(Object.defineProperty,null,Object,"create"); change({value:customCreate});', + 'Function.prototype.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'Function.prototype.call.apply(Object.defineProperty,[null,Object,"create",{value:customCreate}]);', + 'Reflect.apply(Function.prototype.call,Object.defineProperty,[null,Object,"create",{value:customCreate}]);', + 'const change = Function.prototype.apply.bind(Object.defineProperty); change(null,[Object,"create",{value:customCreate}]);', + 'Function.prototype.apply.call(Object.defineProperty,null,[Object,"create",{value:customCreate}]);', + 'Reflect.apply(Function.prototype.apply,Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);', + 'const first = Function.prototype.call.bind(Object.defineProperty,null,Object,"create"); const change = first.bind(fake); change({value:customCreate});', + "const change = Function.prototype.call.bind(Object.defineProperty); change(null,...args);", + "const change = Function.prototype.apply.bind(Object.defineProperty); change(null,args);", + 'const change = Function.prototype.call.bind(Object.defineProperty,null,...args); change(metadata,"x",{value:1});', + 'Function.prototype.call.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'Object.defineProperty.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'Function.prototype.apply.apply(Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);', + 'const change = Object.defineProperty.call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});', + 'const change = Function.prototype.call.call.bind(Object.defineProperty,null,Object,"create"); change({value:customCreate});', + ])("follows the callable receiver of a generic invocation: %s", (write) => { + expect( + analyzeJavaScriptStaticSource( + `${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toEqual([]); + }); + + it.each([ + 'const change = Function.prototype.call.bind(Object.defineProperty); change(null,metadata,"x",{value:1});', + 'const change = Function.prototype.call.bind(Object.defineProperty,null,metadata,"x"); change({value:1});', + 'Function.prototype.call.call(Object.defineProperty,null,metadata,"x",{value:1});', + 'const change = Function.prototype.apply.bind(Object.defineProperty); change(null,[metadata,"x",{value:1}]);', + 'Reflect.apply(Function.prototype.apply,Object.defineProperty,[null,[metadata,"x",{value:1}]]);', + 'Function.prototype.call.call.call(Object.defineProperty,null,metadata,"x",{value:1});', + 'Function.prototype.apply.apply(Object.defineProperty,[null,[metadata,"x",{value:1}]]);', + 'const change = Object.defineProperty.call.bind(Object.defineProperty); change(null,metadata,"x",{value:1});', + ])("preserves fresh targets through a generic invocation: %s", (write) => { + expect( + analyzeJavaScriptStaticSource( + `const metadata = {}; ${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toHaveLength(1); + }); +}); + describe("Electron module interop bound mutators", () => { it.each([ 'const change = Object.defineProperty.bind(null); change(Object,"create",{value:customCreate});', @@ -519,9 +565,9 @@ describe("Electron module interop bound mutators", () => { 'const metadata = {}; Object.defineProperty.call(...args,metadata,"x",{value:1});', 'const metadata = {}; Object.defineProperty.apply(...args,[metadata,"x",{value:1}]);', '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}]);', - 'const change = Reflect.apply.call; change(null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);', + 'const change = Object.defineProperty.call; change.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[Object,"create",{value:customCreate}]);', + 'const change = Reflect.apply.call; change.call(Reflect.apply,null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);', ])("rejects intrinsic writes through a bound mutator: %s", (write) => { expect( analyzeJavaScriptStaticSource( @@ -537,8 +583,8 @@ describe("Electron module interop bound mutators", () => { 'const change = Object.defineProperty.bind(null,Object,"create");', 'Object.defineProperty.call(null,metadata,"x",{value:1});', 'Object.defineProperty.apply(null,[metadata,"x",{value:1}]);', - 'const change = Object.defineProperty.call; change(null,metadata,"x",{value:1});', - 'const {apply:change} = Object.defineProperty; change(null,[metadata,"x",{value:1}]);', + 'const change = Object.defineProperty.call; change.call(Object.defineProperty,null,metadata,"x",{value:1});', + 'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[metadata,"x",{value:1}]);', ])("keeps a fresh bound target or unused binding separate: %s", (write) => { expect( analyzeJavaScriptStaticSource( diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index 32bab79f7..e36f85d6f 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -41,20 +41,34 @@ export const resolveJavaScriptIntrinsicInvocation = ( const visited = new Set(); let callee: t.Node = node.callee; let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments; + let thisArgument: t.Node | undefined; + let explicitReceiver = false; for (;;) { callee = unwrapJavaScriptExpression(callee).node; - let path = intrinsicPath(callee); - // Static projections can capture call/apply themselves as aliases. - while (path !== undefined && path.length > 3) { - const method = path.at(-1); - if (method !== "call" && method !== "apply") break; + const path = intrinsicPath(callee); + const method = + path !== undefined && path.length > 3 ? path.at(-1) : undefined; + if (method === "call" || method === "apply") { + // These properties hold generic function values. Their receiver selects + // the callable; property-chain depth does not consume more arguments. + const callable: t.Node | undefined = explicitReceiver + ? thisArgument + : t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee) + ? callee.object + : undefined; + if (!t.isExpression(callable)) return undefined; + callee = callable; + thisArgument = arguments_?.[0]; + explicitReceiver = true; arguments_ = invocationArguments(method, arguments_); - path = path.slice(0, -1); + continue; } if (path?.join(".") === "intrinsic.Reflect.apply") { const callable = arguments_?.[0]; if (!t.isExpression(callable)) return undefined; callee = callable; + thisArgument = arguments_?.[1]; + explicitReceiver = true; arguments_ = arrayArguments(arguments_?.[2]); continue; } @@ -66,6 +80,8 @@ export const resolveJavaScriptIntrinsicInvocation = ( ); if (method === "call" || method === "apply") { callee = callee.object; + thisArgument = arguments_?.[0]; + explicitReceiver = true; arguments_ = invocationArguments(method, arguments_); continue; } @@ -79,6 +95,9 @@ export const resolveJavaScriptIntrinsicInvocation = ( ) return undefined; const prefix = callee.arguments.slice(1); + // An inner bind owns the receiver even when the result is rebound. + thisArgument = callee.arguments[0]; + explicitReceiver = true; arguments_ = t.isSpreadElement(callee.arguments[0]) ? undefined : prefix.length === 0 diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index ed988df0f..73e6943e4 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -10,6 +10,37 @@ import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +const writePoisonedModules = async (root: string): Promise => { + 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});', + ], + [ + "uncurried.cjs", + 'const change = Function.prototype.call.bind(Object.defineProperty,null,Object,"create"); change({value:customCreate});', + ], + ] as const) + await writeFile( + join(root, name), + `${mutation} + ${ROLLDOWN_MODULE_INTEROP} + let poisoned = require("electron"); + poisoned = __toESM(poisoned, 1); + new poisoned.BrowserWindow({}); + throw new Error("must not execute poisoned source");`, + ); +}; + cliTest.for(["CLI", "stdio MCP"] as const)( "retains converted Electron windows and preload evidence through %s", async (surface, { cli }) => { @@ -22,34 +53,13 @@ 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) - await writeFile( - join(root, name), - `${mutation} - ${ROLLDOWN_MODULE_INTEROP} - let poisoned = require("electron"); - poisoned = __toESM(poisoned, 1); - new poisoned.BrowserWindow({}); - throw new Error("must not execute poisoned source");`, - ); + await writePoisonedModules(root); const source = `const metadata = {}; Object.defineProperty(metadata,"x",{value:1}); const decorateMetadata = Object.defineProperty.bind(null,metadata,"bound"); decorateMetadata({value:1}); + const decorateViaCall = Function.prototype.call.bind(Object.defineProperty); + decorateViaCall(null,metadata,"uncurried",{value:1}); const localFunction = () => {}; Object.defineProperty(localFunction,"x",{value:1}); const localClass = class {}; @@ -86,6 +96,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 +123,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", );