From 018ab2c5d9bfbfdf12b5480f3f810f520ca0156b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:14:52 +0800 Subject: [PATCH] fix(javascript): retain unresolved network targets with provenance Resolve stable lexical and URL targets using shared effects, and preserve unresolved request expressions as partial facts. Carry their uncertainty through workers, artifact graphs, and the CLI. Scope partial endpoint identity to the source site. Refs: #3451, #3534 Co-authored-by: Hemang Varshney <218273197+hemv-857@users.noreply.github.com> --- docs/javascript-application-graph.md | 11 + docs/javascript-application-workflows.md | 6 +- docs/javascript-artifact-reconstruction.md | 11 +- .../JavaScriptArtifactGraphFindings.ts | 16 +- ...ascriptStaticAnalysis.event_source.test.ts | 12 +- ...iptStaticAnalysis.partialEndpoints.test.ts | 265 +++++++++++++++++ ...vascriptStaticAnalysis.send_beacon.test.ts | 13 +- .../javascript/javascriptStaticAnalysis.ts | 26 +- .../javascriptStaticAnalysisCalls.ts | 269 +++++++++++++++++- .../javascriptStaticAnalysisState.ts | 10 + .../javascriptStaticAnalysisTypes.ts | 2 + .../JavaScriptAnalysisWorkerSchemas.test.ts | 13 + .../JavaScriptAnalysisWorkerSchemas.ts | 1 + .../javascriptPartialEndpointsCli.test.ts | 58 ++++ .../javascriptArtifactGraphEndpoints.test.ts | 51 ++++ 15 files changed, 737 insertions(+), 27 deletions(-) create mode 100644 src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts create mode 100644 tests/acceptance/applications/javascriptPartialEndpointsCli.test.ts diff --git a/docs/javascript-application-graph.md b/docs/javascript-application-graph.md index 48e17bf447..fa5fe7bfad 100644 --- a/docs/javascript-application-graph.md +++ b/docs/javascript-application-graph.md @@ -68,6 +68,17 @@ The node vocabulary covers: - runtime script instance; - an explicit `unknown` kind when classification is not justified. +Static network endpoints can have `resolution: "partial"` when the analyzer +recognizes a request call but cannot resolve its target to one exact value. +The observation's `value` then preserves the source expression, and its +evidence points to the call site. Partial and exact endpoints have distinct +identities even when their displayed values match. Partial identities also retain +the source site: matching expression text at different sites does not establish +the same runtime target. This records observed source syntax; it does not +establish that the call ran or contacted that endpoint. +Graph coverage describes the analyzed artifact scope and resource limits, not +runtime execution. A partial target alone does not imply omitted source. + The directed relation vocabulary is `contains`, `loads`, `imports`, `maps_to`, `exposes`, `sends`, `invokes`, `handles`, `calls`, `persists_to`, `observed_as`, and `changed_from`. Provider names are deliberately absent from both diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 5b8de26ff0..5e9c0adab8 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -174,8 +174,10 @@ graph and read literal values from `properties.value`, rather than decoding role keys or labels. Previously stored graphs and their original IDs remain valid inputs. -Endpoint nodes keep the exact endpoint in each observation's `properties.value`; -semantic request and response nodes keep it in `properties.endpoint`. Endpoint +Endpoint observations keep their value in `properties.value`. When +`properties.resolution` is `partial`, that value is the unresolved source +expression, with an identity distinct from an exact endpoint of the same text. +Semantic request and response nodes keep their endpoint in `properties.endpoint`. Endpoint lookup identities are preserved independently of the primitive-value lattice's byte budget. An oversized normalized literal can become unknown while the same string remains an exact endpoint for tracing and comparison. Endpoint diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 9fe784b66f..db80dae951 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -426,10 +426,13 @@ For `.node` bindings, member names mean “requested by JavaScript syntax.” A resolved add-on path does not convert them into verified native exports. Native symbol verification remains a separate deep-analysis claim. -Endpoint observations preserve useful local diagnostics, including query values -and fragments, while removing only URL username/password credentials. Artifact -paths, digests, parse locations, and analysis metadata remain actionable -because REA is local-only. +Exact endpoint values preserve useful local diagnostics, including query values +and fragments, while removing URL username/password credentials when the value +parses as a URL. When a recognized request target cannot be resolved exactly, +the endpoint's partial resolution retains the original source expression with +its call-site provenance. This does not establish that the request executed. +Artifact paths, digests, parse locations, and analysis metadata remain +actionable because REA is local-only. ## Safety boundary diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 57c3961ca4..4699e08e66 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -202,15 +202,22 @@ const addEndpoint = ( ) ?? input.asset; const key = compactStringIdentityKey(input.value.value); const candidate = input.value.interpretation === "candidate"; + const partial = input.value.resolution === "partial"; const candidateLimitation = "The unbound method-like argument is only a candidate; its receiver does not establish a network call."; + const partialLimitation = + "The request target is not an exact literal; the endpoint value retains the unresolved source expression."; const label = `${input.value.kind} ${candidate ? "endpoint candidate" : "endpoint"}`; const node = context.accumulator.addNode({ kind: "endpoint", identity: artifactLocalIdentity( input.file.sha256, input.value.kind, - JSON.stringify([input.value.interpretation ?? "literal", key]), + JSON.stringify([ + input.value.interpretation ?? "literal", + ...(partial ? ["partial", input.value.location] : []), + key, + ]), ), observations: [ { @@ -221,6 +228,7 @@ const addEndpoint = ( value: input.value.value, mechanism: input.value.mechanism, ...(candidate ? { interpretation: input.value.interpretation } : {}), + ...(partial ? { resolution: input.value.resolution } : {}), }, evidence: candidate ? staticInferenceEvidence({ @@ -238,6 +246,7 @@ const addEndpoint = ( range: input.value.location, operation: "discover-static-endpoint", coverage: input.coverage, + ...(partial ? { limitations: [partialLimitation] } : {}), }), }, ], @@ -252,13 +261,16 @@ const addEndpoint = ( properties: { mechanism: input.value.mechanism, ...(candidate ? { interpretation: input.value.interpretation } : {}), + ...(partial ? { resolution: input.value.resolution } : {}), }, ...(candidate ? { confidence: "low" as const, limitations: [candidateLimitation], } - : {}), + : partial + ? { limitations: [partialLimitation] } + : {}), }); }; diff --git a/src/domain/javascript/javascriptStaticAnalysis.event_source.test.ts b/src/domain/javascript/javascriptStaticAnalysis.event_source.test.ts index 0fa5d65432..4f6801e784 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.event_source.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.event_source.test.ts @@ -34,7 +34,15 @@ describe("server-sent event endpoints", () => { ]); }); - it("keeps a dynamic EventSource URL unreported", () => { - expect(endpoints("new EventSource(url);")).toEqual([]); + it("retains a dynamic EventSource target as partial source evidence", () => { + expect( + analyzeJavaScriptStaticSource("new EventSource(url);").endpoints, + ).toEqual([ + expect.objectContaining({ + value: "url", + mechanism: "call:EventSource", + resolution: "partial", + }), + ]); }); }); diff --git a/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts b/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts new file mode 100644 index 0000000000..6eb4c78f4e --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts @@ -0,0 +1,265 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const endpoints = (source: string) => + analyzeJavaScriptStaticSource(source).endpoints.map((endpoint) => ({ + kind: endpoint.kind, + value: endpoint.value, + mechanism: endpoint.mechanism, + resolution: endpoint.resolution, + })); + +it("resolves unchanged lexical strings and object properties", () => { + expect( + endpoints(` + const target = "/items"; + const config = { url: "/accounts" }; + fetch(target); + fetch("/direct"); + fetch(config.url); + `), + ).toEqual([ + { + kind: "network", + value: "/items", + mechanism: "call:fetch", + resolution: undefined, + }, + { + kind: "network", + value: "/direct", + mechanism: "call:fetch", + resolution: undefined, + }, + { + kind: "network", + value: "/accounts", + mechanism: "call:fetch", + resolution: undefined, + }, + ]); +}); + +it("resolves unchanged URL objects and aliases using URL serialization", () => { + expect( + endpoints(` + const socket = new URL("HTTPS://EXAMPLE.COM:443"); + const alias = socket; + const relative = new URL("/items", "https://api.example.test/base"); + fetch(alias); + fetch(relative); + `), + ).toEqual([ + { + kind: "network", + value: "https://example.com/", + mechanism: "call:fetch", + resolution: undefined, + }, + { + kind: "network", + value: "https://api.example.test/items", + mechanism: "call:fetch", + resolution: undefined, + }, + ]); +}); + +it("keeps an interpolated request expression verbatim", () => { + expect( + endpoints("fetch(`https://${hosts['a@b']}/items/${id}`);"), + ).toContainEqual({ + kind: "network", + value: "`https://${hosts['a@b']}/items/${id}`", + mechanism: "call:fetch", + resolution: "partial", + }); +}); + +it("keeps writes to a config property partial instead of resolving its initializer", () => { + expect( + endpoints(` + const config = { url: "https://old.example.test" }; + config.url = "https://new.example.test"; + fetch(config.url); + `), + ).toContainEqual({ + kind: "network", + value: "config.url", + mechanism: "call:fetch", + resolution: "partial", + }); +}); + +it("keeps a reassigned endpoint binding partial", () => { + expect( + endpoints(` + let endpoint = "https://old.example.test"; + endpoint = "https://new.example.test"; + fetch(endpoint); + `), + ).toContainEqual({ + kind: "network", + value: "endpoint", + mechanism: "call:fetch", + resolution: "partial", + }); +}); + +it("does not resolve an endpoint from a declaration after the request", () => { + expect( + endpoints(` + fetch(endpoint); + const endpoint = "https://api.example.test/items"; + `), + ).toEqual([ + { + kind: "network", + value: "endpoint", + mechanism: "call:fetch", + resolution: "partial", + }, + ]); +}); + +it("keeps URLSearchParams mutations partial", () => { + expect( + endpoints(` + const socket = new URL("wss://old.example.test/socket"); + socket.searchParams.set("token", token); + new WebSocket(socket); + `), + ).toContainEqual({ + kind: "network", + value: "socket", + mechanism: "call:WebSocket", + resolution: "partial", + }); +}); + +it("resolves a URL object against a literal base", () => { + expect( + endpoints(` + const socket = new URL("/socket", "wss://api.example.test"); + new WebSocket(socket); + `), + ).toEqual([ + { + kind: "network", + value: "wss://api.example.test/socket", + mechanism: "call:WebSocket", + resolution: undefined, + }, + ]); +}); + +it("resolves an unchanged URL object's toString target", () => { + expect( + endpoints(` + const socket = new URL("wss://api.example.test/socket"); + new WebSocket(socket.toString()); + `), + ).toEqual([ + { + kind: "network", + value: "wss://api.example.test/socket", + mechanism: "call:WebSocket", + resolution: undefined, + }, + ]); +}); + +it("keeps a URL object partial when its base is unresolved", () => { + expect( + endpoints(` + const socket = new URL("/socket", apiOrigin); + new WebSocket(socket); + `), + ).toContainEqual({ + kind: "network", + value: "socket", + mechanism: "call:WebSocket", + resolution: "partial", + }); +}); + +it("keeps URL aliases partial after a member mutation", () => { + expect( + endpoints(` + const socket = new URL("wss://old.example.test/socket"); + const alias = socket; + alias.hostname = "new.example.test"; + fetch(socket); + `), + ).toContainEqual({ + kind: "network", + value: "socket", + mechanism: "call:fetch", + resolution: "partial", + }); +}); + +it("keeps an escaped URL object partial", () => { + expect( + endpoints(` + const socket = new URL("wss://old.example.test/socket"); + configure(socket); + new WebSocket(socket); + `), + ).toContainEqual({ + kind: "network", + value: "socket", + mechanism: "call:WebSocket", + resolution: "partial", + }); +}); + +it("retains dynamic targets on known global fetch receivers", () => { + expect( + endpoints(` + window.fetch(endpoint); + globalThis.fetch(endpoint); + self.fetch(endpoint); + `), + ).toEqual([ + { + kind: "network", + value: "endpoint", + mechanism: "call:window.fetch", + resolution: "partial", + }, + { + kind: "network", + value: "endpoint", + mechanism: "call:globalThis.fetch", + resolution: "partial", + }, + { + kind: "network", + value: "endpoint", + mechanism: "call:self.fetch", + resolution: "partial", + }, + ]); +}); + +it("does not treat a locally bound fetch function as a network API", () => { + expect( + endpoints(` + function fetch(key) { return records[key]; } + fetch(key); + `), + ).toEqual([]); +}); + +it("keeps exact literal targets exact", () => { + expect(endpoints('fetch("https://api.example.test/items");')).toEqual([ + { + kind: "network", + value: "https://api.example.test/items", + mechanism: "call:fetch", + resolution: undefined, + }, + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysis.send_beacon.test.ts b/src/domain/javascript/javascriptStaticAnalysis.send_beacon.test.ts index fd26b88b55..b55ad86488 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.send_beacon.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.send_beacon.test.ts @@ -28,7 +28,16 @@ describe("beacon endpoints", () => { ]); }); - it("keeps a dynamic beacon URL unreported", () => { - expect(endpoints("navigator.sendBeacon(url, payload);")).toEqual([]); + it("retains a dynamic beacon target as partial source evidence", () => { + expect( + analyzeJavaScriptStaticSource("navigator.sendBeacon(url, payload);") + .endpoints, + ).toEqual([ + expect.objectContaining({ + value: "url", + mechanism: "call:navigator.sendBeacon", + resolution: "partial", + }), + ]); }); }); diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index a630ff5783..e66abf22c5 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -21,6 +21,7 @@ import { import { addReference, addSourceMapDirectives, + finishPartialNetworkEndpoints, inspectCall, inspectRouteProperty, inspectRoleProperty, @@ -89,18 +90,19 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( file.program, bindings, ); - yield* traverseStaticSourceSteps( - file, - { - source, - accumulator, - vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program), - openReceiverFacts: receivers.openReceivers, - electronBindings: electronIdentity.bindings, - rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index), - }, - bindings, - ); + const findings: JavaScriptFindingContext = { + source, + accumulator, + vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program), + openReceiverFacts: receivers.openReceivers, + electronBindings: electronIdentity.bindings, + rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index), + }; + yield* traverseStaticSourceSteps(file, findings, bindings); + if (accumulator.pendingNetworkCalls.length > 0) { + yield* index.indexMemberMutationFactsSteps(); + finishPartialNetworkEndpoints(findings, bindings, index); + } accumulator.electronCallPaths = collectElectronWrappedCallPaths( file.program, bindings, diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 5c5b7442f6..8588cbb38d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -34,12 +34,16 @@ import type { } from "./javascriptStaticAnalysisState.js"; import { resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptBindingIndex, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { isUnmodifiedGlobalReference, isUnmodifiedImportMetaUrl, } from "./javascriptSemanticAmbientWrites.js"; +import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; +import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js"; import { semanticFactoryLoaderMemberAt, semanticFactoryRequireLoaderAt, @@ -119,7 +123,7 @@ export const inspectCall = ( kind: "service-worker", specifier: moduleUrlSpecifier(node, bindings), }); - inspectEndpointCall(node, name, first, context); + inspectEndpointCall(node, name, first, context, bindings); inspectStorageCall(node, name, first, context); if (first === "audio" || first === "video") noteMediaElementPreloads(node, accumulator); @@ -424,6 +428,7 @@ const inspectEndpointCall = ( name: string, first: string | undefined, context: FindingContext, + bindings: JavaScriptSemanticAnalysisState, ): void => { const route = STATIC_ROUTE_CALL_NAMES.some( (candidate) => name === candidate || name.endsWith(`.${candidate}`), @@ -435,6 +440,35 @@ const inspectEndpointCall = ( value: first, mechanism: `call:${name}`, }); + if (isKnownNetworkRequest(node, bindings)) { + const target = argumentNode(node.arguments[0]); + if (target === undefined) return; + const literal = stringValue(target); + if (literal !== undefined) { + addEndpoint(context, { + node, + kind: "network", + value: literal, + mechanism: `call:${name}`, + }); + return; + } + const reference = requestReferenceNode(target); + const unwrapped = unwrapJavaScriptExpression(reference).node; + if ( + t.isIdentifier(unwrapped) || + t.isMemberExpression(unwrapped) || + t.isOptionalMemberExpression(unwrapped) + ) { + context.accumulator.pendingNetworkCalls.push({ node, name }); + return; + } + addPartialNetworkEndpoint(context, node, name, target); + return; + } + // A local or shadowed function with a familiar network name is not a known + // browser request API. Do not let the literal fallback turn it into one. + if (isNetworkRequestSpelling(name)) return; const receiverFact = context.openReceiverFacts?.get(node.start ?? -1); const endpoint = endpointArgumentHelper( name, @@ -452,6 +486,229 @@ const inspectEndpointCall = ( }); }; +const isNetworkRequestSpelling = (name: string): boolean => + name === "fetch" || + name === "WebSocket" || + name === "EventSource" || + name === "sendBeacon" || + name === "window.fetch" || + name === "globalThis.fetch" || + name === "self.fetch" || + name === "navigator.sendBeacon"; + +/** Confirm that the call resolves to a built-in network API at this source site. */ +const isKnownNetworkRequest = ( + node: JavaScriptCallLike, + bindings: JavaScriptSemanticAnalysisState, +): boolean => { + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + isUnmodifiedGlobalReference(node.callee, bindings, "fetch") + ) + return true; + if ( + t.isNewExpression(node) && + (isUnmodifiedGlobalReference(node.callee, bindings, "WebSocket") || + isUnmodifiedGlobalReference(node.callee, bindings, "EventSource")) + ) + return true; + return ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + isUnmodifiedGlobalReference(node.callee, bindings, [ + "navigator", + "sendBeacon", + ]) + ); +}; + +/** Finish queued targets after shared member-mutation facts are indexed. */ +export const finishPartialNetworkEndpoints = ( + context: FindingContext, + bindings: JavaScriptSemanticAnalysisState, + index: JavaScriptBindingIndex, +): void => { + for (const { node, name } of context.accumulator.pendingNetworkCalls) { + const target = argumentNode(node.arguments[0]); + if (target === undefined) continue; + const resolved = resolvedRequestTarget(target, node, bindings, index); + if (resolved !== undefined) + addEndpoint(context, { + node, + kind: "network", + value: resolved, + mechanism: `call:${name}`, + }); + else addPartialNetworkEndpoint(context, node, name, target); + } + context.accumulator.pendingNetworkCalls.length = 0; + context.accumulator.endpoints.sort( + (left, right) => left.offset - right.offset, + ); +}; + +const addPartialNetworkEndpoint = ( + context: FindingContext, + node: JavaScriptCallLike, + name: string, + target: t.Node, +): void => + addEndpoint(context, { + node, + kind: "network", + // Partial values preserve the original expression text, byte for byte. + value: sourceSlice(context.source, target), + mechanism: `call:${name}`, + resolution: "partial", + }); + +const resolvedRequestTarget = ( + node: t.Node, + site: t.Node, + bindings: JavaScriptSemanticAnalysisState, + index: JavaScriptBindingIndex, +): string | undefined => { + const reference = requestReferenceNode(node); + // A toString result is selected before the enclosing request invocation. + const expression = unwrapJavaScriptExpression(node).node; + const capture = reference === expression ? site : expression; + if (referenceMayHaveChangedAtSite(reference, capture, index)) + return undefined; + if (!requestInitializerPrecedesSite(reference, capture, bindings, index)) + return undefined; + const evaluated = evaluateSemanticExpression(node, bindings); + if (evaluated.status === "literal" && typeof evaluated.value === "string") + return evaluated.value; + return urlObjectRequestTarget(node, capture, bindings, index); +}; + +const requestInitializerPrecedesSite = ( + reference: t.Node, + site: t.Node, + bindings: JavaScriptSemanticAnalysisState, + index: JavaScriptBindingIndex, +): boolean => { + const effects = index.effectsForReferenceAt(site, reference); + if (effects.bindingId === null) return false; + const binding = bindings.bindingsById.get(effects.bindingId); + const initializer = semanticStableInitializer(binding, bindings); + return ( + initializer !== undefined && + semanticTopLevelEffectFollowsCapture( + initializer.node, + site, + bindings.parentsByNode, + ) + ); +}; + +const referenceMayHaveChangedAtSite = ( + reference: t.Node, + site: t.Node, + index: JavaScriptBindingIndex, +): boolean => { + const effects = index.effectsForReferenceAt(site, reference); + return ( + !effects.memberEffectsIndexed || + effects.resolution !== "resolved" || + effects.effects.some(({ timing }) => timing !== "after") + ); +}; + +const requestReferenceNode = (node: t.Node): t.Node => { + const target = unwrapJavaScriptExpression(node).node; + if ( + (t.isCallExpression(target) || t.isOptionalCallExpression(target)) && + target.arguments.length === 0 + ) { + const callee = unwrapJavaScriptExpression(target.callee).node; + if ( + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + semanticStaticPropertyName(callee.property, callee.computed) === + "toString" + ) + return unwrapJavaScriptExpression(callee.object).node; + } + return target; +}; + +/** Resolve an immutable URL object or alias only while shared effects say it is unchanged. */ +const urlObjectRequestTarget = ( + node: t.Node, + site: t.Node, + bindings: JavaScriptSemanticAnalysisState, + index: JavaScriptBindingIndex, + seen = new Set(), +): string | undefined => { + let target = unwrapJavaScriptExpression(node).node; + if ( + (t.isCallExpression(target) || t.isOptionalCallExpression(target)) && + target.arguments.length === 0 + ) { + const callee = unwrapJavaScriptExpression(target.callee).node; + if ( + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + semanticStaticPropertyName(callee.property, callee.computed) === + "toString" + ) + target = unwrapJavaScriptExpression(callee.object).node; + } + if ( + !t.isIdentifier(target) || + referenceMayHaveChangedAtSite(target, site, index) + ) + return undefined; + const effects = index.effectsForReferenceAt(site, target); + const binding = + effects.bindingId === null + ? undefined + : bindings.bindingsById.get(effects.bindingId); + if (binding === undefined || seen.has(binding.bindingId)) return undefined; + seen.add(binding.bindingId); + const stable = semanticStableInitializer(binding, bindings); + if ( + stable === undefined || + !semanticTopLevelEffectFollowsCapture( + stable.node, + site, + bindings.parentsByNode, + ) + ) + return undefined; + const initializer = stable.node; + if (initializer === undefined) return undefined; + const initialized = unwrapJavaScriptExpression(initializer).node; + if (t.isIdentifier(initialized)) + return urlObjectRequestTarget(initialized, site, bindings, index, seen); + if (!t.isNewExpression(initialized)) return undefined; + if (!isUnmodifiedGlobalReference(initialized.callee, bindings, "URL")) + return undefined; + if ( + ["URL", "Object"].some((owner) => + ["toString", "valueOf"].some((method) => + bindings.ambientWrites.covers(["global", owner, "prototype", method]), + ), + ) + ) + return undefined; + const input = stringValue(initialized.arguments[0]); + if (input === undefined) return undefined; + if (initialized.arguments.length === 1) { + try { + return new URL(input).href; + } catch { + return undefined; + } + } + if (initialized.arguments.length !== 2) return undefined; + const base = stringValue(initialized.arguments[1]); + if (base === undefined) return undefined; + try { + return new URL(input, base).href; + } catch { + return undefined; + } +}; + /** Record tRPC procedures, tRPC link URLs and named GraphQL operations. */ export const inspectRpcNode = (node: t.Node, context: FindingContext): void => { const rpc = context.rpcScan.inspect(node); @@ -567,15 +824,21 @@ export const addReference = ( const addEndpoint = (context: FindingContext, input: EndpointInput): void => addLocatedFinding(context, { collection: context.accumulator.endpoints, - key: `endpoint\0${input.kind}\0${input.interpretation ?? "literal"}\0${input.value}`, + key: `endpoint\0${input.kind}\0${input.interpretation ?? "literal"}\0${input.resolution ?? "exact"}\0${input.value}`, node: input.node, value: { kind: input.kind, - value: sanitizeEndpointCandidate(input.value), + value: + input.resolution === "partial" + ? input.value + : sanitizeEndpointCandidate(input.value), mechanism: input.mechanism, ...(input.interpretation === undefined ? {} : { interpretation: input.interpretation }), + ...(input.resolution === undefined + ? {} + : { resolution: input.resolution }), module_key: null, location: range(input.node), }, diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 70e1eb0860..7c66eb8a00 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -42,6 +42,14 @@ export interface JavaScriptAnalysisAccumulator { electronCallPathLimitReached?: boolean; readonly references: LocatedJavaScriptFinding[]; readonly endpoints: LocatedJavaScriptFinding[]; + /** Dynamic targets held until the shared mutation index is requested on demand. */ + readonly pendingNetworkCalls: { + readonly node: + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression; + readonly name: string; + }[]; readonly storage: LocatedJavaScriptFinding[]; readonly roles: LocatedJavaScriptFinding[]; readonly sourceMaps: JavaScriptStaticAnalysis["source_map_urls"][number][]; @@ -87,6 +95,7 @@ export interface JavaScriptEndpointInput { readonly value: string; readonly mechanism: string; readonly interpretation?: "candidate"; + readonly resolution?: "partial"; } /** Values required to append one module-attributable static finding. */ @@ -105,6 +114,7 @@ export const createJavaScriptAnalysisAccumulator = ( ): JavaScriptAnalysisAccumulator => ({ references: [], endpoints: [], + pendingNetworkCalls: [], storage: [], roles: [], sourceMaps: [], diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index f12327d557..7838d2ae34 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -47,6 +47,8 @@ export interface JavaScriptStaticEndpoint { readonly mechanism: string; /** An overloaded callee name suggests this value; its network receiver is unproven. */ readonly interpretation?: "candidate"; + /** The request target is unresolved source text rather than an exact value. */ + readonly resolution?: "partial"; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts index 0c045ca1d4..c20ee54d93 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts @@ -103,3 +103,16 @@ it("accepts candidate endpoint interpretations from static analysis", () => { ).success, ).toBe(true); }); + +it("preserves partial request targets through the strict worker schema", () => { + const facts = analyzeJavaScriptStaticSource("fetch(endpointUrl);"); + expect(facts.endpoints).toContainEqual( + expect.objectContaining({ + kind: "network", + value: "endpointUrl", + resolution: "partial", + }), + ); + const serialized = JSON.parse(JSON.stringify(facts)) as unknown; + expect(javaScriptWorkerStaticAnalysisSchema.parse(serialized)).toEqual(facts); +}); diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 867d5dc4d5..7446a609c7 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -329,6 +329,7 @@ export const javaScriptWorkerStaticAnalysisSchema = z value: z.string(), mechanism: z.string(), interpretation: z.literal("candidate").exactOptional(), + resolution: z.literal("partial").exactOptional(), }), ), storage: z.array( diff --git a/tests/acceptance/applications/javascriptPartialEndpointsCli.test.ts b/tests/acceptance/applications/javascriptPartialEndpointsCli.test.ts new file mode 100644 index 0000000000..b700747291 --- /dev/null +++ b/tests/acceptance/applications/javascriptPartialEndpointsCli.test.ts @@ -0,0 +1,58 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); + +it("reports partial JavaScript request targets through the public CLI", async () => { + const root = await createTestTempDirectory("rea-js-partial-endpoints-cli-"); + await writeFile( + join(root, "main.js"), + "fetch(target); fetch('target'); fetch(`https://${hosts['a@b']}/items/${id}`); function other(target) { fetch(target); }", + ); + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], + { cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 }, + ); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(JSON.parse(stdout)).normalized_result, + ); + const endpoints = result.graph.nodes.filter( + ({ kind }) => kind === "endpoint", + ); + const partial = endpoints.flatMap(({ observations, node_id }) => + observations + .filter(({ properties }) => properties.resolution === "partial") + .map((observation) => ({ node_id, ...observation })), + ); + + expect(partial.map(({ properties }) => properties.value).sort()).toEqual([ + "`https://${hosts['a@b']}/items/${id}`", + "target", + "target", + ]); + expect(new Set(partial.map(({ node_id }) => node_id)).size).toBe(3); + expect( + partial.find(({ properties }) => properties.value === "target")?.evidence, + ).toMatchObject({ + authority: "ast-static-analysis", + state: "observed", + limitations: [expect.stringContaining("not an exact literal")], + }); + expect( + endpoints.some(({ observations }) => + observations.some( + ({ properties }) => + properties.value === "target" && properties.resolution === undefined, + ), + ), + ).toBe(true); +}, 20_000); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts index 2976625e4e..769c96ed0e 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts @@ -74,3 +74,54 @@ it("keeps ambiguous endpoint candidates inferred through artifact graph projecti await rm(root, { recursive: true, force: true }); } }); + +it("preserves partial and exact endpoint identity through graph projection", async () => { + const root = await createTestTempDirectory( + "rea-js-endpoint-partial-identity-", + ); + await writeFile( + join(root, "app.js"), + 'fetch(target); fetch("target"); function other(target) { fetch(target); }', + ); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const analysis = analyzeJavaScriptArtifactFiles(files); + const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis); + const endpoints = graph.nodes.filter(({ kind }) => kind === "endpoint"); + const partialNodes = endpoints.filter(({ observations }) => + observations.some( + ({ properties }) => properties.resolution === "partial", + ), + ); + const partial = partialNodes[0]; + const exact = endpoints.find(({ observations }) => + observations.some( + ({ properties }) => + properties.value === "target" && properties.resolution === undefined, + ), + ); + + expect(partialNodes).toHaveLength(2); + expect(new Set(partialNodes.map(({ node_id }) => node_id)).size).toBe(2); + expect(exact).toBeDefined(); + expect(partial?.node_id).not.toBe(exact?.node_id); + expect(partial?.observations[0]).toMatchObject({ + properties: { + endpoint_kind: "network", + value: "target", + mechanism: "call:fetch", + resolution: "partial", + }, + evidence: { + authority: "ast-static-analysis", + state: "observed", + limitations: [expect.stringContaining("not an exact literal")], + }, + }); + } finally { + await reader.close(); + await rm(root, { recursive: true, force: true }); + } +});