fix(javascript): count an exposed IPC listener once

A contextBridge member walk used the exposure call as the invocation of
every IPC site it reached. The exposure supplies only unknown public
arguments, so the inferred copy of a directly observed listener got a
distinct invocation identity and survived deduplication, doubling the
renderer listener count and the channel entity.

Bridge member walks now start without a seeding invocation; the first
literal-argument call inside the member becomes the seed, matching the
invocation identity that top-level seed walks record.

Fixes #5586

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-20 15:29:30 +00:00
1 parent 080c0e2b2b
commit f4b4cc4a7b
3 files changed
+109 -3

No files matched your search

@@ -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());`,
@@ -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)),
@@ -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": "<!doctype html><html><body></body></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);
},
);