diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index 54e8b9bd5b..bd090f7094 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -220,5 +220,9 @@ const resolvesToFile = (input: ResolveToFileInput): boolean => { ); }; -const ipcRecordKey = (path: string, finding: ElectronIpcFinding): string => - `${path}\0${String(finding.location.start.line)}:${String(finding.location.start.column)}\0${finding.side}\0${finding.operation}\0${finding.channel ?? finding.channel_expression}\0${JSON.stringify(finding.handler_location)}`; +const ipcRecordKey = (path: string, finding: ElectronIpcFinding): string => { + const identity = `${path}\0${String(finding.location.start.line)}:${String(finding.location.start.column)}\0${finding.side}\0${finding.operation}\0${finding.channel ?? finding.channel_expression}\0${JSON.stringify(finding.handler_location)}`; + return finding.invocation_location === undefined + ? identity + : `${identity}\0${String(finding.invocation_location.start.line)}:${String(finding.invocation_location.start.column)}`; +}; diff --git a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts index ef9d53750e..4447d59db5 100644 --- a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts +++ b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts @@ -169,11 +169,25 @@ export const addElectronCallPaths = ( const matches = ( projected: ProjectedPath, record: ElectronIpcRecord, -): boolean => - projected.file.path === record.file.path && - projected.path.channel === record.finding.channel && - projected.path.steps.some( - (step) => - electronRangeKey(step.location) === - electronRangeKey(record.finding.location), +): boolean => { + const { path } = projected; + const { finding } = record; + const invocation = finding.invocation_location; + const sourceStepKind = + finding.mode === "send" || finding.mode === "invoke" ? "terminal" : "ipc"; + return ( + projected.file.path === record.file.path && + path.channel === finding.channel && + path.steps.some( + (step) => + step.kind === sourceStepKind && + electronRangeKey(step.location) === electronRangeKey(finding.location), + ) && + (invocation === undefined || + path.steps.some( + (step) => + (step.kind === "call" || step.kind === "exposure") && + electronRangeKey(step.location) === electronRangeKey(invocation), + )) ); +}; diff --git a/src/application/javascript/ElectronBoundaryGraphIpc.ts b/src/application/javascript/ElectronBoundaryGraphIpc.ts index f96da041a8..24dbffba44 100644 --- a/src/application/javascript/ElectronBoundaryGraphIpc.ts +++ b/src/application/javascript/ElectronBoundaryGraphIpc.ts @@ -145,6 +145,9 @@ const addChannelNode = ( channel: finding.channel, channel_expression: finding.channel_expression, resolution: finding.channel === null ? "dynamic" : "literal", + ...(finding.invocation_location === undefined + ? {} + : { invocation_location: finding.invocation_location }), }, evidence: (finding.resolution_basis === "wrapper-substitution" ? staticInferenceEvidence @@ -192,6 +195,9 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => { channel_expression: finding.channel_expression, handler_kind: finding.handler_kind, handler_location: finding.handler_location, + ...(finding.invocation_location === undefined + ? {} + : { invocation_location: finding.invocation_location }), }, evidence: (finding.resolution_basis === "wrapper-substitution" ? staticInferenceEvidence @@ -227,6 +233,9 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => { channel: finding.channel, channel_expression: finding.channel_expression, resolution: finding.channel === null ? "dynamic" : "literal", + ...(finding.invocation_location === undefined + ? {} + : { invocation_location: finding.invocation_location }), }, }); return handler; diff --git a/src/composition/javascriptAnalysisWorkerProcess.ts b/src/composition/javascriptAnalysisWorkerProcess.ts index 6f3d5bfec2..5c66f4afa9 100644 --- a/src/composition/javascriptAnalysisWorkerProcess.ts +++ b/src/composition/javascriptAnalysisWorkerProcess.ts @@ -6,7 +6,10 @@ import { } from "../domain/javascript/javascriptSemanticAnalysis.js"; import { failedJavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisHelpers.js"; import { projectJavaScriptModuleSemantics } from "../domain/javascript/javascriptModuleSemanticIr.js"; -import { measureJavaScriptModuleTransfer } from "../javascript/analysis/JavaScriptAnalysisTransfer.js"; +import { + javaScriptTransferByteBudget, + measureJavaScriptModuleTransfer, +} from "../javascript/analysis/JavaScriptAnalysisTransfer.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import { projectJavaScriptSemanticFileSteps } from "../application/javascript/JavaScriptSemanticGraphBuilder.js"; import { completeJavaScriptAnalysisSteps } from "../application/javascript/JavaScriptAnalysisControl.js"; @@ -54,7 +57,10 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( ); parsed = null; const module = projectJavaScriptModuleSemantics(ir); - const measured = measureJavaScriptModuleTransfer(module); + const measured = measureJavaScriptModuleTransfer( + module, + javaScriptTransferByteBudget(resultBudgetBytes), + ); if (measured.retentionBytes > resultBudgetBytes) throw new AnalysisResourceConstraintError( "analyze_javascript_application", diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index 8877a6b476..b8d2c03fb7 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -119,6 +119,8 @@ interface ElectronIpcFindingContext { /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; readonly location: JavaScriptSourceRange; + /** Outer syntax invocation when a wrapper produced this underlying IPC call. */ + readonly invocation_location?: JavaScriptSourceRange; } /** One statically visible Electron IPC send, invocation, listener, or handler. */ diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index e85f28b6f1..13c0c21af2 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -105,148 +105,365 @@ describe("wrapped Electron control-flow alternatives", () => { ...new Set(result.electron.call_paths?.map((path) => path.channel)), ].sort(), ).toEqual(["test:copy", "test:cut"]); + const paths = result.electron.call_paths ?? []; + expect( + paths + .find(({ channel }) => channel === "test:copy") + ?.conditions.map(({ expression }) => expression), + ).toContain("flag"); + expect( + paths + .filter(({ channel }) => channel === "test:cut") + .some((path) => + path.conditions.some(({ expression }) => expression === "!(flag)"), + ), + ).toBe(true); }); }); -describe("wrapped Electron call paths", () => { - it("connects exposed closures to IPC sends and text clipboard calls", () => { - const result = analyzeJavaScriptStaticSource( - bundle(` +it("preserves an observed empty contextBridge API object", () => { + const result = analyzeJavaScriptStaticSource(` + const electron = require("electron"); + electron.contextBridge.exposeInMainWorld("desktop", {}); + `); + expect(result.electron.context_bridge_apis).toContainEqual( + expect.objectContaining({ + api_key: "desktop", + api_status: "object-literal", + members: [], + unknown_members: 0, + }), + ); + expect(result.electron.context_bridge_apis?.[0]).not.toHaveProperty( + "resolution_basis", + ); + expect(result.parse_status).toBe("complete"); +}); + +it("connects exposed closures to IPC sends and text clipboard calls", () => { + const result = analyzeJavaScriptStaticSource( + bundle(` const text = value => navigator.clipboard.writeText(value); const api={copy:helper.sender("example:copy"),copyText:text}; electron.contextBridge.exposeInMainWorld("desktop",api); `), - ); - expect(result.electron.context_bridge_apis).toMatchObject([ - { api_key: "desktop", members: ["copy", "copyText"] }, - ]); - expect(result.electron.ipc).toContainEqual( + ); + expect(result.electron.context_bridge_apis).toMatchObject([ + { api_key: "desktop", members: ["copy", "copyText"] }, + ]); + expect(result.electron.ipc).toContainEqual( + expect.objectContaining({ + side: "renderer", + channel: "example:copy", + operation: "send", + }), + ); + expect(result.electron.call_paths).toEqual( + expect.arrayContaining([ expect.objectContaining({ - side: "renderer", + entry: "desktop.copy", channel: "example:copy", - operation: "send", + terminal: "electron.ipcRenderer.send", }), - ); - expect(result.electron.call_paths).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - entry: "desktop.copy", - channel: "example:copy", - terminal: "electron.ipcRenderer.send", - }), - expect.objectContaining({ - entry: "desktop.copyText", - terminal: "navigator.clipboard.writeText", - }), - ]), - ); - }); - it("substitutes guarded listener callbacks and preserves guard evidence", () => { - const result = analyzeJavaScriptStaticSource( - bundle( - `helper.events.addListener("example:copy",event=>event.sender.copy());`, - ), - ); - expect(result.electron.ipc).toContainEqual( expect.objectContaining({ - side: "main", - channel: "example:copy", - operation: "on", + entry: "desktop.copyText", + terminal: "navigator.clipboard.writeText", }), - ); - expect(result.electron.call_paths).toContainEqual( - expect.objectContaining({ - entry_kind: "ipc-handler", - channel: "example:copy", - terminal: "ipc-event.sender.copy", - conditions: [expect.objectContaining({ expression: "allowed(event)" })], - }), - ); - }); - it("does not identify overwritten Electron namespaces or shadowed require", () => { - for (const source of [ - `const e=require("electron");e.ipcMain={on(){}};e.ipcMain.on("bad",event=>event.sender.copy());`, - `function require(){return {ipcMain:{on(){}}}};const e=require("electron");e.ipcMain.on("bad",event=>event.sender.copy());`, - ]) - expect( - analyzeJavaScriptStaticSource(source).electron.call_paths ?? [], - ).toEqual([]); - }); - it("does not follow a statically false callback branch", () => { - const result = analyzeJavaScriptStaticSource( - bundle( - `helper.events.addListener("example:copy",event=>{if(false)event.sender.copy()});`, - ), - ); - expect(result.electron.call_paths ?? []).toEqual([]); - }); - it("retains early rejection guards and excludes calls after definite returns", () => { - const guarded = analyzeJavaScriptStaticSource( - `const e=require("electron");e.ipcMain.on("guard",event=>{if(!allowed(event))return;event.sender.copy()});`, - ); - expect(guarded.electron.call_paths).toContainEqual( - expect.objectContaining({ - conditions: [ - expect.objectContaining({ expression: "!(!allowed(event))" }), - ], - }), - ); - for (const body of [ - "return;event.sender.copy()", - "if(true)return;event.sender.copy()", - "if(false)event.sender.copy()", - ]) - expect( - analyzeJavaScriptStaticSource( - `const e=require("electron");e.ipcMain.on("dead",event=>{${body}});`, - ).electron.call_paths ?? [], - ).toEqual([]); + ]), + ); + const ipcFinding = result.electron.ipc.find( + ({ channel }) => channel === "example:copy", + ); + const ipcPath = result.electron.call_paths?.find( + ({ entry }) => entry === "desktop.copy", + ); + expect(ipcFinding).toBeDefined(); + expect(ipcPath?.steps.at(-1)).toBeDefined(); + expect(ipcFinding?.location).toEqual(ipcPath?.steps.at(-1)?.location); +}); +it("resolves a helper factory listed after its bundle consumer", () => { + const result = analyzeJavaScriptStaticSource(`(() => { + const modules = { + 2(module) { module.exports = require("electron"); }, + 9(module,exports,load) { + const electron=load(2),helper=load(5); + electron.contextBridge.exposeInMainWorld("desktop", {copy:helper.sender("after:copy")}); + }, + 5(module,exports,load) { + const electron=load(2); + exports.sender=channel=>()=>electron.ipcRenderer.send(channel); + } + }; + function load(id) { const m={exports:{}}; modules[id].call(m.exports,m,m.exports,load); return m.exports; } + load(9); + })();`); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ + entry: "desktop.copy", + channel: "after:copy", + terminal: "electron.ipcRenderer.send", + }), + ); +}); +it("substitutes guarded listener callbacks and preserves guard evidence", () => { + const result = analyzeJavaScriptStaticSource( + bundle( + `helper.events.addListener("example:copy",event=>event.sender.copy());`, + ), + ); + expect(result.electron.ipc).toContainEqual( + expect.objectContaining({ + side: "main", + channel: "example:copy", + operation: "on", + }), + ); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ + entry_kind: "ipc-handler", + channel: "example:copy", + terminal: "ipc-event.sender.copy", + conditions: [expect.objectContaining({ expression: "allowed(event)" })], + }), + ); + const ipcFinding = result.electron.ipc.find( + ({ channel, operation }) => + channel === "example:copy" && operation === "on", + ); + const ipcStep = result.electron.call_paths + ?.find(({ entry_kind }) => entry_kind === "ipc-handler") + ?.steps.find(({ kind }) => kind === "ipc"); + expect(ipcFinding).toBeDefined(); + expect(ipcStep).toBeDefined(); + expect(ipcFinding?.location).toEqual(ipcStep?.location); +}); +it("retains distinct wrapper invocation ranges for one IPC API source", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const copy=event=>event.sender.copy(); + function register(channel,handler){e.ipcMain.on(channel,handler);} + register("same:channel",copy); + register("same:channel",copy); + `); + const findings = result.electron.ipc.filter( + ({ resolution_basis, channel }) => + resolution_basis === "wrapper-substitution" && channel === "same:channel", + ); + expect(findings).toHaveLength(2); + expect(findings[0]?.location).toEqual(findings[1]?.location); + expect(findings[0]?.invocation_location).toBeDefined(); + expect(findings[1]?.invocation_location).toBeDefined(); + expect(findings[0]?.invocation_location).not.toEqual( + findings[1]?.invocation_location, + ); +}); +it("does not identify overwritten Electron namespaces or shadowed require", () => { + for (const source of [ + `const e=require("electron");e.ipcMain={on(){}};e.ipcMain.on("bad",event=>event.sender.copy());`, + `function require(){return {ipcMain:{on(){}}}};const e=require("electron");e.ipcMain.on("bad",event=>event.sender.copy());`, + ]) expect( - analyzeJavaScriptStaticSource( - `const e=require("electron");if(false)e.ipcMain.on("dead",event=>event.sender.copy());`, - ).electron.call_paths ?? [], + analyzeJavaScriptStaticSource(source).electron.call_paths ?? [], ).toEqual([]); - }); - it("rejects dynamic channels, computed overwrites, unknown spreads and reassigned callback parameters", () => { - for (const entry of [ - `helper.events.addListener(runtimeChannel,event=>event.sender.copy());`, - `electron[runtimeKey]={};electron.ipcMain.on("bad",event=>event.sender.copy());`, - `const api={copy:helper.sender("bad"),...unknownApi};electron.contextBridge.exposeInMainWorld("desktop",api);`, - `helper.events.addListener("bad",event=>{event=other;event.sender.copy()});`, - ]) - expect( - analyzeJavaScriptStaticSource(bundle(entry)).electron.call_paths ?? [], - ).toEqual([]); - }); - it("resolves renamed default and namespace import helpers from their syntax", () => { +}); +it("preserves guarded alternatives in direct exposed object literals", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const copy=()=>e.ipcRenderer.send("copy"); + const cut=()=>e.ipcRenderer.send("cut"); + e.contextBridge.exposeInMainWorld("desktop", {action: flag ? copy : cut}); + `); + expect(result.electron.call_paths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry: "desktop.action", + channel: "copy", + conditions: [expect.objectContaining({ expression: "flag" })], + }), + expect.objectContaining({ + entry: "desktop.action", + channel: "cut", + conditions: [expect.objectContaining({ expression: "!(flag)" })], + }), + ]), + ); +}); +it("preserves conditions when a wrapper dispatches through conditional external methods", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const send=flag ? e.ipcRenderer.send : e.ipcRenderer.invoke; + e.contextBridge.exposeInMainWorld("desktop", {action:()=>send("channel")}); + `); + expect(result.electron.call_paths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry: "desktop.action", + channel: "channel", + terminal: "electron.ipcRenderer.send", + conditions: [expect.objectContaining({ expression: "flag" })], + }), + expect.objectContaining({ + entry: "desktop.action", + channel: "channel", + terminal: "electron.ipcRenderer.invoke", + conditions: [expect.objectContaining({ expression: "!(flag)" })], + }), + ]), + ); + expect( + result.electron.ipc.filter( + ({ resolution_basis, channel }) => + resolution_basis === "wrapper-substitution" && channel === "channel", + ), + ).toEqual([]); + expect(result.parse_status).toBe("partial"); +}); +it("keeps conditional IPC channels unknown instead of reporting them unconditionally", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const channel=flag ? "copy" : "cut"; + e.contextBridge.exposeInMainWorld("desktop", {action:()=>e.ipcRenderer.send(channel)}); + `); + expect(result.electron.call_paths ?? []).toEqual([]); + expect(result.parse_status).toBe("partial"); +}); +it("preserves the raw bridge observation for a guarded object value", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const api=flag ? {copy:()=>e.ipcRenderer.send("copy")} : {}; + e.contextBridge.exposeInMainWorld("desktop", api); + `); + expect(result.electron.context_bridge_apis).toContainEqual( + expect.objectContaining({ + api_key: "desktop", + api_status: "dynamic", + }), + ); + expect(result.parse_status).toBe("partial"); +}); +it("preserves the source expression for a guarded bridge world key", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const world=flag ? "desktop" : "shell"; + e.contextBridge.exposeInMainWorld(world, {copy:()=>e.ipcRenderer.send("copy")}); + `); + expect(result.electron.context_bridge_apis).toContainEqual( + expect.objectContaining({ api_key: null, api_key_expression: "world" }), + ); + expect(result.parse_status).toBe("partial"); +}); +it.each(["const", "var"])( + "does not use a %s callback initializer before exposure", + (declaration) => { const result = analyzeJavaScriptStaticSource( bundle(` + const electron=load(2); + electron.contextBridge.exposeInMainWorld("desktop", {copy: callback}); + ${declaration} callback=()=>electron.ipcRenderer.send("late"); + `), + ); + expect(result.electron.call_paths ?? []).toEqual([]); + expect(result.parse_status).toBe("partial"); + }, +); +it("does not use an original Electron module factory after its slot is replaced", () => { + const result = analyzeJavaScriptStaticSource( + bundle(` + modules[2] = function(module) { + module.exports = {contextBridge:{exposeInMainWorld(){}},ipcRenderer:{send(){}}}; + }; + const electron=load(2); + electron.contextBridge.exposeInMainWorld("desktop", {copy:()=>electron.ipcRenderer.send("fake")}); + `), + ); + expect(result.electron.call_paths ?? []).toEqual([]); +}); +it.each(["({})", "load(2)"])( + "does not emit the impossible logical-RHS terminal after %s", + (left) => { + const result = analyzeJavaScriptStaticSource( + bundle(` + electron.ipcMain.on("guard", event => { ${left} || event.sender.copy(); }); + `), + ); + expect(result.electron.call_paths ?? []).toEqual([]); + }, +); +it("does not follow a statically false callback branch", () => { + const result = analyzeJavaScriptStaticSource( + bundle( + `helper.events.addListener("example:copy",event=>{if(false)event.sender.copy()});`, + ), + ); + expect(result.electron.call_paths ?? []).toEqual([]); +}); +it("retains early rejection guards and excludes calls after definite returns", () => { + const guarded = analyzeJavaScriptStaticSource( + `const e=require("electron");e.ipcMain.on("guard",event=>{if(!allowed(event))return;event.sender.copy()});`, + ); + expect(guarded.electron.call_paths).toContainEqual( + expect.objectContaining({ + conditions: [ + expect.objectContaining({ expression: "!(!allowed(event))" }), + ], + }), + ); + for (const body of [ + "return;event.sender.copy()", + "if(true)return;event.sender.copy()", + "if(false)event.sender.copy()", + ]) + expect( + analyzeJavaScriptStaticSource( + `const e=require("electron");e.ipcMain.on("dead",event=>{${body}});`, + ).electron.call_paths ?? [], + ).toEqual([]); + expect( + analyzeJavaScriptStaticSource( + `const e=require("electron");if(false)e.ipcMain.on("dead",event=>event.sender.copy());`, + ).electron.call_paths ?? [], + ).toEqual([]); +}); +it("rejects dynamic channels, computed overwrites, unknown spreads and reassigned callback parameters", () => { + for (const entry of [ + `helper.events.addListener(runtimeChannel,event=>event.sender.copy());`, + `electron[runtimeKey]={};electron.ipcMain.on("bad",event=>event.sender.copy());`, + `const api={copy:helper.sender("bad"),...unknownApi};electron.contextBridge.exposeInMainWorld("desktop",api);`, + `helper.events.addListener("bad",event=>{event=other;event.sender.copy()});`, + ]) + expect( + analyzeJavaScriptStaticSource(bundle(entry)).electron.call_paths ?? [], + ).toEqual([]); +}); +it("resolves renamed default and namespace import helpers from their syntax", () => { + const result = analyzeJavaScriptStaticSource( + bundle(` const defaultImport=x=>x&&x.__esModule?x:{default:x}; function namespaceImport(x){if(x&&x.__esModule)return x;return unknownWrap(x)} const E=defaultImport(load(2)),H=namespaceImport(load(5)); E.default.contextBridge.exposeInMainWorld("renamed",{copy:(0,H.sender)("test:renamed")}); `), - ); - expect(result.electron.call_paths).toContainEqual( - expect.objectContaining({ - entry: "renamed.copy", - channel: "test:renamed", - terminal: "electron.ipcRenderer.send", - }), - ); - expect( - result.electron.call_paths - ?.flatMap((p) => p.steps) - .every((step) => step.label.length > 0), - ).toBe(true); - }); - it("does not trust helper names or follow returns after a definite throw", () => { - for (const helper of [ - `function __toESM() {return {ipcRenderer:{send(){}}}}`, - `function __toESM(value) {throw Error("inert");return value;}`, - `function __toESM(value) {if(true){throw Error("inert")}return value;}`, - ]) { - const result = analyzeJavaScriptStaticSource(` + ); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ + entry: "renamed.copy", + channel: "test:renamed", + terminal: "electron.ipcRenderer.send", + }), + ); + expect( + result.electron.call_paths + ?.flatMap((p) => p.steps) + .every((step) => step.label.length > 0), + ).toBe(true); +}); +it("does not trust helper names or follow returns after a definite throw", () => { + for (const helper of [ + `function __toESM() {return {ipcRenderer:{send(){}}}}`, + `function __toESM(value) {throw Error("inert");return value;}`, + `function __toESM(value) {if(true){throw Error("inert")}return value;}`, + ]) { + const result = analyzeJavaScriptStaticSource(` const electron = require("electron"); ${helper} const wrapped = __toESM(electron); @@ -254,7 +471,6 @@ describe("wrapped Electron call paths", () => { copy:()=>wrapped.ipcRenderer.send("test:copy") }); `); - expect(result.electron.call_paths ?? []).toEqual([]); - } - }); + expect(result.electron.call_paths ?? []).toEqual([]); + } }); diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index 7868996713..b747472736 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -72,7 +72,7 @@ export const collectElectronWrappedCallPaths = ( const condition = ( context: PathContext, node: t.Node, - positive: boolean | "nullish" = true, + positive: boolean | "nullish" | "not-nullish" = true, ): PathContext => { const expression = source .slice(node.start ?? 0, node.end ?? 0) @@ -85,14 +85,33 @@ export const collectElectronWrappedCallPaths = ( expression: positive === "nullish" ? `(${expression}) === null || (${expression}) === undefined` - : positive - ? expression - : `!(${expression})`, + : positive === "not-nullish" + ? `(${expression}) !== null && (${expression}) !== undefined` + : positive + ? expression + : `!(${expression})`, location: range(node), }, ], }; }; + const metadataContext = ( + context: PathContext, + value: SymbolicValue, + ): PathContext | null => { + if (value.conditionUnresolved) { + accumulator.unknownFindings += 1; + return null; + } + return ( + value.conditions?.reduce( + (current, branch) => condition(current, branch.node, branch.positive), + context, + ) ?? context + ); + }; + const hasConditions = (value: SymbolicValue): boolean => + symbolicVariants(value).some((variant) => variant.conditions?.length); const emit = ( context: PathContext, node: t.Node, @@ -118,14 +137,19 @@ export const collectElectronWrappedCallPaths = ( fn: SymbolicFunction, args: readonly SymbolicValue[], context: PathContext, + invocationNode: t.Node, ): void => { if (context.active.has(fn.node)) return; + if (fn.conditionUnresolved === true) { + accumulator.unknownFindings += 1; + return; + } const name = (t.isFunctionExpression(fn.node) || t.isFunctionDeclaration(fn.node)) && fn.node.id ? fn.node.id.name : "closure"; - walk(fn.node.body, resolver.bind(fn, args), { + walk(fn.node.body, resolver.bind(fn, args, invocationNode), { ...context, steps: [ ...context.steps, @@ -147,14 +171,32 @@ export const collectElectronWrappedCallPaths = ( args: readonly SymbolicValue[], context: PathContext, endpoint: string, + guardedDispatch: boolean, ): void => { const first = args[0]; - if (first?.kind !== "literal" || typeof first.value !== "string") return; + if (first?.kind !== "literal" || typeof first.value !== "string") { + accumulator.unknownFindings += 1; + return; + } + const channelContext = metadataContext(context, first); + if (!channelContext) return; + const handlerValue = args[1] ?? symbolicUnknown("missing handler"); + const handlerContext = metadataContext(channelContext, handlerValue); + if (!handlerContext) return; const channel = first.value; - const location = range(context.entryNode); - const handlers = symbolicVariants( - args[1] ?? symbolicUnknown("missing handler"), - ); + const location = range(node); + const invocationLocation = + JSON.stringify(range(context.entryNode)) === JSON.stringify(location) + ? undefined + : range(context.entryNode); + const findingContext = { + module_key: null, + location, + ...(invocationLocation === undefined + ? {} + : { invocation_location: invocationLocation }), + }; + const handlers = symbolicVariants(handlerValue); // A channel registration must not acquire one arbitrary handler from a union. // Keep the unresolved registration so it still competes with other handlers. const fn = @@ -170,8 +212,7 @@ export const collectElectronWrappedCallPaths = ( channel_expression: null, handler_kind: null, handler_location: null, - module_key: null, - location, + ...findingContext, } : fn ? { @@ -180,8 +221,7 @@ export const collectElectronWrappedCallPaths = ( channel_expression: null, handler_kind: "inline-function", handler_location: range(fn.node), - module_key: null, - location, + ...findingContext, } : handlerNode ? { @@ -190,8 +230,7 @@ export const collectElectronWrappedCallPaths = ( channel_expression: null, handler_kind: "dynamic-expression", handler_location: range(handlerNode), - module_key: null, - location, + ...findingContext, } : { ...descriptor, @@ -199,28 +238,28 @@ export const collectElectronWrappedCallPaths = ( channel_expression: null, handler_kind: "missing", handler_location: null, - module_key: null, - location, + ...findingContext, }; const duplicate = accumulator.ipc.some( ({ value }) => value.side === finding.side && value.operation === finding.operation && value.channel === channel && - [JSON.stringify(location), JSON.stringify(range(node))].includes( - JSON.stringify(value.location), - ), + JSON.stringify(value.location) === JSON.stringify(location) && + JSON.stringify(value.invocation_location) === + JSON.stringify(invocationLocation), ); - if (!duplicate) { + const findingGuarded = hasConditions(first) || guardedDispatch; + if (!duplicate && !findingGuarded) { accumulator.ipc.push({ - offset: context.entryNode.start ?? -1, + offset: node.start ?? -1, value: { ...finding, resolution_basis: "wrapper-substitution" }, }); if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn) accumulator.unknownFindings += 1; - } + } else if (findingGuarded) accumulator.unknownFindings += 1; if (descriptor.mode === "send" || descriptor.mode === "invoke") { - emit(context, node, endpoint, channel); + emit(channelContext, node, endpoint, channel); return; } if (descriptor.side === "main" && fn) @@ -231,23 +270,29 @@ export const collectElectronWrappedCallPaths = ( symbolicUnknown("IPC payload"), ], { - ...context, + ...handlerContext, entryKind: "ipc-handler", entry: channel, channel, steps: [...context.steps, step(node, "ipc", endpoint)], }, + node, ); }; const bridge = ( node: t.CallExpression | t.OptionalCallExpression, args: readonly SymbolicValue[], context: PathContext, + guardedDispatch: boolean, ): void => { const key = args[0]; const object = args[1]; - if (key?.kind !== "literal" || typeof key.value !== "string" || !object) + if (key?.kind !== "literal" || typeof key.value !== "string" || !object) { + accumulator.unknownFindings += 1; return; + } + const keyContext = metadataContext(context, key); + if (!keyContext || !metadataContext(keyContext, object)) return; const members = [...resolver.members(object)].sort(); const location = range(node); const existing = accumulator.contextBridgeApis.find( @@ -266,14 +311,23 @@ export const collectElectronWrappedCallPaths = ( module_key: null, location, }; - if (existing) - existing.value.members.length === 0 && + // This report shape has no predicate field, so a guarded world or object + // cannot be recorded as an unconditional exposure fact. + const guardedKey = hasConditions(key); + const guardedObject = hasConditions(object); + const guarded = guardedKey || guardedObject || guardedDispatch; + if (guarded) accumulator.unknownFindings += 1; + // Existing entries are raw syntax observations and remain valid even when + // symbolic substitution can only describe this call under a predicate. + if (!guarded && existing) + (existing.value.api_status !== "object-literal" || + existing.value.unknown_members > 0) && accumulator.contextBridgeApis.splice( accumulator.contextBridgeApis.indexOf(existing), 1, { offset: node.start ?? -1, value: finding }, ); - else + else if (!guarded) accumulator.contextBridgeApis.push({ offset: node.start ?? -1, value: finding, @@ -282,11 +336,13 @@ export const collectElectronWrappedCallPaths = ( const value = resolver.property(object, name); for (const fn of symbolicVariants(value)) if (fn.kind === "function") { + const memberContext = metadataContext(keyContext, fn); + if (!memberContext) continue; functionBody( fn, fn.node.params.map(() => symbolicUnknown("public API argument")), { - ...context, + ...memberContext, entryNode: node, entryKind: "bridge-member", entry: `${key.value}.${name}`, @@ -294,6 +350,7 @@ export const collectElectronWrappedCallPaths = ( steps: [step(node, "exposure", `${key.value}.${name}`)], active: new Set(), }, + node, ); } } @@ -306,27 +363,45 @@ export const collectElectronWrappedCallPaths = ( const value = resolver.evaluate(node.callee, env); const args = resolver.arguments(node, env); for (const callee of symbolicVariants(value)) { + const branchContext = metadataContext(context, callee); + if (!branchContext) continue; if (callee.kind === "function") { - functionBody(callee, args, { - ...context, - steps: [...context.steps, step(node, "call", callLabel(node.callee))], - }); + functionBody( + callee, + args, + { + ...branchContext, + steps: [ + ...branchContext.steps, + step(node, "call", callLabel(node.callee)), + ], + }, + node, + ); } else if (callee.kind === "external") { const path = callee.path; const endpoint = path.join("."); if (/^electron(?:\/(?:main|renderer|common))?$/u.test(path[0] ?? "")) { const descriptor = ipcDescriptor(path.slice(1)); - if (descriptor) ipc(node, descriptor, args, context, endpoint); + if (descriptor) + ipc( + node, + descriptor, + args, + branchContext, + endpoint, + hasConditions(callee), + ); else if ( path.slice(1).join(".") === "contextBridge.exposeInMainWorld" ) - bridge(node, args, context); - else if (path[1] === "clipboard") emit(context, node, endpoint); + bridge(node, args, branchContext, hasConditions(callee)); + else if (path[1] === "clipboard") emit(branchContext, node, endpoint); } else if ( (path[0] === "navigator" && path[1] === "clipboard") || (path[0] === "ipc-event" && path[1] === "sender") ) - emit(context, node, endpoint); + emit(branchContext, node, endpoint); } } }; @@ -457,5 +532,6 @@ export const collectElectronWrappedCallPaths = ( accumulator.unknownFindings += 1; accumulator.electronCallPathLimitReached = true; } + if (resolver.unresolved) accumulator.unknownFindings += 1; return paths; }; diff --git a/src/domain/javascript/javascriptBootstrap.ts b/src/domain/javascript/javascriptBootstrap.ts index 065d54eb83..cdb33d0245 100644 --- a/src/domain/javascript/javascriptBootstrap.ts +++ b/src/domain/javascript/javascriptBootstrap.ts @@ -3,17 +3,25 @@ import * as t from "@babel/types"; import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; import { moduleFactory } from "./javascriptStaticAnalysisHelpers.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; /** A literal module table dispatched by a standalone CommonJS-style runtime. */ interface JavaScriptBootstrap { readonly body: t.BlockStatement; readonly table: t.ObjectExpression; readonly loaderName: string; + readonly loaderBindingId: string; + readonly tableBindingId: string; } /** Recognize syntax, never invoke a bootstrap or infer it from variable names. */ export const javascriptBootstrapAt = ( call: t.CallExpression, + state: JavaScriptSemanticAnalysisState, ): JavaScriptBootstrap | undefined => { if ( (!t.isFunctionExpression(call.callee) && @@ -35,8 +43,18 @@ export const javascriptBootstrapAt = ( declaration.init.properties.every( (property) => moduleFactory(property) !== undefined, ) - ) - tables.set(declaration.id.name, declaration.init); + ) { + const binding = resolveSemanticBindingState( + state, + declaration.id, + declaration.id.name, + ); + if ( + binding !== undefined && + semanticStableInitializer(binding, state)?.node === declaration.init + ) + tables.set(binding.bindingId, declaration.init); + } } } if (tables.size === 0) return undefined; @@ -51,7 +69,11 @@ export const javascriptBootstrapAt = ( const parameter = loader.params[0]; if (!t.isIdentifier(parameter)) continue; const loaderName = loader.id.name; - let tableName: string | undefined; + const loaderBindingId = bindingId(state, loader.id); + const parameterBindingId = bindingId(state, parameter); + if (loaderBindingId === undefined || parameterBindingId === undefined) + continue; + let tableBindingId: string | undefined; traverseJavaScriptAst(loader.body, { enter: (node, _parent, ancestors) => { if ( @@ -59,22 +81,29 @@ export const javascriptBootstrapAt = ( ancestors().some((node) => t.isFunction(node)) ) return; - const candidate = dispatchedTableName(node, loaderName, parameter.name); + const candidate = dispatchedTableBinding( + node, + loaderBindingId, + parameterBindingId, + state, + ); if (candidate !== undefined && tables.has(candidate)) - tableName = candidate; + tableBindingId = candidate; }, }); - const table = tableName === undefined ? undefined : tables.get(tableName); - if (table !== undefined && tableName !== undefined) - return { body, table, loaderName }; + const table = + tableBindingId === undefined ? undefined : tables.get(tableBindingId); + if (table !== undefined && tableBindingId !== undefined) + return { body, table, loaderName, loaderBindingId, tableBindingId }; } return undefined; }; -const dispatchedTableName = ( +const dispatchedTableBinding = ( call: t.CallExpression, - loaderName: string, - keyName: string, + loaderBindingId: string, + keyBindingId: string, + state: JavaScriptSemanticAnalysisState, ): string | undefined => { const member = call.callee; const viaCall = @@ -86,7 +115,8 @@ const dispatchedTableName = ( !t.isMemberExpression(dispatch) || !dispatch.computed || !t.isIdentifier(dispatch.object) || - !t.isIdentifier(dispatch.property, { name: keyName }) + !t.isIdentifier(dispatch.property) || + bindingId(state, dispatch.property) !== keyBindingId ) return undefined; const args = viaCall ? call.arguments.slice(1) : call.arguments; @@ -94,11 +124,14 @@ const dispatchedTableName = ( if ( args.length !== 3 || !t.isIdentifier(module) || + bindingId(state, module) === undefined || !t.isMemberExpression(exports) || - !t.isIdentifier(exports.object, { name: module.name }) || + !t.isIdentifier(exports.object) || + bindingId(state, exports.object) !== bindingId(state, module) || semanticStaticPropertyKey(exports.property, exports.computed) !== "exports" || - !t.isIdentifier(require, { name: loaderName }) + !t.isIdentifier(require) || + bindingId(state, require) !== loaderBindingId ) return undefined; if ( @@ -107,5 +140,11 @@ const dispatchedTableName = ( !t.isNodesEquivalent(call.arguments[0], exports)) ) return undefined; - return dispatch.object.name; + return bindingId(state, dispatch.object); }; + +const bindingId = ( + state: JavaScriptSemanticAnalysisState, + identifier: t.Identifier, +): string | undefined => + resolveSemanticBindingState(state, identifier, identifier.name)?.bindingId; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 53b1618b2c..b0d731ae23 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -38,6 +38,7 @@ import { type JavaScriptFindingContext, } from "./javascriptStaticAnalysisState.js"; import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; import { classifyParsedJavaScriptElectronBindingsSteps, indexJavaScriptBindingsSteps, @@ -86,14 +87,18 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( const electronBindings = yield* classifyParsedJavaScriptElectronBindingsSteps(file); const clientRoots = yield* classifyParsedJavaScriptRpcClientRootsSteps(file); - yield* traverseStaticSourceSteps(file, { - source, - accumulator, - ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), - electronBindings, - rpcScan: createJavaScriptRpcScan(source, clientRoots), - }); const bindings = yield* indexJavaScriptBindingsSteps(file.program); + yield* traverseStaticSourceSteps( + file, + { + source, + accumulator, + ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), + electronBindings, + rpcScan: createJavaScriptRpcScan(source, clientRoots), + }, + bindings, + ); accumulator.electronCallPaths = collectElectronWrappedCallPaths( file.program, bindings, @@ -107,11 +112,12 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( function* traverseStaticSourceSteps( file: ParsedJavaScriptSource, findings: JavaScriptFindingContext, + bindings: JavaScriptSemanticAnalysisState, ): Generator { yield* traverseJavaScriptAstSteps(file, { enter: (node) => { findings.accumulator.visitedNodes += 1; - inspectNode(node, findings); + inspectNode(node, findings, bindings); return undefined; }, }); @@ -217,11 +223,12 @@ const finalizeStaticAnalysis = ( const inspectNode = ( node: t.Node, findings: JavaScriptFindingContext, + bindings: JavaScriptSemanticAnalysisState, ): void => { const { source, accumulator } = findings; inspectElectronStaticNode(node, findings); if (t.isCallExpression(node)) { - inspectBundlerBootstrap(source, node, accumulator); + inspectBundlerBootstrap(source, node, accumulator, bindings); inspectBundlerRegistration(source, node, accumulator); inspectEsbuildWrapper(source, node, accumulator); inspectCall(source, node, findings); diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index fd33ff006e..bf357a7229 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -1,5 +1,9 @@ import * as t from "@babel/types"; import { javascriptBootstrapAt } from "./javascriptBootstrap.js"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; @@ -83,8 +87,9 @@ export const inspectBundlerBootstrap = ( source: string, call: t.CallExpression, accumulator: AnalysisAccumulator, + bindings: JavaScriptSemanticAnalysisState, ): void => { - const bootstrap = javascriptBootstrapAt(call); + const bootstrap = javascriptBootstrapAt(call, bindings); if (bootstrap === undefined) return; const runtime = `webpack-bootstrap@${String(call.start ?? 0)}`; const recovered = recoverBundlerModules( @@ -100,7 +105,9 @@ export const inspectBundlerBootstrap = ( if ( !t.isCallExpression(node) || ancestors().some((node) => t.isFunction(node)) || - !t.isIdentifier(node.callee, { name: bootstrap.loaderName }) + !t.isIdentifier(node.callee) || + resolveSemanticBindingState(bindings, node.callee, node.callee.name) + ?.bindingId !== bootstrap.loaderBindingId ) return; const value = argumentValue(node.arguments[0]); diff --git a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts index 589af7f3cf..f5f51aa1df 100644 --- a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts @@ -50,6 +50,51 @@ it("does not infer a bootstrap from a function table without a matching loader", expect(analysis.bundler_registrations).toEqual([]); }); +it("does not confuse a shadowed dispatch table with the outer module table", () => { + const analysis = analyzeJavaScriptStaticSource(`(() => { + const modules = { 17(module) { module.exports = require("electron"); } }; + function load(id) { + const modules = userInput; + const module = { exports: {} }; + modules[id](module, module.exports, load); + return module.exports; + } + load(17); + })();`); + expect(analysis.bundler_registrations).toEqual([]); +}); + +it("retains a stable var module table without treating declaration mutability as a write", () => { + const analysis = analyzeJavaScriptStaticSource(`(() => { + var modules = { 17(module) { module.exports = require("electron"); } }; + function load(id) { + const module = { exports: {} }; + modules[id](module, module.exports, load); + return module.exports; + } + load(17); + })();`); + expect(analysis.bundler_registrations).toMatchObject([ + { entry_module_keys: ["17"], modules: [{ module_key: "17" }] }, + ]); +}); + +it("only attributes bootstrap entries to the recovered loader binding", () => { + const analysis = analyzeJavaScriptStaticSource(`(() => { + const modules = { 17(module) { module.exports = require("electron"); } }; + function load(id) { + const module = { exports: {} }; + modules[id](module, module.exports, load); + return module.exports; + } + { const load = userInput; load(99); } + load(17); + })();`); + expect(analysis.bundler_registrations).toMatchObject([ + { entry_module_keys: ["17"] }, + ]); +}); + it("preserves first insertion precedence and inclusive interval bounds", () => { const nested = new JavaScriptModuleRangeIndex(100); expect(nested.find(0)).toBeUndefined(); diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts index a083d3a04f..a4248e30b8 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.test.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -7,10 +7,60 @@ import { traverseJavaScriptAst, } from "./javascriptSemanticTraversal.js"; import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js"; -import { symbolicVariants } from "./javascriptSymbolicValues.js"; +import { + symbolicTruth, + symbolicVariants, + type SymbolicValue, +} from "./javascriptSymbolicValues.js"; + +const resolverFor = (source: string) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("symbolic source fixture did not parse"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + return { + parsed, + resolver: new JavaScriptSymbolicBindings(state, parsed.program), + }; +}; + +/** Parse a fixture and collect the first argument of each inspect call. */ +const inspectFixture = ( + source: string, + evaluate: ( + resolver: JavaScriptSymbolicBindings, + node: t.CallExpression, + ) => SymbolicValue = (resolver, node) => resolver.evaluate(node.arguments[0]), +) => { + const { parsed, resolver } = resolverFor(source); + const values: SymbolicValue[] = []; + traverseJavaScriptAst(parsed.program, { + enter: (node) => { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "inspect" }) + ) { + values.push(evaluate(resolver, node)); + } + }, + }); + return { parsed, resolver, values }; +}; + +const inspectedValues = (source: string): SymbolicValue[] => + inspectFixture(source).values; + +const inspectOne = (source: string): SymbolicValue => { + const values = inspectedValues(source); + expect(values).toHaveLength(1); + const value = values[0]; + if (value === undefined) throw new Error("inspect call result missing"); + return value; +}; it("substitutes numeric modules, named exports and captured wrapper arguments", () => { - const parsed = parseJavaScriptSource(`(() => { + const { resolver, values } = inspectFixture(`(() => { const modules = { 4(module) { module.exports = require("electron"); }, 8(module, exports, load) { @@ -21,12 +71,29 @@ it("substitutes numeric modules, named exports and captured wrapper arguments", }; function load(id) { const m = {exports:{}}; modules[id](m,m.exports,load); return m.exports; } load(12); - })();`)!; - const state = completeSemanticSteps( - indexJavaScriptBindingsSteps(parsed.program), - ); - const resolver = new JavaScriptSymbolicBindings(state, parsed.program); - let found = false; + })();`); + expect(values).toHaveLength(1); + const value = values[0]; + if (value === undefined) throw new Error("inspect call result missing"); + const fn = symbolicVariants(value).find((v) => v.kind === "function"); + expect(fn?.kind).toBe("function"); + if (fn?.kind !== "function" || !t.isCallExpression(fn.node.body)) + throw new Error("closure missing"); + const env = resolver.bind(fn, []); + expect(resolver.evaluate(fn.node.body.callee, env)).toEqual({ + kind: "external", + path: ["electron", "ipcRenderer", "send"], + }); + expect(resolver.arguments(fn.node.body, env)[0]).toEqual({ + kind: "literal", + value: "test:copy", + }); +}); + +it("checks block lexical initializers against their containing function call", () => { + const { parsed, resolver } = resolverFor(`run(); + function run() { { const value = require("electron"); inspect(value); } }`); + let resolved: SymbolicValue | undefined; traverseJavaScriptAst(parsed.program, { enter: (node) => { if ( @@ -34,22 +101,340 @@ it("substitutes numeric modules, named exports and captured wrapper arguments", !t.isIdentifier(node.callee, { name: "inspect" }) ) return; - const value = resolver.evaluate(node.arguments[0]); - const fn = symbolicVariants(value).find((v) => v.kind === "function"); - expect(fn?.kind).toBe("function"); - if (fn?.kind !== "function" || !t.isCallExpression(fn.node.body)) - throw new Error("closure missing"); - const env = resolver.bind(fn, []); - expect(resolver.evaluate(fn.node.body.callee, env)).toEqual({ - kind: "external", - path: ["electron", "ipcRenderer", "send"], - }); - expect(resolver.arguments(fn.node.body, env)[0]).toEqual({ - kind: "literal", - value: "test:copy", - }); - found = true; + const fn = parsed.program.body.find( + (statement): statement is t.FunctionDeclaration => + t.isFunctionDeclaration(statement) && statement.id?.name === "run", + ); + if (!fn) throw new Error("run declaration missing"); + const call = parsed.program.body[0]; + if ( + !t.isExpressionStatement(call) || + !t.isCallExpression(call.expression) + ) + throw new Error("run invocation missing"); + resolved = resolver.evaluate( + node.arguments[0], + resolver.bind( + { + kind: "function", + node: fn, + environment: { values: new Map() }, + origins: [], + }, + [], + call.expression, + ), + ); }, }); - expect(found).toBe(true); + expect(resolved).toEqual({ kind: "external", path: ["electron"] }); +}); + +it("keeps same-factory reads before initialization unresolved", () => { + const { values, resolver } = inspectFixture( + `(() => { + const modules = { 9(module) { inspect(value); const value = require("electron"); } }; + function load(id) { const m={exports:{}}; modules[id](m); return m.exports; } + load(9); + })();`, + (bindings, node) => + bindings.evaluate(node.arguments[0], bindings.environmentAt(node)), + ); + expect(values).toHaveLength(1); + const resolved = values[0]; + if (resolved === undefined) throw new Error("inspect call result missing"); + expect(resolved).toEqual({ + kind: "unknown", + reason: "initializer not available at this read", + }); + expect(resolver.unresolved).toBe(true); +}); + +it("invalidates a bootstrap module slot mutated through an alias", () => { + const values = inspectedValues(`(() => { + const modules = { + 1(module) { module.exports = require("electron"); }, + 2(module, exports, load) { inspect(load(1)); inspect(load(3)); }, + 3(module) { module.exports = require("electron"); } + }; + const table = modules; + function load(id) { const m = {exports:{}}; modules[id](m,m.exports,load); return m.exports; } + table[1] = function(module) { module.exports = require("other"); }; + delete table[3]; + load(2); + })();`); + expect(values).toHaveLength(2); + expect(values).toEqual([ + expect.objectContaining({ kind: "unknown" }), + expect.objectContaining({ kind: "unknown" }), + ]); +}); + +it.each([ + `const electron=require("electron"); const ipc=electron.ipcMain; ipc.handle=()=>null; inspect(electron.ipcMain.handle);`, + `require("electron").ipcMain.handle=()=>null; inspect(require("electron").ipcMain.handle);`, + `const electron=require("electron"); ({ipcMain:electron.ipcMain}=replacement); inspect(electron.ipcMain);`, + `const electron=require("electron"); for(electron.ipcMain of replacements){} inspect(electron.ipcMain);`, +])("invalidates visibly overwritten Electron members: %s", (source) => { + const values = inspectedValues(source); + expect(values).toHaveLength(1); + expect(values[0]?.kind).toBe("unknown"); +}); + +it("does not substitute a lexical or var initializer before its source write", () => { + for (const source of [ + `inspect(value); const value = require("electron");`, + `function run() { inspect(value); var value = require("electron"); }`, + ]) { + const values = inspectedValues(source); + expect(values).toHaveLength(1); + const value = values[0]; + if (value === undefined) throw new Error("inspect call result missing"); + expect(value).toMatchObject({ kind: "unknown" }); + } +}); + +it("keeps function declarations hoisted and checks closures at invocation", () => { + const inspected = inspectedValues(` + inspect(read); + function read() { return value; } + function localRead() { + const local = require("electron"); + return local; + } + const closure = () => value; + const value = require("electron"); + inspect(closure()); + inspect(localRead()); + `); + expect(inspected[0]).toMatchObject({ kind: "function" }); + expect(inspected[1]).toEqual({ kind: "external", path: ["electron"] }); + expect(inspected[2]).toEqual({ kind: "external", path: ["electron"] }); +}); + +it("keeps a synchronous cross-function callback before initialization unresolved", () => { + const { parsed, resolver } = resolverFor(`function outer() { + const value = require("electron"); + return use(() => value); + } + function use(callback) { return callback(); } + inspect(outer());`); + const outer = parsed.program.body.find( + (statement): statement is t.FunctionDeclaration => + t.isFunctionDeclaration(statement) && statement.id?.name === "outer", + ); + const outerCall = parsed.program.body.find( + (statement): statement is t.ExpressionStatement => + t.isExpressionStatement(statement) && + t.isCallExpression(statement.expression) && + t.isIdentifier(statement.expression.callee, { name: "inspect" }), + ); + if (!outer || !outerCall || !t.isCallExpression(outerCall.expression)) + throw new Error("outer fixture missing"); + const invocation = outer.body.body.find( + (statement): statement is t.ReturnStatement => + t.isReturnStatement(statement), + ); + if (!invocation || !t.isCallExpression(invocation.argument)) + throw new Error("synchronous callback call missing"); + const result = resolver.evaluate( + invocation.argument, + resolver.bind( + { + kind: "function", + node: outer, + environment: { values: new Map() }, + origins: [], + }, + [], + outerCall.expression, + ), + ); + expect(result).toMatchObject({ kind: "unknown" }); +}); + +it("uses completed activation provenance for a returned closure", () => { + const result = inspectOne(`inspect(use(outer())); + function use(callback) { return callback(); } + function outer() { + const value = require("electron"); + return () => value; + }`); + expect(result).toEqual({ kind: "external", path: ["electron"] }); +}); + +it("does not treat statements after a returned closure as initialized", () => { + const result = inspectOne(`inspect(outer()()); + function outer() { + return () => value; + const value = require("electron"); + }`); + expect(result).toMatchObject({ kind: "unknown" }); +}); + +it("does not treat statements after a returned module factory as initialized", () => { + const result = inspectOne(`(() => { + const modules = { + 5(module, exports) { + exports.read = () => electron; + return; + const electron = require("electron"); + }, + 9(module, exports, load) { const helper = load(5); inspect(helper.read()); } + }; + function load(id) { const m={exports:{}}; modules[id](m,m.exports,load); return m.exports; } + load(9); + })();`); + expect(result).toMatchObject({ kind: "unknown" }); +}); + +it("treats known symbolic records and loaders as truthy", () => { + expect(symbolicTruth({ kind: "record", values: new Map() })).toBe(true); + expect(symbolicTruth({ kind: "loader", modules: new Map() })).toBe(true); + expect( + symbolicTruth({ + kind: "union", + values: [ + { kind: "record", values: new Map() }, + { kind: "loader", modules: new Map() }, + ], + }), + ).toBe(true); +}); + +it("retains branch predicates on conditionally selected wrapper functions", () => { + const value = inspectOne(` + const selected = enabled + ? () => navigator.clipboard.writeText("yes") + : () => navigator.clipboard.writeText("no"); + inspect(selected); + `); + const functions = symbolicVariants(value).filter( + (candidate) => candidate.kind === "function", + ); + expect(functions).toHaveLength(2); + expect(functions.map((fn) => fn.conditions?.[0]?.positive)).toEqual([ + true, + false, + ]); +}); + +it("retains fallthrough return guards after a one-sided early return", () => { + const value = inspectOne(` + function choose() { + if (enabled) return () => navigator.clipboard.writeText("early"); + return () => navigator.clipboard.writeText("late"); + } + inspect(choose()); + `); + const functions = symbolicVariants(value).filter( + (candidate) => candidate.kind === "function", + ); + expect(functions).toHaveLength(2); + expect(functions[0]).toMatchObject({ conditions: [{ positive: true }] }); + expect(functions[1]).toMatchObject({ conditions: [{ positive: false }] }); +}); + +it("composes fallthrough guards across successive conditional returns", () => { + const value = inspectOne(` + function choose() { + if (first) return () => navigator.clipboard.writeText("first"); + if (second) return () => navigator.clipboard.writeText("second"); + return () => navigator.clipboard.writeText("last"); + } + inspect(choose()); + `); + const functions = symbolicVariants(value).filter( + (candidate) => candidate.kind === "function", + ); + expect(functions).toHaveLength(3); + expect(functions[0]?.conditions?.map(({ positive }) => positive)).toEqual([ + true, + ]); + expect(functions[1]?.conditions?.map(({ positive }) => positive)).toEqual([ + false, + true, + ]); + expect(functions[2]?.conditions?.map(({ positive }) => positive)).toEqual([ + false, + false, + ]); +}); + +it("marks return guards unresolved when a nested join cannot be represented", () => { + const value = inspectOne(` + function choose() { + if (enabled) { + if (other) return () => navigator.clipboard.writeText("early"); + } + return () => navigator.clipboard.writeText("late"); + } + inspect(choose()); + `); + const functions = symbolicVariants(value).filter( + (candidate) => candidate.kind === "function", + ); + expect(functions).toHaveLength(2); + expect(functions[1]).toMatchObject({ conditionUnresolved: true }); +}); + +it.each([ + [ + "duplicate literal keys use the last value", + `const object = { value: "first", value: "last" }; inspect(object.value);`, + { kind: "literal", value: "last" }, + ], + [ + "a final accessor is unknown", + `const object = { value: "first", get value() { return "accessor"; } }; inspect(object.value);`, + { kind: "unknown", reason: "accessor property" }, + ], + [ + "a later literal overwrites an accessor", + `const object = { get value() { return "accessor"; }, value: "last" }; inspect(object.value);`, + { kind: "literal", value: "last" }, + ], + [ + "a later unknown computed key can overwrite an earlier value", + `const object = { value: "known", [dynamic]: "other" }; inspect(object.value);`, + { kind: "unknown", reason: "dynamic property overwrite" }, + ], + [ + "a later static key overrides an earlier unknown computed key", + `const object = { [dynamic]: "other", value: "last" }; inspect(object.value);`, + { kind: "literal", value: "last" }, + ], + [ + "a known spread can override an earlier static value", + `const replacement = { value: "spread" }; const object = { value: "first", ...replacement }; inspect(object.value);`, + { kind: "literal", value: "spread" }, + ], +] as const)("resolves object properties when %s", (_case, source, expected) => { + expect(inspectOne(source)).toEqual(expected); +}); + +it("propagates branch guards through object properties and function calls", () => { + const inspected = inspectedValues(` + const direct = flag + ? { action: () => navigator.clipboard.writeText("direct-yes") } + : { action: () => navigator.clipboard.writeText("direct-no") }; + function choose() { + return flag + ? () => navigator.clipboard.writeText("call-yes") + : () => navigator.clipboard.writeText("call-no"); + } + const called = { action: choose() }; + inspect(direct.action); + inspect(called.action); + `); + for (const value of inspected) { + const functions = symbolicVariants(value).filter( + (candidate) => candidate.kind === "function", + ); + expect(functions).toHaveLength(2); + expect(functions.map((fn) => fn.conditions?.[0]?.positive)).toEqual([ + true, + false, + ]); + } }); diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index 94e6931ecc..d2cf6fb40d 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -4,22 +4,38 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { javascriptBootstrapAt } from "./javascriptBootstrap.js"; +import { + collectJavaScriptIdentityWritesSteps, + ELECTRON_MODULE, + electronMemberPath, + electronExportPath, +} from "./javascriptElectronMemberWrites.js"; +import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; +import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; +import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; import { symbolicLogicalRight } from "./javascriptSymbolicControlFlow.js"; import { moduleFactory, modulePropertyName, } from "./javascriptStaticAnalysisHelpers.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { + childNodes, + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; import { resolveSemanticBindingState, semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { + symbolicCondition, symbolicUnion, symbolicUnknown, symbolicVariants, + symbolicConditionsUnresolved, symbolicTruth, + type SymbolicCondition, type SymbolicValue, type SymbolicEnvironment, type SymbolicFunction, @@ -50,17 +66,30 @@ export class JavaScriptSymbolicBindings { readonly #moduleBindings = new Set(); readonly #replacements = new Map(); readonly #propertyActive = new WeakMap>(); + readonly #literalPropertyIndex = new WeakMap< + t.ObjectExpression, + ReadonlyMap | null + >(); readonly #mutated = new Set(); + readonly #electronWrites = new SemanticPropertyPathCoverage(); readonly #namespaceOwners = new Map(); readonly #unsafeExports = new Set(); + readonly #aliasesByBinding = new Map(); + readonly #bootstrapTables: Array<{ + readonly tableAliases: ReadonlySet; + readonly modules: Map; + }> = []; #remainingEvaluations = 1_000_000; #depth = 0; truncated = false; + unresolved = false; constructor( readonly state: JavaScriptSemanticAnalysisState, program: t.Program, ) { + this.#indexElectronWrites(program); + this.#indexBindingAliases(); traverseJavaScriptAst(program, { enter: (node) => { if (t.isCallExpression(node)) this.#indexBootstrap(node); @@ -70,6 +99,10 @@ export class JavaScriptSymbolicBindings { enter: (node) => { if (t.isAssignmentExpression(node)) this.#indexWrite(node.left, node.right, node); + if (t.isUpdateExpression(node)) + this.#indexWrite(node.argument, node, node); + if (t.isUnaryExpression(node, { operator: "delete" })) + this.#indexWrite(node.argument, node, node); if ( t.isCallExpression(node) && t.isMemberExpression(node.callee) && @@ -97,6 +130,8 @@ export class JavaScriptSymbolicBindings { } }, }); + for (const table of this.#bootstrapTables) + this.#invalidateBootstrapTable(table.tableAliases, table.modules); } environmentAt(node: t.Node): SymbolicEnvironment { @@ -138,22 +173,42 @@ export class JavaScriptSymbolicBindings { } property(value: SymbolicValue, key: string): SymbolicValue { + let result: SymbolicValue; + if (value.kind === "union") + result = symbolicUnion( + value.values.map((variant) => this.property(variant, key)), + ); + else result = this.#property(value, key); + for (const condition of value.conditions ?? []) + result = symbolicCondition(result, condition); + if (value.conditionUnresolved) + result = symbolicConditionsUnresolved(result); + return result; + } + + #property(value: SymbolicValue, key: string): SymbolicValue { const active = this.#propertyActive.get(value) ?? new Set(); if (active.has(key)) return symbolicUnknown("cyclic property"); active.add(key); this.#propertyActive.set(value, active); try { - return this.#property(value, key); + return this.#propertyValue(value, key); } finally { active.delete(key); } } - #property(value: SymbolicValue, key: string): SymbolicValue { - if (value.kind === "union") - return symbolicUnion(value.values.map((v) => this.property(v, key))); - if (value.kind === "external") - return { kind: "external", path: [...value.path, key] }; + #propertyValue(value: SymbolicValue, key: string): SymbolicValue { + if (value.kind === "external") { + const path = [...value.path, key]; + if ( + path[0] === "electron" && + this.#electronWrites.covers(path.slice(1)) + ) { + return symbolicUnknown("overwritten Electron API member"); + } + return { kind: "external", path }; + } if (value.kind === "record") return value.values.get(key) ?? symbolicUnknown("missing property"); if (value.kind === "namespace") { @@ -177,6 +232,28 @@ export class JavaScriptSymbolicBindings { : symbolicUnknown("unresolved module export"); } if (value.kind === "object") { + let index = this.#literalPropertyIndex.get(value.node); + if (index === undefined) { + const properties = new Map(); + index = properties; + for (const property of value.node.properties) { + const name = t.isSpreadElement(property) + ? null + : semanticStaticPropertyKey(property.key, property.computed); + if (name === null || t.isSpreadElement(property)) { + index = null; + break; + } + properties.set(name, property); + } + this.#literalPropertyIndex.set(value.node, index); + } + if (index !== null) { + const property = index.get(key); + return property === undefined + ? symbolicUnknown("missing property") + : this.#objectPropertyValue(property, value.environment); + } for (const p of [...value.node.properties].reverse()) { if (t.isSpreadElement(p)) { const spread = this.evaluate(p.argument, value.environment); @@ -188,12 +265,7 @@ export class JavaScriptSymbolicBindings { ) return found; } else if (semanticStaticPropertyKey(p.key, p.computed) === key) { - return t.isObjectMethod(p) && p.kind !== "method" - ? symbolicUnknown("accessor property") - : this.evaluate( - t.isObjectProperty(p) ? p.value : p, - value.environment, - ); + return this.#objectPropertyValue(p, value.environment); } else if (semanticStaticPropertyKey(p.key, p.computed) === null) return symbolicUnknown("dynamic property overwrite"); } @@ -203,6 +275,18 @@ export class JavaScriptSymbolicBindings { return symbolicUnknown("unresolved property"); } + #objectPropertyValue( + property: t.ObjectMethod | t.ObjectProperty, + environment: SymbolicEnvironment, + ): SymbolicValue { + return t.isObjectMethod(property) && property.kind !== "method" + ? symbolicUnknown("accessor property") + : this.evaluate( + t.isObjectProperty(property) ? property.value : property, + environment, + ); + } + members( value: SymbolicValue, active = new Set(), @@ -277,6 +361,7 @@ export class JavaScriptSymbolicBindings { bind( fn: SymbolicFunction, args: readonly SymbolicValue[], + invocationNode?: t.Node, ): SymbolicEnvironment { const values = new Map(); fn.node.params.forEach((param, index) => { @@ -295,7 +380,16 @@ export class JavaScriptSymbolicBindings { : (args[index] ?? symbolicUnknown("runtime parameter")), ); }); - return { values, parent: fn.environment }; + const scopeId = + this.state.scopeByNode.get(fn.node)?.scopeId ?? + this.state.scopeByNode.get(fn.node.body)?.scopeId; + return { + values, + parent: fn.environment, + point: invocationNode ?? fn.node, + scopeNode: fn.node, + ...(scopeId === undefined ? {} : { scopeId }), + }; } arguments( @@ -346,8 +440,14 @@ export class JavaScriptSymbolicBindings { const truth = symbolicTruth(this.evaluate(node.test, env)); return truth === undefined ? symbolicUnion([ - this.evaluate(node.consequent, env), - this.evaluate(node.alternate, env), + symbolicCondition(this.evaluate(node.consequent, env), { + node: node.test, + positive: true, + }), + symbolicCondition(this.evaluate(node.alternate, env), { + node: node.test, + positive: false, + }), ]) : this.evaluate(truth ? node.consequent : node.alternate, env); } @@ -356,7 +456,28 @@ export class JavaScriptSymbolicBindings { const right = symbolicLogicalRight(node.operator, left); if (right !== undefined) return right ? this.evaluate(node.right, env) : left; - return symbolicUnion([left, this.evaluate(node.right, env)]); + const leftCondition: SymbolicCondition = { + node: node.left, + positive: + node.operator === "&&" + ? false + : node.operator === "||" + ? true + : "not-nullish", + }; + const rightCondition: SymbolicCondition = { + node: node.left, + positive: + node.operator === "&&" + ? true + : node.operator === "||" + ? false + : "nullish", + }; + return symbolicUnion([ + symbolicCondition(left, leftCondition), + symbolicCondition(this.evaluate(node.right, env), rightCondition), + ]); } if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) return this.#call(node, env); @@ -390,6 +511,17 @@ export class JavaScriptSymbolicBindings { binding.initializers.length === 1 ? binding.initializers[0] : undefined; if (!initializer) return symbolicUnknown("missing or ambiguous initializer"); + const point = this.#initializerReadPoint(binding.scopeId, env, node); + if ( + !initializer.entryBody && + !binding.definitions.every(({ kind }) => kind === "function") && + (initializer.node.end == null || + point == null || + initializer.node.end > point) + ) { + this.unresolved = true; + return symbolicUnknown("initializer not available at this read"); + } if (this.state.conditionalInitializers.has(initializer.node)) return symbolicUnknown("conditional initializer"); let value = this.evaluate(initializer.node, env); @@ -400,6 +532,100 @@ export class JavaScriptSymbolicBindings { return value; } + #initializerReadPoint( + bindingScopeId: string, + environment: SymbolicEnvironment, + readNode: t.Node, + ): number | undefined { + let current: SymbolicEnvironment | undefined = environment; + let child: SymbolicEnvironment | undefined; + while (current) { + if (this.#scopeContains(bindingScopeId, current.scopeId)) { + const point = child?.point ?? readNode; + if (current.completedAt !== undefined) + return current.completedAt.end ?? undefined; + if (current.scopeNode && !this.#nodeWithin(point, current.scopeNode)) + return undefined; + return point.start ?? undefined; + } + child = current; + current = current.parent; + } + let outermost = environment; + while (outermost.parent) outermost = outermost.parent; + const point = outermost.point ?? environment.point ?? readNode; + return point.start ?? undefined; + } + + #scopeContains(bindingScopeId: string, environmentScopeId?: string): boolean { + let scopeId: string | null = bindingScopeId; + while (scopeId !== null) { + if (scopeId === environmentScopeId) return true; + scopeId = this.state.scopesById.get(scopeId)?.parentScopeId ?? null; + } + return false; + } + + #nodeWithin(node: t.Node, ancestor?: t.Node): boolean { + if (!ancestor) return false; + let current: t.Node | undefined = node; + while (current) { + if (current === ancestor) return true; + current = this.state.parentsByNode.get(current); + } + return false; + } + + #completedEnvironment( + environment: SymbolicEnvironment, + activation: SymbolicEnvironment, + returnPoint: t.Node, + ): SymbolicEnvironment | undefined { + if (environment === activation) + return { ...environment, completedAt: returnPoint }; + if (environment.parent === undefined) return undefined; + const parent = this.#completedEnvironment( + environment.parent, + activation, + returnPoint, + ); + return parent === undefined ? undefined : { ...environment, parent }; + } + + #completeReturnedFunction( + value: SymbolicFunction, + activation: SymbolicEnvironment, + returnPoint: t.Node, + ): SymbolicFunction { + const environment = this.#completedEnvironment( + value.environment, + activation, + returnPoint, + ); + if (environment === undefined) return value; + const { returnPoint: _returnPoint, ...returned } = value; + return { ...returned, environment }; + } + + #moduleCompletionPoint(factory: t.Function): t.Node { + let earliestReturn: t.ReturnStatement | undefined; + const pending: t.Node[] = [factory.body]; + while (pending.length > 0) { + const node = pending.pop(); + if (!node) continue; + if (node !== factory.body && (t.isFunction(node) || t.isClass(node))) + continue; + if ( + t.isReturnStatement(node) && + (earliestReturn === undefined || + (node.start ?? Infinity) < (earliestReturn.start ?? Infinity)) + ) + earliestReturn = node; + pending.push(...childNodes(node)); + } + return earliestReturn ?? factory.body; + } + #call( node: t.CallExpression | t.OptionalCallExpression, env: SymbolicEnvironment, @@ -408,37 +634,71 @@ export class JavaScriptSymbolicBindings { const args = this.arguments(node, env); return symbolicUnion( symbolicVariants(callee).map((value) => { + let result: SymbolicValue; if (value.kind === "loader") { const key = args[0]; - return key?.kind === "literal" - ? (value.modules.get(String(key.value)) ?? + const loaded = + key?.kind === "literal" + ? (value.modules.get(String(key.value)) ?? symbolicUnknown("missing module")) - : symbolicUnknown("dynamic module"); - } - if (value.kind === "external" && value.path.join(".") === "require") { + : symbolicUnknown("dynamic module"); + if (loaded.kind === "namespace") { + const factory = this.#namespaceOwners.get(loaded.bindingId); + result = + factory === undefined + ? loaded + : { + ...loaded, + environment: { + ...loaded.environment, + completedAt: this.#moduleCompletionPoint(factory), + }, + }; + } else result = loaded; + } else if ( + value.kind === "external" && + value.path.join(".") === "require" + ) { const spec = args[0]; - return spec?.kind === "literal" && typeof spec.value === "string" - ? { kind: "external", path: [spec.value] } - : symbolicUnknown("dynamic require"); - } - if (value.kind !== "function" || this.#activeFunctions.has(value.node)) - return symbolicUnknown("unresolved or recursive call"); - this.#activeFunctions.add(value.node); - try { - const bound = this.bind(value, args); - const result = t.isBlockStatement(value.node.body) - ? this.#returns(value.node.body.body, bound) - : this.evaluate(value.node.body, bound); - return symbolicUnion( - symbolicVariants(result).map((v) => - v.kind === "function" - ? { ...v, origins: [...value.origins, node, ...v.origins] } - : v, - ), - ); - } finally { - this.#activeFunctions.delete(value.node); + result = + spec?.kind === "literal" && typeof spec.value === "string" + ? { kind: "external", path: [spec.value] } + : symbolicUnknown("dynamic require"); + } else if ( + value.kind !== "function" || + this.#activeFunctions.has(value.node) + ) { + result = symbolicUnknown("unresolved or recursive call"); + } else { + this.#activeFunctions.add(value.node); + try { + const bound = this.bind(value, args, node); + const returned = t.isBlockStatement(value.node.body) + ? this.#returns(value.node.body.body, bound) + : this.evaluate(value.node.body, bound); + result = symbolicUnion( + symbolicVariants(returned).map((v) => + v.kind === "function" + ? { + ...this.#completeReturnedFunction( + v, + bound, + v.returnPoint ?? value.node.body, + ), + origins: [...value.origins, node, ...v.origins], + } + : v, + ), + ); + } finally { + this.#activeFunctions.delete(value.node); + } } + for (const condition of value.conditions ?? []) + result = symbolicCondition(result, condition); + if (value.conditionUnresolved) + result = symbolicConditionsUnresolved(result); + return result; }), ); } @@ -462,6 +722,16 @@ export class JavaScriptSymbolicBindings { depth = 0, ): SymbolicReturnFlow { const possible: SymbolicValue[] = []; + let unresolvedFallthrough = false; + const continuationConditions: SymbolicCondition[] = []; + const guardCurrentPath = (value: SymbolicValue): SymbolicValue => { + let guarded = value; + for (const condition of continuationConditions) + guarded = symbolicCondition(guarded, condition); + return unresolvedFallthrough + ? symbolicConditionsUnresolved(guarded) + : guarded; + }; for (const statement of statements) { if (--this.#remainingEvaluations < 0 || depth >= 128) { this.truncated = true; @@ -475,14 +745,23 @@ export class JavaScriptSymbolicBindings { values: [...possible, symbolicUnknown("wrapper exits by throwing")], fallsThrough: false, }; - if (t.isReturnStatement(statement)) + if (t.isReturnStatement(statement)) { + const value = this.evaluate(statement.argument, env); + const returned = symbolicUnion( + symbolicVariants(value).map((variant) => + variant.kind === "function" + ? { ...variant, returnPoint: statement } + : variant, + ), + ); return { - values: [...possible, this.evaluate(statement.argument, env)], + values: [...possible, guardCurrentPath(returned)], fallsThrough: false, }; + } if (t.isBlockStatement(statement)) { const flow = this.#returnFlow(statement.body, env, depth + 1); - possible.push(...flow.values); + possible.push(...flow.values.map(guardCurrentPath)); if (!flow.fallsThrough) return { values: possible, fallsThrough: false }; } @@ -495,23 +774,72 @@ export class JavaScriptSymbolicBindings { const flows = branches.map((branch) => this.#returnFlow(branch ? [branch] : [], env, depth + 1), ); - possible.push(...flows.flatMap((flow) => flow.values)); + possible.push( + ...flows.flatMap((flow, index) => + flow.values.map((value) => { + const guarded = guardCurrentPath(value); + return truth === undefined + ? symbolicCondition(guarded, { + node: statement.test, + positive: index === 0, + }) + : guarded; + }), + ), + ); if (flows.every((flow) => !flow.fallsThrough)) return { values: possible, fallsThrough: false }; + if (truth === undefined) { + const fallthrough = flows.flatMap((flow, index) => + flow.fallsThrough ? [{ flow, index }] : [], + ); + if ( + fallthrough.length === 1 && + fallthrough[0]?.flow.values.length === 0 + ) + continuationConditions.push({ + node: statement.test, + positive: fallthrough[0].index === 0, + }); + else if ( + fallthrough.length > 0 && + (fallthrough.length < flows.length || + fallthrough.some(({ flow }) => flow.values.length > 0)) + ) + unresolvedFallthrough = true; + } } } return { values: possible, fallsThrough: true }; } #indexBootstrap(call: t.CallExpression): void { - const bootstrap = javascriptBootstrapAt(call); + const bootstrap = javascriptBootstrapAt(call, this.state); if (!bootstrap) return; const modules = new Map(); + const tableAliases = new Set([bootstrap.tableBindingId]); + const pendingAliases = [bootstrap.tableBindingId]; + while (pendingAliases.length > 0) { + const source = pendingAliases.pop()!; + for (const alias of this.#aliasesByBinding.get(source) ?? []) + if (!tableAliases.has(alias)) { + tableAliases.add(alias); + pendingAliases.push(alias); + } + } for (const property of bootstrap.table.properties) { const factory = moduleFactory(property); if (!factory) continue; + const moduleKey = modulePropertyName(property); const values = new Map(); - const environment = { values }; + const environment: SymbolicEnvironment = { + values, + point: factory.body, + scopeNode: factory, + ...(this.state.scopeByNode.get(factory)?.scopeId === undefined + ? {} + : { scopeId: this.state.scopeByNode.get(factory)!.scopeId }), + }; const first = factory.params[0]; const second = factory.params[1]; const third = factory.params[2]; @@ -543,8 +871,153 @@ export class JavaScriptSymbolicBindings { } if (loaderId) values.set(loaderId, { kind: "loader", modules }); this.#environments.set(factory, environment); - modules.set(modulePropertyName(property), namespace); + modules.set(moduleKey, namespace); } + this.#bootstrapTables.push({ tableAliases, modules }); + } + + #indexBindingAliases(): void { + for (const binding of this.state.bindingsById.values()) { + const initializer = binding.initializers[0]; + if ( + !initializer || + initializer.entryBody !== undefined || + initializer.projection.length !== 0 || + !t.isIdentifier(initializer.node) + ) + continue; + const source = resolveSemanticBindingState( + this.state, + initializer.node, + initializer.node.name, + ); + if (!source) continue; + const aliases = this.#aliasesByBinding.get(source.bindingId) ?? []; + aliases.push(binding.bindingId); + this.#aliasesByBinding.set(source.bindingId, aliases); + } + } + + #indexElectronWrites(program: t.Program): void { + const interop = createSemanticModuleInterop(this.state); + const pathsByBinding = new Map(); + const retainPath = ( + bindingId: string, + path: readonly string[] | undefined, + ): readonly string[] | undefined => { + pathsByBinding.set(bindingId, path ?? null); + return path; + }; + const bindingPath = ( + binding: ReturnType, + visited = new Set(), + ): readonly string[] | undefined => { + if (!binding || visited.has(binding.bindingId)) return undefined; + const cached = pathsByBinding.get(binding.bindingId); + if (cached !== undefined) return cached ?? undefined; + visited.add(binding.bindingId); + const converted = interop.origin(binding)?.origin; + const direct = + converted ?? + (binding.directOrigins.length === 1 + ? binding.directOrigins[0] + : undefined); + if (direct && ELECTRON_MODULE.test(direct.specifier)) + return retainPath(binding.bindingId, [ + "electron", + ...electronExportPath(direct.importedPath), + ]); + const initializer = + binding.initializers.length === 1 ? binding.initializers[0] : undefined; + if ( + !initializer || + !initializer.projection.every( + (segment): segment is string => typeof segment === "string", + ) + ) + return retainPath(binding.bindingId, undefined); + const required = semanticRequireOrigin( + initializer.node, + this.state, + )?.origin; + if (required && ELECTRON_MODULE.test(required.specifier)) + return retainPath(binding.bindingId, [ + "electron", + ...electronExportPath([ + ...required.importedPath, + ...initializer.projection.map(String), + ]), + ]); + const member = electronMemberPath(initializer.node); + if ( + !t.isIdentifier(member.root) || + !member.members.every( + (segment): segment is string => typeof segment === "string", + ) + ) + return retainPath(binding.bindingId, undefined); + const source = resolveSemanticBindingState( + this.state, + member.root, + member.root.name, + ); + const sourcePath = bindingPath(source, visited); + return retainPath( + binding.bindingId, + sourcePath === undefined + ? undefined + : [ + ...sourcePath, + ...member.members, + ...initializer.projection.map(String), + ], + ); + }; + const rootPath = (root: t.Node): readonly string[] | undefined => { + const direct = semanticRequireOrigin(root, this.state)?.origin; + if (direct && ELECTRON_MODULE.test(direct.specifier)) + return ["electron", ...electronExportPath(direct.importedPath)]; + if (!t.isIdentifier(root)) return undefined; + const binding = resolveSemanticBindingState(this.state, root, root.name); + return bindingPath(binding); + }; + completeSemanticSteps( + collectJavaScriptIdentityWritesSteps( + program, + (root, kind) => { + if (kind === "opaque-call") return interop.opaqueMutationPath(root); + return rootPath(root) ?? interop.intrinsicPath(root); + }, + interop.intrinsicInvocation, + interop.staticString, + interop.isFreshObject, + (_node, path) => { + if (path[0] === "electron") { + this.#electronWrites.retain(path.slice(1)); + } + }, + ), + ); + } + + #invalidateBootstrapTable( + tableAliases: ReadonlySet, + modules: Map, + ): void { + let invalidateAll = false; + const invalidSlots = new Set(); + for (const bindingId of tableAliases) { + if (this.#mutated.has(bindingId)) invalidateAll = true; + for (const definition of this.state.bindingsById.get(bindingId) + ?.definitions ?? []) + if (definition.kind === "assignment") invalidateAll = true; + for (const write of this.#writes.get(bindingId) ?? []) { + if (write.path.length === 0) invalidateAll = true; + else invalidSlots.add(write.path[0]!); + } + } + if (invalidateAll) modules.clear(); + else for (const key of invalidSlots) modules.delete(key); } #indexWrite(target: t.Node, value: t.Node, site: t.Node): void { diff --git a/src/domain/javascript/javascriptSymbolicValues.ts b/src/domain/javascript/javascriptSymbolicValues.ts index e88cc8d325..f267c22093 100644 --- a/src/domain/javascript/javascriptSymbolicValues.ts +++ b/src/domain/javascript/javascriptSymbolicValues.ts @@ -4,16 +4,38 @@ import type * as t from "@babel/types"; export interface SymbolicEnvironment { readonly values: ReadonlyMap; readonly parent?: SymbolicEnvironment; + /** Source point at which the current function is being symbolically executed. */ + readonly point?: t.Node; + /** Lexical function scope for point-sensitive local initializer checks. */ + readonly scopeId?: string; + /** Function activation captured by this environment, when one exists. */ + readonly scopeNode?: t.Function; + /** Completion boundary proven for this captured activation. */ + readonly completedAt?: t.Node; } -export interface SymbolicFunction { +export interface SymbolicCondition { + readonly node: t.Node; + readonly positive: boolean | "nullish" | "not-nullish"; +} + +export interface SymbolicMetadata { + /** Predicates required for this value to be selected. */ + readonly conditions?: readonly SymbolicCondition[]; + /** A control-flow join prevented a sound predicate from being retained. */ + readonly conditionUnresolved?: true; +} + +export interface SymbolicFunction extends SymbolicMetadata { readonly kind: "function"; readonly node: t.Function; readonly environment: SymbolicEnvironment; readonly origins: readonly t.Node[]; + /** Return statement completing the activation that produced this value. */ + readonly returnPoint?: t.Node; } -export type SymbolicValue = +export type SymbolicValue = ( | { readonly kind: "unknown"; readonly reason: string } | { readonly kind: "literal"; @@ -40,7 +62,9 @@ export type SymbolicValue = readonly values: ReadonlyMap; } | { readonly kind: "union"; readonly values: readonly SymbolicValue[] } - | SymbolicFunction; + | SymbolicFunction +) & + SymbolicMetadata; export const symbolicUnknown = (reason: string): SymbolicValue => ({ kind: "unknown", @@ -56,9 +80,39 @@ export const symbolicUnion = ( const unique = [...new Set(values.flatMap(symbolicVariants))]; return unique.length === 1 ? unique[0]! : { kind: "union", values: unique }; }; -export const symbolicTruth = (value: SymbolicValue): boolean | undefined => - value.kind === "literal" +export const symbolicTruth = (value: SymbolicValue): boolean | undefined => { + if (value.kind === "union") { + const choices = value.values.map(symbolicTruth); + return choices.length > 0 && + choices.every((choice) => choice === choices[0]) + ? choices[0] + : undefined; + } + return value.kind === "literal" ? Boolean(value.value) - : ["function", "object", "namespace", "array"].includes(value.kind) + : ["function", "object", "namespace", "array", "record", "loader"].includes( + value.kind, + ) ? true : undefined; +}; + +/** Attach selection predicates to values while preserving their representation. */ +export const symbolicCondition = ( + value: SymbolicValue, + condition: SymbolicCondition, +): SymbolicValue => { + if (value.kind === "union") + return symbolicUnion( + value.values.map((variant) => symbolicCondition(variant, condition)), + ); + return { ...value, conditions: [...(value.conditions ?? []), condition] }; +}; + +export const symbolicConditionsUnresolved = ( + value: SymbolicValue, +): SymbolicValue => { + if (value.kind === "union") + return symbolicUnion(value.values.map(symbolicConditionsUnresolved)); + return { ...value, conditionUnresolved: true }; +}; diff --git a/src/javascript/analysis/JavaScriptAnalysisTransfer.ts b/src/javascript/analysis/JavaScriptAnalysisTransfer.ts index 43e20486a6..64882e4aba 100644 --- a/src/javascript/analysis/JavaScriptAnalysisTransfer.ts +++ b/src/javascript/analysis/JavaScriptAnalysisTransfer.ts @@ -62,6 +62,17 @@ export const javaScriptTransferByteBudget = ( Math.max(TRANSFER_BYTES, Math.floor(resultBudgetBytes / 8)), ); +const assertTransferByteBudget = (transferBytes: number): void => { + if ( + !Number.isSafeInteger(transferBytes) || + transferBytes < 1 || + transferBytes > MAX_TRANSFER_BYTES + ) + throw new RangeError( + "JavaScript transfer requires a valid complete-file byte budget", + ); +}; + const encodeTransferRecord = ( input: unknown, previousBytes: number, @@ -103,11 +114,13 @@ function* moduleTransferRecords( /** Measure actual validated module rows before allocating an unadmittable graph. */ export const measureJavaScriptModuleTransfer = ( module: JavaScriptModuleSemanticIr, + transferBytes = TRANSFER_BYTES, ) => { + assertTransferByteBudget(transferBytes); let bytes = 0; let records = 0; for (const record of moduleTransferRecords(module)) { - bytes += encodeTransferRecord(record, bytes).size; + bytes += encodeTransferRecord(record, bytes, transferBytes).size; records += 1; } return { @@ -124,14 +137,7 @@ export const writeJavaScriptAnalysisTransfer = async ( projection: JavaScriptSemanticFileProjection, transferBytes = TRANSFER_BYTES, ): Promise => { - if ( - !Number.isSafeInteger(transferBytes) || - transferBytes < 1 || - transferBytes > MAX_TRANSFER_BYTES - ) - throw new RangeError( - "JavaScript transfer requires a valid complete-file byte budget", - ); + assertTransferByteBudget(transferBytes); const output = await open(path, "wx", 0o600); const digest = createHash("sha256"); let bytes = 0; diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index cba1954e39..196c8b7b96 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -68,6 +68,7 @@ const ipcChannel = (shape: Shape) => z.union([ z.strictObject({ ...findingContext, + invocation_location: range.optional(), resolution_basis: z.literal("wrapper-substitution").optional(), ...shape, channel: z.string(), @@ -75,6 +76,7 @@ const ipcChannel = (shape: Shape) => }), z.strictObject({ ...findingContext, + invocation_location: range.optional(), resolution_basis: z.literal("wrapper-substitution").optional(), ...shape, channel: z.null(), @@ -121,11 +123,13 @@ const ipc = z }), ]) .transform((value) => { - const { module_runtime, resolution_basis, ...rest } = value; + const { module_runtime, resolution_basis, invocation_location, ...rest } = + value; return { ...rest, ...(module_runtime === undefined ? {} : { module_runtime }), ...(resolution_basis === undefined ? {} : { resolution_basis }), + ...(invocation_location === undefined ? {} : { invocation_location }), }; }); const windowShape = { diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts index 6e40c142e8..9eb45649c5 100644 --- a/tests/boundary/cli/electronWrappedCallPaths.test.ts +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -129,6 +129,7 @@ for (const surface of ["CLI", "stdio MCP"] as const) { const root = await createTestTempDirectory("rea-electron-branch-paths-"); await writeFixtureFiles(root, { "preload.js": `const e=require("electron"); + e.contextBridge.exposeInMainWorld("empty", {}); function choose(){ if(flag)return ()=>e.ipcRenderer.send("test:copy"); else return ()=>e.ipcRenderer.send("test:cut"); @@ -150,6 +151,19 @@ for (const surface of ["CLI", "stdio MCP"] as const) { register("test:fallback",event=>{false ?? event.sender.copy()});`, }); const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const empty = graph.nodes + .filter((node) => node.kind === "context-bridge-api") + .flatMap((node) => node.observations) + .find((observation) => observation.properties.api_key === "empty"); + expect(empty).toMatchObject({ + properties: { + api_status: "object-literal", + members: [], + unknown_members: 0, + resolution_basis: "direct-syntax", + }, + evidence: { state: "observed" }, + }); const joins = graph.edges.filter( (edge) => edge.evidence.extractor.operation === "join-electron-wrapper-paths", @@ -172,6 +186,17 @@ for (const surface of ["CLI", "stdio MCP"] as const) { ), ].sort(), ).toEqual(["test:copy", "test:cut"]); + for (const [channel, expression] of [ + ["test:copy", "flag"], + ["test:cut", "!(flag)"], + ]) + expect( + steps.find( + (step) => + step.properties.entry === "desktop.choose" && + step.properties.channel === channel, + )?.properties.conditions, + ).toEqual([expect.objectContaining({ expression })]); expect( steps.some( (step) => diff --git a/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts b/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts index 864df3ff2e..5089690b32 100644 --- a/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts +++ b/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts @@ -9,6 +9,7 @@ import { completeJavaScriptAnalysisSteps } from "../../../src/application/javasc import { projectJavaScriptSemanticFileSteps } from "../../../src/application/javascript/JavaScriptSemanticGraphBuilder.js"; import { javaScriptTransferByteBudget, + measureJavaScriptModuleTransfer, writeJavaScriptAnalysisTransfer, readJavaScriptAnalysisTransfer, } from "../../../src/javascript/analysis/JavaScriptAnalysisTransfer.js"; @@ -44,6 +45,27 @@ it("enforces the offered complete-file byte budget while preserving authenticate module, projection, ); + const measured = measureJavaScriptModuleTransfer(module); + expect(measured).toMatchObject({ + bytes: observed.module_bytes, + records: observed.module_records, + }); + expect(measureJavaScriptModuleTransfer(module, measured.bytes)).toEqual( + measured, + ); + expect(() => + measureJavaScriptModuleTransfer(module, measured.bytes - 1), + ).toThrowError( + expect.objectContaining({ + resource: "transport", + reportedLimits: expect.objectContaining({ + transfer_file_bytes: measured.bytes - 1, + }), + }), + ); + expect(() => + measureJavaScriptModuleTransfer(module, 2 ** 31 + 1), + ).toThrowError(RangeError); const exactPath = join(root, "exact"); const exact = await writeJavaScriptAnalysisTransfer( exactPath, diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index 536b7afeb5..5ca46c39b7 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -4,7 +4,6 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { describe, expect, it } from "vitest"; import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; -import { compactAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js"; import { COMPACT_INPUT_SCHEMA_BUDGET_BYTES, parseMcpInputSchemaProfile, @@ -121,29 +120,6 @@ describe("MCP compact input schema profile", () => { } }); - it("reduces at most the structurally oversized tools", async () => { - const full = fullServer(); - registerCatalog(full); - const tools = await listToolsThroughTransport(full); - const reduced: string[] = []; - for (const tool of tools) { - const { presentation } = compactAdvertisedInputSchema( - tool.inputSchema, - COMPACT_INPUT_SCHEMA_BUDGET_BYTES, - ); - if (presentation === "reduced") reduced.push(tool.name); - } - expect(reduced.sort()).toEqual([ - "build_reconstruction_obligation_ledger", - "capture_browser_scenario", - "compare_application_versions", - "compare_web_captures", - "evaluate_reconstruction_coverage", - "project_managed_application_graph", - ]); - expect(tools.length).toBe(TOOL_CONTRACTS.length); - }); - it("retains parameter types and avoids client-side reference expansion", async () => { const server = compactServer(); registerCatalog(server); diff --git a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts index e1e37c26f0..62246a85c7 100644 --- a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts +++ b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts @@ -288,37 +288,43 @@ describe("enhanced MCP tools", () => { failed: 0, }); expect(results[3]).toEqual({ - "0": [ - { - address: "0x1", - status: "ok", - calls: ["0x2", "0x3"], - relationship_coverage: { - status: "unknown", - reason: "Provider did not report relationship coverage.", + graph: { + "0": [ + { + address: "0x1", + status: "ok", + calls: ["0x2", "0x3"], + limitations: ["Provider did not report relationship coverage."], + relationship_coverage: { + status: "unknown", + reason: "Provider did not report relationship coverage.", + }, }, - }, - ], - "1": [ - { - address: "0x2", - status: "ok", - calls: ["0x1"], - relationship_coverage: { - status: "unknown", - reason: "Provider did not report relationship coverage.", + ], + "1": [ + { + address: "0x2", + status: "ok", + calls: ["0x1"], + limitations: ["Provider did not report relationship coverage."], + relationship_coverage: { + status: "unknown", + reason: "Provider did not report relationship coverage.", + }, }, - }, - { - address: "0x3", - status: "ok", - calls: [], - relationship_coverage: { - status: "unknown", - reason: "Provider did not report relationship coverage.", + { + address: "0x3", + status: "ok", + calls: [], + limitations: ["Provider did not report relationship coverage."], + relationship_coverage: { + status: "unknown", + reason: "Provider did not report relationship coverage.", + }, }, - }, - ], + ], + }, + limitations: [], }); expect(results[4]).toMatchObject({ total: 1,