diff --git a/src/map/entries.ts b/src/map/entries.ts index a9778c8d..56568d06 100644 --- a/src/map/entries.ts +++ b/src/map/entries.ts @@ -1,7 +1,8 @@ import type { Endpoint, Sink, TsModule } from './types.js'; import { hasExport, isFnLike, methodFromObjectArg, spanOf, unwindChain } from './ast.js'; import type { Bindings } from './bindings.js'; -import { functionNameFromPath, isRoutePath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js'; +import { functionNameFromPath, isPagesApiFile, isRoutePath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js'; +import { declarationOf } from './scope.js'; import { withCoordinates } from './coordinates.js'; import { inputsFromHandler, inputsFromValidator } from './inputs.js'; import { sinksFrom, type LocalSinks, type SinkContext } from './sinks.js'; @@ -14,6 +15,7 @@ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', ' export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, bindings: Bindings, ctx: SinkContext): Omit[] { const out: Omit[] = []; const isServerActionsFile = fileHasUseServer(sf, ts); + const pagesApi = isPagesApiFile(ctx.owner); const visit = (node: any) => { if (ts.isVariableStatement(node) && hasExport(node, ts)) { @@ -62,6 +64,15 @@ export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, b } } + // (2d) Next.js Pages Router: the default export of a `pages/api/**` file handles that route, whether + // declared in place or exported by name (`export default handler`). + if (pagesApi) { + const handler = defaultExportedHandler(node, ts); + if (handler) { + out.push(handlerEntry(handler.name ?? 'default', 'default-export', handler.fn.parameters, handler.fn.body, ts, localSinks, bindings, ctx, spanOf(handler.fn))); + } + } + // (2c) Deno / WinterCG function entry: `Deno.serve(handler)` or `serve(handler)` — Supabase Edge // Functions, Base44 backend functions, Deno workers. These platforms have no router and no route // file: one handler per module, invoked by the function's NAME, so the endpoint's identity comes @@ -117,6 +128,21 @@ export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, b return out; } +/** The function a module's default export names, when it is one: `export default function`, `export default fn`. */ +function defaultExportedHandler(node: any, ts: TsModule): { name?: string; fn: any } | undefined { + const isDefault = (n: any) => Boolean(n.modifiers?.some((m: any) => m.kind === ts.SyntaxKind.DefaultKeyword)); + if (ts.isFunctionDeclaration(node) && hasExport(node, ts) && isDefault(node) && node.body) return { name: node.name?.text, fn: node }; + if (!ts.isExportAssignment(node) || node.isExportEquals) return undefined; + const target = node.expression; + if (isFnLike(target, ts)) return { fn: target }; + if (!ts.isIdentifier(target)) return undefined; + const declaration = declarationOf(target, ts); + const owner = declaration?.parent; + if (owner && ts.isFunctionDeclaration(owner) && owner.body) return { name: target.text, fn: owner }; + if (owner && ts.isVariableDeclaration(owner) && owner.initializer && isFnLike(owner.initializer, ts)) return { name: target.text, fn: owner.initializer }; + return undefined; +} + // Next server actions: a `'use server'` directive at the top of a module (whole file) or a function body. function fileHasUseServer(sf: any, ts: TsModule): boolean { const first = sf.statements?.[0]; @@ -148,7 +174,7 @@ function handlerEntry( : 'route-handler'; // A server action receives its payload as the first argument; a route handler receives a Request. const payloadStyle = kindLabel === 'server-action'; - const inputs = inputsFromHandler(params, body, ts, bindings, { + const { inputs, schemaUnresolved } = inputsFromHandler(params, body, ts, bindings, { payloadParam: payloadStyle, validatorSource: payloadStyle ? 'server-fn-data' : 'json-body', }); @@ -164,5 +190,7 @@ function handlerEntry( inputs, sinks, ...linkedFlows(body, params, inputs, sinks, ts, handlerInvocations(body, ts, bindings, ctx)), + // The handler validates its request with a schema declared in another module: its fields are unknown. + ...(schemaUnresolved ? { inputsResolved: false as const } : {}), }; } diff --git a/src/map/extract.ts b/src/map/extract.ts index c9414db1..3da7fabe 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -6,7 +6,7 @@ import { guessScriptKind } from './ast.js'; import { buildModuleBindings } from './bindings.js'; import { collectSources, detectDeploymentShapes, detectFramework, hasEntrySignal, type WalkStats } from './sources.js'; import { classifyServerSurface, surfaceNote } from './surface.js'; -import { functionNameFromPath, routeFromFilePath } from './routes.js'; +import { functionNameFromPath, isPagesApiFile, routeFromFilePath } from './routes.js'; import { collectLocalSinks } from './sinks.js'; import { createModuleGraph } from './module-graph.js'; import { isProvenFlow } from './coordinates.js'; @@ -65,7 +65,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac // Imports are collected from EVERY file, entry point or not: the data layer of an AI-built app // usually lives in a file with no handler in it, so a pre-filtered file is exactly where the // interesting dependency is imported. - if (!hasEntrySignal(text)) { + if (!hasEntrySignal(text) && !isPagesApiFile(relFile)) { preFiltered++; const scanned = scanFileImports(text, ts); if (scanned === null) importScanFailures++; // this file's imports are unknown, not empty diff --git a/src/map/flows.ts b/src/map/flows.ts index d3f70298..42a02d73 100644 --- a/src/map/flows.ts +++ b/src/map/flows.ts @@ -1,6 +1,6 @@ import type { AddressSpace, ApiInvocation, ArgumentRole, DependencyInputFlow, Flow, InputField, Limitation, Sink, TsModule } from './types.js'; import { bindingKey, calleeName, isValueRead, lineOf, rootIdentifierNode } from './ast.js'; -import { declarationOf } from './scope.js'; +import { declarationOf, isGlobal } from './scope.js'; import { REQ_SOURCES } from './inputs.js'; import { addressSpaceOf } from './coordinates.js'; import { argumentRoleOf, CANDIDATE_FAMILIES } from './sinks.js'; @@ -29,6 +29,11 @@ interface Root { * of reachability but never of an exact, untransformed value. */ reassigned?: boolean; + /** + * The binding is a URL of the request (`new URL(request.url)`, `request.nextUrl`, an event's `url`). + * Its members are not request fields: only its `searchParams` read the query string. + */ + url?: boolean; } /** @@ -162,9 +167,9 @@ function linkFlows( const rootPath = new Roots(ts); // A binding assigned again after its declaration may no longer hold what it was declared with. const reassigned = reassignedDeclarations(bodyNode, ts); - const addRoot = (declaration: any, path: string, space?: AddressSpace, accessor = false, request = false, inherited = false) => { + const addRoot = (declaration: any, path: string, space?: AddressSpace, accessor = false, request = false, inherited = false, url = false) => { const changed = inherited || reassigned.has(declaration); - rootPath.add(declaration, { path, space, ...(accessor ? { accessor } : {}), ...(request ? { request } : {}), ...(changed ? { reassigned: true } : {}) }); + rootPath.add(declaration, { path, space, ...(accessor ? { accessor } : {}), ...(request ? { request } : {}), ...(changed ? { reassigned: true } : {}), ...(url ? { url } : {}) }); }; for (const [index, p] of (params ?? []).entries()) { if (!p?.name) continue; @@ -174,6 +179,9 @@ function linkFlows( for (const el of p.name.elements) { if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; const key = bindingKey(el, ts); + // A request event (SvelteKit, Astro) carries the request and its URL: `({ request, url })`. + if (index === 0 && key === 'request') { addRoot(el.name, '', undefined, false, true); continue; } + if (index === 0 && key === 'url') { addRoot(el.name, '', undefined, false, false, false, true); continue; } // A destructured request source (`{ body }`) is a container: its members ARE the paths. const container = key !== undefined && CONTAINER_KEYS.has(key); addRoot(el.name, container ? '' : key ?? el.name.text, container ? spaceOfKey(key) : undefined, container && ACCESSOR_NAMESPACES.has(key!)); @@ -188,14 +196,19 @@ function linkFlows( let cur = init; while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; if (!cur) return undefined; + const params = searchParamsOrigin(cur, ts, rootPath); + if (params) return { path: '', space: 'get', accessor: true, ...carried(params) }; + const url = requestUrlOrigin(cur, ts, rootPath); + if (url) return { path: '', url: true, ...carried(url) }; if (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { const m = cur.expression.name.text; if (['json', 'formData', 'text'].includes(m)) { const root = rootIdentifierNode(cur.expression.expression, ts); + const owner = root ? rootPath.get(root) : undefined; // A body read: whatever the field names turn out to be, they are addressed in `post`. - if (!root || !rootPath.get(root)) return undefined; + if (!owner) return undefined; - return m === 'formData' ? { path: '', space: 'post', accessor: true } : { path: '', space: 'post' }; + return m === 'formData' ? { path: '', space: 'post', accessor: true, ...carried(owner) } : { path: '', space: 'post', ...carried(owner) }; } if (['parse', 'safeParse', 'validate', 'cast'].includes(m)) { for (const a of cur.arguments) { @@ -212,11 +225,16 @@ function linkFlows( if (ts.isVariableDeclaration(n) && n.initializer) { const base = requestReadPath(n.initializer); if (base !== undefined) { - if (ts.isIdentifier(n.name)) addRoot(n.name, base.path, base.space, base.accessor === true, false, base.reassigned === true); + if (ts.isIdentifier(n.name)) addRoot(n.name, base.path, base.space, base.accessor === true, base.request === true, base.reassigned === true, base.url === true); else if (ts.isObjectBindingPattern(n.name)) { for (const el of n.name.elements) { if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; const key = bindingKey(el, ts); + // Off a URL of the request, only `searchParams` is request data. + if (base.url === true) { + if (key === 'searchParams') addRoot(el.name, '', 'get', true, false, base.reassigned === true); + continue; + } // A request namespace (`query`, `headers`, …) only when destructured from the request itself: // off a parsed body the same key is an ordinary field, and its members are paths under it. const namespace = base.request === true && key !== undefined && REQ_SOURCES.includes(key); @@ -572,10 +590,74 @@ function accessorRead(node: any, ts: TsModule, rootPath: Roots): Root | undefine while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; if (!cur || !ts.isCallExpression(cur) || !ts.isPropertyAccessExpression(cur.expression)) return undefined; const [name] = cur.arguments; - if (cur.expression.name.text !== 'get' || cur.arguments.length !== 1 || !name || !ts.isStringLiteralLike(name)) return undefined; - if (!isAccessor(cur.expression.expression, ts, rootPath)) return undefined; + if (cur.arguments.length !== 1 || !name || !ts.isStringLiteralLike(name)) return undefined; + const method = cur.expression.name.text; + const receiver = cur.expression.expression; + // Hono: `c.req.query('q')`, `c.req.param('id')`, `c.req.header('x-token')` on the handler's context. + const hono = HONO_ACCESSOR_SPACES[method]; + if (hono && ts.isPropertyAccessExpression(receiver) && receiver.name.text === 'req' && ts.isIdentifier(receiver.expression)) { + const context = rootPath.get(receiver.expression); + if (context?.request === true) return { path: normalizePath(name.text), space: hono, ...carried(context) }; + } + if (method !== 'get') return undefined; + // `new URL(request.url).searchParams.get('q')`: the query-string field `q`. + const params = ts.isIdentifier(receiver) ? undefined : searchParamsOrigin(receiver, ts, rootPath); + if (params) return { path: normalizePath(name.text), space: 'get', ...carried(params) }; + if (!isAccessor(receiver, ts, rootPath)) return undefined; - return pathFromTainted(cur.expression.expression, ts, rootPath, [name.text]); + return pathFromTainted(receiver, ts, rootPath, [name.text]); +} + +/** Hono request accessors and the address space each one reads. */ +const HONO_ACCESSOR_SPACES: Record = { query: 'get', param: 'route-param', header: 'server' }; + +/** The reassignment marker of the binding a read derives from, to carry onto the read. */ +function carried(origin: Root): { reassigned?: true } { + return origin.reassigned === true ? { reassigned: true } : {}; +} + +/** + * When `node` is a URL of the request — `new URL(request.url)` (also on a Hono context's `req`), + * `request.nextUrl`, or a binding that holds one — the binding it derives from. `URL` must be the + * global constructor: a local or imported `URL` can return anything. + */ +function requestUrlOrigin(node: any, ts: TsModule, rootPath: Roots): Root | undefined { + let cur = node; + while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAwaitExpression(cur) || ts.isAsExpression(cur))) cur = cur.expression; + if (!cur) return undefined; + if (ts.isIdentifier(cur)) { + const root = rootPath.get(cur); + return root?.url === true ? root : undefined; + } + const request = (e: any): Root | undefined => { + let inner = e; + while (inner && (ts.isParenthesizedExpression(inner) || ts.isNonNullExpression(inner) || ts.isAsExpression(inner))) inner = inner.expression; + if (!inner || !ts.isIdentifier(inner)) return undefined; + const root = rootPath.get(inner); + return root?.request === true ? root : undefined; + }; + if (ts.isPropertyAccessExpression(cur) && cur.name.text === 'nextUrl') return request(cur.expression); + if (ts.isNewExpression(cur) && isGlobal(cur.expression, 'URL', ts)) { + const [href] = cur.arguments ?? []; + if (!href || !ts.isPropertyAccessExpression(href) || href.name.text !== 'url') return undefined; + const owner = href.expression; + if (ts.isPropertyAccessExpression(owner) && owner.name.text === 'req') return request(owner.expression) ?? request(owner); + return request(owner); + } + return undefined; +} + +/** When `node` is the `searchParams` of a request URL, or a binding that holds them, the binding they derive from. */ +function searchParamsOrigin(node: any, ts: TsModule, rootPath: Roots): Root | undefined { + let cur = node; + while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAsExpression(cur))) cur = cur.expression; + if (!cur) return undefined; + if (ts.isIdentifier(cur)) { + const root = rootPath.get(cur); + return root?.accessor === true && root.space === 'get' ? root : undefined; + } + if (!ts.isPropertyAccessExpression(cur) || cur.name.text !== 'searchParams') return undefined; + return requestUrlOrigin(cur.expression, ts, rootPath); } /** @@ -614,7 +696,7 @@ function pathFromTainted(node: any, ts: TsModule, rootPath: Roots, trailing: str } if (!cur || !ts.isIdentifier(cur)) return undefined; const base = rootPath.get(cur); - if (base === undefined) return undefined; + if (base === undefined || base.url === true) return undefined; segs.push(...trailing); let space = base.space; // Drop a leading NAMESPACE segment (`req.body.webhookUrl` → `webhookUrl`). Input names — and the @@ -624,14 +706,19 @@ function pathFromTainted(node: any, ts: TsModule, rootPath: Roots, trailing: str // proven. // The dropped segment is exactly what names the address space, so capture it before discarding it — // losing it is what made `req.query.id` and `req.body.id` the same read. - if (base.request === true && base.path === '' && segs.length > 1 && REQ_SOURCES.includes(segs[0]!)) { - space = spaceOfKey(segs[0]!) ?? space; + // A bare namespace (`const b = req.body`) is that namespace's container: its members are the fields. + let namespace: string | undefined; + if (base.request === true && base.path === '' && segs.length > 0 && REQ_SOURCES.includes(segs[0]!)) { + namespace = segs[0]!; + space = spaceOfKey(namespace) ?? space; segs.shift(); } // Read bare, the request is still the request — which is what lets `const { headers } = request` // destructure a namespace, and `const { headers } = await request.json()` not. - const request = base.request === true && segs.length === 0 ? { request: true } : {}; + const request = base.request === true && namespace === undefined && segs.length === 0 ? { request: true } : {}; + // `const headers = request.headers` holds an accessor, so `headers.get('x')` reads the header `x`. + const accessor = namespace !== undefined && segs.length === 0 && ACCESSOR_NAMESPACES.has(namespace) ? { accessor: true } : {}; const reassigned = base.reassigned === true ? { reassigned: true } : {}; - return { path: normalizePath([base.path, ...segs].filter(Boolean).join('.')), space, ...request, ...reassigned }; + return { path: normalizePath([base.path, ...segs].filter(Boolean).join('.')), space, ...request, ...accessor, ...reassigned }; } diff --git a/src/map/inputs.ts b/src/map/inputs.ts index d497553d..db2721d9 100644 --- a/src/map/inputs.ts +++ b/src/map/inputs.ts @@ -1,6 +1,6 @@ import type { FieldShape, InputField, InputSource, TsModule } from './types.js'; -import { bindingKey, rootIdentifier } from './ast.js'; -import { declarationOf } from './scope.js'; +import { bindingKey, rootIdentifier, rootIdentifierNode } from './ast.js'; +import { declarationOf, isGlobal } from './scope.js'; import { npmPackageOf, type Bindings } from './bindings.js'; import { addressSpaceOf, inputIdOf, runtimeCoordinate } from './coordinates.js'; @@ -24,11 +24,14 @@ export function inputsFromHandler( ts: TsModule, bindings: Bindings, opts: { payloadParam?: boolean; validatorSource?: InputSource } = {}, -): InputField[] { +): { inputs: InputField[]; schemaUnresolved: boolean } { // A validated schema inside a handler describes the request body — except for a payload-style entry // (a server action), where the schema describes the action's own argument. const schemaSource = opts.validatorSource ?? 'json-body'; - const schemaFields = zodObjectFields(body, ts, bindings); + const inline = zodObjectFields(body, ts, bindings); + // A schema declared outside the handler and applied to the request (`Schema.parse(await req.json())`). + const referenced = inline.length > 0 ? { fields: [], unresolved: false } : referencedSchemaFields(params, body, ts, bindings); + const schemaFields = inline.length > 0 ? inline : referenced.fields; const reads = requestMemberAccesses(params, body, ts, opts); // Keyed by IDENTITY — `:` — not by field name. A handler that reads `query.id` and @@ -49,7 +52,67 @@ export function inputsFromHandler( put(name, source ?? schemaSource, shape); } for (const { name, sources } of reads) for (const source of sources) put(name, source); - return [...byId.values()]; + return { inputs: [...byId.values()], schemaUnresolved: referenced.unresolved }; +} + +const SCHEMA_METHODS = new Set(['parse', 'safeParse', 'parseAsync', 'safeParseAsync']); + +/** + * Fields of a schema the handler applies to its request but declares elsewhere: `Schema.parse(x)` where + * `x` derives from a handler parameter. A schema declared in this file is read; one imported from + * another module cannot be, and is reported as `unresolved` so its fields count as unknown, not absent. + */ +function referencedSchemaFields(params: any, body: any, ts: TsModule, bindings: Bindings): { fields: FieldShape[]; unresolved: boolean } { + if (!body) return { fields: [], unresolved: false }; + const paramDecls = new Set(); + for (const p of params ?? []) { + if (!p?.name) continue; + if (ts.isIdentifier(p.name)) paramDecls.add(p.name); + else if (ts.isObjectBindingPattern(p.name)) for (const el of p.name.elements) if (ts.isIdentifier(el.name)) paramDecls.add(el.name); + } + // Rooted at a handler parameter, directly or through one local (`const raw = await req.json()`). + const fromParams = (e: any): boolean => { + const root = rootIdentifierNode(e, ts); + const declaration = root ? declarationOf(root, ts) : undefined; + if (declaration === undefined) return false; + if (paramDecls.has(declaration)) return true; + const owner = declaration.parent; + if (owner && ts.isVariableDeclaration(owner) && owner.name === declaration && owner.initializer) { + const inner = rootIdentifierNode(owner.initializer, ts); + const innerDeclaration = inner ? declarationOf(inner, ts) : undefined; + return innerDeclaration !== undefined && paramDecls.has(innerDeclaration); + } + return false; + }; + let fields: FieldShape[] = []; + let unresolved = false; + const visit = (n: any) => { + if (fields.length > 0) return; + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && SCHEMA_METHODS.has(n.expression.name.text) + && ts.isIdentifier(n.expression.expression) + && n.arguments.some((a: any) => fromParams(a))) { + const declaration = declarationOf(n.expression.expression, ts); + const owner = declaration?.parent; + if (owner && ts.isVariableDeclaration(owner) && owner.name === declaration && owner.initializer) { + const literal = findValidatorObject(owner.initializer, ts, bindings); + if (literal) fields = fieldsOfObject(literal, ts, bindings, ''); + else unresolved = true; + } else if (declaration !== undefined && isImported(declaration, ts)) { + unresolved = true; + } + } + ts.forEachChild(n, visit); + }; + visit(body); + return { fields, unresolved: fields.length === 0 && unresolved }; +} + +function isImported(declaration: any, ts: TsModule): boolean { + for (let cur = declaration?.parent; cur; cur = cur.parent) { + if (ts.isImportDeclaration(cur)) return true; + if (ts.isSourceFile(cur) || ts.isStatement(cur)) return false; + } + return false; } // Find the first validator `.object({...})` in a subtree — gated on the receiver tracing to a known @@ -151,9 +214,18 @@ function requestMemberAccesses( out.set(name, list); }; const p0 = params?.[0]; - // The request binding itself, so an inner function's own parameter of the same name is not the request. + // The request bindings, by declaration, so an inner function's own parameter of the same name is not the + // request: the handler's first parameter, a destructured `({ request })`, and aliases (`const r = req`). const reqDecl = p0 && ts.isIdentifier(p0.name) ? p0.name : undefined; - const isRequest = (e: any): boolean => reqDecl !== undefined && ts.isIdentifier(e) && declarationOf(e, ts) === reqDecl; + const requestDecls = new Set(reqDecl ? [reqDecl] : []); + const isRequest = (e: any): boolean => ts.isIdentifier(e) && requestDecls.has(declarationOf(e, ts)); + // A request as an object that carries the request API: the request itself, or a context's `req` (Hono). + const isRequestObject = (e: any): boolean => + isRequest(e) || (ts.isPropertyAccessExpression(e) && e.name.text === 'req' && isRequest(e.expression)); + // `new URL(request.url)`, `request.nextUrl`, a destructured `({ url })` and their aliases — the query + // string is read through their `searchParams`. + const urlDecls = new Set(); + const searchParamDecls = new Set(); // Identifiers that ARE a request-input object (destructured `({ body })` param, `await req.json()`), // mapped to the NAMESPACE each one came from. It has to be a map, not a set of names: with // `({ query: q })` the local is `q`, and matching the local against the literal 'query'/'params' @@ -167,9 +239,18 @@ function requestMemberAccesses( if (p0 && !reqDecl && ts.isObjectBindingPattern(p0.name)) { for (const el of p0.name.elements) { const key = bindingKey(el, ts); - if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) { - sourceNames.set(el.name, namespaceSource(key)); - } + if (!key || !ts.isIdentifier(el.name)) continue; + if (REQ_SOURCES.includes(key)) sourceNames.set(el.name, namespaceSource(key)); + // A request event (SvelteKit, Astro): `({ request, url })`. + else if (key === 'request') requestDecls.add(el.name); + else if (key === 'url') urlDecls.add(el.name); + } + } + // A route context after the request: `(request, { params })` (Next.js). + for (const p of (params ?? []).slice(1)) { + if (!p?.name || !ts.isObjectBindingPattern(p.name)) continue; + for (const el of p.name.elements) { + if (bindingKey(el, ts) === 'params' && ts.isIdentifier(el.name)) sourceNames.set(el.name, 'route-param'); } } const unwrap = (e: any): any => { @@ -186,13 +267,35 @@ function requestMemberAccesses( const inner = unwrap(e); return Boolean(inner && ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) && ['json', 'formData'].includes(inner.expression.name.text) && - isRequest(inner.expression.expression)); + isRequestObject(inner.expression.expression)); + }; + const isRequestUrl = (e: any): boolean => { + const cur = unwrap(e); + if (!cur) return false; + if (ts.isIdentifier(cur)) return urlDecls.has(declarationOf(cur, ts)); + if (ts.isPropertyAccessExpression(cur) && cur.name.text === 'nextUrl') return isRequest(cur.expression); + if (ts.isNewExpression(cur) && isGlobal(cur.expression, 'URL', ts)) { + const [href] = cur.arguments ?? []; + return Boolean(href && ts.isPropertyAccessExpression(href) && href.name.text === 'url' && isRequestObject(href.expression)); + } + return false; + }; + const isSearchParams = (e: any): boolean => { + const cur = unwrap(e); + if (!cur) return false; + if (ts.isIdentifier(cur)) return searchParamDecls.has(declarationOf(cur, ts)); + return ts.isPropertyAccessExpression(cur) && cur.name.text === 'searchParams' && isRequestUrl(cur.expression); + }; + const literalArgument = (call: any): string | undefined => { + const [arg] = call.arguments ?? []; + return call.arguments?.length === 1 && arg && ts.isStringLiteral(arg) ? arg.text : undefined; }; // `request.headers.get` in `request.headers.get('x')` is a METHOD of the namespace, not a field of it. // Recording it would invent an input named `get` — a coordinate no request carries. const isCallee = (n: any): boolean => Boolean(n.parent && (ts.isCallExpression(n.parent) || ts.isNewExpression(n.parent)) && n.parent.expression === n); const ACCESSOR_SOURCES = new Set(['header', 'cookie', 'form-body']); + const HONO_ACCESSORS: Record = { query: 'query', param: 'route-param', header: 'header' }; const visit = (n: any) => { // . if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression) && !isCallee(n)) { @@ -215,8 +318,37 @@ function requestMemberAccesses( && ACCESSOR_SOURCES.has(sourceOfExpr(n.expression.expression))) { record((n.arguments[0] as any).text, sourceOfExpr(n.expression.expression)); } + // `url.searchParams.get('q')` — the query string, read through a URL of the request. + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'get' + && isSearchParams(n.expression.expression)) { + const name = literalArgument(n); + if (name !== undefined) record(name, 'query'); + } + // Hono: `c.req.query('q')`, `c.req.param('id')`, `c.req.header('x-token')`. + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) + && ts.isPropertyAccessExpression(n.expression.expression) && n.expression.expression.name.text === 'req' + && isRequest(n.expression.expression.expression)) { + const source = HONO_ACCESSORS[n.expression.name.text]; + const name = literalArgument(n); + if (source && name !== undefined) record(name, source); + } if (ts.isVariableDeclaration(n) && n.initializer) { const init = unwrap(n.initializer); + if (ts.isIdentifier(n.name)) { + // const r = req → another name for the request. + if (isRequest(init)) requestDecls.add(n.name); + // const b = req.body → a namespace of the request, read under another name. + else if (ts.isPropertyAccessExpression(init) && isRequest(init.expression) && REQ_SOURCES.includes(init.name.text)) { + sourceNames.set(n.name, namespaceSource(init.name.text)); + } else if (isRequestUrl(init)) urlDecls.add(n.name); + else if (isSearchParams(init)) searchParamDecls.add(n.name); + } + // const { searchParams } = new URL(request.url) + if (ts.isObjectBindingPattern(n.name) && isRequestUrl(init)) { + for (const el of n.name.elements) { + if (bindingKey(el, ts) === 'searchParams' && ts.isIdentifier(el.name)) searchParamDecls.add(el.name); + } + } // const b = await request.json() → b is a request-input object from here on. if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name, bodyReadSource(n.initializer)); // const { query: q } = req → the SAME namespace capture as a destructured handler param, just one diff --git a/src/map/routes.ts b/src/map/routes.ts index 45fe9d30..44c3c965 100644 --- a/src/map/routes.ts +++ b/src/map/routes.ts @@ -70,6 +70,13 @@ export function routeFromFilePath(relFile: string): { route?: string; dynamic?: return { route: route.length > 1 ? route.replace(/\/+$/, '') : '/', dynamic }; } +/** Whether a file is a Next.js Pages Router API route (`pages/api/**`), whose default export is the handler. */ +export function isPagesApiFile(relFile: string): boolean { + const dirs = relFile.split(/[\\/]/).filter(Boolean).slice(0, -1); + const i = dirs.lastIndexOf('pages'); + return i !== -1 && dirs[i + 1] === 'api'; +} + /** Where a Nuxt server route's URL starts, from its directories; undefined outside `server/api` / `server/routes`. */ function nuxtRoot(dirs: string[]): string[] | undefined { const i = dirs.lastIndexOf('server'); diff --git a/src/map/scope.ts b/src/map/scope.ts index 0202f084..2e201f6b 100644 --- a/src/map/scope.ts +++ b/src/map/scope.ts @@ -29,6 +29,13 @@ export function refersTo(id: any, declaration: any, ts: TsModule): boolean { return declaration !== undefined && declarationOf(id, ts) === declaration; } +/** Whether `node` names the global `name` (`URL`, `globalThis.URL`) rather than a binding of that name. */ +export function isGlobal(node: any, name: string, ts: TsModule): boolean { + if (ts.isIdentifier(node)) return node.text === name && declarationOf(node, ts) === undefined; + return ts.isPropertyAccessExpression(node) && node.name.text === name && ts.isIdentifier(node.expression) + && node.expression.text === 'globalThis' && declarationOf(node.expression, ts) === undefined; +} + /** The identifier nodes a binding name (`x`, `{ a, b: c }`, `[d, ...e]`) declares. */ export function boundIdentifiers(name: any, ts: TsModule): any[] { if (!name) return []; diff --git a/src/map/sinks.ts b/src/map/sinks.ts index 9f1a7b6d..22315e2a 100644 --- a/src/map/sinks.ts +++ b/src/map/sinks.ts @@ -185,6 +185,17 @@ export function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: LocalSinks return dedupeSinks(sinks, ctx?.owner ?? ''); } +/** `c.req` where `c` is a function parameter: a request handler's context, not a database client. */ +function isContextRequest(node: any, ts: TsModule): boolean { + if (!ts.isPropertyAccessExpression(node) || node.name.text !== 'req' || !ts.isIdentifier(node.expression)) return false; + const declaration = declarationOf(node.expression, ts); + for (let cur = declaration?.parent; cur; cur = cur.parent) { + if (ts.isParameter(cur)) return true; + if (!ts.isBindingElement(cur) && !ts.isObjectBindingPattern(cur) && !ts.isArrayBindingPattern(cur)) return false; + } + return false; +} + /** * Whether a declaring identifier is bound at module level (an import, a top-level `require`), which is * where module bindings resolve names. A parameter or a function-local of the same name is not. @@ -306,7 +317,8 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings, ctx?: SinkCont } // db: raw `.query(` / `.execute(`. Any object can have a `.query` method, so an untraceable // receiver stays in the inventory with NO attribution — visible to a human, never auto-ruled. - if (method === 'query' || method === 'execute') { + // A handler context's request accessor (Hono's `c.req.query('q')`) reads the query string. + if ((method === 'query' || method === 'execute') && !isContextRequest(callee.expression, ts)) { const pkg = b.pkg ?? infer('db'); const attribution = attributionOf(b, pkg); push({ kind: 'db', ...dbApi(pkg, attribution), package: pkg, op: method, attribution, ...spanOf(n) }); diff --git a/tests/map/request-input-idioms.test.ts b/tests/map/request-input-idioms.test.ts new file mode 100644 index 00000000..78d26960 --- /dev/null +++ b/tests/map/request-input-idioms.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { buildInputMap } from '../../src/map/index.js'; + +/** + * Common ways a handler reads its request: through the URL's query string, through another name for the + * request or one of its namespaces, through a framework's event or context object, and through a schema + * declared outside the handler. Each must name the field it reads, in the namespace it arrives in. + */ +async function mapOf(files: Record, deps: Record) { + const dir = mkdtempSync(path.join(tmpdir(), 'ps-idioms-')); + try { + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', dependencies: deps })); + for (const [file, source] of Object.entries(files)) { + mkdirSync(path.join(dir, path.dirname(file)), { recursive: true }); + writeFileSync(path.join(dir, file), source); + } + const { map } = await buildInputMap(dir, {}); + + return map!; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const EXEC = "import { exec } from 'node:child_process';\n"; +const NEXT = { next: '15.0.0' }; +const inputs = (endpoint: any): string[] => endpoint.inputs.map((i: any) => `${i.source}:${i.name}`).sort(); +const flow = (endpoint: any, name: string) => endpoint.flows.find((f: any) => f.input === name); + +const routeHandler = async (body: string, head = '') => { + const map = await mapOf({ 'app/api/run/route.ts': `${EXEC}${head}export async function GET(request: Request) {\n${body}\n return new Response('ok');\n}\n` }, NEXT); + return map.endpoints[0]!; +}; +const expressHandler = async (body: string) => { + const map = await mapOf({ 'src/server.ts': `${EXEC}import express from 'express';\nconst app = express();\napp.post('/run', (req, res) => {\n${body}\n res.end();\n});\n` }, { express: '4' }); + return map.endpoints[0]!; +}; + +describe('the query string through a URL of the request', () => { + it.each([ + ['read in place', " exec(new URL(request.url).searchParams.get('q'));"], + ['from a URL binding', " const url = new URL(request.url);\n exec(url.searchParams.get('q'));"], + ['from destructured searchParams', " const { searchParams } = new URL(request.url);\n exec(searchParams.get('q'));"], + ['from a searchParams binding', " const params = new URL(request.url).searchParams;\n exec(params.get('q'));"], + ])('names the query field when %s', async (_label, body) => { + const endpoint = await routeHandler(body); + + expect(inputs(endpoint)).toEqual(['query:q']); + expect(flow(endpoint, 'q')).toMatchObject({ confidence: 'exact-local', ruleGeneratable: true }); + }); + + it("reads Next.js's nextUrl", async () => { + const endpoint = await routeHandler(" exec(request.nextUrl.searchParams.get('q'));", "import type { NextRequest } from 'next/server';\n"); + + expect(inputs(endpoint)).toEqual(['query:q']); + expect(flow(endpoint, 'q')?.confidence).toBe('exact-local'); + }); + + it("reads a request event's url", async () => { + const map = await mapOf({ 'src/routes/api/run/+server.ts': `${EXEC}export async function GET({ url }) {\n exec(url.searchParams.get('q'));\n return new Response('ok');\n}\n` }, { '@sveltejs/kit': '2' }); + + expect(inputs(map.endpoints[0])).toEqual(['query:q']); + expect(flow(map.endpoints[0], 'q')?.confidence).toBe('exact-local'); + }); + + it('does not read a URL built from something other than the request', async () => { + const endpoint = await routeHandler(" exec(new URL(process.env.TARGET!).searchParams.get('q'));"); + + expect(inputs(endpoint)).toEqual([]); + }); + + it('does not treat other members of the request URL as request fields', async () => { + const endpoint = await routeHandler(" const { href } = await request.json();\n const url = new URL(request.url);\n console.log(href);\n exec(url.href);"); + + expect(flow(endpoint, 'href')?.confidence).toBe('heuristic'); + }); +}); + +describe('another name for the request or one of its namespaces', () => { + it('follows an alias of the request', async () => { + const endpoint = await expressHandler(' const r = req;\n exec(r.body.cmd);'); + + expect(inputs(endpoint)).toEqual(['body:cmd']); + expect(flow(endpoint, 'cmd')?.confidence).toBe('exact-local'); + }); + + it('follows an alias of the body', async () => { + const endpoint = await expressHandler(' const b = req.body;\n exec(b.cmd);'); + + expect(inputs(endpoint)).toEqual(['body:cmd']); + expect(flow(endpoint, 'cmd')?.confidence).toBe('exact-local'); + }); + + it('keeps the namespace of an aliased query object', async () => { + const endpoint = await expressHandler(' const q = req.query;\n exec(q.id);'); + + expect(inputs(endpoint)).toEqual(['query:id']); + expect(flow(endpoint, 'id')?.confidence).toBe('exact-local'); + }); + + it('proves a field destructured from the body', async () => { + const endpoint = await expressHandler(' const { cmd } = req.body;\n exec(cmd);'); + + expect(flow(endpoint, 'cmd')?.confidence).toBe('exact-local'); + }); + + it('reads a header through an alias of the headers', async () => { + const endpoint = await routeHandler(" const headers = request.headers;\n exec(headers.get('x-cmd'));"); + + expect(inputs(endpoint)).toEqual(['header:x-cmd']); + expect(flow(endpoint, 'x-cmd')?.confidence).toBe('exact-local'); + }); + + it('does not treat an alias of the body as the request itself', async () => { + const endpoint = await expressHandler(" const b = req.body;\n console.log(req.headers['x-cmd']);\n exec(b.headers.get('x-cmd'));"); + + expect(inputs(endpoint)).toContain('header:x-cmd'); + expect(flow(endpoint, 'x-cmd')?.confidence).toBe('heuristic'); + }); + + it('does not read a parsed body field named after a namespace as that namespace', async () => { + const endpoint = await expressHandler(" const b = req.body;\n const { query } = b;\n exec(query.id);"); + + expect(inputs(endpoint)).not.toContain('query:id'); + }); +}); + +describe('framework request objects', () => { + it("reads the request from a request event's `request`", async () => { + const map = await mapOf({ 'src/routes/api/run/+server.ts': `${EXEC}export async function POST({ request }) {\n exec(request.headers.get('x-cmd'));\n return new Response('ok');\n}\n` }, { '@sveltejs/kit': '2' }); + + expect(inputs(map.endpoints[0])).toEqual(['header:x-cmd']); + expect(flow(map.endpoints[0], 'x-cmd')?.confidence).toBe('exact-local'); + }); + + it("reads route params from Next.js's route context", async () => { + const map = await mapOf({ 'app/api/run/[id]/route.ts': `${EXEC}export async function GET(request: Request, { params }: { params: { id: string } }) {\n exec(params.id);\n return new Response('ok');\n}\n` }, NEXT); + + expect(inputs(map.endpoints[0])).toEqual(['route-param:id']); + }); + + it('reads the Hono request accessors', async () => { + const map = await mapOf({ + 'src/index.ts': `${EXEC}import { Hono } from 'hono';\nconst app = new Hono();\napp.post('/run', async (c) => {\n const body = await c.req.json();\n exec(body.cmd);\n exec(c.req.query('q'));\n exec(c.req.param('id'));\n exec(c.req.header('x-cmd'));\n return c.text('ok');\n});\n`, + }, { hono: '4' }); + const endpoint = map.endpoints[0]!; + + expect(inputs(endpoint)).toEqual(['header:x-cmd', 'json-body:cmd', 'query:q', 'route-param:id']); + for (const name of ['cmd', 'q', 'id', 'x-cmd']) { + expect(endpoint.flows.filter((f: any) => f.input === name && f.confidence === 'exact-local')).toHaveLength(1); + } + }); + + it("does not inventory a Hono query accessor as a database call", async () => { + const map = await mapOf({ + 'src/index.ts': "import { Hono } from 'hono';\nconst app = new Hono();\napp.get('/run', (c) => c.text(c.req.query('q') ?? ''));\n", + }, { hono: '4' }); + + expect(map.endpoints[0]!.sinks).toEqual([]); + }); + + it('still inventories a query call on a request accessor of an imported object', async () => { + const map = await mapOf({ + 'src/server.ts': "import express from 'express';\nimport sdk from 'data-sdk';\nconst app = express();\napp.post('/run', (req, res) => { sdk.req.query(req.body.sql); res.end(); });\n", + }, { express: '4', 'data-sdk': '1' }); + + expect(map.endpoints[0]!.sinks.map((s: any) => s.kind)).toEqual(['db']); + }); + + it('still inventories a query call on a client', async () => { + const map = await mapOf({ + 'src/server.ts': "import express from 'express';\nimport { Pool } from 'pg';\nconst pool = new Pool();\nconst app = express();\napp.post('/run', (req, res) => { pool.query(req.body.sql); res.end(); });\n", + }, { express: '4', pg: '8' }); + + expect(map.endpoints[0]!.sinks.map((s: any) => s.kind)).toEqual(['db']); + }); + + it('does not read an accessor call on the request itself as a Hono accessor', async () => { + const endpoint = await expressHandler(" exec(req.param('id'));"); + + expect(inputs(endpoint)).toEqual([]); + }); +}); + +describe('Next.js Pages Router API routes', () => { + it.each([ + ['a default-exported function', 'export default function handler(req, res) {\n exec(req.body.cmd);\n res.end();\n}\n'], + ['a named handler exported as default', 'const handler = (req, res) => {\n exec(req.body.cmd);\n res.end();\n};\nexport default handler;\n'], + ['a default-exported arrow', 'export default async (req, res) => {\n exec(req.body.cmd);\n res.end();\n};\n'], + ])('maps %s under pages/api to its route', async (_label, source) => { + const map = await mapOf({ 'pages/api/run.ts': EXEC + source }, NEXT); + + expect(map.endpoints).toHaveLength(1); + expect(map.endpoints[0]).toMatchObject({ entryKind: 'route-handler', route: '/api/run' }); + expect(flow(map.endpoints[0], 'cmd')?.confidence).toBe('exact-local'); + }); + + it('does not map the default export of a page', async () => { + const map = await mapOf({ 'pages/about.tsx': 'export default function About() { return null; }\n' }, NEXT); + + expect(map.endpoints).toEqual([]); + }); +}); + +describe('a schema declared outside the handler', () => { + it('reads the fields of a schema declared in the same module', async () => { + const endpoint = await routeHandler(' const data = Schema.parse(await request.json());\n exec(data.cmd);', "import { z } from 'zod';\nconst Schema = z.object({ cmd: z.string() });\n"); + + expect(inputs(endpoint)).toEqual(['json-body:cmd']); + expect(flow(endpoint, 'cmd')?.confidence).toBe('exact-local'); + expect(endpoint.inputsResolved).toBeUndefined(); + }); + + it('reads a schema applied to a local that holds the body', async () => { + const endpoint = await routeHandler(' const raw = await request.json();\n const data = Schema.parse(raw);\n exec(data.cmd);', "import { z } from 'zod';\nconst Schema = z.object({ cmd: z.string() });\n"); + + expect(inputs(endpoint)).toEqual(['json-body:cmd']); + }); + + it('reports the inputs of a handler validated by an imported schema as unresolved', async () => { + const endpoint = await routeHandler(' const data = Schema.parse(await request.json());\n exec(data.cmd);', "import { Schema } from '../../../lib/schema';\n"); + + expect(endpoint.inputsResolved).toBe(false); + }); + + it('does not read JSON.parse as a schema', async () => { + const endpoint = await routeHandler(' const data = JSON.parse(await request.text());\n exec(data.cmd);'); + + expect(endpoint.inputsResolved).toBeUndefined(); + }); + + it('does not read a schema applied to something other than the request', async () => { + const endpoint = await routeHandler(' const data = Schema.parse(defaults);\n exec(data.cmd);', "import { Schema } from '../../../lib/schema';\nconst defaults = {};\n"); + + expect(endpoint.inputsResolved).toBeUndefined(); + }); +}); + +describe('a reassigned binding behind a recognised read', () => { + // Reachability may still hold, but the value is no longer exactly the request field. + const notExact = (f: any) => { + expect(f).toBeDefined(); + expect(f.confidence).not.toBe('exact-local'); + }; + + it.each([ + ['a URL binding', " let url = new URL(request.url);\n url = new URL('https://example.test/?q=ls');\n exec(url.searchParams.get('q'));"], + ['a searchParams binding', " let params = new URL(request.url).searchParams;\n params = new URLSearchParams('q=ls');\n exec(params.get('q'));"], + ['destructured searchParams', " let { searchParams } = new URL(request.url);\n searchParams = new URLSearchParams('q=ls');\n exec(searchParams.get('q'));"], + ['the request, read through its URL', " request = new Request('https://example.test/?q=ls');\n exec(new URL(request.url).searchParams.get('q'));"], + ['the request, read through nextUrl', " request = {} as any;\n exec(request.nextUrl.searchParams.get('q'));"], + ['the request, bound to searchParams', " request = new Request('https://example.test/?q=ls');\n const params = new URL(request.url).searchParams;\n exec(params.get('q'));"], + ['the request, bound to a URL', " request = new Request('https://example.test/?q=ls');\n const url = new URL(request.url);\n exec(url.searchParams.get('q'));"], + ])('does not prove an exact query field through %s', async (_label, body) => { + notExact(flow(await routeHandler(body), 'q')); + }); + + it("does not prove an exact query field through a request event's reassigned url", async () => { + const map = await mapOf({ 'src/routes/api/run/+server.ts': `${EXEC}export async function GET({ url }) {\n url = new URL('https://example.test/?q=ls');\n exec(url.searchParams.get('q'));\n return new Response('ok');\n}\n` }, { '@sveltejs/kit': '2' }); + + notExact(flow(map.endpoints[0], 'q')); + }); + + it("does not prove an exact header through a request event's reassigned request", async () => { + const map = await mapOf({ 'src/routes/api/run/+server.ts': `${EXEC}export async function POST({ request }) {\n request = new Request('https://example.test/');\n exec(request.headers.get('x-cmd'));\n return new Response('ok');\n}\n` }, { '@sveltejs/kit': '2' }); + + notExact(flow(map.endpoints[0], 'x-cmd')); + }); + + it.each([ + ['an alias of the request', " let r = req;\n r = { body: { cmd: 'ls' } };\n exec(r.body.cmd);"], + ['an alias of the body', " let b = req.body;\n b = { cmd: 'ls' };\n exec(b.cmd);"], + ['the request behind an alias', " req = { body: { cmd: 'ls' } } as any;\n const r = req;\n exec(r.body.cmd);"], + ])('does not prove an exact body field through %s', async (_label, body) => { + notExact(flow(await expressHandler(body), 'cmd')); + }); + + it("does not prove an exact route param through a reassigned route context's params", async () => { + const map = await mapOf({ 'app/api/run/[id]/route.ts': `${EXEC}export async function GET(request: Request, { params }: { params: { id: string } }) {\n params = { id: 'fixed' };\n exec(params.id);\n return new Response('ok');\n}\n` }, NEXT); + + notExact(flow(map.endpoints[0], 'id')); + }); + + it('does not prove an exact Hono accessor read on a reassigned context', async () => { + const map = await mapOf({ + 'src/index.ts': `${EXEC}import { Hono } from 'hono';\nconst app = new Hono();\napp.get('/run', (c: any) => {\n c = { req: { query: () => 'ls' } };\n exec(c.req.query('q'));\n return new Response('ok');\n});\n`, + }, { hono: '4' }); + + notExact(flow(map.endpoints[0], 'q')); + }); + + it('does not prove an exact Hono body field on a reassigned context', async () => { + const map = await mapOf({ + 'src/index.ts': `${EXEC}import { Hono } from 'hono';\nconst app = new Hono();\napp.post('/run', async (c: any) => {\n c = { req: { json: async () => ({ cmd: 'ls' }) } };\n const body = await c.req.json();\n exec(body.cmd);\n return new Response('ok');\n});\n`, + }, { hono: '4' }); + + notExact(flow(map.endpoints[0], 'cmd')); + }); +}); + +describe('the URL constructor is the global one', () => { + it.each([ + ['a local class', 'class URL { searchParams = new Map([["q", "ls"]]); constructor(_: string) {} }\n'], + ['a local function', 'function URL(_: string): any { return { searchParams: new Map([["q", "ls"]]) }; }\n'], + ['an import', "import { URL } from './fake-url';\n"], + ])('does not read the query string through %s named URL', async (_label, head) => { + const endpoint = await routeHandler(" exec(new URL(request.url).searchParams.get('q'));", head); + + expect(inputs(endpoint)).toEqual([]); + expect(flow(endpoint, 'q')).toBeUndefined(); + }); + + it('does not read the query string through a URL binding built by a local URL', async () => { + const endpoint = await routeHandler(" const url = new URL(request.url);\n exec(url.searchParams.get('q'));", 'class URL { searchParams = new Map(); constructor(_: string) {} }\n'); + + expect(inputs(endpoint)).toEqual([]); + expect(flow(endpoint, 'q')).toBeUndefined(); + }); + + it('reads the query string through globalThis.URL', async () => { + const endpoint = await routeHandler(" exec(new globalThis.URL(request.url).searchParams.get('q'));"); + + expect(inputs(endpoint)).toEqual(['query:q']); + expect(flow(endpoint, 'q')?.confidence).toBe('exact-local'); + }); + + it('does not read globalThis.URL when globalThis is shadowed', async () => { + const endpoint = await routeHandler(" const globalThis = { URL: class { searchParams = new Map(); constructor(_: string) {} } };\n exec(new globalThis.URL(request.url).searchParams.get('q'));"); + + expect(inputs(endpoint)).toEqual([]); + }); +});