diff --git a/docs/javascript-application-graph.md b/docs/javascript-application-graph.md index 48e17bf44..fa5fe7bfa 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 5b8de26ff..5e9c0adab 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 9fe784b66..db80dae95 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 57c3961ca..4699e08e6 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 0fa5d6543..4f6801e78 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 000000000..6eb4c78f4 --- /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 fd26b88b5..b55ad8648 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 a630ff578..e66abf22c 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 5c5b7442f..8588cbb38 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 70e1eb086..7c66eb8a0 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 f12327d55..7838d2ae3 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 0c045ca1d..c20ee54d9 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 867d5dc4d..7446a609c 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 000000000..b70074729 --- /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 2976625e4..769c96ed0 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 }); + } +});