diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 62a921b87d..4201e131b0 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -71,6 +71,14 @@ export function* collectJavaScriptIdentityWritesSteps( staticString: (node: t.Node | undefined) => string | undefined, freshObject: (node: t.Node) => boolean, retain: (node: t.Node, path: readonly (string | null)[]) => void, + observeInvocation?: ( + node: + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression + | t.TaggedTemplateExpression, + invocation: JavaScriptIntrinsicInvocation | undefined, + ) => void, ): Generator { const objectMutators = new Set([ "defineProperty", @@ -87,8 +95,19 @@ export function* collectJavaScriptIdentityWritesSteps( const keyedMutators = new Set(["set", "deleteProperty", "defineProperty"]); yield* traverseJavaScriptAstSteps(program, { enter: (node) => { - if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { - const invocation = intrinsicInvocation(node); + if ( + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) || + t.isTaggedTemplateExpression(node) + ) { + const invocation = + t.isNewExpression(node) || t.isTaggedTemplateExpression(node) + ? undefined + : intrinsicInvocation(node); + observeInvocation?.(node, invocation); + if (t.isNewExpression(node) || t.isTaggedTemplateExpression(node)) + return; if (invocation?.kind === "opaque") { for (const scope of origin(node, "opaque-call", node)) retain(node, scope); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 65bef2a9a4..b6a2377340 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -16,7 +16,12 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js"; -import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticIterationSources, + type SemanticIterationSource, +} from "./javascriptSemanticIterationSources.js"; +import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"]; @@ -42,17 +47,42 @@ type AmbientPath = readonly (string | null)[]; /** Undefined means a cyclic or conflicting projection can select any depth. */ type AmbientProjection = AmbientPath | undefined; +const joinAmbientProjection = ( + left: AmbientProjection, + right: AmbientProjection, +): AmbientProjection => + left === undefined || right === undefined || left.length !== right.length + ? undefined + : left.map((key, index) => (key === right[index] ? key : null)); + +interface AmbientReferenceFacts { + readonly stores: WeakMap< + t.Node, + Map + >; + readonly prototypeTarget: (node: t.Node) => t.Node | undefined; + readonly freshObject: (node: t.Node) => boolean; +} + /** * Follow local object identities without equating shallow copies with their - * sources. Write lookup retains every visible initializer: reassigning an alias - * later cannot erase a possible earlier mutation. Intrinsic reads stay strict. + * sources. Writes select aliases at their capture; uncertain execution order + * retains possible initializers. Intrinsic reads stay strict. */ -const createAmbientPathResolver = ( +export const createAmbientPathResolver = ( state: JavaScriptSemanticAnalysisState, possibleWrites: boolean, + facts?: AmbientReferenceFacts, ) => { const cached = new WeakMap(); - return (node: t.Node, capture?: t.Node): readonly AmbientPath[] => { + const returnedReferences = createSemanticReturnedReferences(state); + return ( + node: t.Node, + capture?: t.Node, + escapedContents = false, + projection: AmbientProjection = [], + localIdentity?: (node: t.Node, path: AmbientProjection) => void, + ): readonly AmbientPath[] => { const prior = possibleWrites ? undefined : cached.get(node); if (prior !== undefined) return prior; const result = new Map(); @@ -62,7 +92,7 @@ const createAmbientPathResolver = ( node: t.Node; suffix: AmbientProjection; readsAtSelf?: true; - }[] = [{ node, suffix: [] }]; + }[] = [{ node, suffix: projection }]; // Join projections at their producing expression, rather than enumerating // alias histories. Each key can only widen to a wildcard, and conflicting // depths widen to unknown, so diamonds and cycles have finite state. @@ -71,6 +101,15 @@ const createAmbientPathResolver = ( let canonical = canonicalAmbientPath(path); for (;;) { result.set(JSON.stringify(canonical), canonical); + if ( + possibleWrites && + canonical[0] === "global" && + canonical[2] === "prototype" && + canonical[3] === "constructor" + ) { + canonical = ["global", canonical[1] ?? null, ...canonical.slice(4)]; + continue; + } // An unknown key may select a global self reference as well as a // distinct property. Retain both possible object identities for writes. if ( @@ -114,6 +153,64 @@ const createAmbientPathResolver = ( suffix: path, ...(current.readsAtSelf && { readsAtSelf: true }), }); + const continueReturned = (callee: t.Node, path: AmbientProjection) => { + for (const source of returnedReferences(callee)) + continueWith( + source.node, + path === undefined + ? undefined + : [ + ...source.projection.map((key) => + key === null || typeof key === "object" + ? null + : String(key), + ), + ...path, + ], + ); + }; + if (!t.isIdentifier(root) && facts?.freshObject(root)) { + localIdentity?.(root, suffix); + for (const [source, path] of facts?.stores.get(root) ?? []) { + const selected = + suffix === undefined || + path === undefined || + path.every( + (key, index) => + index >= suffix.length || + key === null || + suffix[index] === null || + key === suffix[index], + ); + if ( + !selected || + (suffix !== undefined && + path !== undefined && + suffix.length < path.length && + !escapedContents) + ) + continue; + const remaining = + suffix === undefined || path === undefined + ? undefined + : suffix.slice(path.length); + pending.push({ + node: source.node, + readsAtSelf: true, + suffix: + remaining === undefined + ? undefined + : [ + ...source.projection.map((key) => + key === null || typeof key === "object" + ? null + : String(key), + ), + ...remaining, + ], + }); + } + } if ( t.isMetaProperty(root) && root.meta.name === "import" && @@ -144,8 +241,8 @@ const createAmbientPathResolver = ( const initializers = possibleWrites ? readAt === undefined ? binding.initializers - : semanticMutationInitializers(binding, readAt, state.parentsByNode) - .initializers + : semanticBindingAtRead(binding, readAt, state.parentsByNode) + .binding.initializers : stable === undefined ? [] : [stable]; @@ -165,6 +262,47 @@ const createAmbientPathResolver = ( }); continue; } + if ( + possibleWrites && + t.isNewExpression(root) && + (suffix?.[0] === "__proto__" || suffix?.[0] === "constructor") + ) { + continueWith( + root.callee, + suffix[0] === "__proto__" + ? ["prototype", ...suffix.slice(1)] + : suffix.slice(1), + ); + continue; + } + const prototypeTarget = possibleWrites + ? facts?.prototypeTarget(root) + : undefined; + if (prototypeTarget !== undefined) { + continueWith( + prototypeTarget, + suffix === undefined ? undefined : ["__proto__", ...suffix], + ); + continue; + } + if ( + possibleWrites && + (t.isCallExpression(root) || + t.isOptionalCallExpression(root) || + t.isNewExpression(root) || + t.isTaggedTemplateExpression(root) || + (escapedContents && (t.isFunction(root) || t.isClass(root)))) + ) { + continueReturned( + t.isFunction(root) || t.isClass(root) + ? root + : t.isTaggedTemplateExpression(root) + ? root.tag + : root.callee, + suffix, + ); + continue; + } if (possibleWrites && t.isConditionalExpression(root)) { continueWith(root.consequent); continueWith(root.alternate); @@ -175,15 +313,51 @@ const createAmbientPathResolver = ( continueWith(root.right); continue; } + if (possibleWrites && t.isSequenceExpression(root)) { + const value = root.expressions.at(-1); + if (value !== undefined) continueWith(value); + continue; + } + if ( + possibleWrites && + t.isAssignmentExpression(root) && + root.operator === "=" + ) { + continueWith(root.right); + continue; + } const key = suffix?.[0]; + // Container escapes reuse the finite worklist, including alias cycles. + if (escapedContents && suffix?.length === 0) { + if (t.isObjectExpression(root)) { + for (const property of root.properties) + if (t.isSpreadElement(property)) + continueWith(property.argument, []); + else if ( + t.isObjectProperty(property) && + t.isExpression(property.value) + ) + continueWith(property.value, []); + else if (t.isObjectMethod(property) && property.kind === "get") + continueReturned(property, []); + continue; + } + if (t.isArrayExpression(root)) { + for (const element of root.elements) + if (t.isSpreadElement(element)) continueWith(element.argument, []); + else if (t.isExpression(element)) continueWith(element, []); + continue; + } + } if (suffix !== undefined && key === undefined) continue; if (t.isObjectExpression(root)) { if (possibleWrites) { for (const property of root.properties) { if (t.isSpreadElement(property)) continueWith(property.argument); else if ( - t.isObjectProperty(property) && - t.isExpression(property.value) + (t.isObjectProperty(property) && + t.isExpression(property.value)) || + (t.isObjectMethod(property) && property.kind === "get") ) { const ownKey = semanticStaticPropertyKey( property.key, @@ -195,7 +369,10 @@ const createAmbientPathResolver = ( ownKey === null || ownKey === key ) - continueWith(property.value, suffix?.slice(1)); + if (t.isObjectMethod(property)) + continueReturned(property, suffix?.slice(1)); + else if (t.isExpression(property.value)) + continueWith(property.value, suffix?.slice(1)); } } } else if ( @@ -255,9 +432,24 @@ const createAmbientPathResolver = ( export function* indexJavaScriptAmbientWritesSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, + observeOpaqueInvocation?: (node: t.Node) => void, ): Generator { const ambientReads = createAmbientPathResolver(state, false); - const ambientWrites = createAmbientPathResolver(state, true); + const browserReceiverMethods = new Set(); + const storedSites = new WeakSet(); + const storedReferences: SemanticIterationSource[] = []; + const writes: { node: t.Node; path: AmbientPath }[] = []; + const targets: { node: t.Node; capture: t.Node }[] = []; + const escapes: { + node: t.Node; + target: t.Node; + browserReceiver: boolean; + projection?: AmbientProjection; + }[] = []; + const stores = new WeakMap< + t.Node, + Map + >(); const interop = createSemanticModuleInterop(state, { intrinsicPath: (node) => { const [path] = ambientReads(node); @@ -266,6 +458,26 @@ export function* indexJavaScriptAmbientWritesSteps( : undefined; }, }); + const ambientWrites = createAmbientPathResolver(state, true, { + stores, + freshObject: interop.isFreshObject, + prototypeTarget: (node) => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return undefined; + const invocation = interop.intrinsicInvocation(node); + if ( + invocation?.kind !== "known" || + invocation.path.length !== 3 || + !["Object", "Reflect"].includes(invocation.path[1] ?? "") || + invocation.path[2] !== "getPrototypeOf" + ) + return undefined; + const target = invocation.arguments?.[0]; + return t.isNode(target) && !t.isSpreadElement(target) + ? target + : undefined; + }, + }); yield* collectJavaScriptIdentityWritesSteps( program, (root, kind, writeSite) => { @@ -276,16 +488,153 @@ export function* indexJavaScriptAmbientWritesSteps( isUnshadowedGlobal(root, state, root.name) ? [["global", root.name]] : []; - return ambientWrites(root, writeSite); + if (!storedSites.has(writeSite)) { + storedSites.add(writeSite); + for (const source of semanticIterationSources( + writeSite, + state.parentsByNode.get(writeSite) ?? null, + )) { + if (source.kind === "reference-store") storedReferences.push(source); + } + } + // Resolve identities after the traversal has collected every local store. + const id = targets.push({ node: root, capture: writeSite }) - 1; + return [["pending-target", String(id)]]; }, interop.intrinsicInvocation, interop.staticString, interop.isFreshObject, (node, path) => { - state.ambientWrites.retain(path); - state.ambientWriteEffects.push({ path, node }); + writes.push({ node, path }); + }, + (node, invocation) => { + observeOpaqueInvocation?.(node); + // Escaping objects can change contents without replacing their identity. + const retainEscape = (target: t.Node, browserReceiver = false) => { + escapes.push({ target, node, browserReceiver }); + }; + for (const argument of t.isTaggedTemplateExpression(node) + ? node.quasi.expressions + : node.arguments) + if (t.isNode(argument)) + retainEscape( + t.isSpreadElement(argument) ? argument.argument : argument, + ); + const callee = unwrapJavaScriptExpression( + t.isTaggedTemplateExpression(node) ? node.tag : node.callee, + ).node; + const browserMethod = + invocation?.kind === "known" && + invocation.path.length === 2 && + ["open", "fetch"].includes(invocation.path[1] ?? "") + ? invocation.path[1] + : undefined; + if ( + !t.isNewExpression(node) && + (t.isMemberExpression(callee) || + t.isOptionalMemberExpression(callee)) && + !( + invocation?.kind === "known" && + ["Object", "Reflect"].includes(invocation.path[1] ?? "") + ) + ) { + if (browserMethod !== undefined) + browserReceiverMethods.add(browserMethod); + retainEscape(callee.object, browserMethod !== undefined); + } }, ); + const pendingStores = new Set(storedReferences); + const dependents = new WeakMap>(); + while (pendingStores.size > 0) { + const source = pendingStores.values().next().value; + if (source === undefined) break; + pendingStores.delete(source); + if (source.target === undefined) continue; + const identities = new Map(); + ambientWrites(source.target, source.mutation, false, [], (node, path) => { + const users = dependents.get(node) ?? new Set(); + users.add(source); + dependents.set(node, users); + identities.set( + node, + identities.has(node) + ? joinAmbientProjection(identities.get(node), path) + : path, + ); + }); + // Publish only completed queries; each source/root projection can only widen. + for (const [node, path] of identities) { + const entries = + stores.get(node) ?? + new Map(); + const previous = entries.get(source); + const joined = entries.has(source) + ? joinAmbientProjection(previous, path) + : path; + if ( + entries.has(source) && + (previous === undefined || + (joined !== undefined && + previous.every((key, index) => key === joined[index]))) + ) + continue; + entries.set(source, joined); + stores.set(node, entries); + for (const dependent of dependents.get(node) ?? []) + pendingStores.add(dependent); + } + yield; + } + for (const source of storedReferences) { + if (source.target === undefined) continue; + let local = false; + const ambient = ambientWrites( + source.target.object, + source.mutation, + false, + [], + () => { + local = true; + }, + ); + if (!local || ambient.length > 0) + escapes.push({ + node: source.mutation, + target: source.node, + browserReceiver: false, + projection: source.projection.map((key) => + key === null || typeof key === "object" ? null : String(key), + ), + }); + } + for (const { node, path } of writes) { + const target = + path[0] === "pending-target" ? targets[Number(path[1])] : undefined; + const paths = + target === undefined + ? [path] + : ambientWrites(target.node, target.capture).map((identity) => [ + ...identity, + ...path.slice(2), + ]); + for (const resolved of paths) { + state.ambientWrites.retain(resolved); + state.ambientWriteEffects.push({ path: resolved, node }); + } + } + for (const { node, target, browserReceiver, projection } of escapes) + for (const path of ambientWrites(target, node, true, projection)) + // Only proven browser APIs keep their global receiver from user code. + if (!(browserReceiver && path.length === 1 && path[0] === "global")) + state.ambientEscapes.retain([...path, null]); + // A function may call a browser method replaced later in source order. + if ( + [...browserReceiverMethods].some((method) => + state.ambientWrites.covers(["global", method]), + ) + ) + state.ambientEscapes.retain(["global", null]); } /** diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index bb0d424215..ad7ff2f7d0 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -1,4 +1,5 @@ import { + createSemanticOpaqueGlobalObserver, indexSemanticOpaqueEffects, semanticOpaqueBindingEffects, semanticBindingCanBeRebound, @@ -169,7 +170,13 @@ function* indexSemanticAmbientWritesSteps( state: JavaScriptSemanticAnalysisState, ): Generator { if (indexedAmbientWriteStates.has(state)) return; - yield* indexJavaScriptAmbientWritesSteps(state.program, state); + yield* indexJavaScriptAmbientWritesSteps( + state.program, + state, + createSemanticOpaqueGlobalObserver(state), + ); + if (state.opaqueEffects.globalSites.length > 0) + clearSemanticValueCaches(state); indexedAmbientWriteStates.add(state); } @@ -179,7 +186,12 @@ function* indexSemanticMemberMutationFactsSteps( ): Generator { if (indexedMemberMutationStates.has(state)) return; yield* indexSemanticAmbientWritesSteps(state); - yield* collectSemanticMemberMutationsSteps(state.program, state); + yield* collectSemanticMemberMutationsSteps( + state.program, + state, + (site, reference, path) => + referenceEffectsMayHaveChanged(state, site, reference, path), + ); indexedMemberMutationStates.add(state); } @@ -237,12 +249,12 @@ const visitSemanticReferenceEffectsAtSite = ( if (!retain("escape", node, [])) return metadata; for (const node of state.bindingWrites.get(binding.bindingId) ?? []) if (!retain("binding-write", node)) return metadata; - if (indexedMemberMutationStates.has(state)) { - for (const effect of binding.memberWrites) - if (!retain("member-write", effect.node, effect.path)) return metadata; - for (const effect of binding.escapedPaths) - if (!retain("escape", effect.node, effect.path)) return metadata; - } + // The mutation producer also queries its growing facts during convergence. + // Completion stays explicit in metadata; callers cannot infer readiness. + for (const effect of binding.memberWrites) + if (!retain("member-write", effect.node, effect.path)) return metadata; + for (const effect of binding.escapedPaths) + if (!retain("escape", effect.node, effect.path)) return metadata; } else if (lookup.kind === "ambient" || lookup.kind === "uncertain-ambient") { for (const node of state.ambientBindingWrites.get(root.name) ?? []) if (!retain("ambient-write", node)) return metadata; @@ -250,6 +262,27 @@ const visitSemanticReferenceEffectsAtSite = ( return metadata; }; +/** Query current producer facts; public consumers additionally require completion. */ +const referenceEffectsMayHaveChanged = ( + state: JavaScriptSemanticAnalysisState, + site: t.Node, + referenceNode: t.Node, + propertyPath?: JavaScriptSemanticPropertyPath, +): boolean => { + let changed = false; + const metadata = visitSemanticReferenceEffectsAtSite( + state, + site, + referenceNode, + (effect) => { + changed = effect.timing !== "after"; + return !changed; + }, + propertyPath, + ); + return metadata.resolution !== "resolved" || changed; +}; + /** Preserve the complete diagnostic contract for consumers needing every effect. */ const semanticReferenceEffectsAtSite = ( state: JavaScriptSemanticAnalysisState, @@ -477,18 +510,12 @@ export function* indexJavaScriptBindingsWithReceiversSteps( }, referenceMayHaveChangedAt(site, referenceNode, propertyPath) { if (!indexedMemberMutationStates.has(state)) return true; - let changed = false; - const metadata = visitSemanticReferenceEffectsAtSite( + return referenceEffectsMayHaveChanged( state, site, referenceNode, - (effect) => { - changed = effect.timing !== "after"; - return !changed; - }, propertyPath, ); - return metadata.resolution !== "resolved" || changed; }, *referencesSteps() { references ??= yield* collectSemanticReferencesSteps(program, state); @@ -919,10 +946,12 @@ const createState = ( return { program, opaqueEffects: { + mayReferenceGlobalEval: false, sites: new WeakSet(), sloppySites: new WeakSet(), byScope: new Map(), children: new Map(), + globalSites: [], }, staticImportResolutionMode, valueResourceLimits: new Set(), @@ -945,10 +974,19 @@ const createState = ( requiredModuleEffects: new Map(), ambientBindingWrites: new Map(), ambientWrites: new SemanticPropertyPathCoverage(), + ambientEscapes: new SemanticPropertyPathCoverage(), ambientWriteEffects: [], }; }; +const globalEvalRoots = new Set([ + "eval", + "globalThis", + "global", + "window", + "self", +]); + function* collectDefinitionsSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, @@ -962,7 +1000,10 @@ function* collectDefinitionsSteps( readonly scope: JavaScriptSemanticScopeState; readonly conditionallySkipped: boolean; }[] = []; - const opaqueCandidates: { node: t.CallExpression; scopeId: string }[] = []; + const opaqueCandidates: { + node: t.CallExpression; + scopeId: string; + }[] = []; let needsPrototypeFacts = false; const functionBodies: { readonly body: t.BlockStatement; @@ -1040,6 +1081,10 @@ function* collectDefinitionsSteps( state, }); bindInnerDeclaration(node, parent, scope, state); + // Every supported global eval origin starts with one of these decoded + // identifiers, including computed properties and destructured aliases. + if (t.isIdentifier(node) && globalEvalRoots.has(node.name)) + state.opaqueEffects.mayReferenceGlobalEval = true; if ( t.isCallExpression(node) && t.isIdentifier(unwrapJavaScriptExpression(node.callee).node, { @@ -1096,7 +1141,7 @@ function* collectDefinitionsSteps( copyParameterToBody({ identifier, body, scope, state }); } indexSemanticOpaqueEffects(opaqueCandidates, state); - if (opaqueCandidates.length > 0) clearSemanticValueCaches(state); + if (state.opaqueEffects.byScope.size > 0) clearSemanticValueCaches(state); } type PrototypeOwner = "Object" | "Array" | "String"; diff --git a/src/domain/javascript/javascriptSemanticIterationSources.ts b/src/domain/javascript/javascriptSemanticIterationSources.ts index 0521f76b8d..0fc3962b26 100644 --- a/src/domain/javascript/javascriptSemanticIterationSources.ts +++ b/src/domain/javascript/javascriptSemanticIterationSources.ts @@ -20,6 +20,7 @@ interface IterationSourceValue { export interface SemanticIterationSource extends IterationSourceValue { readonly kind: "iteration" | "reference-store"; readonly mutation: t.Node; + readonly target?: t.MemberExpression; readonly fallbackSources?: readonly IterationSourceValue[]; readonly requiredSources?: readonly IterationSourceValue[]; } @@ -40,6 +41,7 @@ const add = ( mutation: t.Node, guards: SourceGuards, kind: SemanticIterationSource["kind"] = "iteration", + target?: t.MemberExpression, ): void => { sources.push({ kind, @@ -47,6 +49,7 @@ const add = ( projection: source.projection, mutation, ...guards, + ...(target === undefined ? {} : { target }), }); }; const project = ( @@ -95,6 +98,7 @@ const visit = ( mutation, guards, "reference-store", + pattern, ); return; } diff --git a/src/domain/javascript/javascriptSemanticMapOperations.ts b/src/domain/javascript/javascriptSemanticMapOperations.ts new file mode 100644 index 0000000000..9cf0a2a4c1 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticMapOperations.ts @@ -0,0 +1,74 @@ +import * as t from "@babel/types"; + +import { + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptBindingIndex, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; + +/** Prove an unchanged Map operation; undefined requests shared member-mutation facts. */ +export const isIntrinsicMapLookup = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + bindings: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => { + const callee = unwrapJavaScriptExpression(node.callee).node; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return false; + const method = semanticStaticPropertyName(callee.property, callee.computed); + if (method !== "get" && method !== "delete") return false; + const prototypeMemberMayHaveChanged = (member: string): boolean => + ["Map", "Object"].some((owner) => { + const path = ["global", owner, "prototype", member]; + return ( + bindings.ambientWrites.covers(path) || + bindings.ambientEscapes.covers(path) + ); + }); + if (prototypeMemberMayHaveChanged(method)) return false; + let receiver = unwrapJavaScriptExpression(callee.object).node; + const seen = new Set(); + while (t.isIdentifier(receiver)) { + if ( + index !== undefined && + (index.referenceMayHaveChangedAt(node, receiver, [method]) || + index.referenceMayHaveChangedAt(node, receiver, ["__proto__"])) + ) + return false; + const binding = resolveSemanticBindingState( + bindings, + receiver, + receiver.name, + ); + if (binding === undefined || seen.has(binding.bindingId)) return false; + seen.add(binding.bindingId); + const initializer = semanticStableInitializer(binding, bindings); + if ( + initializer === undefined || + initializer.projection.length !== 0 || + !semanticBindingAtRead(binding, receiver, bindings.parentsByNode) + .hasDefiniteInitializer + ) + return false; + receiver = unwrapJavaScriptExpression(initializer.node).node; + } + if ( + !t.isNewExpression(receiver) || + !isUnmodifiedGlobalReference(receiver.callee, bindings, "Map") + ) + return false; + // Iterable construction reads and invokes the instance's set hook. A changed + // adder can install its own get/delete before the constructed value is read. + if (receiver.arguments.length > 0 && prototypeMemberMayHaveChanged("set")) + return false; + if (semanticOpaqueGlobalMayHaveChanged(callee, bindings)) return false; + return index === undefined && seen.size > 0 ? undefined : true; +}; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 1400d25258..18f3be72e6 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -1,4 +1,5 @@ import * as t from "@babel/types"; +import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; import { semanticGlobalProcessOrigin, @@ -11,6 +12,7 @@ import { import { resolveSemanticBindingState, + type JavaScriptBindingIndex, type JavaScriptSemanticBindingState, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; @@ -132,7 +134,11 @@ const localSetterReceiver = ( export function* collectSemanticMemberMutationsSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, + referenceMayHaveChangedAt: JavaScriptBindingIndex["referenceMayHaveChangedAt"], ): Generator { + const deferredMapReceivers = new Set< + t.CallExpression | t.OptionalCallExpression + >(); const parents = state.parentsByNode; const observablePaths = observableMutationPaths(state); const returnedReferences = createSemanticReturnedReferences(state); @@ -771,7 +777,11 @@ export function* collectSemanticMemberMutationsSteps( }); else markEscaped(argument, node); // Constructing a member does not pass its container as `this`. - if (!t.isNewExpression(node)) markReceiver(node.callee, node); + if (!t.isNewExpression(node)) { + const mapLookup = isIntrinsicMapLookup(node, state); + if (mapLookup === undefined) deferredMapReceivers.add(node); + else if (!mapLookup) markReceiver(node.callee, node); + } } else if (t.isTaggedTemplateExpression(node)) { markReceiver(node.tag, node); for (const expression of node.quasi.expressions) @@ -781,7 +791,11 @@ export function* collectSemanticMemberMutationsSteps( }); // A later effect can make a previously defined destructuring source uncertain. // Reconsider defaults and iterable origins until their effects are stable. - while (pendingReferences.length > 0 || iterableReferences.length > 0) { + while ( + pendingReferences.length > 0 || + iterableReferences.length > 0 || + deferredMapReceivers.size > 0 + ) { const effectsBefore = recordedEffectCount; const iterablesBefore = iterableReferences.length; const iterationBefore: boolean = arrayIterationUnknown; @@ -870,6 +884,20 @@ export function* collectSemanticMemberMutationsSteps( reference.widenedGetterResult === true, ); } + // Native Map lookup methods do not expose their receiver to user code. + // Settle genuine effects first, then replay every unproven receiver escape. + // Keep proven sites until convergence: later propagated effects can revoke them. + let receiverIndex = 0; + for (const node of deferredMapReceivers) { + if (receiverIndex++ % 64 === 0) yield; + if ( + isIntrinsicMapLookup(node, state, { referenceMayHaveChangedAt }) === + true + ) + continue; + deferredMapReceivers.delete(node); + markReceiver(node.callee, node); + } if ( recordedEffectCount === effectsBefore && iterableReferences.length === iterablesBefore && diff --git a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts index 04991e1428..3fd065f070 100644 --- a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts +++ b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; +import { createAmbientPathResolver } from "./javascriptSemanticAmbientWrites.js"; +import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; import { semanticEffectTiming, semanticBindingEffectFollowsCapture, @@ -13,14 +15,20 @@ import { /** One source owns sites and the minimal scope tree reaching them; O(AST) storage. */ export interface JavaScriptOpaqueScopeEffects { + /** Cheap source evidence collected with declarations, before alias queries. */ + mayReferenceGlobalEval: boolean; readonly sites: WeakSet; readonly sloppySites: WeakSet; readonly byScope: Map; readonly children: Map>; + readonly globalSites: (t.CallExpression | t.OptionalCallExpression)[]; } export const indexSemanticOpaqueEffects = ( - candidates: readonly { node: t.CallExpression; scopeId: string }[], + candidates: readonly { + node: t.CallExpression; + scopeId: string; + }[], state: JavaScriptSemanticAnalysisState, ): void => { for (const { node, scopeId } of candidates) { @@ -44,6 +52,44 @@ export const indexSemanticOpaqueEffects = ( } }; +/** The existing ambient invocation walk owns global effects; ordinary sources + * neither retain a second call list nor create another alias resolver. */ +export const createSemanticOpaqueGlobalObserver = ( + state: JavaScriptSemanticAnalysisState, +): ((node: t.Node) => void) | undefined => { + if (!state.opaqueEffects.mayReferenceGlobalEval) return undefined; + const ambientPaths = createAmbientPathResolver(state, true); + let invocationSite: t.Node | undefined; + const interop = createSemanticModuleInterop(state, { + intrinsicPath: (node) => { + const paths = ambientPaths(node, invocationSite); + const path = paths.find((path) => path[1] === "eval") ?? paths[0]; + if (path?.[0] !== "global" || path.some((key) => key === null)) + return undefined; + // Keep the global callable owner for generic call/apply/bind resolution. + return path[1] === "eval" + ? ["intrinsic", ...(path as readonly string[])] + : ["intrinsic", ...(path.slice(1) as readonly string[])]; + }, + }); + return (node) => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; + invocationSite = node; + const invocation = interop.intrinsicInvocation(node); + if (invocation?.path.join(".") === "intrinsic.global.eval") + state.opaqueEffects.globalSites.push(node); + }; +}; + +/** Indirect eval can change globals, but cannot rebind its caller's locals. */ +export const semanticOpaqueGlobalMayHaveChanged = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => + state.opaqueEffects.globalSites.some( + (effect) => semanticEffectTiming(read, effect, state) !== "after", + ); + /** Rebinding requires lexical visibility. Object references can remain reachable * through aliases even where the original name is shadowed or out of block scope. * Without a disjointness proof, consider their enclosing activation and closures. diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 67b27a863a..2f3eec92df 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -146,6 +146,8 @@ export interface JavaScriptSemanticAnalysisState { readonly ambientBindingWrites: Map; /** Source-local writes, tagged by global, import.meta or CJS module identity. */ readonly ambientWrites: SemanticPropertyPathCoverage; + /** Ambient object identities passed to calls whose effects are not evaluated. */ + readonly ambientEscapes: SemanticPropertyPathCoverage; /** Source sites distinguish replacement before an ambient identity capture from later writes. */ readonly ambientWriteEffects: JavaScriptSemanticMemberEffect[]; } diff --git a/src/domain/javascript/javascriptStaticAnalysis.mapEndpoints.test.ts b/src/domain/javascript/javascriptStaticAnalysis.mapEndpoints.test.ts new file mode 100644 index 0000000000..80739e52f4 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysis.mapEndpoints.test.ts @@ -0,0 +1,197 @@ +import { expect, it } from "vitest"; +import * as t from "@babel/types"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { inspectCall } from "./javascriptStaticAnalysisCalls.js"; +import { createJavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisState.js"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; + +it("does not classify a local Map lookup as a network endpoint", () => { + const analysis = analyzeJavaScriptStaticSource(` + const records = new Map([["/accounts", { name: "Alice" }]]); + console.log(records.get("/accounts")); + fetch("/network"); + `); + expect(analysis.endpoints).toMatchObject([ + { kind: "network", value: "/network", mechanism: "call:fetch" }, + ]); + expect(analysis.endpoints[0]?.interpretation).toBeUndefined(); +}); + +it.each([ + 'const records = new Map(); const alias = records; alias.get("/accounts"); records.delete("/accounts"); alias.get("/accounts")', + 'new Map().get("/accounts")', + 'new Map().delete("https://example.test/accounts")', + 'const records = new Map(); const alias = records; alias.get("/accounts")', + 'const records = new globalThis.Map(); records?.["get"]("/accounts")', + 'function inspect() { const records = new Map(); return records.get("/accounts"); }', + 'const inspect = () => { const records = new Map(); const alias = records; return alias.delete("/accounts"); }', + 'const browser = window; browser.open("about:blank"); new Map().get("/accounts")', + 'const box = {}; box.value = { name: "Alice" }; inspect(box); new Map().get("/accounts")', + 'patch(() => ({ name: "Alice" })); new Map().get("/accounts")', + 'const box = {}; box.prototype = Map.prototype; new Map().get("/accounts")', + 'const box = {}; const alias = box; alias.prototype = Map.prototype; inspect({}); new Map().get("/accounts")', + 'const box = {}; box.make = () => Map.prototype; new Map().get("/accounts")', + 'let box = {}; const first = box; box = {}; box.p = Map.prototype; patch(first); new Map().get("/accounts")', + 'const box = {}; box.a = {}; box.a.p = Map.prototype; box.safe = {}; patch(box.safe); new Map().get("/accounts")', + 'const records = new Map(); records.get = fetch; new Map().get("/accounts")', + 'const holder = { get prototype() { return Map.prototype; } }; new Map().get("/accounts")', + 'const holder = { get selected() { return {}; }, get other() { return Map.prototype; } }; holder.selected.get = fetch; new Map().get("/accounts")', + 'function Map() {} new Map().constructor.prototype.get = fetch; globalThis.Map.prototype; new globalThis.Map().get("/accounts")', + 'const factory = { make() { return {}; } }; factory.make = () => ({}); patch(factory.make()); new Map().get("/accounts")', +])("omits a proven Map operation: %s", (source) => { + expect(analyzeJavaScriptStaticSource(source).endpoints).toEqual([]); +}); + +it.each([ + 'client.get("/accounts")', + 'Object.getPrototypeOf = () => Map.prototype; Object.getPrototypeOf({}).get = fetch; new Map().get("/accounts")', + 'Reflect.getPrototypeOf = () => Map.prototype; Reflect.getPrototypeOf({}).get = fetch; new Map().get("/accounts")', + 'const records = new Map(); records.get = request; records.get("/accounts")', + 'let records = new Map(); records = client; records.get("/accounts")', + 'function inspect(Map) { new Map().get("/accounts"); }', + 'Map.prototype.get = request; const records = new Map(); records.get("/accounts")', + 'const records = new Map(); records.__proto__ = { get: fetch }; records.get("/accounts")', + 'const records = new Map(); const alias = records; alias.__proto__ = { get: fetch }; records.get("/accounts")', + 'const records = new Map(); records["__proto__"] = { delete: fetch }; records.delete("/accounts")', + 'function inspect() { records.get("/accounts"); const records = new Map(); }', + 'const alias = records; const records = new Map(); alias.get("/accounts")', + 'function inspect() { const alias = records; const records = new Map(); alias.get("/accounts"); }', + 'function inspect() { const records = new Map(); records.get = fetch; records.get("/accounts"); }', + 'patch(Map.prototype); const records = new Map(); records.get("/accounts")', + 'const prototype = Map.prototype; patch(prototype); new Map().get("/accounts")', + 'patch(Map); new Map().get("/accounts")', + 'new Patch(Map.prototype); new Map().get("/accounts")', + 'Map.prototype.patch = function () { this.get = fetch; }; Map.prototype.patch(); new Map().get("/accounts")', + 'Map.patch(); new Map().get("/accounts")', + 'globalThis.patch = function () { this.Map.prototype.get = fetch; }; globalThis.patch(); new Map().get("/accounts")', + 'window.open = function () { this.Map.prototype.get = fetch; }; window.open(); new Map().get("/accounts")', + 'function invoke() { window.open(); } window.open = patch; invoke(); new Map().get("/accounts")', + 'patch`prototype ${Map.prototype}`; new Map().get("/accounts")', + 'const box = { prototype: Map.prototype }; patch(box); new Map().get("/accounts")', + 'patch((() => Map.prototype)()); new Map().get("/accounts")', + 'const prototype = (() => Map.prototype)(); prototype.get = fetch; new Map().get("/accounts")', + 'const box = {}; box.prototype = Map.prototype; box.prototype.get = fetch; new Map().get("/accounts")', + 'const box = {}; box.p = (anything(), Map.prototype); patch(box); new Map().get("/accounts")', + 'window.prototype = Map.prototype; new Map().get("/accounts")', + 'const box = new ExternalBox(); box.prototype = Map.prototype; new Map().get("/accounts")', + 'function expose(box) { box.prototype = Map.prototype; } new Map().get("/accounts")', + 'const factory = { make() { return Map.prototype; } }; factory.make().get = fetch; new Map().get("/accounts")', + 'new Map().__proto__.get = fetch; new Map().get("/accounts")', + 'new Map().constructor.prototype.get = fetch; new Map().get("/accounts")', + 'Object.getPrototypeOf(new Map()).constructor.prototype.get = fetch; new Map().get("/accounts")', + 'const holder = { get prototype() { return Map.prototype; } }; holder.prototype.get = fetch; new Map().get("/accounts")', + 'const holder = { get ["prototype"]() { return { nested: Map.prototype }; } }; holder.prototype.nested.get = fetch; new Map().get("/accounts")', + 'const holder = { get prototype() { return Map.prototype; } }; patch(holder); new Map().get("/accounts")', + 'function store() { box.child.prototype = Map.prototype; } const box = {}; const inner = {}; box.child = inner; store(); patch(inner); new Map().get("/accounts")', + 'function store() { box.child.next.prototype = Map.prototype; } const box = {}; const middle = {}; const inner = {}; middle.next = inner; box.child = middle; store(); patch(inner); new Map().get("/accounts")', + 'const records = new Map(); Object.getPrototypeOf(records).get = fetch; new Map().get("/accounts")', + 'const getPrototype = Reflect.getPrototypeOf; getPrototype(new Map()).get = fetch; new Map().get("/accounts")', + 'patch(() => Map.prototype); new Map().get("/accounts")', + 'const factory = { make() { return {}; } }; factory.make = () => Map.prototype; patch(factory.make()); new Map().get("/accounts")', + 'function make() { return { prototype: Map.prototype }; } patch(make()); new Map().get("/accounts")', + 'const factory = { prototype() { return Map.prototype; } }; patch(factory.prototype()); new Map().get("/accounts")', + 'patch([Map.prototype]); new Map().get("/accounts")', + 'Object.setPrototypeOf(Map.prototype, custom); new Map().get("/accounts")', +])("keeps unresolved lookup semantics as a candidate: %s", (source) => { + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { kind: "network", value: "/accounts", interpretation: "candidate" }, + ]); +}); + +it.each([ + "records.delete = fetch;", + "patch(records);", + "Map.prototype.delete = fetch;", + "const alias = records; alias.__proto__ = { delete: fetch };", +])("retains genuine changes between Map operations: %s", (mutation) => { + const analysis = analyzeJavaScriptStaticSource( + `const records = new Map(); records.get('/before'); ${mutation} records.delete('/after');`, + ); + expect(analysis.endpoints).toContainEqual( + expect.objectContaining({ value: "/after", interpretation: "candidate" }), + ); +}); + +it.each(["get", "delete"])( + "does not assume an unchanged constructor adder for Map.%s", + (method) => { + const analysis = analyzeJavaScriptStaticSource(` + Map.prototype.set = function() { this.${method} = fetch; return this; }; + new Map([['x', 1]]).${method}('/accounts'); + `); + expect(analysis.endpoints).toContainEqual( + expect.objectContaining({ + value: "/accounts", + interpretation: "candidate", + }), + ); + }, +); + +it.each([ + "box.prototype = Map.prototype;", + 'const alias = box; const prototype = Map.prototype; alias["prototype"] = prototype;', + "({ value: box.prototype } = { value: Map.prototype });", + "({ value: box.prototype = Map.prototype } = {});", + "for (box.prototype of [Map.prototype]) {}", +])( + "retains prototype identities stored after container initialization: %s", + (store) => { + const source = `const box = {}; ${store} + function patch(b) { b.prototype.get = fetch; } + patch(box); new Map().get("/accounts");`; + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { kind: "network", value: "/accounts", interpretation: "candidate" }, + ]); + }, +); + +it.each([ + ['new Headers().get("content-type")', 0], + ['new URLSearchParams().delete("q")', 0], + ['new Map().get("/accounts")', 0], + ['new Map().delete("/accounts")', 0], + ['client.get("/accounts")', 0], + ['const client = createClient(); client.get("/accounts")', 0], + ['const records = new Map(); records.get("/accounts")', 1], +] as const)( + "requests mutation facts only for unresolved local receivers: %s", + (source, pending) => { + const file = parseJavaScriptSource(source); + if (file === null) throw new Error("Expected parsed source"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(file.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + const accumulator = createJavaScriptAnalysisAccumulator(source.length); + const context = { + source, + accumulator, + vitePreloadDependencyTable: null, + openReceiverFacts: index.receivers.openReceivers, + rpcScan: createJavaScriptRpcScan( + source, + index.receivers.rpcClientRoots, + index, + ), + }; + traverseJavaScriptAst(file.program, { + enter(node) { + if ( + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) + ) + inspectCall(source, node, context, index.bindings); + }, + }); + expect(accumulator.pendingNetworkCalls).toHaveLength(pending); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysis.mapOpaqueEffects.test.ts b/src/domain/javascript/javascriptStaticAnalysis.mapOpaqueEffects.test.ts new file mode 100644 index 0000000000..a7176d202a --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysis.mapOpaqueEffects.test.ts @@ -0,0 +1,70 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +it.each([ + 'eval("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'eval("Map.prototype.delete = fetch"); new Map().delete("/accounts");', + '"use strict"; eval("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'function run() { eval("Map.prototype.get = fetch"); return new Map().get("/accounts"); }', + 'function patch() { eval("Map.prototype.get = fetch"); } patch(); new Map().get("/accounts");', + 'for (const item of [1, 2]) { new Map().get("/accounts"); eval("Map.prototype.get = fetch"); }', + 'const run = eval; run("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'let run; (run = eval)("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'let run; (run = globalThis.eval)("Map.prototype.get = fetch"); new Map().get("/accounts");', + '(0, eval)("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'globalThis.eval("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'eval?.("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'let run = eval; run("Map.prototype.get = fetch"); run = () => {}; new Map().get("/accounts");', + 'let run = () => {}; run = eval; run("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'const { eval: run } = globalThis; run("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'eval.call(null, "Map.prototype.get = fetch"); new Map().get("/accounts");', + 'const run = eval.bind(null); run("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'globalThis["ev" + "al"]("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'const { ["eval"]: run } = globalThis; run("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'const root = globalThis; const run = root["eval"]; run("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'function invoke() { run("Map.prototype.get = fetch"); } const run = eval; invoke(); new Map().get("/accounts");', + 'function getRun() { return eval; } getRun()("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'Reflect.apply(eval, null, ["Map.prototype.get = fetch"]); new Map().get("/accounts");', +])( + "keeps a Map lookup after possible opaque mutation as a candidate: %s", + (source) => { + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { kind: "network", value: "/accounts", interpretation: "candidate" }, + ]); + }, +); + +it.each([ + 'new Map().get("/accounts"); eval("Map.prototype.get = fetch");', + '(new Map().get("/accounts"), eval("Map.prototype.get = fetch"));', + 'function run(eval) { eval("Map.prototype.get = fetch"); return new Map().get("/accounts"); }', + 'const eval = () => {}; eval("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'for (const item of []) { eval("Map.prototype.get = fetch"); } new Map().get("/accounts");', + 'const run = eval; new Map().get("/accounts"); run("Map.prototype.get = fetch");', + 'function inspect(eval) { const run = eval; run("Map.prototype.get = fetch"); return new Map().get("/accounts"); }', + 'let run = eval; run = () => {}; run("Map.prototype.get = fetch"); new Map().get("/accounts");', + 'eval.bind(null); new Map().get("/accounts");', +])( + "preserves a Map proof when eval cannot precede the lookup: %s", + (source) => { + expect(analyzeJavaScriptStaticSource(source).endpoints).toEqual([]); + }, +); + +it("does not grant indirect eval access to the caller's lexical bindings", () => { + const analysis = analyzeJavaScriptSemantics(` + export function inspect() { + let value = 1; + const run = eval; + run("value = 2"); + return value; + } + `); + expect(onlyCallable(analysis, "inspect").returnSites[0]?.value).toEqual({ + status: "literal", + value: 1, + }); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index 0e98a90c51..dde8b84dbe 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -63,9 +63,9 @@ it.each(expressionWrappers)( ]), ).toEqual([ ["setting", "candidate"], - ["/api/header-shaped", null], - ["https://api.example.test/config-shaped", null], - ["/api/items", null], + ["/api/header-shaped", "candidate"], + ["https://api.example.test/config-shaped", "candidate"], + ["/api/items", "candidate"], ["changed-setting", "candidate"], ["conditional-setting", "candidate"], ]); @@ -197,7 +197,7 @@ it("retains endpoint observations for exact template and TypeScript member keys" ]), ) .toEqual([ - ["network", "/api/items", "call:client.get", null], + ["network", "/api/items", "call:client.get", "candidate"], ["network", "/api/telemetry", "call:navigator.sendBeacon", null], ["network", "setting", "call:client.get", "candidate"], ]); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 04a504d6ae..3655e71bb0 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -1,4 +1,5 @@ import * as t from "@babel/types"; +import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { htmlUrlText, stripQueryAndFragment } from "../artifactPathSyntax.js"; @@ -472,6 +473,33 @@ const inspectEndpointCall = ( // 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; + if (first !== undefined && /(?:^|\.)(?:get|delete)$/u.test(name)) { + // Apply existing literal exclusions before requesting whole-source alias + // mutation facts. Inline receivers and unbound names need no such index. + if ( + endpointArgumentHelper( + name, + node.arguments, + node.callee, + context.openReceiverFacts?.get(node.start ?? -1), + ) === undefined + ) + return; + const mapLookup = isIntrinsicMapLookup(node, bindings); + if (mapLookup === true) return; + if (mapLookup === undefined) { + context.accumulator.pendingNetworkCalls.push({ node, name }); + return; + } + } + inspectLiteralEndpointCall(node, name, context); +}; + +const inspectLiteralEndpointCall = ( + node: JavaScriptCallLike, + name: string, + context: FindingContext, +): void => { const receiverFact = context.openReceiverFacts?.get(node.start ?? -1); const endpoint = endpointArgumentHelper( name, @@ -531,6 +559,11 @@ export const finishPartialNetworkEndpoints = ( index: JavaScriptBindingIndex, ): void => { for (const { node, name } of context.accumulator.pendingNetworkCalls) { + if (!isKnownNetworkRequest(node, bindings)) { + if (!isIntrinsicMapLookup(node, bindings, index)) + inspectLiteralEndpointCall(node, name, context); + continue; + } const target = argumentNode(node.arguments[0]); if (target === undefined) continue; const resolved = resolvedRequestTarget(target, node, bindings, index); diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 952b2c7d03..d1ee8b4cdb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -655,7 +655,10 @@ export const endpointArgument = ( ) return undefined; return { - kind: isRequestUrlReference(value) ? "endpoint" : "candidate", + kind: + method !== "get" && method !== "delete" && isRequestUrlReference(value) + ? "endpoint" + : "candidate", value, }; } diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index d035805544..25d06463ed 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -42,7 +42,7 @@ export interface JavaScriptAnalysisAccumulator { electronCallPathLimitReached?: boolean; readonly references: LocatedJavaScriptFinding[]; readonly endpoints: LocatedJavaScriptFinding[]; - /** Dynamic targets held until the shared mutation index is requested on demand. */ + /** Target and receiver identities awaiting the shared mutation index. */ readonly pendingNetworkCalls: { readonly node: | t.CallExpression diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index a60d6bc413..ff13fde934 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -64,6 +64,55 @@ it.each([ }, ); +it.each([ + [ + "const records = new Map(); const alias = records; alias.get('/accounts');", + false, + ], + ["new Map().delete('/accounts');", false], + [ + "const records = new Map(); records.get('/accounts'); records.delete('/accounts');", + false, + ], + [ + "const records = new Map(); records.get = fetch; records.get('/accounts');", + true, + ], + [ + "Object.getPrototypeOf = () => Map.prototype; Object.getPrototypeOf({}).get = fetch; new Map().get('/accounts');", + true, + ], + ["client.get('/accounts');", true], + ["eval('Map.prototype.get = fetch'); new Map().get('/accounts');", true], +] as const)( + "shares Map receiver certainty with application analysis: %s", + (source, candidate) => { + const input = source + " fetch('/network');"; + const result = analyzeCapturedWebBundle(inspection(input)); + expect(result.observations.endpoints.map(({ value }) => value)).toEqual([ + "/network", + ]); + expect( + result.inferences + .filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value), + ).toEqual(candidate ? ["/accounts"] : []); + const staticResult = analyzeJavaScriptStaticSource(input); + expect( + staticResult.endpoints + .map(({ value, interpretation }) => [value, interpretation ?? null]) + .sort(), + ).toEqual( + candidate + ? [ + ["/accounts", "candidate"], + ["/network", null], + ] + : [["/network", null]], + ); + }, +); + describe("web bundle analyzer", () => { it("extracts graph, route, endpoint, vendor, and WebMCP evidence", () => { const source = ` @@ -385,20 +434,20 @@ describe("web bundle API classification parity", () => { ); expect( result.observations.endpoints.map(({ value }) => value).sort(), - ).toEqual([ - "../search", - "/search", - "/users", - "/v1/headers", - "https://api.example.test/headers", - "https://api.example.test/users", - ]); + ).toEqual(["/search", "/v1/headers"]); expect( result.inferences .filter(({ kind }) => kind === "endpoint_candidate") .map(({ value }) => value) .sort(), - ).toEqual(["cache-key", "users"]); + ).toEqual([ + "../search", + "/users", + "cache-key", + "https://api.example.test/headers", + "https://api.example.test/users", + "users", + ]); }); it("keeps undici header-store reads out of endpoint observations", () => { @@ -410,15 +459,12 @@ describe("web bundle API classification parity", () => { api.get("/api/users"); `), ); - expect(result.observations.endpoints.map(({ value }) => value)).toEqual([ - "/header-list", - "/api/users", - ]); + expect(result.observations.endpoints).toEqual([]); expect( result.inferences .filter(({ kind }) => kind === "endpoint_candidate") .map(({ value }) => value), - ).toEqual([]); + ).toEqual(["/header-list", "/api/users"]); }); }); @@ -495,13 +541,17 @@ describe("web bundle explicit request reference forms", () => { fs.open(path, "r"); `; const expected = [ - ...references, "?view=1", "#view=1", "#literal=1", "https:api.example.test/xhr", ].sort(); - const expectedCandidates = [...ambiguous, "POST", "X"].sort(); + const expectedCandidates = [ + ...references, + ...ambiguous, + "POST", + "X", + ].sort(); const web = analyzeCapturedWebBundle(inspection(source)); expect(web.observations.endpoints.map(({ value }) => value).sort()).toEqual( expected, @@ -545,11 +595,12 @@ describe("web bundle endpoint candidate classification", () => { const result = analyzeCapturedWebBundle(inspection(source)); expect( result.observations.endpoints.map(({ value }) => value).sort(), - ).toEqual(["/api/users", "login", "ping"]); + ).toEqual(["login", "ping"]); const candidates = result.inferences.filter( ({ kind }) => kind === "endpoint_candidate", ); expect(candidates.map(({ value }) => value).sort()).toEqual([ + "/api/users", "ADDRESS_ADDED", "POST", "X", @@ -684,15 +735,13 @@ describe("web bundle configuration and navigation parity", () => { "users", ]; const web = analyzeCapturedWebBundle(inspection(source)); - expect(web.observations.endpoints.map(({ value }) => value)).toEqual([ - "/v1/settings", - ]); + expect(web.observations.endpoints).toEqual([]); expect( web.inferences .filter(({ kind }) => kind === "endpoint_candidate") .map(({ value }) => value) .sort(), - ).toEqual(expected.slice(1)); + ).toEqual(expected); expect( analyzeJavaScriptStaticSource(source) .endpoints.map(({ value }) => value) @@ -735,8 +784,8 @@ describe("web bundle configuration and navigation parity", () => { }; }; const proven = { - endpoints: ["/v1/state"], - candidates: ["token", "users"], + endpoints: [], + candidates: ["/v1/state", "token", "users"], }; expect(analyze(`const vscode = require("vscode");${reads}`)).toEqual({ web: proven, @@ -747,8 +796,9 @@ describe("web bundle configuration and navigation parity", () => { application: proven, }); const unproven = { - endpoints: ["/v1/state"], + endpoints: [], candidates: [ + "/v1/state", "closedRepositories", "lastVersion", "onboarded", diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index a54eac2997..87e1022d12 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -8,6 +8,7 @@ import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal. import { indexJavaScriptBindingsWithReceiversSteps } from "./javascript/javascriptSemanticAnalysis.js"; import { completeSemanticSteps } from "./javascript/javascriptSemanticTraversal.js"; import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js"; +import { isIntrinsicMapLookup } from "./javascript/javascriptSemanticMapOperations.js"; import { semanticStaticPropertyKey, semanticStaticPropertyName, @@ -69,8 +70,21 @@ export const analyzeScript = ( const index = completeSemanticSteps( indexJavaScriptBindingsWithReceiversSteps(file.program), ); + completeSemanticSteps(index.indexAmbientWritesSteps()); const { receivers } = index; const openReceiverFacts = receivers.openReceivers; + let mutationFactsIndexed = false; + const isMapLookup = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + ): boolean => { + const proof = isIntrinsicMapLookup(node, index.bindings); + if (proof !== undefined) return proof; + if (!mutationFactsIndexed) { + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + mutationFactsIndexed = true; + } + return isIntrinsicMapLookup(node, index.bindings, index) === true; + }; const rpc = createJavaScriptRpcScan( script.source.artifact.text, receivers.rpcClientRoots, @@ -79,7 +93,7 @@ export const analyzeScript = ( traverseJavaScriptAst(file, { enter: (node) => { accumulator.visitedNodes += 1; - inspectNode(script, node, accumulator, openReceiverFacts); + inspectNode(script, node, accumulator, openReceiverFacts, isMapLookup); const endpoint = rpc.inspect(node); if (endpoint !== undefined) addFinding({ @@ -109,6 +123,9 @@ const inspectNode = ( node: t.Node, accumulator: AnalysisAccumulator, openReceiverFacts: ReadonlyMap, + isMapLookup: ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + ) => boolean, ): void => { if ( (t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) && @@ -146,7 +163,7 @@ const inspectNode = ( t.isOptionalCallExpression(node) || t.isNewExpression(node) ) - inspectCall(script, node, accumulator, openReceiverFacts); + inspectCall(script, node, accumulator, openReceiverFacts, isMapLookup); }; const inspectCall = ( @@ -154,6 +171,9 @@ const inspectCall = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, accumulator: AnalysisAccumulator, openReceiverFacts: ReadonlyMap, + isMapLookup: ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + ) => boolean, ): void => { const name = calleeName(node.callee); const first = stringValue(node.arguments[0]); @@ -198,13 +218,21 @@ const inspectCall = ( node.callee, openReceiverFacts.get(node.start ?? -1), ); - if (endpoint?.kind === "candidate") - addEndpointCandidate(script, node, name, endpoint.value, accumulator); - else if (endpoint !== undefined) + const networkEndpoint = + endpoint !== undefined && !isMapLookup(node) ? endpoint : undefined; + if (networkEndpoint?.kind === "candidate") + addEndpointCandidate( + script, + node, + name, + networkEndpoint.value, + accumulator, + ); + else if (networkEndpoint !== undefined) addFinding({ collection: accumulator.endpoints, script, - rawValue: endpoint.value, + rawValue: networkEndpoint.value, mechanism: `call:${name}`, node, accumulator,