From eb68761eb0a35b876e33dd6115d0d07f97e99dd5 Mon Sep 17 00:00:00 2001 From: Iwakurakaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:28:19 -0700 Subject: [PATCH 1/2] fix(javascript): keep keyed-collection reads out of endpoints Static endpoint extraction read the first literal argument of any call named get, post, put, patch, delete, or request as an HTTP request URL. Reads of a provable keyed collection also became network endpoints in application and web bundle analysis: `new URLSearchParams(...).get("q")`, `response.headers.get("content-type")`, `new Map().get("key")`, and `url.searchParams.get("page")`. Skip these calls only when the receiver is a constructed Map, WeakMap, Set, WeakSet, Headers, FormData, or URLSearchParams, or a platform `headers`/`searchParams` property. Any other receiver may still be an HTTP client with a base URL, so `api.get("users")` keeps its endpoint. Co-Authored-By: Claude Opus 5.5 --- src/domain/javascriptStaticAnalysisCalls.ts | 2 +- src/domain/javascriptStaticAnalysisHelpers.ts | 40 ++++++++++++++++++- src/domain/webBundleAnalyzer.test.ts | 20 ++++++++++ src/domain/webBundleAnalyzerInspection.ts | 2 +- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/domain/javascriptStaticAnalysisCalls.ts b/src/domain/javascriptStaticAnalysisCalls.ts index f669548a2..4208307b1 100644 --- a/src/domain/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascriptStaticAnalysisCalls.ts @@ -162,7 +162,7 @@ const inspectEndpointCall = ( value: first, mechanism: `call:${name}`, }); - const endpoint = endpointArgumentHelper(name, node.arguments); + const endpoint = endpointArgumentHelper(name, node.arguments, node.callee); if (endpoint !== undefined) addEndpoint(context, { node, diff --git a/src/domain/javascriptStaticAnalysisHelpers.ts b/src/domain/javascriptStaticAnalysisHelpers.ts index 5461e5ba4..879ac96a7 100644 --- a/src/domain/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascriptStaticAnalysisHelpers.ts @@ -370,6 +370,43 @@ const isXhrMethodArgument = (node: t.Node | null | undefined): boolean => { ); }; +const KEYED_COLLECTION_CONSTRUCTORS = new Set([ + "FormData", + "Headers", + "Map", + "Set", + "URLSearchParams", + "WeakMap", + "WeakSet", +]); + +/** + * Whether a get/delete receiver is provably a keyed collection rather than an + * HTTP client: a constructed Map, Set, Headers, FormData, or URLSearchParams, + * or the platform `headers`/`searchParams` properties of a request, response, + * or URL. Other receivers stay possible clients. + */ +const isKeyedCollectionReceiver = (callee: t.Node): boolean => { + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return false; + const receiver = callee.object; + if (t.isNewExpression(receiver)) + return ( + t.isIdentifier(receiver.callee) && + KEYED_COLLECTION_CONSTRUCTORS.has(receiver.callee.name) + ); + if ( + !t.isMemberExpression(receiver) && + !t.isOptionalMemberExpression(receiver) + ) + return false; + const property = semanticStaticPropertyName( + receiver.property, + receiver.computed, + ); + return property === "headers" || property === "searchParams"; +}; + /** Select the literal URL argument for recognized network callees. */ export const endpointArgument = ( name: string, @@ -379,6 +416,7 @@ export const endpointArgument = ( | t.JSXNamespacedName | t.ArgumentPlaceholder )[], + callee: t.Node, ): string | undefined => { if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket") return stringValue(args[0]); @@ -390,7 +428,7 @@ export const endpointArgument = ( (method) => name === method || name.endsWith(`.${method}`), ) ) - return stringValue(args[0]); + return isKeyedCollectionReceiver(callee) ? undefined : stringValue(args[0]); if (name.endsWith("loadURL")) return stringValue(args[0]); return undefined; }; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index ed1bee5d3..121c75093 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -190,6 +190,26 @@ describe("web bundle static-analysis parity", () => { ).toEqual(["/dav/", "/xhr-get", "https://xhr.example.test/submit"]); }); + it("keeps HTTP-method-named reads of provable keyed collections out of endpoints", () => { + const result = analyzeCapturedWebBundle( + inspection(` + axios.get("https://api.example.test/users"); + api.get("users"); + client.delete("cache-key"); + new Map().get("map-key"); + new URLSearchParams(location.search).get("q"); + new Headers(init).delete("x-trace"); + new FormData(form).get("file"); + response.headers.get("content-type"); + request?.headers.delete("cookie"); + new URL(location.href).searchParams.get("page"); + `), + ); + expect( + result.observations.endpoints.map(({ value }) => value).sort(), + ).toEqual(["cache-key", "https://api.example.test/users", "users"]); + }); + it("recognizes loadURL endpoints like static analysis", () => { const result = analyzeCapturedWebBundle( inspection('window.win.loadURL("https://embedded.test/app");'), diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 85eb2d011..4f8451181 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -147,7 +147,7 @@ const inspectCall = ( accumulator, }); } - const endpoint = endpointArgument(name, node.arguments); + const endpoint = endpointArgument(name, node.arguments, node.callee); if (endpoint !== undefined) addFinding({ collection: accumulator.endpoints, From 00f707b250451246d69a1205c39c6843fab6bb5b Mon Sep 17 00:00:00 2001 From: Iwakurakaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:34:47 -0700 Subject: [PATCH 2/2] fix(javascript): filter keyed-collection receivers only for get/delete The collection APIs share only `get` and `delete` with HTTP clients, so `api.headers.post("/v1/headers")` and `api.searchParams.request("/s")` keep their endpoints. Co-Authored-By: Claude Opus 5.5 --- src/domain/javascriptStaticAnalysisHelpers.ts | 15 +++++++++------ src/domain/webBundleAnalyzer.test.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/domain/javascriptStaticAnalysisHelpers.ts b/src/domain/javascriptStaticAnalysisHelpers.ts index 879ac96a7..0109dead9 100644 --- a/src/domain/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascriptStaticAnalysisHelpers.ts @@ -423,12 +423,15 @@ export const endpointArgument = ( // `XMLHttpRequest.open(method, url)`. if (name.endsWith(".open") && isXhrMethodArgument(args[0])) return stringValue(args[1]); - if ( - ["get", "post", "put", "patch", "delete", "request"].some( - (method) => name === method || name.endsWith(`.${method}`), - ) - ) - return isKeyedCollectionReceiver(callee) ? undefined : stringValue(args[0]); + const method = ["get", "post", "put", "patch", "delete", "request"].find( + (candidate) => name === candidate || name.endsWith(`.${candidate}`), + ); + if (method !== undefined) + // Only get and delete also belong to the keyed-collection APIs. + return (method === "get" || method === "delete") && + isKeyedCollectionReceiver(callee) + ? undefined + : stringValue(args[0]); if (name.endsWith("loadURL")) return stringValue(args[0]); return undefined; }; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 121c75093..279785546 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -203,11 +203,19 @@ describe("web bundle static-analysis parity", () => { response.headers.get("content-type"); request?.headers.delete("cookie"); new URL(location.href).searchParams.get("page"); + api.headers.post("/v1/headers"); + api.searchParams.request("/search"); `), ); expect( result.observations.endpoints.map(({ value }) => value).sort(), - ).toEqual(["cache-key", "https://api.example.test/users", "users"]); + ).toEqual([ + "/search", + "/v1/headers", + "cache-key", + "https://api.example.test/users", + "users", + ]); }); it("recognizes loadURL endpoints like static analysis", () => {