From f0b8ae1fc8347bffa363ec49ac210850123ee699 Mon Sep 17 00:00:00 2001 From: Avocado Date: Wed, 14 Oct 2026 01:16:44 +1100 Subject: [PATCH] fix(javascript): trace nested Electron bridge members (#2745) * fix(javascript): trace nested Electron bridge members * fix(javascript): preserve shallow Electron bridge paths * fix(javascript): preserve exact Electron bridge member paths * fix(javascript): preserve exact Electron bridge paths * fix(javascript): preserve bridge entry ownership * fix(javascript): preserve bridge path identity * fix(javascript): normalize typed feature seeds before tracing * test(javascript): normalize direct selector seed inputs * refactor(javascript): consume normalized feature seed defaults --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../ElectronBoundaryGraphCallPaths.ts | 9 + ...vaScriptSemanticGraphElectronProjection.ts | 6 + .../javascript/electronStaticAnalysisTypes.ts | 2 + .../electronStaticAnalysisValues.ts | 7 + .../electronWrappedCallPaths.test.ts | 77 +++++ .../javascript/electronWrappedCallPaths.ts | 76 +++-- ...avascriptFeatureSeed.array_members.test.ts | 44 ++- .../javascript/javascriptFeatureSeed.ts | 22 +- .../javascript/javascriptFeatureTrace.ts | 10 +- .../javascriptFeatureTraceSchemas.ts | 59 +++- .../javascript/javascriptSymbolicBindings.ts | 32 ++- .../JavaScriptAnalysisWorkerSchemas.ts | 8 + .../cli/electronWrappedCallPaths.test.ts | 272 +++++++++++++++++- ...javascriptApplicationEmptyLiterals.test.ts | 70 ++++- 14 files changed, 643 insertions(+), 51 deletions(-) diff --git a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts index 4447d59db5..b815e82bf8 100644 --- a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts +++ b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts @@ -51,6 +51,9 @@ export const addElectronCallPaths = ( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), entry_kind: path.entry_kind, channel: path.channel, terminal: path.terminal, @@ -97,6 +100,9 @@ export const addElectronCallPaths = ( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), resolution: "candidate", }, }); @@ -119,6 +125,9 @@ export const addElectronCallPaths = ( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), channel: path.channel, resolution: "candidate", conditions: path.conditions, diff --git a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts index 53b5bdce66..c10e8f6327 100644 --- a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts @@ -34,6 +34,9 @@ export function* projectElectronSemanticCallPaths( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), channel: path.channel, terminal: path.terminal, step_kind: step.kind, @@ -62,6 +65,9 @@ export function* projectElectronSemanticCallPaths( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), channel: path.channel, basis: "lexical-wrapper-substitution", }, diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index b8d2c03fb7..97cd323756 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -219,6 +219,8 @@ export interface ElectronCallPathStep { export interface ElectronCallPath { readonly entry_kind: "bridge-member" | "ipc-handler"; readonly entry: string; + /** Exact bridge API and property segments; `entry` remains display text. */ + readonly entry_path?: readonly string[]; readonly channel: string | null; readonly terminal: string; readonly resolution: "candidate"; diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 2fe9d5858f..90ef1f0a59 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -153,6 +153,13 @@ const collectMembersAt = ( state.unknown += 1; continue; } + if ( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__" + ) + continue; const segments = [...prefix, name]; const path = segments.join("."); state.members.push(path); diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 2740d7915d..6d30a10f52 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -357,6 +357,83 @@ it("preserves guarded alternatives in direct exposed object literals", () => { ]), ); }); +it("preserves bridge member segments and excludes prototype setters", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const write=()=>e.ipcRenderer.send("write"); + e.contextBridge.exposeInMainWorld("desktop", { + "nested.write": write, + nested: { write }, + __proto__: { inherited: write }, + ["__proto__"]: { own: write }, + }); + `); + expect(result.electron.call_paths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry: "desktop.nested.write", + entry_path: ["desktop", "nested.write"], + }), + expect.objectContaining({ + entry: "desktop.nested.write", + entry_path: ["desktop", "nested", "write"], + }), + expect.objectContaining({ + entry: "desktop.__proto__.own", + entry_path: ["desktop", "__proto__", "own"], + }), + ]), + ); + expect(result.electron.call_paths).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry_path: ["desktop", "__proto__", "inherited"], + }), + ]), + ); +}); +it("follows an explicit object-literal prototype lookup in a bridge wrapper", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const prototype={send:()=>e.ipcRenderer.send("through-prototype")}; + const object={__proto__:prototype}; + e.contextBridge.exposeInMainWorld("desktop", { + action:()=>object.__proto__.send(), + }); + `); + expect(result.electron.call_paths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry_path: ["desktop", "action"], + channel: "through-prototype", + }), + ]), + ); +}); +it("does not spread a large nested bridge member list into function arguments", () => { + const members = Array.from( + { length: 125_000 }, + (_, index) => `m${index}(){}`, + ).join(","); + const source = `const e=require("electron");e.contextBridge.exposeInMainWorld("desktop",{nested:{${members}}});`; + expect(() => analyzeJavaScriptStaticSource(source)).not.toThrow(); +}); +it("reports nested bridge depth truncation while retaining shallow paths", () => { + const nested = + Array.from({ length: 130 }, (_, index) => `level${index}:{`).join("") + + `deep:()=>e.ipcRenderer.send("deep")` + + "}".repeat(130); + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + e.contextBridge.exposeInMainWorld("desktop", { + shallow:()=>e.ipcRenderer.send("shallow"), ${nested} + }); + `); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ entry: "desktop.shallow", channel: "shallow" }), + ); + expect(result.limitations.join(" ")).toContain("depth 128"); +}); it("preserves conditions when a wrapper dispatches through conditional external methods", () => { const result = analyzeJavaScriptStaticSource(` const e=require("electron"); diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index 048ad30f5b..051595eb6e 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -44,6 +44,7 @@ const callLabel = (node: t.Node): string => interface PathContext { readonly entryNode: t.Node; readonly entry: string; + readonly entryPath?: readonly string[]; readonly entryKind: ElectronCallPath["entry_kind"] | null; readonly channel: string | null; readonly steps: readonly ElectronCallPathStep[]; @@ -122,6 +123,37 @@ export const collectElectronWrappedCallPaths = ( }; const hasConditions = (value: SymbolicValue): boolean => symbolicVariants(value).some((variant) => variant.conditions?.length); + function* bridgeMemberFunctions( + value: SymbolicValue, + segments: readonly string[] = [], + active = new Set(), + ): Generator<{ + readonly name: string; + readonly path: readonly string[]; + readonly fn: SymbolicFunction; + }> { + if (active.has(value)) return; + if (active.size >= 128) { + accumulator.unknownFindings += 1; + accumulator.electronCallPathLimitReached = true; + return; + } + const nested = new Set(active); + nested.add(value); + for (const member of [...resolver.members(value)].sort()) { + if (--remainingVisits < 0) return; + const path = [...segments, member]; + const name = path.join("."); + for (const variant of symbolicVariants( + resolver.property(value, member), + )) { + if (variant.kind === "function") yield { name, path, fn: variant }; + else if (resolver.members(variant).length > 0) + yield* bridgeMemberFunctions(variant, path, nested); + } + if (remainingVisits < 0) return; + } + } const isolatedBridgeCall = ( node: t.CallExpression | t.OptionalCallExpression, ): @@ -167,6 +199,10 @@ export const collectElectronWrappedCallPaths = ( const path: ElectronCallPath = { entry_kind: context.entryKind, entry: context.entry, + ...(context.entryKind !== "bridge-member" || + context.entryPath === undefined + ? {} + : { entry_path: context.entryPath }), channel, terminal, resolution: "candidate", @@ -405,27 +441,25 @@ export const collectElectronWrappedCallPaths = ( offset: node.start ?? -1, value: finding, }); - for (const name of members) { - 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")), - { - ...memberContext, - entryNode: node, - entryKind: "bridge-member", - entry: `${key.value}.${name}`, - channel: null, - steps: [step(node, "exposure", `${key.value}.${name}`)], - active: new Set(), - }, - node, - ); - } + for (const { name, path, fn } of bridgeMemberFunctions(object)) { + const memberContext = metadataContext(keyContext, fn); + if (!memberContext) continue; + const entry = `${key.value}.${name}`; + functionBody( + fn, + fn.node.params.map(() => symbolicUnknown("public API argument")), + { + ...memberContext, + entryNode: node, + entryKind: "bridge-member", + entry, + entryPath: [key.value, ...path], + channel: null, + steps: [step(node, "exposure", entry)], + active: new Set(), + }, + node, + ); } }; const call = ( diff --git a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts index 9cab84239e..50f6ceb095 100644 --- a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts +++ b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts @@ -11,7 +11,10 @@ import { } from "./javascriptApplicationGraph.fixture.js"; import { findApplicationFeatureSeeds } from "./javascriptFeatureSeed.js"; import { traceApplicationFeature } from "./javascriptFeatureTrace.js"; -import type { ApplicationFeatureSeed } from "./javascriptFeatureTraceSchemas.js"; +import { + applicationFeatureSeedSchema, + type ApplicationFeatureSeed, +} from "./javascriptFeatureTraceSchemas.js"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; import type { JsonValue } from "../jsonValue.js"; @@ -268,6 +271,32 @@ describe("feature seed selection controls", () => { }); describe("public feature trace from contextBridge source members", () => { + it.each(["openProject", "missingProject"])( + "normalizes optional seed defaults before deriving trace identity for %s", + (value) => { + const input = { + sourceEvidenceId, + graph: contextBridgeGraph(), + nativeEvidence: [], + direction: "both" as const, + }; + const omitted = traceApplicationFeature({ + ...input, + seed: { kind: "api", value }, + }); + const explicit = traceApplicationFeature({ + ...input, + seed: { + kind: "api", + value, + match: null, + case_sensitive: false, + }, + }); + expect(omitted).toEqual(explicit); + }, + ); + it("traces one exact member seed through the public feature workflow", () => { const mode = { value: "openProject", @@ -381,12 +410,13 @@ const sourceEvidenceId = `ev_${"b".repeat(64)}`; const seed = ( kind: ApplicationFeatureSeed["kind"], value = "openProject", -): ApplicationFeatureSeed => ({ - kind, - value, - match: "exact", - case_sensitive: true, -}); +): ApplicationFeatureSeed => + applicationFeatureSeedSchema.parse({ + kind, + value, + match: "exact", + case_sensitive: true, + }); const memberNode = ( kind: ApplicationNode["kind"], diff --git a/src/domain/javascript/javascriptFeatureSeed.ts b/src/domain/javascript/javascriptFeatureSeed.ts index 929733f002..d5f06b8172 100644 --- a/src/domain/javascript/javascriptFeatureSeed.ts +++ b/src/domain/javascript/javascriptFeatureSeed.ts @@ -52,10 +52,28 @@ const seedMatch = ( ] : []; if (!kindMatches(node, seed.kind)) return []; + const requestedEntryPath = + seed.kind === "api" && "entry_path" in seed ? seed.entry_path : undefined; + if ( + seed.kind === "api" && + requestedEntryPath !== undefined && + !node.observations.some(({ properties }) => { + const entryPath = properties.entry_path; + return ( + Array.isArray(entryPath) && + entryPath.length === requestedEntryPath.length && + entryPath.every( + (segment, index) => segment === requestedEntryPath[index], + ) + ); + }) + ) + return []; const mode = featureSeedMatchMode(seed); - const query = normalized(seed.value, seed.case_sensitive); + const caseSensitive = seed.case_sensitive; + const query = normalized(seed.value, caseSensitive); const match = candidateFields(node, seed.kind).find((candidate) => { - const value = normalized(candidate.value, seed.case_sensitive); + const value = normalized(candidate.value, caseSensitive); return mode === "exact" ? value === query : value.includes(query); }); return match === undefined diff --git a/src/domain/javascript/javascriptFeatureTrace.ts b/src/domain/javascript/javascriptFeatureTrace.ts index 47333bab60..e4109b85d4 100644 --- a/src/domain/javascript/javascriptFeatureTrace.ts +++ b/src/domain/javascript/javascriptFeatureTrace.ts @@ -12,6 +12,8 @@ import type { } from "./javascriptApplicationGraphSchemas.js"; import { applicationFeatureTraceResultSchema, + applicationFeatureSeedSchema, + type ApplicationFeatureSeedInput, type ApplicationFeatureTraceResult, type TraceApplicationFeatureInput, } from "./javascriptFeatureTraceSchemas.js"; @@ -30,14 +32,18 @@ export interface ApplicationFeatureTraceProjectionInput { readonly sourceEvidenceId: string; readonly graph: JavaScriptApplicationGraph; readonly nativeEvidence: readonly Evidence[]; - readonly seed: TraceApplicationFeatureInput["seed"]; + readonly seed: ApplicationFeatureSeedInput; readonly direction: TraceApplicationFeatureInput["direction"]; } /** Trace a literal feature through its complete reachable, authority-preserving JAG. */ export const traceApplicationFeature = ( - input: ApplicationFeatureTraceProjectionInput, + source: ApplicationFeatureTraceProjectionInput, ): ApplicationFeatureTraceResult => { + const input = { + ...source, + seed: applicationFeatureSeedSchema.parse(source.seed), + }; const allSeedMatches = findApplicationFeatureSeeds( input.graph.nodes, input.seed, diff --git a/src/domain/javascript/javascriptFeatureTraceSchemas.ts b/src/domain/javascript/javascriptFeatureTraceSchemas.ts index ee34bad092..b341ce1420 100644 --- a/src/domain/javascript/javascriptFeatureTraceSchemas.ts +++ b/src/domain/javascript/javascriptFeatureTraceSchemas.ts @@ -17,33 +17,65 @@ export const featureSeedMatchMode = (seed: { }): "exact" | "contains" => seed.match ?? (seed.kind === "string" ? "contains" : "exact"); -const literalSeedKinds = [ - "route", - "string", - "api", - "channel", - "module", - "native-export", -] as const; +const apiSeedSchema = z.union([ + z.strictObject({ + kind: z.literal("api"), + value: boundedTextSchema, + match: z.enum(["exact", "contains"]).nullable().default(null), + case_sensitive: z.boolean().default(false), + }), + z.strictObject({ + kind: z.literal("api"), + value: z.literal(""), + match: z.literal("exact"), + case_sensitive: z.boolean().default(false), + }), +]); + +const apiPathSeedSchema = z.union([ + z.strictObject({ + kind: z.literal("api"), + value: boundedTextSchema, + match: z.enum(["exact", "contains"]).nullable().default(null), + case_sensitive: z.boolean().default(false), + entry_path: z.array(z.string()).min(1), + }), + z.strictObject({ + kind: z.literal("api"), + value: z.literal(""), + match: z.literal("exact"), + case_sensitive: z.boolean().default(false), + entry_path: z.array(z.string()).min(1), + }), +]); /** * Literal starting point for one application feature trace. An empty value, * such as the channel in `ipcMain.handle("")`, must be an explicit exact * literal seed: empty containment would select every node. */ -const applicationFeatureSeedSchema = z.union([ +export const applicationFeatureSeedSchema = z.union([ z.strictObject({ - kind: z.enum(["node-id", ...literalSeedKinds]), + kind: z.enum([ + "node-id", + "route", + "string", + "channel", + "module", + "native-export", + ]), value: boundedTextSchema, match: z.enum(["exact", "contains"]).nullable().default(null), case_sensitive: z.boolean().default(false), }), z.strictObject({ - kind: z.enum(literalSeedKinds), + kind: z.enum(["route", "string", "channel", "module", "native-export"]), value: z.literal(""), match: z.literal("exact"), case_sensitive: z.boolean().default(false), }), + apiSeedSchema, + apiPathSeedSchema, ]); /** Check unique native identities before projecting an application trace. */ @@ -156,7 +188,10 @@ export const applicationFeatureTraceResultSchema = z.strictObject({ export type TraceApplicationFeatureInput = z.infer< typeof traceApplicationFeatureInputSchema >; -export type ApplicationFeatureSeed = z.infer< +export type ApplicationFeatureSeed = z.output< + typeof applicationFeatureSeedSchema +>; +export type ApplicationFeatureSeedInput = z.input< typeof applicationFeatureSeedSchema >; export type ApplicationFeatureTraceResult = z.infer< diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index b1f37269fe..62c70f953e 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -47,6 +47,14 @@ interface SymbolicReturnFlow { readonly fallsThrough: boolean; } const emptyEnvironment: SymbolicEnvironment = { values: new Map() }; +const isObjectPrototypeSetter = ( + property: t.ObjectMethod | t.ObjectProperty, + name: string, +): boolean => + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__"; /** Demand-driven substitutions for local bindings, closures and literal module tables. */ export class JavaScriptSymbolicBindings { @@ -243,15 +251,30 @@ export class JavaScriptSymbolicBindings { index = null; break; } + if (isObjectPrototypeSetter(property, name)) continue; 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); + if (property !== undefined) + return this.#objectPropertyValue(property, value.environment); + if (key === "__proto__") { + const prototypeSetter = [...value.node.properties] + .reverse() + .filter( + (candidate): candidate is t.ObjectMethod | t.ObjectProperty => + !t.isSpreadElement(candidate), + ) + .find((candidate) => isObjectPrototypeSetter(candidate, key)); + if (prototypeSetter !== undefined) + return this.#objectPropertyValue( + prototypeSetter, + value.environment, + ); + } + return symbolicUnknown("missing property"); } for (const p of [...value.node.properties].reverse()) { if (t.isSpreadElement(p)) { @@ -314,7 +337,8 @@ export class JavaScriptSymbolicBindings { t.isSpreadElement(p) ? this.members(this.evaluate(p.argument, value.environment), active) : [semanticStaticPropertyKey(p.key, p.computed)].filter( - (k): k is string => k !== null, + (k): k is string => + k !== null && !isObjectPrototypeSetter(p, k), ), ), ), diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 84a63d79bf..40073f1ae9 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -157,6 +157,7 @@ const electron = z z.strictObject({ entry_kind: z.enum(["bridge-member", "ipc-handler"]), entry: z.string(), + entry_path: strings.optional(), channel: z.string().nullable(), terminal: z.string(), resolution: z.literal("candidate"), @@ -181,6 +182,13 @@ const electron = z ), }), ) + .transform((paths) => + paths.map(({ entry_kind, entry, entry_path, ...rest }) => + entry_path === undefined + ? { entry_kind, entry, ...rest } + : { entry_kind, entry, entry_path, ...rest }, + ), + ) .optional(), browser_windows: z.array( z.union([ diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts index af61f08851..4dd0d02a85 100644 --- a/tests/boundary/cli/electronWrappedCallPaths.test.ts +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -23,7 +23,7 @@ const analyze = async (cli: TestCli, extra = "") => { const output = await cli.run({ arguments: ["analyze-javascript-application", root, "--json"], }); - expect(output.exitCode).toBe(0); + expect(output.exitCode, output.stderr).toBe(0); const { normalized_result: result } = z .object({ normalized_result: javascriptApplicationAnalysisResultSchema, @@ -122,6 +122,190 @@ cliTest( }, ); +cliTest( + "traces a nested contextBridge member through a unique IPC channel", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-nested-bridge-"); + await writeFixtureFiles(root, { + "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + contextBridge.exposeInMainWorld("api", { + nested: { write: (value) => ipcRenderer.invoke("nested:write", value) } + });`, + "main.js": `const { ipcMain, clipboard } = require("electron"); + ipcMain.handle("nested:write", (event, value) => clipboard.writeText(value));`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const { graph } = application; + const paths = graph.nodes + .flatMap((node) => node.observations) + .filter( + (observation) => + observation.properties.entry === "api.nested.write" && + observation.properties.entry_kind === "bridge-member", + ); + expect( + paths.some( + (observation) => observation.properties.step_kind === "exposure", + ), + ).toBe(true); + expect( + paths.some( + (observation) => observation.properties.step_kind === "terminal", + ), + ).toBe(true); + const joins = graph.edges.filter( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths", + ); + expect(joins.map((edge) => edge.properties.channel)).toEqual([ + "nested:write", + ]); + + const traceInput = join(root, "trace-nested-input.json"); + await writeFile( + traceInput, + JSON.stringify({ + application: output.json, + seed: { kind: "api", value: "api.nested.write", match: "exact" }, + direction: "outgoing", + }), + ); + const traced = await cli.run({ + arguments: ["trace-application-feature", traceInput, "--json"], + }); + expect(traced.exitCode).toBe(0); + const { normalized_result: trace } = z + .object({ normalized_result: applicationFeatureTraceResultSchema }) + .parse(traced.json); + expect(trace.seed_matches).toHaveLength(1); + expect(trace.paths.some((path) => path.contains_inference)).toBe(true); + }, +); + +cliTest( + "preserves exact bridge member segments through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-bridge-segments-"); + await writeFixtureFiles(root, { + "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + const write = () => ipcRenderer.send("write"); + contextBridge.exposeInMainWorld("desktop", { + "nested.write": write, + nested: { write }, + __proto__: { inherited: write }, + ["__proto__"]: { own: write }, + });`, + "main.js": `const { ipcMain } = require("electron"); + ipcMain.on("write", (event) => event.sender.copy());`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const observations = application.graph.nodes.flatMap((node) => + node.observations.filter( + (observation) => + observation.properties.entry_kind === "bridge-member" && + observation.properties.step_kind === "exposure", + ), + ); + const bridge = application.graph.nodes + .filter((node) => node.kind === "context-bridge-api") + .flatMap((node) => node.observations) + .find((observation) => observation.properties.api_key === "desktop"); + expect(bridge?.properties.members).not.toContain("__proto__.inherited"); + expect(bridge?.properties.members).toContain("__proto__.own"); + const paths = observations.map( + (observation) => observation.properties.entry_path, + ); + expect(paths).toEqual( + expect.arrayContaining([ + ["desktop", "nested.write"], + ["desktop", "nested", "write"], + ["desktop", "__proto__", "own"], + ]), + ); + expect(paths).not.toContainEqual(["desktop", "__proto__", "inherited"]); + const handlerPaths = application.graph.nodes + .flatMap((node) => node.observations) + .filter( + (observation) => observation.properties.entry_kind === "ipc-handler", + ); + expect(handlerPaths.length).toBeGreaterThan(0); + expect( + handlerPaths.every( + (observation) => !("entry_path" in observation.properties), + ), + ).toBe(true); + }, +); + +cliTest( + "retains a nested bridge path when its IPC handler has no external terminal", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-nested-return-"); + await writeFixtureFiles(root, { + "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + contextBridge.exposeInMainWorld("api", { + nested: { write: (value) => ipcRenderer.invoke("nested:write", value) } + });`, + "main.js": `const { ipcMain } = require("electron"); + ipcMain.handle("nested:write", (event, value) => value);`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const { graph } = application; + expect( + graph.nodes.some((node) => + node.observations.some( + (observation) => + observation.properties.entry === "api.nested.write" && + observation.properties.step_kind === "exposure", + ), + ), + ).toBe(true); + expect( + graph.edges.some( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths", + ), + ).toBe(false); + + const traceInput = join(root, "trace-nested-input.json"); + await writeFile( + traceInput, + JSON.stringify({ + application: output.json, + seed: { kind: "api", value: "api.nested.write", match: "exact" }, + direction: "outgoing", + }), + ); + const traced = await cli.run({ + arguments: ["trace-application-feature", traceInput, "--json"], + }); + expect(traced.exitCode).toBe(0); + const { normalized_result: trace } = z + .object({ normalized_result: applicationFeatureTraceResultSchema }) + .parse(traced.json); + expect(trace.seed_matches).toHaveLength(1); + expect(trace.graph?.nodes.length).toBeGreaterThan(1); + }, +); + for (const surface of ["CLI", "stdio MCP"] as const) { cliTest( `preserves handler ambiguity and short-circuit factory paths through ${surface}`, @@ -225,3 +409,89 @@ cliTest( expect(joins.map((e) => e.properties.channel)).toEqual(["test:cut"]); }, ); + +cliTest( + "keeps shallow bridge members when a sibling exceeds the nesting limit", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-deep-bridge-"); + let deepMember = '() => e.ipcRenderer.send("deep:run")'; + for (let index = 0; index < 129; index += 1) + deepMember = `{ level${index}: ${deepMember} }`; + await writeFixtureFiles(root, { + "preload.js": `const e=require("electron"); + e.contextBridge.exposeInMainWorld("desktop", { + deep: ${deepMember}, + shallow: () => e.ipcRenderer.send("shallow:run") + });`, + "main.js": `const e=require("electron"); + e.ipcMain.on("shallow:run", event => event.sender.copy()); + e.ipcMain.on("deep:run", event => event.sender.copy());`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const { graph } = application; + const joins = graph.edges.filter( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths", + ); + expect(joins.map((edge) => edge.properties.channel)).toEqual([ + "shallow:run", + ]); + expect( + graph.nodes + .flatMap((node) => node.observations) + .some( + (observation) => + observation.properties.entry === "desktop.shallow" && + observation.properties.step_kind === "exposure", + ), + ).toBe(true); + }, +); + +cliTest( + "preserves empty bridge member segments in nested paths", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-electron-empty-bridge-key-", + ); + await writeFixtureFiles(root, { + "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + contextBridge.exposeInMainWorld("desktop", { + "": { nested: { call: () => ipcRenderer.send("empty:key") } } + });`, + "main.js": `const { ipcMain } = require("electron"); + ipcMain.on("empty:key", event => event.sender.copy());`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const observations = application.graph.nodes.flatMap( + (node) => node.observations, + ); + expect( + observations.some( + (observation) => + observation.properties.entry === "desktop..nested.call" && + observation.properties.step_kind === "exposure", + ), + ).toBe(true); + expect( + application.graph.edges + .filter( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths", + ) + .map((edge) => edge.properties.channel), + ).toEqual(["empty:key"]); + }, +); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts index 0306f250f6..0490ae2116 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts @@ -9,7 +9,10 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { matchJavaScriptApplicationVersions } from "../../../../src/domain/javascript/javascriptApplicationVersionKeys.js"; import { findApplicationFeatureSeeds } from "../../../../src/domain/javascript/javascriptFeatureSeed.js"; -import { traceApplicationFeatureInputSchema } from "../../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; +import { + applicationFeatureSeedSchema, + traceApplicationFeatureInputSchema, +} from "../../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; const analyzeSource = async ( @@ -76,6 +79,59 @@ it("retains empty literal evidence across the application boundary surfaces", as ); }); +it("keeps bridge entry paths stable through worker validation and API seed matching", async () => { + const output = await analyzeSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const literal = () => ipcRenderer.send("literal"); + const nested = () => ipcRenderer.send("nested"); + contextBridge.exposeInMainWorld("desktop", { + "nested.write": literal, + nested: { write: nested }, + }); + `); + const semanticPathIds = new Set( + (output.semantic_graph?.nodes ?? []) + .map(({ properties }) => properties.path_id) + .filter((pathId): pathId is string => typeof pathId === "string"), + ); + const applicationPathIds = new Set( + output.graph.nodes.flatMap(({ observations }) => + observations + .map(({ properties }) => properties.path_id) + .filter((pathId): pathId is string => typeof pathId === "string"), + ), + ); + expect(semanticPathIds).toEqual(applicationPathIds); + + const matches = findApplicationFeatureSeeds( + output.graph.nodes, + applicationFeatureSeedSchema.parse({ + kind: "api", + value: "desktop.nested.write", + entry_path: ["desktop", "nested.write"], + }), + ); + expect(matches).toHaveLength(1); + expect( + output.graph.nodes + .find(({ node_id: nodeId }) => nodeId === matches[0]?.node_id) + ?.observations.some( + ({ properties }) => + Array.isArray(properties.entry_path) && + properties.entry_path.join("\0") === "desktop\0nested.write", + ), + ).toBe(true); + expect( + findApplicationFeatureSeeds( + output.graph.nodes, + applicationFeatureSeedSchema.parse({ + kind: "api", + value: "desktop.nested.write", + }), + ), + ).toHaveLength(2); +}); + it("analyzes a plain long-line loader without package metadata", async () => { const output = await analyzeSource( `var Module = {}; fetch(""); new Worker(""); var payload = "${"x".repeat(65_536)}";`, @@ -240,8 +296,18 @@ it.each([ [{ kind: "channel", value: "" }, false], [{ kind: "channel", value: "", match: "contains" }, false], [{ kind: "node-id", value: "", match: "exact" }, false], + [{ kind: "api", value: "desktop.nested.write" }, true], + [ + { + kind: "api", + value: "desktop.nested.write", + entry_path: ["desktop", "nested.write"], + }, + true, + ], + [{ kind: "channel", value: "channel", entry_path: ["channel"] }, false], ] as const)( - "validates empty seed %j at runtime and in the advertised schema", + "validates seed %j at runtime and in the advertised schema", (seed, accepted) => { expect(seedSchema.safeParse(seed).success).toBe(accepted); expect(validateAdvertisedSeed(seed)).toBe(accepted);