diff --git a/docs/guides/javascript/javascript-artifact-reconstruction.md b/docs/guides/javascript/javascript-artifact-reconstruction.md index f568cc4153..68a773a18f 100644 --- a/docs/guides/javascript/javascript-artifact-reconstruction.md +++ b/docs/guides/javascript/javascript-artifact-reconstruction.md @@ -328,6 +328,12 @@ failure does not authorize expanding relationships that parent admission decline ## What is reconstructed +Electron `BrowserView` and `WebContentsView` expose `webContents` and accept +`webPreferences`, including declared preloads. `BaseWindow` has neither surface +of its own. Preload associations retain the parsed constructor's mechanism rather +than labeling every view as a BrowserWindow. These are static associations, not +proof that a renderer or preload executed. + The projector reuses the content-addressed artifact inventory and safe artifact readers. It retains canonical artifact-relative paths, exact byte counts, SHA-256 digests, inventory IDs, ASAR container identity, and `.asar.unpacked` diff --git a/src/application/javascript/ElectronBoundaryGraphWindows.ts b/src/application/javascript/ElectronBoundaryGraphWindows.ts index b0bda84c59..2e29fde073 100644 --- a/src/application/javascript/ElectronBoundaryGraphWindows.ts +++ b/src/application/javascript/ElectronBoundaryGraphWindows.ts @@ -89,8 +89,11 @@ const addBrowserWindow = ( ), observations: [ { - label: `BrowserWindow@${javascriptSourceRangeKey(value.location)}`, + label: `${value.window_constructor ?? "BrowserWindow"}@${javascriptSourceRangeKey(value.location)}`, properties: { + ...(value.window_constructor === undefined + ? {} + : { window_constructor: value.window_constructor }), source_path: file.path, options_status: value.options_status, web_preferences_status: value.web_preferences_status, @@ -190,7 +193,7 @@ const addWindowPreload = ( kind: "electron-preload", anchor: input.file, resolution, - mechanism: "BrowserWindow:webPreferences.preload", + mechanism: `${input.value.window_constructor ?? "BrowserWindow"}:webPreferences.preload`, range: input.value.location, coverage: input.coverage, }); diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index 568f6d15f5..6f8e6613a8 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -8,6 +8,7 @@ import type { ElectronStaticValue, ElectronUtilityProcessFinding, ElectronWebPreference, + ElectronWindowConstructor, } from "./electronStaticAnalysisTypes.js"; import { contextBridgeExposureArguments } from "./javascriptContextBridgeExposure.js"; import { @@ -38,6 +39,26 @@ import { hasElectronOptionObjectCandidate, resolveElectronOptionObject, } from "./electronStaticOptionObjects.js"; +import { + ELECTRON_WINDOW_CONSTRUCTOR_SURFACES, + electronWindowConstructor, + type ElectronWindowConstructorSurface, +} from "./electronWindowConstructorSurfaces.js"; + +const constructorSurfaces = ( + constructor: ElectronWindowConstructor, +): ElectronWindowConstructorSurface => + ELECTRON_WINDOW_CONSTRUCTOR_SURFACES[constructor]; + +/** The modeled window constructor a `new` callee names, if any. */ +const electronWindowConstructorOf = ( + callee: t.Node, + context: JavaScriptFindingContext, +): ElectronWindowConstructor | undefined => { + const path = electronCalleePath(callee, context); + const name = path?.length === 1 ? path[0] : undefined; + return name === undefined ? undefined : electronWindowConstructor(name); +}; /** Inspect BrowserWindow, contextBridge, and utility-process syntax. */ export const inspectElectronBrowserNode = ( @@ -45,11 +66,15 @@ export const inspectElectronBrowserNode = ( context: JavaScriptFindingContext, ): void => { if (t.isNewExpression(node)) { - const path = electronCalleePath(node.callee, context); - if (path?.length === 1 && path[0] === "BrowserWindow") { - if (needsConstantOptions(node)) - context.accumulator.pendingBrowserWindows.push(node); - else inspectBrowserWindow(node, context); + const constructor = electronWindowConstructorOf(node.callee, context); + if (constructor !== undefined) { + const preferences = constructorSurfaces(constructor).preferences; + if (preferences && needsConstantOptions(node)) + context.accumulator.pendingBrowserWindows.push({ + node, + constructor, + }); + else inspectBrowserWindow(node, context, constructor); } } if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; @@ -76,7 +101,7 @@ export function* finishElectronBrowserWindowsSteps( const pending = context.accumulator.pendingBrowserWindows; if (pending.length === 0) return; if ( - pending.some((node) => { + pending.some(({ node }) => { const argument = argumentNode(node.arguments[0]); const options = argument && unwrapJavaScriptExpression(argument).node; if (t.isIdentifier(options)) @@ -89,8 +114,8 @@ export function* finishElectronBrowserWindowsSteps( }) ) yield* index.indexMemberMutationFactsSteps(); - for (const node of pending) { - inspectBrowserWindow(node, context, index); + for (const { node, constructor } of pending) { + inspectBrowserWindow(node, context, constructor, index); yield; } // Inline findings arrive during traversal; deferred findings join that same @@ -104,17 +129,25 @@ export function* finishElectronBrowserWindowsSteps( const inspectBrowserWindow = ( node: t.NewExpression, context: JavaScriptFindingContext, + constructorName: ElectronWindowConstructor, index?: JavaScriptBindingIndex, ): void => { + // A constructor that accepts no webPreferences has none to read: the literal + // option it carries is inert, so reporting it as a preference surface would + // invent a preload the runtime never loads. + const readsPreferences = constructorSurfaces(constructorName).preferences; const argument = argumentNode(node.arguments[0]); const options = argument === undefined ? undefined : unwrapJavaScriptExpression(argument).node; - const resolvedOptions = - (index && - resolveElectronOptionObject(options, node, index, ["webPreferences"])) ?? - options; + const resolvedOptions = readsPreferences + ? ((index && + resolveElectronOptionObject(options, node, index, [ + "webPreferences", + ])) ?? + options) + : options; const lookup = staticObjectProperty(resolvedOptions, "webPreferences"); const declaredPreferences = lookup.status === "explicit" @@ -134,13 +167,16 @@ const inspectBrowserWindow = ( ]; const collected = collectWindowOptions( context.source, - lookup.status, - resolvedPreferences, + readsPreferences ? lookup.status : "missing", + readsPreferences ? resolvedPreferences : undefined, context.pathIntrinsics, ); context.accumulator.unknownFindings += collected.unknown; const finding: ElectronBrowserWindowFinding = { ...electronCandidateAuthority(node.callee, context), + ...(constructorName === "BrowserWindow" + ? {} + : { window_constructor: constructorName }), options_status: resolvedOptions === undefined ? "missing" @@ -169,14 +205,14 @@ const inspectBrowserWindow = ( role: "preload", path: finding.preload_path, resolution_context: finding.preload_resolution_context, - mechanism: "BrowserWindow:webPreferences.preload", + mechanism: `${constructorName}:webPreferences.preload`, module_key: null, location: range(node), }, }); addLocatedFinding(context, { collection: context.accumulator.browserWindows, - key: "electron-window\0BrowserWindow", + key: `electron-window\0${constructorName}`, node, value: finding, }); diff --git a/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts b/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts index f9b9935162..956a82f901 100644 --- a/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts +++ b/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts @@ -142,3 +142,109 @@ it.each(["webContents.ipc", "webContents.mainFrame.ipc"])( ).toEqual([]); }, ); + +it.each([ + ["BrowserView", "webContents.ipc", "web-contents"], + ["WebContentsView", "webContents.mainFrame.ipc", "main-frame"], +] as const)( + "retains a proven %s %s handler through the view's own contents", + (constructor, surface, kind) => { + const analysis = analyzeJavaScriptStaticSource(` + const { ${constructor} } = require("electron"); + const owner = new ${constructor}({}); + owner.${surface}.handle("view-channel", () => {}); + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toMatchObject([ + { window_constructor: constructor, options_status: "object-literal" }, + ]); + expect(analysis.electron.ipc).toMatchObject([ + { + side: "main", + operation: "handle", + channel: "view-channel", + receiver: { kind }, + }, + ]); + }, +); + +it.each(["webContents.ipc", "webContents.mainFrame.ipc", "loadURL"])( + "omits a BaseWindow %s receiver that the runtime does not own", + (surface) => { + const analysis = analyzeJavaScriptStaticSource(` + const { BaseWindow } = require("electron"); + const base = new BaseWindow({ width: 800 }); + base.${surface}.handle("inert", () => {}); + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.ipc).toEqual([]); + expect(analysis.electron.browser_windows).toMatchObject([ + { + window_constructor: "BaseWindow", + options_status: "object-literal", + web_preferences_status: "missing", + preload_path: null, + }, + ]); + }, +); + +it("keeps a lookalike construction unproven for a view constructor", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { WebContentsView } = require("electron"); + function shadow(WebContentsView) { + const view = new WebContentsView({ webPreferences: { sandbox: true } }); + view.webContents.ipc.handle("shadowed", () => {}); + } + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.electron.ipc).toEqual([]); +}); + +it.each(["BrowserView", "WebContentsView"])( + "reads literal webPreferences on a %s construction", + (constructor) => { + expect( + analyzeJavaScriptStaticSource(` + const { ${constructor} } = require("electron"); + const owner = new ${constructor}({ webPreferences: { preload: "./p.js", sandbox: true } }); + `).electron.browser_windows, + ).toMatchObject([ + { + window_constructor: constructor, + web_preferences_status: "object-literal", + web_preferences: [ + { name: "preload", value: { status: "literal", value: "./p.js" } }, + { name: "sandbox", value: { status: "literal", value: true } }, + ], + preload_path: "./p.js", + preload_resolution_context: "module-specifier", + }, + ]); + }, +); + +it.each(["BaseWindow"])( + "does not report a webPreferences surface a %s never accepts", + (constructor) => { + const analysis = analyzeJavaScriptStaticSource(` + const { ${constructor} } = require("electron"); + const owner = new ${constructor}({ webPreferences: { preload: "./p.js", sandbox: true } }); + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toMatchObject([ + { + window_constructor: constructor, + options_status: "object-literal", + web_preferences_status: "missing", + web_preferences: [], + preload_path: null, + }, + ]); + expect( + analysis.role_paths.filter(({ role }) => role === "preload"), + ).toEqual([]); + }, +); diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index a974ebf8e6..4f8977c46c 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -33,8 +33,27 @@ export type ElectronBrowserWindowPreload = readonly preload_resolution_context: null; }; -/** Statically visible BrowserWindow construction and preference surface. */ +/** + * Constructors that create a window-like Electron surface. Each one is read + * through the capability table in `electronWindowConstructorSurfaces.ts`, + * because they do not all expose the same ones. + */ +export type ElectronWindowConstructor = + | "BrowserWindow" + | "BaseWindow" + | "BrowserView" + | "WebContentsView"; + +/** Statically visible window construction and preference surface. */ export type ElectronBrowserWindowFinding = ElectronBrowserWindowPreload & { + /** + * Constructor that produced this surface. Absent means `BrowserWindow`; the + * others are named so the construction site is never read as a window API. + */ + readonly window_constructor?: Exclude< + ElectronWindowConstructor, + "BrowserWindow" + >; readonly options_status: "object-literal" | "dynamic" | "missing"; readonly web_preferences_status: "object-literal" | "dynamic" | "missing"; readonly web_preferences: readonly ElectronWebPreference[]; diff --git a/src/domain/javascript/electronStaticRenderer.ts b/src/domain/javascript/electronStaticRenderer.ts index 9a207ec895..4b68c08e21 100644 --- a/src/domain/javascript/electronStaticRenderer.ts +++ b/src/domain/javascript/electronStaticRenderer.ts @@ -15,6 +15,11 @@ import { } from "./javascriptSemanticState.js"; import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; +import { + ELECTRON_WINDOW_CONSTRUCTOR_SURFACES, + electronWindowConstructor, + type ElectronWindowConstructorSurface, +} from "./electronWindowConstructorSurfaces.js"; /** A renderer load belongs to an intact, source-proven BrowserWindow instance. */ export const electronRendererLoadAuthority = ( @@ -90,17 +95,28 @@ export const resolveElectronBrowserWindowReceiver = ( receiver = unwrapJavaScriptExpression(initializer.node).node; } if (!t.isNewExpression(receiver)) return undefined; - const constructor = electronCalleePath(receiver.callee, context); + const constructorPath = electronCalleePath(receiver.callee, context); + const constructorName = + constructorPath?.length === 1 ? constructorPath[0] : undefined; + const constructor = + constructorName === undefined + ? undefined + : electronWindowConstructor(constructorName); + // Receivers are admitted only for constructors whose capability entry proves + // the addressed surface, so a window-like construction never gains IPC or + // load facts the runtime would not produce. if ( - constructor?.length !== 1 || - constructor[0] !== "BrowserWindow" || - !supportedReceiverPath(path) + constructor === undefined || + !supportedReceiverPath( + path, + ELECTRON_WINDOW_CONSTRUCTOR_SURFACES[constructor], + ) ) return undefined; if ( context.electronExportIntact?.([ "electron", - "BrowserWindow", + constructor, "prototype", ...path, ]) !== true @@ -119,11 +135,19 @@ export const resolveElectronBrowserWindowReceiver = ( }; }; -const supportedReceiverPath = (path: readonly string[]): boolean => { +const supportedReceiverPath = ( + path: readonly string[], + surfaces: ElectronWindowConstructorSurface, +): boolean => { + // A constructor that owns its webContents accepts preferences, preload, + // renderer loads and scoped IPC through it; one that does not owns only the + // native frame, so none of those surfaces exist on the instance. + if (!surfaces.webContents) return false; const operation = path.at(-1); if (operation === "loadFile" || operation === "loadURL") return ( - path.length === 1 || (path.length === 2 && path[0] === "webContents") + (surfaces.selfLoad && path.length === 1) || + (path.length === 2 && path[0] === "webContents") ); return ( path[0] === "webContents" && diff --git a/src/domain/javascript/electronWindowConstructorSurfaces.ts b/src/domain/javascript/electronWindowConstructorSurfaces.ts new file mode 100644 index 0000000000..882fd4eaaf --- /dev/null +++ b/src/domain/javascript/electronWindowConstructorSurfaces.ts @@ -0,0 +1,41 @@ +import type { ElectronWindowConstructor } from "./electronStaticAnalysisTypes.js"; + +/** + * Electron modules that build a window-like `webContents` surface and the + * surfaces each one exposes. One table so the construction inspector and the + * scoped-IPC receiver resolver cannot disagree about which constructors carry + * a preference, preload or `webContents` surface. + */ +export interface ElectronWindowConstructorSurface { + /** The constructor accepts `webPreferences` in its options object. */ + readonly preferences: boolean; + /** The instance owns `webContents`, and so `webContents.ipc`, directly. */ + readonly webContents: boolean; + /** The instance exposes `loadFile`/`loadURL` on itself, not via contents. */ + readonly selfLoad: boolean; +} + +export const ELECTRON_WINDOW_CONSTRUCTOR_SURFACES: Readonly< + Record +> = { + // A BrowserWindow is created with its own contents and preferences. + BrowserWindow: { preferences: true, webContents: true, selfLoad: true }, + // BaseWindow owns native frame geometry; its contents live in separately + // attached WebContentsViews, so it accepts no webPreferences and exposes no + // webContents of its own. + BaseWindow: { preferences: false, webContents: false, selfLoad: false }, + // A BrowserView is constructed with webPreferences and owns its webContents. + BrowserView: { preferences: true, webContents: true, selfLoad: false }, + // A WebContentsView accepts preferences or an explicitly adopted webContents. + WebContentsView: { preferences: true, webContents: true, selfLoad: false }, +}; + +const CONSTRUCTOR_NAMES: ReadonlySet = new Set( + Object.keys(ELECTRON_WINDOW_CONSTRUCTOR_SURFACES), +); + +/** Narrow an Electron export path segment to a modeled window constructor. */ +export const electronWindowConstructor = ( + name: string, +): ElectronWindowConstructor | undefined => + CONSTRUCTOR_NAMES.has(name) ? (name as ElectronWindowConstructor) : undefined; diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 962a94043c..35fb8a684b 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -23,6 +23,7 @@ import type { ElectronSenderValidationFinding, ElectronProtocolFinding, ElectronUtilityProcessFinding, + ElectronWindowConstructor, } from "./electronStaticAnalysisTypes.js"; import { JavaScriptModuleRangeIndex } from "./javascriptStaticAnalysisFindings.js"; @@ -80,7 +81,10 @@ export interface JavaScriptAnalysisAccumulator { readonly sourceMaps: JavaScriptStaticAnalysis["source_map_urls"][number][]; readonly registrations: JavaScriptBundlerRegistration[]; readonly browserWindows: LocatedJavaScriptFinding[]; - readonly pendingBrowserWindows: t.NewExpression[]; + readonly pendingBrowserWindows: { + readonly node: t.NewExpression; + readonly constructor: ElectronWindowConstructor; + }[]; readonly contextBridgeApis: LocatedJavaScriptFinding[]; readonly ipc: LocatedJavaScriptFinding[]; readonly senderValidations: LocatedJavaScriptFinding[]; diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index c5235a56ba..413b8abf16 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -153,6 +153,9 @@ const ipc = z }); const windowShape = { ...electronFindingContext, + window_constructor: z + .enum(["BaseWindow", "BrowserView", "WebContentsView"]) + .exactOptional(), options_status: z.enum(["object-literal", "dynamic", "missing"]), web_preferences_status: z.enum(["object-literal", "dynamic", "missing"]), web_preferences: z.array( diff --git a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts index dea99967f5..dad21e441c 100644 --- a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts +++ b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts @@ -164,6 +164,77 @@ describe("static Electron application analysis", () => { }); }); +describe("view constructor surfaces", () => { + it("reconstructs view constructors without inventing window surfaces", async () => { + const root = await viewConstructorFixtureDirectory(); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const graph = parseJavaScriptApplicationGraph(result.graph); + + const windows = graph.nodes.filter(({ kind }) => kind === "browser-window"); + expect(windows).toHaveLength(4); + const byLabel = (prefix: string) => + windows + .flatMap(({ observations }) => observations) + .find(({ label }) => label?.startsWith(`${prefix}@`))?.properties ?? {}; + const browserWindow = byLabel("BrowserWindow"); + const baseWindow = byLabel("BaseWindow"); + // A control: BrowserWindow keeps its construction label and surface. + expect(browserWindow).toMatchObject({ + options_status: "object-literal", + web_preferences_status: "object-literal", + preload_path: "./browser-preload.js", + }); + expect(browserWindow).not.toHaveProperty("window_constructor"); + expect(windows.map(({ observations }) => observations[0]?.label)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^BrowserWindow@/u), + expect.stringMatching(/^BaseWindow@/u), + ]), + ); + // BaseWindow accepts no webPreferences and exposes no webContents, so its + // inert option must not become a preload or an IPC surface. + expect(baseWindow).toMatchObject({ + window_constructor: "BaseWindow", + options_status: "object-literal", + web_preferences_status: "missing", + preload_path: null, + }); + const preloadRoles = graph.nodes.filter( + ({ kind }) => kind === "electron-preload", + ); + expect(preloadRoles).toHaveLength(3); + expect(preloadRoles[0]?.observations[0]?.properties).toMatchObject({ + declared_path: "./browser-preload.js", + }); + for (const [constructor, path] of [ + ["BrowserView", "./view-preload.js"], + ["WebContentsView", "./contents-preload.js"], + ] as const) { + expect(byLabel(constructor)).toMatchObject({ + window_constructor: constructor, + web_preferences_status: "object-literal", + preload_path: path, + }); + expect( + preloadRoles + .flatMap((node) => node.observations) + .some( + ({ properties }) => + properties.mechanism === `${constructor}:webPreferences.preload`, + ), + ).toBe(true); + } + expect(result.electron_summary.ipc).toMatchObject({ + main_handlers: 3, + }); + const channels = graph.nodes.filter(({ kind }) => kind === "ipc-channel"); + expect(channels.flatMap(({ observations }) => observations)).toHaveLength( + 3, + ); + }); +}); + describe("native addon association", () => { it.each([ { native: false, associations: [] }, @@ -602,6 +673,43 @@ const fixtureDirectory = async (): Promise => { return root; }; +const viewConstructorFixtureDirectory = async (): Promise => { + const root = await createTestTempDirectory("rea-electron-view-constructors-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "rea-electron-view-constructors", + main: "main.cjs", + }), + ), + writeFile(join(root, "browser-preload.js"), "module.exports = {};"), + writeFile(join(root, "view-preload.js"), "module.exports = {};"), + writeFile(join(root, "contents-preload.js"), "module.exports = {};"), + writeFile( + join(root, "main.cjs"), + String.raw` +const { BrowserWindow, BaseWindow, BrowserView, WebContentsView } = require("electron"); +const window = new BrowserWindow({ + webPreferences: { preload: "./browser-preload.js" }, +}); +window.webContents.ipc.handle("window-channel", () => "window"); +const base = new BaseWindow({ + width: 800, + webPreferences: { preload: "./view-preload.js" }, +}); +base.webContents.ipc.handle("base-channel", () => "base"); +const view = new BrowserView({ webPreferences: { preload: "./view-preload.js" } }); +view.webContents.ipc.handle("view-channel", () => "view"); +const options = { webPreferences: { preload: "./contents-preload.js" } }; +const contents = new WebContentsView(options); +contents.webContents.mainFrame.ipc.handle("contents-channel", () => "contents"); +`, + ), + ]); + return root; +}; + const requireAliasFixtureDirectory = async (): Promise => { const root = await createTestTempDirectory("rea-electron-require-aliases-"); await Promise.all([