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:
co-authored by
Claude Opus 5.5
parent
3a7a3074e3
commit
fe574c0c9f
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user