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..0109dead9 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,18 +416,22 @@ export const endpointArgument = ( | t.JSXNamespacedName | t.ArgumentPlaceholder )[], + callee: t.Node, ): string | undefined => { if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket") return stringValue(args[0]); // `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 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 ed1bee5d3..279785546 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -190,6 +190,34 @@ 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"); + api.headers.post("/v1/headers"); + api.searchParams.request("/search"); + `), + ); + expect( + result.observations.endpoints.map(({ value }) => value).sort(), + ).toEqual([ + "/search", + "/v1/headers", + "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,