diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 449ca776d2..8de8a3468d 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -384,6 +384,29 @@ it("keeps a directly observed renderer operation singular when tracing its expos expect.objectContaining({ entry: "desktop.ping", channel: "ping" }), ); }); +it("keeps an exposed renderer listener singular and seeds its wrapper call", () => { + const direct = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + contextBridge.exposeInMainWorld("desktop",{onPong:callback=>ipcRenderer.on("pong",callback)}); + `); + expect(direct.electron.ipc).toEqual([ + expect.objectContaining({ channel: "pong", operation: "on" }), + ]); + expect(direct.electron.ipc[0]).not.toHaveProperty("resolution_basis"); + const wrapped = analyzeJavaScriptStaticSource(` + const {contextBridge,ipcRenderer}=require("electron"); + const listen=(channel,callback)=>ipcRenderer.on(channel,callback); + contextBridge.exposeInMainWorld("desktop",{onPong:callback=>listen("pong",callback)}); + `); + const resolved = wrapped.electron.ipc.filter( + ({ channel }) => channel === "pong", + ); + expect(resolved).toHaveLength(1); + expect(resolved[0]).toMatchObject({ + resolution_basis: "wrapper-substitution", + invocation_location: { start: { line: 4 } }, + }); +}); it("does not identify overwritten Electron namespaces or shadowed require", () => { for (const source of [ `const e=require("electron");e.ipcMain={on(){}};e.ipcMain.on("bad",event=>event.sender.copy());`, diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index c32bca04b8..86cc544cfc 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -73,7 +73,10 @@ const indexIpcFinding = ( }; interface PathContext { - readonly entryNode: t.Node; + // The syntax invocation whose literal arguments seeded this walk. A bridge + // member is called with unknown public arguments, so its exposure supplies + // none; the first literal-argument call inside the member becomes the seed. + readonly entryNode: t.Node | undefined; readonly entry: string; readonly entryPath?: readonly string[]; readonly entryKind: ElectronCallPath["entry_kind"] | null; @@ -337,6 +340,7 @@ export const collectElectronWrappedCallPaths = ( const channel = first.value; const location = range(node); const invocationLocation = + context.entryNode === undefined || JSON.stringify(range(context.entryNode)) === JSON.stringify(location) ? undefined : range(context.entryNode); @@ -408,7 +412,7 @@ export const collectElectronWrappedCallPaths = ( endpoint, channel, location, - range(context.entryNode), + context.entryNode === undefined ? null : range(context.entryNode), context.entryKind, context.entry, context.entryPath, @@ -560,7 +564,7 @@ export const collectElectronWrappedCallPaths = ( ), { ...memberContext, - entryNode: node, + entryNode: undefined, entryKind: "bridge-member", entry, entryPath: [key.value, ...path], @@ -589,6 +593,9 @@ export const collectElectronWrappedCallPaths = ( args, { ...branchContext, + entryNode: + branchContext.entryNode ?? + (t.isStringLiteral(node.arguments[0]) ? node : undefined), steps: [ ...branchContext.steps, step(node, "call", callLabel(node.callee)), diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts index 2a712e7874..ecc01b21ee 100644 --- a/tests/boundary/cli/electronWrappedCallPaths.test.ts +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -785,3 +785,79 @@ cliTest( } }, ); + +cliTest( + "counts one exposed renderer listener once through analysis and tracing", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-electron-exposed-listener-", + ); + await writeFixtureFiles(root, { + "package.json": '{"name":"ipc-probe","version":"1.0.0","main":"main.js"}', + "main.js": `const { app, BrowserWindow, ipcMain } = require("electron"); + function createWindow() { + const window = new BrowserWindow({ webPreferences: { preload: "./preload.js" } }); + window.loadFile("index.html"); + } + ipcMain.handle("ping", () => "pong"); + app.whenReady().then(createWindow);`, + "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + contextBridge.exposeInMainWorld("api", { + ping: () => ipcRenderer.invoke("ping"), + onPong: (callback) => ipcRenderer.on("pong", callback), + });`, + "index.html": "", + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const { normalized_result: result } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + expect(result.summary.ipc).toMatchObject({ + operations: 3, + renderer_listeners: 1, + }); + const pong = result.graph.nodes.filter( + (node) => + node.kind === "ipc-channel" && + node.observations.some( + (observation) => observation.properties.channel === "pong", + ), + ); + expect(pong).toHaveLength(1); + expect(pong[0]?.observations).toHaveLength(1); + expect(pong[0]?.observations[0]?.evidence.state).toBe("observed"); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "context-bridge-api", + observations: [ + expect.objectContaining({ + properties: expect.objectContaining({ + members: ["onPong", "ping"], + }), + }), + ], + }), + ); + const input = join(root, "trace-input.json"); + await writeFile( + input, + JSON.stringify({ + application: output.json, + seed: { kind: "channel", value: "pong" }, + direction: "incoming", + }), + ); + const traced = await cli.run({ + arguments: ["trace-application-feature", input, "--json"], + }); + expect(traced.exitCode, traced.stderr).toBe(0); + const { normalized_result: trace } = z + .object({ normalized_result: applicationFeatureTraceResultSchema }) + .parse(traced.json); + expect(trace.summary.matched_seeds).toBe(1); + expect(trace.seed_matches).toHaveLength(1); + }, +);