fix(electron): retain scoped IPC registration provenance (#7031)

* fix(electron): retain scoped IPC registration provenance

* fix(electron): defer mutation analysis until scoped receiver admission
This commit is contained in:
Wenyu Chiou
2026-10-24 15:57:02 -04:00
committed by GitHub
parent 2acdd54130
commit c7ae27807f
12 changed files with 546 additions and 36 deletions
@@ -54,16 +54,18 @@ export const classifyElectronIpcPairings = (
? []
: handlers.filter(
(handler) =>
handler.finding.channel === transmission.finding.channel &&
(handler.finding.channel === transmission.finding.channel ||
(handler.finding.receiver !== undefined &&
handler.finding.channel === null)) &&
compatibleIpcModes(transmission.finding, handler.finding),
);
return {
transmission,
candidates,
status:
candidates.length === 1
candidates.length === 1 && candidates[0]?.finding.receiver === undefined
? "paired"
: candidates.length > 1
: candidates.length > 0
? "ambiguous"
: "unpaired",
};
@@ -119,10 +119,22 @@ const addChannelNode = (
coverage: JavaScriptArtifactGraphCoverage,
): ApplicationNode => {
const { finding, file } = record;
const identityKey =
const channelKey =
finding.channel === null
? `dynamic:${record.key}`
: `literal:${finding.channel}`;
const identityKey =
finding.receiver === undefined
? channelKey
: JSON.stringify([
file.path,
file.sha256,
finding.module_runtime ?? null,
finding.module_key,
finding.receiver.kind,
finding.receiver.owner_location,
channelKey,
]);
return context.accumulator.addNode({
kind: "ipc-channel",
identity:
@@ -141,6 +153,9 @@ const addChannelNode = (
{
label: finding.channel ?? finding.channel_expression,
properties: {
...(finding.receiver === undefined
? {}
: { receiver: finding.receiver }),
side: finding.side,
operation: finding.operation,
mode: finding.mode,
@@ -159,16 +174,23 @@ const addChannelNode = (
range: finding.location,
operation: "observe-ipc-channel-expression",
coverage,
limitations:
finding.resolution_basis === "wrapper-substitution"
? [
limitations: [
...(finding.receiver === undefined
? []
: [
"The receiver is a source-level allocation, not a runtime frame identity; registration and dispatch remain unverified.",
]),
...(finding.resolution_basis !== "wrapper-substitution"
? []
: [
"The channel is inferred through lexical wrapper substitution; runtime registration and dispatch remain unverified.",
]
: finding.channel === null
? [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
]
: [],
]),
...(finding.channel !== null
? []
: [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
]),
],
}),
},
],
@@ -179,17 +201,31 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
const { context, record, source, channel, coverage } = input;
const { finding, file } = record;
const handlerRange = finding.handler_location ?? finding.location;
const handlerKey = `${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`;
const identityKey =
finding.receiver === undefined
? handlerKey
: JSON.stringify([
file.path,
finding.module_runtime ?? null,
finding.module_key,
finding.receiver,
handlerKey,
]);
const handler = context.accumulator.addNode({
kind: "ipc-handler",
identity: artifactLocalIdentity(
file.sha256,
"electron-ipc-handler",
`${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`,
identityKey,
),
observations: [
{
label: finding.channel,
properties: {
...(finding.receiver === undefined
? {}
: { receiver: finding.receiver }),
side: finding.side,
operation: finding.operation,
mode: finding.mode,
@@ -3,6 +3,7 @@ import * as t from "@babel/types";
import type {
ElectronIpcDescriptor,
ElectronIpcFinding,
ElectronIpcReceiver,
ElectronSenderValidationFinding,
} from "./electronStaticAnalysisTypes.js";
import {
@@ -37,7 +38,10 @@ const inspectIpcCall = (
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
const descriptor = ipcDescriptor(electronCalleePath(node.callee, context));
const scoped = scopedIpcDescriptor(node, context);
const descriptor =
scoped?.descriptor ??
ipcDescriptor(electronCalleePath(node.callee, context));
if (descriptor === undefined) return;
const channelNode = argumentNode(node.arguments[0]);
const channelValue = electronStaticValue(context.source, channelNode);
@@ -88,6 +92,7 @@ const inspectIpcCall = (
context.accumulator.unknownFindings += 1;
const finding: ElectronIpcFinding = {
...findingState,
...(scoped === undefined ? {} : { receiver: scoped.receiver }),
...channelReference,
module_key: null,
location: range(node),
@@ -100,6 +105,48 @@ const inspectIpcCall = (
});
};
const scopedIpcDescriptor = (
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
):
| {
readonly descriptor: ElectronIpcDescriptor;
readonly receiver: ElectronIpcReceiver;
}
| undefined => {
if (
!t.isMemberExpression(node.callee) &&
!t.isOptionalMemberExpression(node.callee)
)
return undefined;
const operation = semanticStaticPropertyName(
node.callee.property,
node.callee.computed,
);
const descriptor = ipcDescriptor(["ipcMain", operation]);
if (descriptor === undefined) return undefined;
const receiver = context.browserWindowReceiver?.(node.callee.object, node, [
operation,
]);
if (receiver === undefined) return undefined;
const path = receiver.path;
const kind =
path.length === 3 && path[0] === "webContents" && path[1] === "ipc"
? "web-contents"
: path.length === 4 &&
path[0] === "webContents" &&
path[1] === "mainFrame" &&
path[2] === "ipc"
? "main-frame"
: undefined;
return kind === undefined
? undefined
: {
descriptor,
receiver: { kind, owner_location: range(receiver.owner) },
};
};
const inspectValidationComparison = (
node: t.BinaryExpression,
context: JavaScriptFindingContext,
@@ -0,0 +1,120 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
["webContents", "window.webContents.ipc"],
["main frame", "window.webContents.mainFrame.ipc"],
] as const)(
"retains a proven %s IPC handler beside the global control",
(_, receiver) => {
const analysis = analyzeJavaScriptStaticSource(`
const { BrowserWindow, ipcMain } = require("electron");
const window = new BrowserWindow();
ipcMain.handle("global-control", () => "global");
${receiver}.handle("scoped-channel", () => "scoped");
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.browser_windows).toHaveLength(1);
expect(analysis.electron.ipc).toContainEqual(
expect.objectContaining({
side: "main",
operation: "handle",
channel: "global-control",
handler_kind: "inline-function",
}),
);
expect(analysis.electron.ipc).toContainEqual(
expect.objectContaining({
side: "main",
operation: "handle",
channel: "scoped-channel",
handler_kind: "inline-function",
}),
);
},
);
it("does not label lookalike or shadowed receivers as Electron IPC", () => {
const analysis = analyzeJavaScriptStaticSource(`
const { BrowserWindow, ipcMain } = require("electron");
ipcMain.handle("global-control", () => "global");
const fake = { webContents: { ipc: { handle() {} } } };
fake.webContents.ipc.handle("fake", () => {});
function shadow(BrowserWindow) {
const window = new BrowserWindow();
window.webContents.ipc.handle("shadowed", () => {});
}
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.ipc.map(({ channel }) => channel)).toEqual([
"global-control",
]);
});
it.each([
[
'const contents = window.webContents; contents.ipc.handle("same", () => {});',
"web-contents",
],
[
'const frame = window.webContents.mainFrame; frame.ipc.handle("same", () => {});',
"main-frame",
],
[
'const contents = window.webContents; const frame = contents.mainFrame; const ipc = frame.ipc; ipc.handle("same", () => {});',
"main-frame",
],
])("retains a proven scoped alias: %s", (call, kind) => {
const findings =
analyzeJavaScriptStaticSource(`const { BrowserWindow } = require("electron");
const window = new BrowserWindow(); ${call}`).electron.ipc;
expect(findings).toHaveLength(1);
expect(findings[0]?.receiver).toEqual({
kind,
owner_location: {
start: { line: 2, column: 19 },
end: { line: 2, column: 38 },
},
});
});
it("keeps the shared owner's conservative omission after an aliased receiver call", () => {
const findings =
analyzeJavaScriptStaticSource(`const { BrowserWindow } = require("electron");
const window = new BrowserWindow();
const ipc = window.webContents.mainFrame.ipc;
ipc.handle("first", () => {});
window.webContents.mainFrame.ipc.on("after-escape", () => {});`).electron
.ipc;
expect(findings.map(({ channel }) => channel)).toEqual(["first"]);
});
it.each([
"window.webContents = replacement;",
"window.webContents.ipc = replacement;",
"window.webContents.ipc.handle = replacement;",
"const alias = window; alias.webContents.ipc.handle = replacement;",
"const contents = window.webContents; contents.ipc = replacement;",
"BrowserWindow.prototype.webContents = replacement;",
])("omits a mutated receiver: %s", (mutation) => {
const analysis = analyzeJavaScriptStaticSource(`
const { BrowserWindow } = require("electron");
const window = new BrowserWindow();
${mutation}
window.webContents.ipc.handle("changed", () => {});
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.ipc).toEqual([]);
});
it.each(["webContents.ipc", "webContents.mainFrame.ipc"])(
"does not split one dotted member key into receiver segments: %s",
(key) => {
expect(
analyzeJavaScriptStaticSource(`const {BrowserWindow} = require("electron");
const window = new BrowserWindow(); window[${JSON.stringify(key)}].handle("fake", () => {});`)
.electron.ipc,
).toEqual([]);
},
);
@@ -114,7 +114,14 @@ type ElectronIpcHandler =
readonly handler_location: JavaScriptSourceRange;
};
export interface ElectronIpcReceiver {
readonly kind: "web-contents" | "main-frame";
/** Constructor syntax allocation, never a runtime frame identifier. */
readonly owner_location: JavaScriptSourceRange;
}
interface ElectronIpcFindingContext {
readonly receiver?: ElectronIpcReceiver;
/** Absent on direct syntax observations; wrappers require inferred evidence. */
readonly resolution_basis?: "wrapper-substitution";
readonly module_key: string | null;
@@ -0,0 +1,80 @@
import { expect, it } from "vitest";
import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js";
import { resolveElectronBrowserWindowReceiver } from "./electronStaticRenderer.js";
import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
import {
indexJavaScriptBindingsWithReceiversSteps,
indexJavaScriptElectronIdentitySteps,
} from "./javascriptSemanticAnalysis.js";
import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js";
import {
completeSemanticSteps as complete,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import {
createJavaScriptAnalysisAccumulator,
type JavaScriptFindingContext,
} from "./javascriptStaticAnalysisState.js";
const inspect = (source: string) => {
const file = parseJavaScriptSource(source);
if (file === null) throw new Error("Fixture must parse");
const index = complete(
indexJavaScriptBindingsWithReceiversSteps(file.program),
);
complete(index.indexAmbientWritesSteps());
const identity = complete(
indexJavaScriptElectronIdentitySteps(file.program, index.bindings),
);
let mutationIndexCalls = 0;
const original = index.indexMemberMutationFactsSteps.bind(index);
index.indexMemberMutationFactsSteps = function* () {
mutationIndexCalls += 1;
yield* original();
};
const context: JavaScriptFindingContext = {
source,
accumulator: createJavaScriptAnalysisAccumulator(source.length),
vitePreloadDependencyTable: null,
electronBindings: identity.bindings,
electronExportIntact: identity.intactExport,
rpcScan: createJavaScriptRpcScan(
source,
index.receivers.rpcClientRoots,
index,
),
expressionShapes: createSemanticExpressionShapeResolver(index.bindings),
browserWindowReceiver: (node, site, suffix) =>
resolveElectronBrowserWindowReceiver(node, site, suffix, context, index),
};
traverseJavaScriptAst(file, {
enter: (node) => inspectElectronIpcNode(node, context),
});
return { findings: context.accumulator.ipc, mutationIndexCalls };
};
it.each([
[
"ordinary EventEmitter",
`import {EventEmitter} from "node:events";
const emitter = new EventEmitter(); emitter.on("event", listener);`,
],
[
"unsupported BrowserWindow path",
`const {BrowserWindow} = require("electron");
const window = new BrowserWindow(); window.on("ready", listener);`,
],
])("does not request whole-file mutation facts for %s", (_, source) => {
const result = inspect(source);
expect(result.findings).toEqual([]);
expect(result.mutationIndexCalls).toBe(0);
});
it("still checks mutations for a source-proven scoped Electron receiver", () => {
const result = inspect(`const {BrowserWindow} = require("electron");
const window = new BrowserWindow(); window.webContents.ipc.handle("channel", () => {});`);
expect(result.findings).toHaveLength(1);
expect(result.mutationIndexCalls).toBeGreaterThan(0);
});
+75 -20
View File
@@ -25,20 +25,51 @@ export const isElectronRendererLoad = (
return false;
const method = semanticStaticPropertyKey(callee.property, callee.computed);
if (method !== "loadFile" && method !== "loadURL") return false;
let receiver = unwrapJavaScriptExpression(callee.object).node;
const path: string[] = [method];
if (
t.isMemberExpression(receiver) &&
semanticStaticPropertyKey(receiver.property, receiver.computed) ===
"webContents"
) {
receiver = unwrapJavaScriptExpression(receiver.object).node;
path.unshift("webContents");
}
const receiver = resolveElectronBrowserWindowReceiver(
callee.object,
site,
[method],
context,
index,
);
return (
receiver !== undefined &&
(receiver.path.length === 1 ||
(receiver.path.length === 2 && receiver.path[0] === "webContents"))
);
};
/** Source allocation identity, not a claim about runtime window/frame identity. */
export const resolveElectronBrowserWindowReceiver = (
node: t.Node,
site: t.Node,
suffix: readonly string[],
context: JavaScriptFindingContext,
index: JavaScriptBindingIndex,
):
| { readonly owner: t.NewExpression; readonly path: readonly string[] }
| undefined => {
let receiver = unwrapJavaScriptExpression(node).node;
const path = [...suffix];
const seen = new Set<string>();
while (t.isIdentifier(receiver)) {
const aliases: { receiver: t.Identifier; path: readonly string[] }[] = [];
for (;;) {
if (
t.isMemberExpression(receiver) ||
t.isOptionalMemberExpression(receiver)
) {
const member = semanticStaticPropertyKey(
receiver.property,
receiver.computed,
);
if (member === null) return undefined;
path.unshift(member);
receiver = unwrapJavaScriptExpression(receiver.object).node;
continue;
}
if (!t.isIdentifier(receiver)) break;
const binding = index.bindingForReferenceAt(receiver);
if (binding === undefined || seen.has(binding.bindingId)) return false;
if (binding === undefined || seen.has(binding.bindingId)) return undefined;
seen.add(binding.bindingId);
const initializer = semanticStableInitializer(binding, index.bindings);
if (
@@ -47,21 +78,45 @@ export const isElectronRendererLoad = (
!semanticBindingAtRead(binding, receiver, index.bindings.parentsByNode)
.hasDefiniteInitializer
)
return false;
completeSemanticSteps(index.indexMemberMutationFactsSteps());
if (index.referenceMayHaveChangedAt(site, receiver, path)) return false;
return undefined;
aliases.push({ receiver, path: [...path] });
receiver = unwrapJavaScriptExpression(initializer.node).node;
}
if (!t.isNewExpression(receiver)) return false;
if (!t.isNewExpression(receiver)) return undefined;
const constructor = electronCalleePath(receiver.callee, context);
return (
constructor?.length === 1 &&
constructor[0] === "BrowserWindow" &&
if (
constructor?.length !== 1 ||
constructor[0] !== "BrowserWindow" ||
!supportedReceiverPath(path)
)
return undefined;
if (
context.electronExportIntact?.([
"electron",
"BrowserWindow",
"prototype",
...path,
]) === true
]) !== true
)
return undefined;
// Only source-proven supported receivers justify the whole-file mutation pass.
// Keep every alias's consumed path and check at the original invocation site.
completeSemanticSteps(index.indexMemberMutationFactsSteps());
for (const alias of aliases)
if (index.referenceMayHaveChangedAt(site, alias.receiver, alias.path))
return undefined;
return { owner: receiver, path };
};
const supportedReceiverPath = (path: readonly string[]): boolean => {
const operation = path.at(-1);
if (operation === "loadFile" || operation === "loadURL")
return (
path.length === 1 || (path.length === 2 && path[0] === "webContents")
);
return (
path[0] === "webContents" &&
((path.length === 3 && path[1] === "ipc") ||
(path.length === 4 && path[1] === "mainFrame" && path[2] === "ipc"))
);
};
@@ -14,7 +14,10 @@ import {
traverseJavaScriptAstSteps,
} from "./javascriptSemanticTraversal.js";
import { isElectronRendererLoad } from "./electronStaticRenderer.js";
import {
isElectronRendererLoad,
resolveElectronBrowserWindowReceiver,
} from "./electronStaticRenderer.js";
import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js";
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
import {
@@ -109,6 +112,8 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
);
const findings: JavaScriptFindingContext = {
pathIntrinsics: createStaticPathIntrinsicQuery(index),
browserWindowReceiver: (node, site, suffix) =>
resolveElectronBrowserWindowReceiver(node, site, suffix, findings, index),
rendererLoad: (callee, site) =>
isElectronRendererLoad(callee, site, findings, index),
source,
@@ -101,6 +101,13 @@ export interface JavaScriptFindingContext {
/** Native path/URL identities established by shared lexical and mutation owners. */
readonly pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery;
readonly electronExportIntact?: (path: readonly string[]) => boolean;
readonly browserWindowReceiver?: (
node: t.Node,
site: t.Node,
suffix: readonly string[],
) =>
| { readonly owner: t.NewExpression; readonly path: readonly string[] }
| undefined;
readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean;
readonly rpcScan: JavaScriptRpcScan;
/** Shared bounded argv/nullish argument shapes for optional-slot signatures. */
@@ -151,3 +151,32 @@ it("preserves partial request targets through the strict worker schema", () => {
const serialized = JSON.parse(JSON.stringify(facts)) as unknown;
expect(javaScriptWorkerStaticAnalysisSchema.parse(serialized)).toEqual(facts);
});
it("preserves scoped IPC receiver provenance and rejects malformed scope metadata", () => {
const facts =
analyzeJavaScriptStaticSource(`const {BrowserWindow} = require("electron");
const window = new BrowserWindow(); window.webContents.mainFrame.ipc.handle("scoped", () => {});`);
expect(facts.electron.ipc).toHaveLength(1);
expect(facts.electron.ipc[0]?.receiver?.kind).toBe("main-frame");
expect(
javaScriptWorkerStaticAnalysisSchema.parse(
JSON.parse(JSON.stringify(facts)) as unknown,
),
).toEqual(facts);
const finding = facts.electron.ipc[0]!;
for (const receiver of [
{ kind: "global", owner_location: finding.receiver?.owner_location },
{ kind: "main-frame" },
{ ...finding.receiver, runtime_frame_id: 42 },
]) {
expect(
javaScriptWorkerStaticAnalysisSchema.safeParse({
...facts,
electron: {
...facts.electron,
ipc: [{ ...finding, receiver }],
},
}).success,
).toBe(false);
}
});
@@ -67,10 +67,17 @@ const locatedHandler = {
]),
handler_location: range,
};
const ipcReceiver = z
.strictObject({
kind: z.enum(["web-contents", "main-frame"]),
owner_location: range,
})
.exactOptional();
const ipcChannel = <Shape extends z.ZodRawShape>(shape: Shape) =>
z.union([
z.strictObject({
...findingContext,
receiver: ipcReceiver,
invocation_location: range.optional(),
resolution_basis: z.literal("wrapper-substitution").optional(),
...shape,
@@ -79,6 +86,7 @@ const ipcChannel = <Shape extends z.ZodRawShape>(shape: Shape) =>
}),
z.strictObject({
...findingContext,
receiver: ipcReceiver,
invocation_location: range.optional(),
resolution_basis: z.literal("wrapper-substitution").optional(),
...shape,
@@ -0,0 +1,114 @@
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"preserves scoped IPC allocations without global dispatch pairing through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-scoped-ipc-");
const copy = `const {BrowserWindow}=require("electron"); const window=new BrowserWindow(); window.webContents.ipc.handle("copied",()=>{});`;
await writeFixtureFiles(root, {
"copy-one.cjs": copy,
"copy-two.cjs": copy,
"main.cjs": `const { BrowserWindow, ipcMain } = require("electron");
const first = new BrowserWindow();
const second = new BrowserWindow();
first.webContents.mainFrame.ipc.handle("shared", () => "first");
second.webContents.mainFrame.ipc.handle("shared", () => "second");
ipcMain.handle("shared", () => "global");
const dynamicWindow = new BrowserWindow();
dynamicWindow.webContents.ipc.handle(dynamicChannel, () => "dynamic");
ipcMain.handle("dynamic-shadow", () => "global");
const fake = { webContents: { ipc: { handle() {} } } };
fake.webContents.ipc.handle("fake", () => {});`,
"preload.cjs": `const { ipcRenderer } = require("electron");
ipcRenderer.invoke("shared");
ipcRenderer.invoke("dynamic-shadow");`,
});
const application = await analyzeJavaScriptDirectory(surface, cli, root);
expect(application.statistics.parse_failures).toBe(0);
expect(application.summary.ipc).toMatchObject({
operations: 9,
main_handlers: 7,
renderer_transmissions: 2,
paired_renderer_transmissions: 0,
ambiguous_renderer_transmissions: 2,
});
const channels = application.graph.nodes.filter(
({ kind }) => kind === "ipc-channel",
);
const copies = application.graph.nodes.filter(
({ kind, observations }) =>
kind === "ipc-handler" &&
observations.some(({ properties }) => properties.channel === "copied"),
);
expect(copies).toHaveLength(2);
expect(
channels.filter(({ observations }) =>
observations.some(({ properties }) => properties.channel === "copied"),
),
).toHaveLength(2);
const shared = channels.filter(({ observations }) =>
observations.some(({ properties }) => properties.channel === "shared"),
);
expect(shared).toHaveLength(3);
const scoped = shared.filter(({ observations }) =>
observations.some(({ properties }) => properties.receiver !== undefined),
);
expect(scoped).toHaveLength(2);
const owners = scoped.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.receiver),
);
expect(new Set(owners.map((owner) => JSON.stringify(owner))).size).toBe(2);
expect(
channels.some(({ observations }) =>
observations.some(({ properties }) => properties.channel === "fake"),
),
).toBe(false);
expect(
application.graph.edges.filter(
({ evidence }) =>
evidence.extractor.operation === "pair-ipc-literal-channel",
),
).toEqual([]);
},
);
cliTest.for(["CLI", "stdio MCP"] as const)(
"does not uniquely pair a renderer with exactly one scoped literal handler through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-single-scoped-ipc-");
await writeFixtureFiles(root, {
"main.cjs": `const { BrowserWindow } = require("electron");
const window = new BrowserWindow();
window.webContents.mainFrame.ipc.handle("only-scoped", () => "result");`,
"preload.cjs": `const { ipcRenderer } = require("electron");
ipcRenderer.invoke("only-scoped");`,
});
const application = await analyzeJavaScriptDirectory(surface, cli, root);
expect(application.summary.ipc).toMatchObject({
operations: 2,
main_handlers: 1,
renderer_transmissions: 1,
dynamic_channel_operations: 0,
paired_renderer_transmissions: 0,
ambiguous_renderer_transmissions: 1,
});
const handlers = application.graph.nodes.filter(
({ kind }) => kind === "ipc-handler",
);
expect(handlers).toHaveLength(1);
expect(handlers[0]?.observations[0]?.properties.receiver).toMatchObject({
kind: "main-frame",
});
expect(
application.graph.edges.filter(
({ evidence }) =>
evidence.extractor.operation === "pair-ipc-literal-channel",
),
).toEqual([]);
},
);