From 1c37914306b3ff257d7a0360f11c9bf2a3f2cac7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:25 +0800 Subject: [PATCH] fix(javascript): retain wrapped isolated-world bridge exposures Share contextBridge argument ownership between direct and wrapped analysis, seed intact isolated-world calls, and preserve dynamic world IDs and conditional metadata as unknown. Keep callee evaluation scoped to each invocation. Validation: 99 focused source tests and bundled isolated-world acceptance through dedicated and generic CLI routes. Removed duplicate literal source regression in favor of public workflow coverage. Fixes #2174. --- .../electronStaticAnalysisBrowser.ts | 26 ++--- .../electronWrappedCallPaths.test.ts | 54 ++++++++++ .../javascript/electronWrappedCallPaths.ts | 101 +++++++++++++++--- .../javascriptContextBridgeExposure.ts | 35 ++++++ .../applicationWorkflowCli.test.ts | 55 ++++++++++ 5 files changed, 243 insertions(+), 28 deletions(-) create mode 100644 src/domain/javascript/javascriptContextBridgeExposure.ts diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index afe03b8e9..438648892 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -9,6 +9,7 @@ import type { ElectronUtilityProcessFinding, ElectronWebPreference, } from "./electronStaticAnalysisTypes.js"; +import { contextBridgeExposureArguments } from "./javascriptContextBridgeExposure.js"; import { boundedExpression, collectContextBridgeMembers, @@ -166,18 +167,19 @@ const inspectContextBridge = ( context: JavaScriptFindingContext, ): void => { const path = electronCalleePath(node.callee, context); - const exposureMethod = - path?.length === 2 && path[0] === "contextBridge" ? path[1] : undefined; - const main = exposureMethod === "exposeInMainWorld"; - const isolated = exposureMethod === "exposeInIsolatedWorld"; - if (!main && !isolated) return; - const keyNode = argumentNode(node.arguments[isolated ? 1 : 0]); - const apiNode = argumentNode(node.arguments[isolated ? 2 : 1]); + const exposure = + path?.length === 2 && path[0] === "contextBridge" + ? contextBridgeExposureArguments(path[1] ?? "", node.arguments) + : undefined; + if (exposure === undefined) return; + const keyNode = argumentNode(exposure.apiKey); + const apiNode = argumentNode(exposure.api); const key = electronStaticValue(context.source, keyNode); const api = collectContextBridgeMembers(apiNode); - const worldId = isolated - ? electronStaticValue(context.source, argumentNode(node.arguments[0])) - : null; + const worldId = + exposure.worldId === null + ? null + : electronStaticValue(context.source, argumentNode(exposure.worldId)); const unknown = (key.status === "dynamic" ? 1 : 0) + (worldId?.status === "dynamic" ? 1 : 0) + @@ -191,7 +193,7 @@ const inspectContextBridge = ( : { api_key: null, api_key_expression: null }; const finding: ElectronContextBridgeFinding = { ...apiKey, - world: isolated ? "isolated" : "main", + world: exposure.world, world_id: worldId, api_status: api.status, members: api.members, @@ -201,7 +203,7 @@ const inspectContextBridge = ( }; addLocatedFinding(context, { collection: context.accumulator.contextBridgeApis, - key: `context-bridge\0${isolated ? "isolated" : "main"}\0${finding.api_key ?? finding.api_key_expression ?? "[missing]"}`, + key: `context-bridge\0${exposure.world}\0${finding.api_key ?? finding.api_key_expression ?? "[missing]"}`, node, value: finding, }); diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 47d61004a..2740d7915 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -140,6 +140,60 @@ it("preserves an observed empty contextBridge API object", () => { expect(result.parse_status).toBe("complete"); }); +describe("wrapped contextBridge exposure methods", () => { + it("retains the original expression for a dynamic isolated world id", () => { + const result = analyzeJavaScriptStaticSource( + bundle( + `const worldId = selectWorld(); electron.contextBridge.exposeInIsolatedWorld(worldId, "isolatedApi", {ping: true});`, + ), + ); + expect(result.electron.context_bridge_apis).toContainEqual( + expect.objectContaining({ + world: "isolated", + world_id: { + status: "dynamic", + value: null, + expression: "worldId", + }, + api_key: "isolatedApi", + members: ["ping"], + resolution_basis: "wrapper-substitution", + }), + ); + expect(result.parse_status).toBe("partial"); + }); + + it("does not report a conditionally selected world as unconditional", () => { + const result = analyzeJavaScriptStaticSource( + bundle( + `if (useIsolated) electron.contextBridge.exposeInIsolatedWorld(1001, "isolatedApi", {ping: true});`, + ), + ); + expect(result.electron.context_bridge_apis).not.toContainEqual( + expect.objectContaining({ + api_key: "isolatedApi", + resolution_basis: "wrapper-substitution", + }), + ); + expect(result.parse_status).toBe("partial"); + }); + + it("does not report conditionally resolved world ids as unconditional", () => { + const result = analyzeJavaScriptStaticSource( + bundle( + `const worldId = useFirstWorld ? 1001 : 1002; electron.contextBridge.exposeInIsolatedWorld(worldId, "isolatedApi", {ping: true});`, + ), + ); + expect(result.electron.context_bridge_apis).not.toContainEqual( + expect.objectContaining({ + api_key: "isolatedApi", + resolution_basis: "wrapper-substitution", + }), + ); + expect(result.parse_status).toBe("partial"); + }); +}); + it("connects exposed closures to IPC sends and text clipboard calls", () => { const result = analyzeJavaScriptStaticSource( bundle(` diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index b7cc873b4..048ad30f5 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -14,9 +14,17 @@ import type { JavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisSt import type { ElectronCallPath, ElectronCallPathStep, + ElectronContextBridgeFinding, ElectronIpcDescriptor, ElectronIpcFinding, + ElectronStaticValue, } from "./electronStaticAnalysisTypes.js"; +import { + contextBridgeExposureArguments, + type JavaScriptContextBridgeExposureArguments, +} from "./javascriptContextBridgeExposure.js"; +import { boundedExpression } from "./electronStaticAnalysisValues.js"; +import { argumentNode } from "./javascriptStaticAnalysisHelpers.js"; import { childNodes, traverseJavaScriptAst, @@ -114,6 +122,41 @@ export const collectElectronWrappedCallPaths = ( }; const hasConditions = (value: SymbolicValue): boolean => symbolicVariants(value).some((variant) => variant.conditions?.length); + const isolatedBridgeCall = ( + node: t.CallExpression | t.OptionalCallExpression, + ): + | { readonly env: SymbolicEnvironment; readonly callee: SymbolicValue } + | undefined => { + const env = resolver.environmentAt(node); + const callee = resolver.evaluate(node.callee, env); + return symbolicVariants(callee).some( + (callee) => + callee.kind === "external" && + identity.intactExport(callee.path) && + callee.path.slice(1).join(".") === + "contextBridge.exposeInIsolatedWorld", + ) + ? { env, callee } + : undefined; + }; + const electronStaticSymbolicValue = ( + value: SymbolicValue | undefined, + node: t.Node | undefined, + ): ElectronStaticValue => { + const variants = value === undefined ? [] : symbolicVariants(value); + const variant = variants.length === 1 ? variants[0] : undefined; + if (variant?.kind === "literal" && variant.value !== undefined) + return { + status: "literal", + value: variant.value, + expression: null, + }; + return { + status: "dynamic", + value: null, + expression: boundedExpression(source, node), + }; + }; const emit = ( context: PathContext, node: t.Node, @@ -288,17 +331,34 @@ export const collectElectronWrappedCallPaths = ( }; const bridge = ( node: t.CallExpression | t.OptionalCallExpression, - args: readonly SymbolicValue[], + exposure: JavaScriptContextBridgeExposureArguments, context: PathContext, guardedDispatch: boolean, ): void => { - const key = args[0]; - const object = args[1]; + const key = exposure.apiKey; + const object = exposure.api; + const worldValue = + exposure.worldId === null + ? null + : (exposure.worldId ?? symbolicUnknown("missing world id")); + const worldContext = + worldValue === null ? context : metadataContext(context, worldValue); + if (!worldContext) return; + const worldId = + worldValue === null + ? null + : electronStaticSymbolicValue( + worldValue, + exposure.worldIdIndex === null + ? undefined + : argumentNode(node.arguments[exposure.worldIdIndex]), + ); + if (worldId?.status === "dynamic") accumulator.unknownFindings += 1; if (key?.kind !== "literal" || typeof key.value !== "string" || !object) { accumulator.unknownFindings += 1; return; } - const keyContext = metadataContext(context, key); + const keyContext = metadataContext(worldContext, key); if (!keyContext || !metadataContext(keyContext, object)) return; const members = [...resolver.members(object)].sort(); const location = range(node); @@ -306,9 +366,9 @@ export const collectElectronWrappedCallPaths = ( ({ value }) => JSON.stringify(value.location) === JSON.stringify(location), ); - const finding = { - world: "main" as const, - world_id: null, + const finding: ElectronContextBridgeFinding = { + world: exposure.world, + world_id: worldId, api_key: key.value, api_key_expression: null, api_status: "dynamic" as const, @@ -320,10 +380,12 @@ export const collectElectronWrappedCallPaths = ( }; // This report shape has no predicate field, so a guarded world or object // cannot be recorded as an unconditional exposure fact. + const guardedWorld = worldValue !== null && hasConditions(worldValue); const guardedKey = hasConditions(key); const guardedObject = hasConditions(object); const guarded = context.conditions.length > 0 || + guardedWorld || guardedKey || guardedObject || guardedDispatch; @@ -370,8 +432,9 @@ export const collectElectronWrappedCallPaths = ( node: t.CallExpression | t.OptionalCallExpression, env: SymbolicEnvironment, context: PathContext, + evaluatedCallee?: SymbolicValue, ): void => { - const value = resolver.evaluate(node.callee, env); + const value = evaluatedCallee ?? resolver.evaluate(node.callee, env); const args = resolver.arguments(node, env); for (const callee of symbolicVariants(value)) { const branchContext = metadataContext(context, callee); @@ -394,7 +457,13 @@ export const collectElectronWrappedCallPaths = ( const endpoint = path.join("."); if (identity.intactExport(path)) { const descriptor = ipcDescriptor(path.slice(1)); - if (descriptor) + const exposure = + path.length === 3 && path[1] === "contextBridge" + ? contextBridgeExposureArguments(path[2] ?? "", args) + : undefined; + if (exposure) + bridge(node, exposure, branchContext, hasConditions(callee)); + else if (descriptor) ipc( node, descriptor, @@ -403,10 +472,6 @@ export const collectElectronWrappedCallPaths = ( endpoint, hasConditions(callee), ); - else if ( - path.slice(1).join(".") === "contextBridge.exposeInMainWorld" - ) - bridge(node, args, branchContext, hasConditions(callee)); else if (path[1] === "clipboard") emit(branchContext, node, endpoint); } else if ( (path[0] === "navigator" && path[1] === "clipboard") || @@ -488,8 +553,12 @@ export const collectElectronWrappedCallPaths = ( enter: (node) => { if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; - if (!t.isStringLiteral(node.arguments[0])) return; - const env = resolver.environmentAt(node); + const isolatedSeed = t.isStringLiteral(node.arguments[0]) + ? undefined + : isolatedBridgeCall(node); + if (!t.isStringLiteral(node.arguments[0]) && isolatedSeed === undefined) + return; + const env = isolatedSeed?.env ?? resolver.environmentAt(node); let context: PathContext = { entryNode: node, entry: "", @@ -536,7 +605,7 @@ export const collectElectronWrappedCallPaths = ( return; } } - call(node, env, context); + call(node, env, context, isolatedSeed?.callee); }, }); if (remainingVisits < 0 || resolver.truncated) { diff --git a/src/domain/javascript/javascriptContextBridgeExposure.ts b/src/domain/javascript/javascriptContextBridgeExposure.ts new file mode 100644 index 000000000..960bfadd5 --- /dev/null +++ b/src/domain/javascript/javascriptContextBridgeExposure.ts @@ -0,0 +1,35 @@ +/** One contextBridge exposure method and its API argument positions. */ +export interface JavaScriptContextBridgeExposureArguments { + readonly world: "main" | "isolated"; + readonly worldId: Argument | null | undefined; + readonly worldIdIndex: number | null; + readonly apiKey: Argument | undefined; + readonly api: Argument | undefined; +} + +/** + * Select the semantic arguments for a contextBridge exposure method. + * Callers retain authority over callee identity and interpretation of values. + */ +export const contextBridgeExposureArguments = ( + method: string, + arguments_: readonly Argument[], +): JavaScriptContextBridgeExposureArguments | undefined => { + if (method === "exposeInMainWorld") + return { + world: "main", + worldId: null, + worldIdIndex: null, + apiKey: arguments_[0], + api: arguments_[1], + }; + if (method === "exposeInIsolatedWorld") + return { + world: "isolated", + worldId: arguments_[0], + worldIdIndex: 0, + apiKey: arguments_[1], + api: arguments_[2], + }; + return undefined; +}; diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index a81471e9d..6548d210b 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -18,6 +18,7 @@ import { SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, } from "../../../src/contracts/javascript/javascriptApplicationWorkflowExamples.js"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; import { javascriptApplicationAnalysisResultSchema, type JavaScriptApplicationAnalysisResult, @@ -1530,6 +1531,60 @@ const analyzeComparisonSources = async ( ); }; +it("retains bundled isolated-world contextBridge APIs through dedicated and generic CLI routes", async () => { + const root = await createTestTempDirectory( + "rea-context-bridge-isolated-cli-", + ); + await writeFile( + join(root, "preload.js"), + `(() => { + const modules = { + 2(module) { module.exports = require("electron"); }, + 5(module, exports, load) { + const electron = load(2); + electron.contextBridge.exposeInIsolatedWorld(1001, "isolatedApi", { ping: true }); + }, + }; + function load(id) { + const module = { exports: {} }; + modules[id].call(module.exports, module, module.exports, load); + return module.exports; + } + load(5); + })();`, + ); + + for (const arguments_ of [ + ["analyze-javascript-application", root, "--json"], + ["analyze", root, "--format", "json"], + ]) { + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(await runCli(arguments_)).normalized_result, + ); + expect(result.summary.context_bridge_apis).toBe(1); + const bridge = result.graph.nodes.find( + ({ kind }) => kind === "context-bridge-api", + ); + expect(bridge?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + world: "isolated", + world_id: { status: "literal", value: 1001, expression: null }, + api_key: "isolatedApi", + members: ["ping"], + resolution_basis: "wrapper-substitution", + }), + evidence: expect.objectContaining({ + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ source: "preload.js" }), + }), + }), + }), + ); + } +}); + const runCli = async ( arguments_: readonly string[], environment: Readonly> = {},