diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index f6c6db25d7..a3045fbd8b 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -247,19 +247,24 @@ export function* classifyParsedJavaScriptRpcClientRootsSteps( return roots; } -/** Receiver identity facts for the overloaded `.open` syntax only. */ +/** + * Receiver identity facts for the overloaded `.open` syntax, plus + * `configuration` for `.get` reads of a `getConfiguration(...)` result. + */ export type JavaScriptOpenReceiverFact = | "window" | "indexed-db" | "cache-storage" | "local" | "local-indexed-db" - | "local-cache-storage"; + | "local-cache-storage" + | "configuration"; /** * Resolve only the lexical receiver identity needed to distinguish browser - * `window.open` from XHR and global storage `.open` calls. This deliberately - * builds definitions without running semantic value or provenance analysis. + * `window.open` from XHR and global storage `.open` calls, and configuration + * `.get` reads from HTTP client calls. This deliberately builds definitions + * without running semantic value or provenance analysis. */ export const classifyParsedJavaScriptOpenReceivers = ( file: ParsedJavaScriptSource, @@ -278,14 +283,19 @@ export function* classifyParsedJavaScriptOpenReceiversSteps( if ( (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) || (!t.isMemberExpression(node.callee) && - !t.isOptionalMemberExpression(node.callee)) || - semanticStaticPropertyKey( - node.callee.property, - node.callee.computed, - ) !== "open" || - !t.isIdentifier(node.callee.object) + !t.isOptionalMemberExpression(node.callee)) ) return; + const method = semanticStaticPropertyKey( + node.callee.property, + node.callee.computed, + ); + if (method === "get") { + if (isConfigurationReceiver(state, node.callee.object)) + facts.set(node.start ?? -1, "configuration"); + return; + } + if (method !== "open" || !t.isIdentifier(node.callee.object)) return; const receiver = node.callee.object.name; const binding = resolveSemanticBindingState(state, node, receiver); if (binding === undefined) { @@ -351,6 +361,82 @@ export function* classifyParsedJavaScriptOpenReceiversSteps( return facts; } +/** + * Whether a call spells VS Code's `workspace.getConfiguration(section)`, whose + * result `.get` reads setting keys rather than request paths. The `workspace` + * member is required: a bare or differently scoped `getConfiguration` may be + * an application's own HTTP client factory. Minifiers keep property names, so + * bundled `x.workspace.getConfiguration` still matches. + */ +const isConfigurationAccessorCall = ( + state: JavaScriptSemanticAnalysisState, + node: t.Node, +): boolean => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return false; + const callee = node.callee; + if ( + (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) || + semanticStaticPropertyKey(callee.property, callee.computed) !== + "getConfiguration" + ) + return false; + const host = callee.object; + const matchesSpelling = t.isIdentifier(host) + ? host.name === "workspace" + : (t.isMemberExpression(host) || t.isOptionalMemberExpression(host)) && + semanticStaticPropertyKey(host.property, host.computed) === "workspace"; + if (!matchesSpelling) return false; + const root = t.isIdentifier(host) + ? host + : t.isMemberExpression(host) || t.isOptionalMemberExpression(host) + ? host.object + : undefined; + if (!t.isIdentifier(root)) return false; + const binding = resolveSemanticBindingState(state, root, root.name); + if (binding === undefined) + return !semanticResolutionBlocked(state, root, root.name); + const initializer = semanticStableInitializer(binding, state); + const origins = [ + ...binding.directOrigins, + ...(initializer === undefined + ? [] + : [semanticRequireOrigin(initializer.node, state)?.origin]), + ]; + return origins.some( + (origin) => + origin?.specifier === "vscode" && + (t.isIdentifier(host) + ? origin.importedPath.at(-1) === "workspace" || + initializer?.projection.at(-1) === "workspace" + : origin.importedPath.length === 0 || + (origin.importedPath.length === 1 && + (origin.importedPath[0] === "*" || + origin.importedPath[0] === "default"))), + ); +}; + +/** + * Whether a `.get` receiver is a configuration accessor result, either inline + * or through a binding whose only visible write is one unconditional + * initializer. Minifiers emit `let` for such bindings, so declaration kind + * alone does not decide. + */ +const isConfigurationReceiver = ( + state: JavaScriptSemanticAnalysisState, + receiver: t.Node, +): boolean => { + if (isConfigurationAccessorCall(state, receiver)) return true; + if (!t.isIdentifier(receiver)) return false; + const binding = resolveSemanticBindingState(state, receiver, receiver.name); + const initializer = semanticStableInitializer(binding, state); + return ( + initializer !== undefined && + initializer.projection.length === 0 && + isConfigurationAccessorCall(state, initializer.node) + ); +}; + const openGlobalFact = ( name: string, ): JavaScriptOpenReceiverFact | undefined => { diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index f2045a1eba..71628bc8ce 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -17,6 +17,7 @@ import { semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; +import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js"; import { compareUnicodeCodePoints, compositeKey, @@ -470,13 +471,7 @@ const xhrOpenUrl = ( name: string, methodNode: t.Node | null | undefined, urlNode: t.Node | null | undefined, - receiverFact?: - | "window" - | "indexed-db" - | "cache-storage" - | "local" - | "local-indexed-db" - | "local-cache-storage", + receiverFact?: JavaScriptOpenReceiverFact, ): string | undefined => { const url = stringValue(urlNode); if (url === undefined) return undefined; @@ -555,13 +550,7 @@ export const endpointArgument = ( | t.ArgumentPlaceholder )[], callee: t.Node, - receiverFact?: - | "window" - | "indexed-db" - | "cache-storage" - | "local" - | "local-indexed-db" - | "local-cache-storage", + receiverFact?: JavaScriptOpenReceiverFact, ): string | undefined => { if (receiverFact === "window") return undefined; if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket") @@ -575,10 +564,12 @@ export const endpointArgument = ( const value = stringValue(args[0]); // Only get and delete also belong to the keyed-collection APIs, whose // keys are header, field, and parameter names rather than URLs or paths. + // Configuration `get` reads setting keys. return value !== undefined && (method === "get" || method === "delete") && !isRequestUrlLiteral(value) && - isKeyedCollectionReceiver(callee) + (isKeyedCollectionReceiver(callee) || + (method === "get" && receiverFact === "configuration")) ? undefined : value; } diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 3c93d2fa3d..5173362faf 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -1,4 +1,5 @@ import type * as t from "@babel/types"; +import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js"; import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; import type { @@ -64,15 +65,7 @@ export interface JavaScriptFindingContext { readonly rpcScan: JavaScriptRpcScan; readonly source: string; readonly accumulator: JavaScriptAnalysisAccumulator; - readonly openReceiverFacts?: ReadonlyMap< - number, - | "window" - | "indexed-db" - | "cache-storage" - | "local" - | "local-indexed-db" - | "local-cache-storage" - >; + readonly openReceiverFacts?: ReadonlyMap; /** Proven Electron export path segments by root offset. */ readonly electronBindings?: ReadonlyMap; } diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 5913b0f403..ad82b5730f 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -321,6 +321,61 @@ describe("web bundle API classification parity", () => { ]); }); + it("keeps configuration setting reads out of endpoints in both analyzers", () => { + const source = ` + import vscodeDefault from "vscode"; + vscodeDefault.workspace.getConfiguration("git").get("default-setting"); + vscode.workspace.getConfiguration("git").get("autofetch"); + workspace.getConfiguration("git").get("enabled", true); + const config = vscode.workspace.getConfiguration("git"); + config.get("autofetchPeriod", 180); + config.get("/v1/settings"); + let c = Mt.workspace.getConfiguration("git", uri), l = c.get("timeline.date"); + let changed = vscode.workspace.getConfiguration("git"); + changed = api; + changed.get("reassigned"); + const maybe = flag ? vscode.workspace.getConfiguration("git") : api; + maybe.get("conditional"); + { + const config = api; + config.get("shadowed"); + } + api.get("users"); + const client = getConfiguration("/api/v1"); + client.get("accounts"); + http.getConfiguration(baseUrl).get("orders"); + { + const workspace = { getConfiguration: () => api }; + workspace.getConfiguration("/api").get("local-users"); + } + { + const vscode = { workspace: { getConfiguration: () => api } }; + vscode.workspace.getConfiguration("/api").get("local-orders"); + } + `; + const expected = [ + "/v1/settings", + "accounts", + "conditional", + "local-orders", + "local-users", + "orders", + "reassigned", + "shadowed", + "users", + ]; + expect( + analyzeCapturedWebBundle(inspection(source)) + .observations.endpoints.map(({ value }) => value) + .sort(), + ).toEqual(expected); + expect( + analyzeJavaScriptStaticSource(source) + .endpoints.map(({ value }) => value) + .sort(), + ).toEqual(expected); + }); + it("recognizes loadURL endpoints like static analysis", () => { const result = analyzeCapturedWebBundle( inspection('window.win.loadURL("https://embedded.test/app");'),