diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index 7657614ae0..afe03b8e96 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -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); diff --git a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts new file mode 100644 index 0000000000..78e4b314ca --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts @@ -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", + }, + ]); + }); +}); diff --git a/src/domain/javascript/electronStaticAnalysisIpc.ts b/src/domain/javascript/electronStaticAnalysisIpc.ts index e307868816..0b43f0727b 100644 --- a/src/domain/javascript/electronStaticAnalysisIpc.ts +++ b/src/domain/javascript/electronStaticAnalysisIpc.ts @@ -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"; diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 5981337914..b8cd3839bd 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -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."; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 5d65f413ea..9fbbd0f717 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -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; };