mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
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:
@@ -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);
|
||||
});
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user