fix(javascript): recover Electron APIs through default imports and optional calls (#1673)

`import electron from "electron"` bound the module with a leading `default`
export segment, so `electron.ipcMain.handle(...)`, `new
electron.BrowserWindow(...)` and destructuring from that binding matched no
Electron API. Electron's CommonJS exports define no `default` member, so the
segment can only denote the module object; drop it when classifying
Electron bindings.

The binding classifier already recorded optional-call roots, but the IPC,
context-bridge, utility-process and sender-validation visitors accepted only
plain calls. `ipcMain?.handle(...)`, `contextBridge?.exposeInMainWorld(...)`
and the common `event.senderFrame?.url.startsWith(...)` check were dropped
without a limitation.

Refs #1656

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-11 10:31:08 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3a7a3074e3
commit fe574c0c9f
5 changed files with 99 additions and 11 deletions
@@ -33,7 +33,7 @@ export const inspectElectronBrowserNode = (
context: JavaScriptFindingContext,
): void => {
if (t.isNewExpression(node)) inspectBrowserWindow(node, context);
if (!t.isCallExpression(node)) return;
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
inspectContextBridge(node, context);
inspectUtilityProcess(node, context);
};
@@ -162,7 +162,7 @@ const collectWebPreferences = (
};
const inspectContextBridge = (
node: t.CallExpression,
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
const path = electronCalleePath(node.callee, context);
@@ -208,7 +208,7 @@ const inspectContextBridge = (
};
const inspectUtilityProcess = (
node: t.CallExpression,
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
const path = electronCalleePath(node.callee, context);
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("Electron API identity", () => {
it("recovers APIs through an ES module default import of Electron", () => {
const analysis = analyzeJavaScriptStaticSource(`
import electron from "electron";
const { ipcMain } = electron;
new electron.BrowserWindow({ webPreferences: { preload: "./preload.js" } });
electron.contextBridge.exposeInMainWorld("api", { ping: true });
electron.ipcRenderer.invoke("default:ping");
ipcMain.handle("destructured:ping", () => "pong");
electron.default.ipcMain.handle("nested-default", () => "local");
`);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: "./preload.js" },
]);
expect(analysis.electron.context_bridge_apis).toMatchObject([
{ api_key: "api", members: ["ping"] },
]);
expect(
analysis.electron.ipc.map(({ side, operation, channel }) => ({
side,
operation,
channel,
})),
).toEqual([
{ side: "renderer", operation: "invoke", channel: "default:ping" },
{ side: "main", operation: "handle", channel: "destructured:ping" },
]);
});
it("recovers APIs and sender checks written with optional chaining", () => {
const analysis = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcMain, utilityProcess } = require("electron");
contextBridge?.exposeInMainWorld("api", {});
utilityProcess?.fork("./worker.js");
ipcMain?.handle("optional:ping", (event) => {
if (!event.senderFrame?.url.startsWith("file://")) return null;
return event.sender?.getURL() === "file:///index.html";
});
`);
expect(analysis.electron.context_bridge_apis).toMatchObject([
{ api_key: "api" },
]);
expect(analysis.electron.utility_processes).toMatchObject([
{ module_path: "./worker.js" },
]);
expect(analysis.electron.ipc).toMatchObject([
{ side: "main", operation: "handle", channel: "optional:ping" },
]);
expect(
analysis.electron.sender_validations.map(
({ subject, mechanism, expected }) => ({
subject,
mechanism,
expected: expected.value,
}),
),
).toEqual([
{
subject: "sender-url",
mechanism: "call:startsWith",
expected: "file://",
},
{
subject: "sender-url",
mechanism: "comparison:===",
expected: "file:///index.html",
},
]);
});
});
@@ -26,7 +26,7 @@ export const inspectElectronIpcNode = (
node: t.Node,
context: JavaScriptFindingContext,
): void => {
if (t.isCallExpression(node)) {
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
inspectIpcCall(node, context);
inspectValidationCall(node, context);
}
@@ -34,7 +34,7 @@ export const inspectElectronIpcNode = (
};
const inspectIpcCall = (
node: t.CallExpression,
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
const descriptor = ipcDescriptor(electronCalleePath(node.callee, context));
@@ -117,7 +117,7 @@ const inspectValidationComparison = (
};
const inspectValidationCall = (
node: t.CallExpression,
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
if (
@@ -225,9 +225,10 @@ const ipcDescriptor = (
const senderSubject = (
node: t.Node,
): ElectronSenderValidationFinding["subject"] | undefined => {
const name = t.isCallExpression(node)
? calleeName(node.callee)
: calleeName(node);
const name =
t.isCallExpression(node) || t.isOptionalCallExpression(node)
? calleeName(node.callee)
: calleeName(node);
if (/(?:^|\.)sender\.getURL$/u.test(name)) return "sender-url";
if (/(?:^|\.)senderFrame\.origin$/u.test(name)) return "sender-origin";
if (/(?:^|\.)senderFrame\.url$/u.test(name)) return "sender-url";
@@ -12,6 +12,16 @@ import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
export const ELECTRON_MODULE =
/^electron(?:\/(?:common|main|renderer|utility))?$/u;
/**
* Electron's CommonJS exports define no `default` member, so a leading
* `default` segment can only denote the module object itself, as an ES module
* default import (`import electron from "electron"`) binds it.
*/
export const electronExportPath = (
importedPath: readonly string[],
): readonly string[] =>
importedPath[0] === "default" ? importedPath.slice(1) : importedPath;
/** Static identity recovery cannot establish the absence of Electron usage. */
export const ELECTRON_IDENTITY_LIMITATION =
"Electron API identity follows proven lexical module aliases. Reassigned bindings, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated.";
@@ -47,6 +47,7 @@ import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDeri
import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js";
import {
collectElectronMemberWritesSteps,
electronExportPath,
electronMemberPath,
ELECTRON_MODULE,
} from "./javascriptElectronMemberWrites.js";
@@ -385,7 +386,7 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
}
const origin = semanticRequireOrigin(root, state);
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
? origin.importedPath
? electronExportPath(origin.importedPath)
: undefined;
};
const writes = yield* collectElectronMemberWritesSteps(
@@ -436,7 +437,7 @@ const electronExport = (
? provenance.origins[0]
: undefined);
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
? origin.importedPath
? electronExportPath(origin.importedPath)
: null;
};