From 0e1500dc8d2ace183cb34058e058eba97c21c781 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:15:21 +0800 Subject: [PATCH] fix(javascript): qualify renderer endpoint observations --- .../javascriptStaticAnalysisCalls.ts | 13 +++- .../javascriptStaticAnalysisHelpers.ts | 8 +- src/domain/webBundleAnalyzer.test.ts | 11 ++- .../cli/javascriptRendererEndpoints.test.ts | 75 +++++++++++++++++++ 4 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 tests/boundary/cli/javascriptRendererEndpoints.test.ts diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index bf934a9d30..dbc90e9d58 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -518,7 +518,7 @@ const inspectEndpointCall = ( context: FindingContext, bindings: JavaScriptSemanticAnalysisState, ): void => { - if (isKnownNetworkRequest(node, bindings)) { + if (isKnownNetworkRequest(node, bindings, context)) { const target = argumentNode(node.arguments[0]); if (target === undefined) return; const literal = @@ -609,8 +609,17 @@ const isNetworkRequestSpelling = (name: string): boolean => const isKnownNetworkRequest = ( node: JavaScriptCallLike, bindings: JavaScriptSemanticAnalysisState, + context: FindingContext, ): boolean => { if (isFetchCall(node, bindings)) return true; + const callee = unwrapJavaScriptExpression(node.callee).node; + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + semanticStaticPropertyKey(callee.property, callee.computed) === "loadURL" && + context.rendererLoad?.(node.callee, node) === true + ) + return true; if ( t.isNewExpression(node) && (isUnmodifiedGlobalReference(node.callee, bindings, "WebSocket") || @@ -633,7 +642,7 @@ export const finishPartialNetworkEndpoints = ( index: JavaScriptBindingIndex, ): void => { for (const { node, name } of context.accumulator.pendingNetworkCalls) { - if (!isKnownNetworkRequest(node, bindings)) { + if (!isKnownNetworkRequest(node, bindings, context)) { if (!isIntrinsicMapLookup(node, bindings, index)) inspectLiteralEndpointCall(node, name, context); continue; diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index b3569e29b1..0ee4859981 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -657,7 +657,7 @@ const isKeyedCollectionReceiver = (callee: t.Node): boolean => { ); }; -/** A literal endpoint shape or an ambiguous argument of an HTTP-method-named call. */ +/** A literal endpoint shape or an argument whose callee spelling is unproven. */ export interface JavaScriptEndpointArgument { readonly kind: "endpoint" | "candidate"; readonly value: string; @@ -728,8 +728,10 @@ export const endpointArgument = ( // Beacon URLs are endpoint observations, retaining the upstream API support. if (name === "sendBeacon" || name.endsWith(".sendBeacon")) return literal(stringValue(args[0])); - if (name === "loadURL" || name.endsWith(".loadURL")) - return literal(stringValue(args[0])); + if (name === "loadURL" || name.endsWith(".loadURL")) { + const value = stringValue(args[0]); + return value === undefined ? undefined : { kind: "candidate", value }; + } return undefined; }; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index e8bbb3cadb..787ef2ca78 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -1087,12 +1087,17 @@ describe("web bundle configuration and navigation parity", () => { }); }); - it("recognizes loadURL endpoints like static analysis", () => { + it("keeps loadURL spelling without Electron receiver proof as a candidate", () => { const result = analyzeWebBundleFixture( inspection('window.win.loadURL("https://embedded.test/app");'), ); - expect(result.observations.endpoints).toContainEqual( - expect.objectContaining({ value: "https://embedded.test/app" }), + expect(result.observations.endpoints).toEqual([]); + expect(result.inferences).toContainEqual( + expect.objectContaining({ + kind: "endpoint_candidate", + value: "https://embedded.test/app", + confidence: "low", + }), ); }); }); diff --git a/tests/boundary/cli/javascriptRendererEndpoints.test.ts b/tests/boundary/cli/javascriptRendererEndpoints.test.ts new file mode 100644 index 0000000000..02a50402e3 --- /dev/null +++ b/tests/boundary/cli/javascriptRendererEndpoints.test.ts @@ -0,0 +1,75 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest( + "preserves renderer endpoint certainty through CLI graph delivery", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-renderer-endpoints-"); + await writeFixtureFiles(root, { + "main.cjs": ` + const { BrowserWindow } = require("electron"); + const replaced = new BrowserWindow(); + replaced.loadURL = () => {}; + replaced.loadURL("https://replaced.test"); + const local = { loadURL(value) { return value; } }; + local.loadURL("https://local.test"); + const intact = new BrowserWindow(); + intact.loadURL("https://intact.test"); + new BrowserWindow().loadURL(runtimeUrl); + new BrowserWindow().loadFile("page.html"); + `, + "page.html": "

Local renderer

", + }); + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + const endpoints = result.graph.nodes.filter( + ({ kind }) => kind === "endpoint", + ); + expect(endpoints).toHaveLength(4); + for (const value of ["https://replaced.test", "https://local.test"]) { + const endpoint = endpoints.find(({ observations }) => + observations.some(({ properties }) => properties.value === value), + ); + expect(endpoint?.observations[0]).toMatchObject({ + properties: { value, interpretation: "candidate" }, + evidence: { state: "inferred", confidence: "low" }, + }); + expect(endpoint?.observations[0]?.evidence.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("receiver does not establish a network call"), + ]), + ); + expect( + result.graph.edges.find( + ({ target_node_id }) => target_node_id === endpoint?.node_id, + ), + ).toMatchObject({ + relation: "calls", + properties: { interpretation: "candidate" }, + evidence: { state: "inferred", confidence: "low" }, + }); + } + const intact = endpoints.find(({ observations }) => + observations.some( + ({ properties }) => properties.value === "https://intact.test", + ), + ); + expect(intact?.observations[0]).toMatchObject({ + properties: { value: "https://intact.test" }, + evidence: { state: "observed", confidence: "exact" }, + }); + expect(intact?.observations[0]?.properties).not.toHaveProperty( + "interpretation", + ); + const dynamic = endpoints.find(({ observations }) => + observations.some(({ properties }) => properties.value === "runtimeUrl"), + ); + expect(dynamic?.observations[0]).toMatchObject({ + properties: { value: "runtimeUrl", resolution: "partial" }, + evidence: { state: "observed" }, + }); + }, +);