diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 9d61a7b1df..c9d97e5927 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -371,7 +371,15 @@ Recognized literal module-table bootstraps also support bounded lexical substitution through module exports, import helper bodies, local wrapper calls, returned closures, object spreads, and callback parameters. Recovered Electron paths retain source ranges, module identities, literal channels and branch -conditions as static candidates. Unknown spreads, reassigned bindings, accessors, +conditions as static candidates. A channel the IPC call site itself +determines, such as a local constant, resolves that call's observation rather +than adding a second one. A channel or registration handler supplied through +a wrapper argument is a candidate beside the call's own unresolved +observation, because callers outside the traced paths may pass other values. +A wrapper call that is both its own entry and reached through another entry +is recorded once when both paths find the same facts; calls that supply +different handlers or channels stay distinct. +Unknown spreads, reassigned bindings, accessors, dynamic channels and unsupported syntax do not become exact implementations. This pass neither executes the bundle nor proves that runtime guards pass. An ambiguous or unresolved handler retains its channel registration but does diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index d52e8f57f6..a930fdce61 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -384,6 +384,324 @@ it("keeps a directly observed renderer operation singular when tracing its expos expect.objectContaining({ entry: "desktop.ping", channel: "ping" }), ); }); +it("resolves a site-determined constant channel in place of its dynamic observation", () => { + const result = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + const PING = "ping"; + contextBridge.exposeInMainWorld("desktop",{ping:()=>ipcRenderer.send(PING)}); + `); + expect(result.electron.ipc).toEqual([ + expect.objectContaining({ + side: "renderer", + operation: "send", + channel: "ping", + resolution_basis: "wrapper-substitution", + }), + ]); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ entry: "desktop.ping", channel: "ping" }), + ); + // The syntax pass's unknown channel is resolved, not left as partial. + expect(result.parse_status).toBe("complete"); +}); +it("resolves a site-determined registration whose handler is a named local", () => { + const result = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + const CH = "ch"; + const handler = () => {}; + contextBridge.exposeInMainWorld("desktop",{on:()=>ipcRenderer.on(CH, handler)}); + `); + expect(result.electron.ipc).toEqual([ + expect.objectContaining({ + side: "renderer", + mode: "listen", + channel: "ch", + handler_kind: "inline-function", + }), + ]); + expect(result.parse_status).toBe("complete"); +}); +it("keeps an argument-supplied channel unresolved beside its resolved candidates", () => { + const result = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + const forward = channel => ipcRenderer.send(channel); + contextBridge.exposeInMainWorld("desktop",{ + a:()=>forward("a"), + b:()=>forward("b"), + any:name=>forward(name), + }); + `); + const sends = result.electron.ipc.filter( + ({ side, operation }) => side === "renderer" && operation === "send", + ); + expect(sends.map(({ channel }) => channel).sort()).toEqual(["a", "b", null]); + expect( + new Set(sends.map(({ location }) => JSON.stringify(location))).size, + ).toBe(1); +}); +it.each([ + [ + "an exposed member", + `const forward = (channel, callback) => ipcRenderer.on(channel, callback); + contextBridge.exposeInMainWorld("desktop",{on:()=>forward("pong",()=>{})});`, + ], + [ + "an enclosing local call", + `const forward = (channel, callback) => ipcRenderer.on(channel, callback); + const notify = kind => forward("pong", () => {}); + notify("ready");`, + ], +])( + "keeps one invocation of a wrapper call also reached through %s", + (_, entry) => { + const result = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + ${entry} + `); + expect( + result.electron.ipc.filter(({ channel }) => channel === "pong"), + ).toHaveLength(1); + }, +); +it("keeps distinct handlers that separate outer calls pass through one wrapper call", () => { + const result = analyzeJavaScriptStaticSource(` + const e = require("electron"); + function register(channel, handler) { e.ipcMain.on(channel, handler); } + function setup(tag, handler) { register("same", handler); } + setup("first", () => 1); + setup("second", () => 2); + `); + const registrations = result.electron.ipc.filter( + ({ channel }) => channel === "same", + ); + // The wrapper call traced alone has an unresolved handler; each outer call + // supplies its own concrete handler. + expect(registrations.map(({ handler_kind }) => handler_kind).sort()).toEqual([ + "dynamic-expression", + "inline-function", + "inline-function", + ]); + expect( + new Set( + registrations.map(({ handler_location }) => + JSON.stringify(handler_location), + ), + ).size, + ).toBe(3); +}); +it("keeps the source registration when only its channel is site-determined", () => { + const result = analyzeJavaScriptStaticSource(` + const { ipcMain } = require("electron"); + const CH = "ch"; + function register(seed, handler) { ipcMain.on(CH, handler); } + register("seed", () => 1); + `); + expect( + result.electron.ipc.map(({ channel, handler_kind, resolution_basis }) => [ + channel, + handler_kind, + resolution_basis ?? "direct", + ]), + ).toEqual([ + [null, "identifier", "direct"], + ["ch", "inline-function", "wrapper-substitution"], + ]); +}); +it.each([ + [ + "a closure over a wrapper parameter", + `function register(seed) { + const handler = () => ipcRenderer.send(seed); + ipcRenderer.on(CH, handler); + }`, + ], + [ + "an arrow function reading its wrapper's arguments", + `function register(seed) { + const handler = () => arguments[0]; + ipcRenderer.on(CH, handler); + }`, + ], + [ + "an arrow function reading its wrapper's new.target", + `function register(seed) { + const handler = () => new.target; + ipcRenderer.on(CH, handler); + }`, + ], + [ + "a closure returned from a factory of a wrapper parameter", + `const make = value => () => value; + function register(seed) { + const handler = make(seed); + ipcRenderer.on(CH, handler); + }`, + ], +])("keeps distinct listeners when one handler node is %s", (_, wrapper) => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const CH = "ch", A = "a", B = "b"; + ${wrapper} + contextBridge.exposeInMainWorld("first", { on: () => register(A) }); + contextBridge.exposeInMainWorld("second", { on: () => register(B) }); + `); + // Each invocation installs its own closure; the direct source observation + // stays for callers this pass does not trace. + expect( + result.electron.ipc + .filter(({ mode }) => mode === "listen") + .map(({ channel, resolution_basis }) => [ + channel, + resolution_basis ?? "direct", + ]), + ).toEqual([ + [null, "direct"], + ["ch", "wrapper-substitution"], + ["ch", "wrapper-substitution"], + ]); + expect( + (result.electron.call_paths ?? []) + .filter(({ channel }) => channel === "ch") + .map(({ entry }) => entry) + .sort(), + ).toEqual(["first.on", "second.on"]); +}); +it("resolves a bundle module's handler in place as one shared listener", () => { + const result = analyzeJavaScriptStaticSource( + bundle(`const CH = "ch"; + const handler = () => electron.ipcRenderer.send("reply"); + electron.contextBridge.exposeInMainWorld("api", { + on: () => electron.ipcRenderer.on(CH, handler), + });`), + ); + expect( + result.electron.ipc + .filter(({ mode, module_key }) => mode === "listen" && module_key === "9") + .map(({ channel, resolution_basis }) => [ + channel, + resolution_basis ?? "direct", + ]), + ).toEqual([["ch", "wrapper-substitution"]]); +}); +it("resolves an arrow handler in place when its inherited this is module scope", () => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const CH = "ch"; + const register = () => { + const handler = () => this; + ipcRenderer.on(CH, handler); + }; + contextBridge.exposeInMainWorld("api", { on: () => register() }); + `); + expect( + result.electron.ipc + .filter(({ mode }) => mode === "listen") + .map(({ channel, resolution_basis }) => [ + channel, + resolution_basis ?? "direct", + ]), + ).toEqual([["ch", "wrapper-substitution"]]); +}); +it("resolves a handler a module-level factory call created once in place", () => { + const result = analyzeJavaScriptStaticSource(` + const { ipcMain } = require("electron"); + const CH = "ch"; + const make = () => { const value = Math.random(); return () => value; }; + const handler = make(); + function register(seed) { ipcMain.on(CH, handler); } + register("x"); + `); + expect( + result.electron.ipc.map(({ channel, resolution_basis }) => [ + channel, + resolution_basis ?? "direct", + ]), + ).toEqual([["ch", "wrapper-substitution"]]); + expect(result.parse_status).toBe("complete"); +}); +it("keeps separate outer calls that pass one shared handler through a wrapper", () => { + const result = analyzeJavaScriptStaticSource(` + const { ipcMain } = require("electron"); + const copy = () => {}; + function register(ch, handler) { ipcMain.on(ch, handler); } + function setup(tag) { register("same", copy); } + setup("first"); + setup("second"); + `); + const registrations = result.electron.ipc.filter( + ({ channel }) => channel === "same", + ); + // The shorter entry traced the same wrapper call alone; both outer calls + // supersede it as distinct invocations. + expect(registrations).toHaveLength(2); + expect( + new Set( + registrations.map(({ invocation_location }) => + JSON.stringify(invocation_location), + ), + ).size, + ).toBe(2); +}); +it("keeps a parameterless factory's closure as a per-invocation handler", () => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const CH = "ch"; + const make = () => { const value = Math.random(); return () => value; }; + function register() { + const handler = make(); + ipcRenderer.on(CH, handler); + } + contextBridge.exposeInMainWorld("api", { on: () => register() }); + `); + expect( + result.electron.ipc + .filter(({ mode }) => mode === "listen") + .map(({ resolution_basis }) => resolution_basis ?? "direct"), + ).toEqual(["direct", "wrapper-substitution"]); +}); +it("resolves a module-level handler in place when the entry binds an unused parameter", () => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const CH = "ch"; + const handler = () => {}; + contextBridge.exposeInMainWorld("api", { + on: unused => ipcRenderer.on(CH, handler), + }); + `); + expect(result.electron.ipc).toEqual([ + expect.objectContaining({ + mode: "listen", + channel: "ch", + resolution_basis: "wrapper-substitution", + }), + ]); + expect(result.parse_status).toBe("complete"); +}); +it.each([ + ["a named local", 'const handler = () => tag; register("same", handler);'], + ["an inline argument", 'register("same", () => tag);'], + [ + "a nested helper", + 'const inner = () => register("same", () => tag); inner();', + ], +])("keeps closures that separate outer calls create through %s", (_, body) => { + const result = analyzeJavaScriptStaticSource(` + const { ipcMain } = require("electron"); + function register(ch, handler) { ipcMain.on(ch, handler); } + function setup(tag) { ${body} } + setup("first"); + setup("second"); + `); + const invocations = result.electron.ipc + .filter( + ({ channel, resolution_basis }) => + channel === "same" && resolution_basis === "wrapper-substitution", + ) + .map(({ invocation_location }) => JSON.stringify(invocation_location)); + // Both outer calls stay, beside the shorter entry that traced the same + // wrapper call without either caller's value. + expect(new Set(invocations).size).toBe(3); +}); it("counts one exposed renderer listener callsite once and retains its bridge path", () => { const result = analyzeJavaScriptStaticSource(` const {contextBridge,ipcRenderer}=require("electron"); diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index 9a9632850e..4226b49b50 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -9,7 +9,11 @@ import { type SymbolicEnvironment, type SymbolicFunction, } from "./javascriptSymbolicValues.js"; -import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; +import { + resolveSemanticBindingState, + semanticResolutionBlocked, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; import type { JavaScriptElectronIdentity } from "./javascriptSemanticAnalysis.js"; import type { JavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisState.js"; import type { @@ -54,14 +58,79 @@ interface IpcEndpointFacts { // that source registration, not another registration at the same callsite. observedRendererListener: boolean; readonly invocations: Set; + // Call chains of substituted findings, by value and then by final call. + // Chains can only share a suffix when they end in the same call. + readonly reached: Map>; +} + +/** One substituted finding's call chain and the slot holding its record. */ +interface ReachedChain { + calls: readonly string[]; + readonly slot: number; } const ipcInvocationKey = (location: unknown): string => String(JSON.stringify(location)); +/** A finding's facts apart from which entry invoked it. */ +const ipcValueKey = (value: ElectronIpcFinding): string => + JSON.stringify({ ...value, invocation_location: undefined }); + +/** + * Whether two call chains end in the same calls, so one entry traced the same + * physical call sequence with less enclosing context than the other. + */ +const sharesCallSuffix = ( + left: readonly string[], + right: readonly string[], +): boolean => { + const [shorter, longer] = + left.length <= right.length ? [left, right] : [right, left]; + const offset = longer.length - shorter.length; + return shorter.every((call, index) => longer[offset + index] === call); +}; + +/** + * How an identical finding was already reached through the same trailing + * calls. A path with as much or more enclosing context makes this one a + * repeat. A shorter path is a nested entry this path invokes, so this path's + * fuller invocation supersedes its record, while other outer calls through the + * same sequence stay distinct invocations. + */ +const priorPath = ( + facts: IpcEndpointFacts, + value: string, + calls: readonly string[], +): { readonly repeated: boolean; readonly shorter?: ReachedChain } => { + const byLast = facts.reached.get(value); + if (byLast === undefined) return { repeated: false }; + // An empty chain is a suffix of every chain. + const candidates = + calls.length === 0 + ? [...byLast.values()].flat() + : [...(byLast.get(calls.at(-1) ?? "") ?? []), ...(byLast.get("") ?? [])]; + let shorter: ReachedChain | undefined; + for (const prior of candidates) { + if (!sharesCallSuffix(prior.calls, calls)) continue; + if (prior.calls.length >= calls.length) return { repeated: true }; + shorter ??= prior; + } + return shorter === undefined + ? { repeated: false } + : { repeated: false, shorter }; +}; + +/** One physical IPC operation, independent of how its channel was resolved. */ +const ipcSiteKey = (value: ElectronIpcFinding): string => + JSON.stringify([value.side, value.operation, value.location]); + const indexIpcFinding = ( index: Map, value: ElectronIpcFinding, + // A substitution that resolves a directly observed operation inherits its + // observation, so later paths to that operation stay duplicates. + observed = value.resolution_basis === undefined, + reached?: ReachedChain, ): void => { const key = ipcEndpointKey(value); let facts = index.get(key); @@ -70,21 +139,31 @@ const indexIpcFinding = ( observedTransmission: false, observedRendererListener: false, invocations: new Set(), + reached: new Map(), }; index.set(key, facts); } facts.observedTransmission ||= - value.resolution_basis === undefined && - (value.mode === "send" || value.mode === "invoke"); + observed && (value.mode === "send" || value.mode === "invoke"); facts.observedRendererListener ||= - value.resolution_basis === undefined && - value.side === "renderer" && - value.mode === "listen"; + observed && value.side === "renderer" && value.mode === "listen"; facts.invocations.add(ipcInvocationKey(value.invocation_location)); + if (reached !== undefined) { + const valueKey = ipcValueKey(value); + const byLast = + facts.reached.get(valueKey) ?? new Map(); + facts.reached.set(valueKey, byLast); + const last = reached.calls.at(-1) ?? ""; + const chains = byLast.get(last); + if (chains === undefined) byLast.set(last, [reached]); + else chains.push(reached); + } }; interface PathContext { readonly entryNode: t.Node; + /** Source ranges of the local calls this path made since its entry. */ + readonly calls: string[]; readonly entry: string; readonly entryPath?: readonly string[]; readonly entryKind: ElectronCallPath["entry_kind"] | null; @@ -193,13 +272,188 @@ export const collectElectronWrappedCallPaths = ( // Duplicate checks consult only facts at the same endpoint and call site. // Scanning every prior fact made bundles with many registrations quadratic. let ipcByEndpoint: Map | undefined; + // Direct operations whose channel the syntax pass left unresolved, by site. + let unresolvedIpcSites: Map | undefined; const ipcAtEndpoint = (): Map => { if (ipcByEndpoint !== undefined) return ipcByEndpoint; ipcByEndpoint = new Map(); - for (const { value } of accumulator.ipc) + unresolvedIpcSites = new Map(); + for (const [index, { value }] of accumulator.ipc.entries()) { indexIpcFinding(ipcByEndpoint, value); + if (value.resolution_basis === undefined && value.channel === null) + unresolvedIpcSites.set(ipcSiteKey(value), index); + } return ipcByEndpoint; }; + /** The literal channel at an IPC site independent of any invocation. */ + const siteDeterminedChannel = ( + node: t.CallExpression | t.OptionalCallExpression, + ): string | undefined => { + const argument = node.arguments[0]; + if (argument === undefined || t.isSpreadElement(argument)) return undefined; + const variants = symbolicVariants( + resolver.evaluate(argument, resolver.environmentAt(node)), + ); + const only = variants.length === 1 ? variants[0] : undefined; + return only?.kind === "literal" && + typeof only.value === "string" && + !only.conditions?.length + ? only.value + : undefined; + }; + const activationCaptures = new WeakMap(); + /** + * Whether the handler closes over a binding of a function this trace + * activated, such as a wrapper parameter or a factory local. Each activation + * may bind a different value, so such a closure is not one fact the call + * site determines. Bindings of unactivated scopes, such as module or bundle + * factory scope, and the handler's own bindings are shared by every trace. + */ + const capturesActivationBindings = ( + fn: SymbolicFunction, + context: PathContext, + ): boolean => { + const cached = activationCaptures.get(fn); + if (cached !== undefined) return cached; + const { captures, traceDependent } = closesOverActivation(fn, context); + if (!traceDependent) activationCaptures.set(fn, captures); + return captures; + }; + /** + * Whether an activation was called outside every function except bundle + * module factories, such as a factory call in a module-level initializer. + * Unless the trace itself makes that call, it ran once per module load and + * its bindings are shared by every trace. + */ + const calledAtModuleScope = (env: SymbolicEnvironment): boolean => { + const call = env.point; + if (call === undefined || call === env.scopeNode) return false; + for ( + let current = state.parentsByNode.get(call); + current; + current = state.parentsByNode.get(current) + ) + if (t.isFunction(current) && !resolver.isModuleFactory(current)) + return false; + return true; + }; + const closesOverActivation = ( + fn: SymbolicFunction, + context: PathContext, + ): { readonly captures: boolean; readonly traceDependent: boolean } => { + const activated = new Set(); + let traceDependent = false; + const traced = (call: t.Node): boolean => + call === context.entryNode || + context.calls.includes(ipcInvocationKey(range(call))); + for ( + let env: SymbolicEnvironment | undefined = fn.environment; + env; + env = env.parent + ) + if ( + env.activation === undefined || + env.scopeNode === undefined || + resolver.isModuleFactory(env.scopeNode) + ) + continue; + else if (!calledAtModuleScope(env)) activated.add(env.scopeNode); + else { + traceDependent = true; + if (env.point !== undefined && traced(env.point)) + activated.add(env.scopeNode); + } + // Scopes from the handler out to the outermost activated function, and + // whether the function owning an arrow handler's inherited context, such + // as `this` and `arguments`, lies within them. + const enclosing: string[] = []; + let activatedScopes = 0; + let contextOwnerActivated = false; + let contextOwnerFound = !t.isArrowFunctionExpression(fn.node); + for ( + let current = state.parentsByNode.get(fn.node); + current; + current = state.parentsByNode.get(current) + ) { + const scopeId = state.scopeByNode.get(current)?.scopeId; + if (scopeId !== undefined) enclosing.push(scopeId); + if (t.isFunction(current) && !t.isArrowFunctionExpression(current)) + contextOwnerFound = true; + // An activated arrow inside the owner does not rebind its context. + if (activated.has(current)) { + activatedScopes = enclosing.length; + contextOwnerActivated ||= contextOwnerFound; + } + } + if (activatedScopes === 0) return { captures: false, traceDependent }; + const scopes = new Set(enclosing.slice(0, activatedScopes)); + let captures = false; + t.traverseFast(fn.node, (node) => { + if (captures) return; + if ( + t.isThisExpression(node) || + t.isSuper(node) || + (t.isMetaProperty(node) && node.meta.name === "new") + ) { + captures = + contextOwnerActivated && inheritsFunctionContext(node, fn.node); + return; + } + if (!t.isIdentifier(node)) return; + const parent = state.parentsByNode.get(node); + if (parent !== undefined && !t.isReferenced(node, parent)) return; + const binding = resolveSemanticBindingState(state, node, node.name); + captures = + binding === undefined + ? (node.name === "arguments" && + contextOwnerActivated && + inheritsFunctionContext(node, fn.node)) || + semanticResolutionBlocked(state, node, node.name) + : scopes.has(binding.scopeId); + }); + return { captures, traceDependent }; + }; + /** + * Whether `this`, `arguments`, `super` or `new.target` at a node belongs to + * a function enclosing the handler, as it does through arrow functions. + */ + const inheritsFunctionContext = (node: t.Node, handler: t.Function) => { + for ( + let current = state.parentsByNode.get(node); + current; + current = state.parentsByNode.get(current) + ) { + if (t.isFunction(current) && !t.isArrowFunctionExpression(current)) + return false; + if (current === handler) return t.isArrowFunctionExpression(handler); + } + return false; + }; + /** + * Whether a registration's handler at an IPC site is this substituted + * handler independent of any invocation: the same function value in the + * site's lexical environment that closes over no traced activation's + * bindings, or no handler argument at all. + */ + const siteDeterminesHandler = ( + node: t.CallExpression | t.OptionalCallExpression, + fn: SymbolicFunction | undefined, + context: PathContext, + ): boolean => { + const argument = node.arguments[1]; + if (argument === undefined) return fn === undefined; + if (fn === undefined || t.isSpreadElement(argument)) return false; + const variants = symbolicVariants( + resolver.evaluate(argument, resolver.environmentAt(node)), + ); + const only = variants.length === 1 ? variants[0] : undefined; + return ( + only?.kind === "function" && + only.node === fn.node && + !capturesActivationBindings(fn, context) && + !only.conditions?.length + ); + }; const hasConditions = (value: SymbolicValue): boolean => symbolicVariants(value).some((variant) => variant.conditions?.length); function* bridgeMemberFunctions( @@ -404,13 +658,28 @@ export const collectElectronWrappedCallPaths = ( }; // A fully observed transmission stays one source fact when its callback is // traced. Registrations retain invocation identity because handlers compete. + // An entry nested in another entry's calls traces the same physical call + // sequence; an identical finding from it adds no invocation. + const perInvocationHandler = + fn !== undefined && capturesActivationBindings(fn, context); const recorded = ipcAtEndpoint().get(ipcEndpointKey(finding)); + const valueKey = ipcValueKey({ + ...finding, + resolution_basis: "wrapper-substitution", + }); + // A closure over this invocation's bindings is its own handler, even when + // another entry already traced the same calls. + const prior = + recorded === undefined || perInvocationHandler + ? undefined + : priorPath(recorded, valueKey, context.calls); const duplicate = recorded !== undefined && (recorded.observedTransmission || (context.entryKind === "bridge-member" && recorded.observedRendererListener) || - recorded.invocations.has(ipcInvocationKey(invocationLocation))); + recorded.invocations.has(ipcInvocationKey(invocationLocation)) || + prior?.repeated === true); const findingGuarded = context.conditions.length > 0 || hasConditions(first) || guardedDispatch; // Registry facts do not establish every bridge entry's reachability. Only @@ -435,10 +704,51 @@ export const collectElectronWrappedCallPaths = ( ...finding, resolution_basis: "wrapper-substitution", }; - accumulator.ipc.push({ offset: node.start ?? -1, value }); - indexIpcFinding(ipcAtEndpoint(), value); - if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn) - accumulator.unknownFindings += 1; + const shorter = prior?.shorter; + if (shorter !== undefined && recorded !== undefined) { + // This path invokes the sequence a nested entry traced alone, so its + // fuller invocation takes over that record instead of adding another. + accumulator.ipc[shorter.slot] = { offset: node.start ?? -1, value }; + shorter.calls = context.calls; + recorded.invocations.add(ipcInvocationKey(invocationLocation)); + } else { + // A finding the site itself determines, such as one whose channel is a + // local constant, is the same physical operation the syntax pass left + // unresolved, so it resolves that observation. A channel or handler from + // an invocation argument is one candidate; other callers may pass + // values this pass never sees. + const site = ipcSiteKey(value); + const observed = unresolvedIpcSites?.get(site); + const unresolved = + observed !== undefined && + siteDeterminedChannel(node) === channel && + (descriptor.mode === "send" || + descriptor.mode === "invoke" || + siteDeterminesHandler(node, fn, context)) + ? observed + : undefined; + const slot = unresolved ?? accumulator.ipc.length; + if (unresolved === undefined) + accumulator.ipc.push({ offset: node.start ?? -1, value }); + else { + accumulator.ipc[unresolved] = { offset: node.start ?? -1, value }; + unresolvedIpcSites?.delete(site); + // The syntax pass counted this channel as unknown; it is now known. + accumulator.unknownFindings = Math.max( + 0, + accumulator.unknownFindings - 1, + ); + } + indexIpcFinding( + ipcAtEndpoint(), + value, + unresolved !== undefined, + // Its call chain can never deduplicate a later path. + perInvocationHandler ? undefined : { calls: context.calls, slot }, + ); + if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn) + accumulator.unknownFindings += 1; + } } else if ( findingGuarded && (registrationKey === undefined || @@ -580,6 +890,7 @@ export const collectElectronWrappedCallPaths = ( { ...memberContext, entryNode: node, + calls: [], entryKind: "bridge-member", entry, entryPath: [key.value, ...path], @@ -608,6 +919,7 @@ export const collectElectronWrappedCallPaths = ( args, { ...branchContext, + calls: [...branchContext.calls, ipcInvocationKey(range(node))], steps: [ ...branchContext.steps, step(node, "call", callLabel(node.callee)), @@ -713,6 +1025,7 @@ export const collectElectronWrappedCallPaths = ( const env = isolatedSeed?.env ?? resolver.environmentAt(node); let context: PathContext = { entryNode: node, + calls: [], entry: "", entryKind: null, channel: null, diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index 0aacc9a09b..613a8dc6b6 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -111,6 +111,7 @@ export class JavaScriptSymbolicBindings { readonly #mutated = new Set(); readonly #electronIdentity: JavaScriptElectronIdentity; readonly #namespaceOwners = new Map(); + readonly #moduleFactories = new WeakSet(); readonly #unsafeExports = new Set(); readonly #aliasesByBinding = new Map(); readonly #bootstrapTables: Array<{ @@ -171,6 +172,11 @@ export class JavaScriptSymbolicBindings { this.#invalidateBootstrapTable(table.tableAliases, table.modules); } + /** Whether a function is a recognized bundle module factory, which runs once per module load. */ + isModuleFactory(node: t.Node): boolean { + return t.isFunction(node) && this.#moduleFactories.has(node); + } + environmentAt(node: t.Node): SymbolicEnvironment { let current: t.Node | undefined = node; while (current) { @@ -1313,6 +1319,7 @@ export class JavaScriptSymbolicBindings { for (const property of bootstrap.table.properties) { const factory = moduleFactory(property); if (!factory) continue; + this.#moduleFactories.add(factory); const moduleKey = modulePropertyName(property); const values = new Map(); const environment: SymbolicEnvironment = { diff --git a/tests/acceptance/applications/electronIpcConstantChannelCli.test.ts b/tests/acceptance/applications/electronIpcConstantChannelCli.test.ts new file mode 100644 index 0000000000..5b5294f067 --- /dev/null +++ b/tests/acceptance/applications/electronIpcConstantChannelCli.test.ts @@ -0,0 +1,76 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for([ + { + shape: "one constant-channel send", + preload: `const PING = "ping"; + contextBridge.exposeInMainWorld("api",{sendPing:()=>ipcRenderer.send(PING)});`, + counts: { operations: 2, renderer_transmissions: 1 }, + dynamic: 0, + }, + { + shape: "two constant-channel sends", + preload: `const PING = "ping"; + contextBridge.exposeInMainWorld("api",{ + sendPing:()=>ipcRenderer.send(PING), + sendAgain:()=>ipcRenderer.send(PING), + });`, + counts: { operations: 3, renderer_transmissions: 2 }, + dynamic: 0, + }, + { + // The helper parameter stays unresolved beside the one resolved candidate. + shape: "a helper-forwarded channel", + preload: `const forward = (ch, v) => ipcRenderer.send(ch, v); + contextBridge.exposeInMainWorld("api",{forwarded:()=>forward("ping")});`, + counts: { operations: 3, renderer_transmissions: 2 }, + dynamic: 1, + }, +])( + "counts each renderer IPC observation once for $shape", + async ({ preload, counts, dynamic }, { cli }) => { + const root = await createTestTempDirectory("rea-electron-ipc-constant-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "ipc-const", + version: "1.0.0", + main: "main.js", + }), + ), + writeFile( + join(root, "main.js"), + `const {app,BrowserWindow,ipcMain}=require("electron"); + ipcMain.on("ping",()=>{}); + app.whenReady().then(()=>new BrowserWindow({webPreferences:{preload:"./preload.js"}}));`, + ), + writeFile( + join(root, "preload.js"), + `const {contextBridge,ipcRenderer}=require("electron"); + ${preload}`, + ), + writeFile(join(root, "index.html"), "\n"), + ]); + + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(response.json); + expect(application.summary.ipc).toMatchObject({ + ...counts, + dynamic_channel_operations: dynamic, + }); + }, +);