Skip to content

Commit 1e821ad

Browse files
Resolve request bindings and helpers by scope, and recognise only served routes (#308)
Taint roots and same-file helpers are now keyed by the declaration an identifier resolves to, not by its name. A block-scoped binding, a callback parameter or an inner function's own request parameter that shares a name with a request binding is a different variable and no longer proves a flow. A binding that is assigned again after its declaration still proves reachability, but not an exact value. A route registration needs a URL path as its first argument, server actions no longer take a route from their file location, and Nuxt file routes come only from server/api and server/routes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9750a0f commit 1e821ad

11 files changed

Lines changed: 585 additions & 69 deletions

File tree

‎src/map/ast.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ export function rootIdentifier(node: any, ts: TsModule): string | undefined {
1717
return undefined;
1818
}
1919

20+
/** The leftmost identifier NODE of a chain, for callers that resolve it to its declaration. */
21+
export function rootIdentifierNode(node: any, ts: TsModule): any | undefined {
22+
let cur = node;
23+
while (cur) {
24+
if (ts.isIdentifier(cur)) return cur;
25+
if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) || ts.isNewExpression(cur) || ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) {
26+
cur = cur.expression;
27+
} else return undefined;
28+
}
29+
return undefined;
30+
}
31+
2032
// Source span of a node: the auditable coordinate, AND the sink's identity for flow analysis (a line is
2133
// not an identity — two sinks can share one, and an enclosing statement can hold unrelated expressions).
2234
export function spanOf(node: any): { line?: number; start?: number; end?: number } {
@@ -140,6 +152,17 @@ export function opCallOf(propAccess: any, ts: TsModule): any {
140152
return p && ts.isCallExpression(p) && p.expression === propAccess ? p : propAccess;
141153
}
142154

155+
/** The callee identifiers of plain calls (`run(x)`) in a subtree, for resolving each to its declaration. */
156+
export function localCallIdentifiers(node: any, ts: TsModule): any[] {
157+
const out: any[] = [];
158+
const visit = (n: any) => {
159+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) out.push(n.expression);
160+
ts.forEachChild(n, visit);
161+
};
162+
visit(node);
163+
return out;
164+
}
165+
143166
export function localCalls(node: any, ts: TsModule): string[] {
144167
const names: string[] = [];
145168
const visit = (n: any) => {

‎src/map/entries.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import type { Endpoint, Sink, TsModule } from './types.js';
22
import { hasExport, isFnLike, methodFromObjectArg, spanOf, unwindChain } from './ast.js';
33
import type { Bindings } from './bindings.js';
4-
import { functionNameFromPath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js';
4+
import { functionNameFromPath, isRoutePath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js';
55
import { withCoordinates } from './coordinates.js';
66
import { inputsFromHandler, inputsFromValidator } from './inputs.js';
7-
import { sinksFrom, type SinkContext } from './sinks.js';
7+
import { sinksFrom, type LocalSinks, type SinkContext } from './sinks.js';
88
import { linkedFlows } from './flows.js';
99
import { collectInvocations } from './invocations.js';
1010

1111
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
1212

1313
// --- entry-point recognizers -----------------------------------------------
14-
export function extractFromFile(sf: any, ts: TsModule, localSinks: Map<string, Sink[]>, bindings: Bindings, ctx: SinkContext): Omit<Endpoint, 'file'>[] {
14+
export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, bindings: Bindings, ctx: SinkContext): Omit<Endpoint, 'file'>[] {
1515
const out: Omit<Endpoint, 'file'>[] = [];
1616
const isServerActionsFile = fileHasUseServer(sf, ts);
1717

@@ -88,7 +88,7 @@ export function extractFromFile(sf: any, ts: TsModule, localSinks: Map<string, S
8888
const first = args[0];
8989
const route = first && ts.isStringLiteralLike(first) ? first.text : routeFromChain(node.expression.expression, ts);
9090
const handler = args[args.length - 1];
91-
if (route !== undefined && handler && isFnLike(handler, ts)) {
91+
if (route !== undefined && isRoutePath(route) && handler && isFnLike(handler, ts)) {
9292
out.push(handlerEntry(route, 'route-registration', handler.parameters, handler.body, ts, localSinks, bindings, ctx, {
9393
// `use`/`all` register handlers but are not HTTP methods — leave method undefined.
9494
method: HTTP_METHODS.has(mname.toUpperCase()) ? mname.toUpperCase() : undefined,
@@ -102,7 +102,7 @@ export function extractFromFile(sf: any, ts: TsModule, localSinks: Map<string, S
102102
const arg = node.arguments[0];
103103
if (arg && ts.isObjectLiteralExpression(arg)) {
104104
const reg = routeObject(arg, ts);
105-
if (reg.url && reg.handler) {
105+
if (reg.url && isRoutePath(reg.url) && reg.handler) {
106106
for (const m of reg.methods.length ? reg.methods : [undefined]) {
107107
out.push(handlerEntry(reg.url, 'route-registration', reg.handler.parameters, reg.handler.body, ts, localSinks, bindings, ctx, { method: m, route: reg.url, ...spanOf(node) }));
108108
}
@@ -138,7 +138,7 @@ function handlerEntry(
138138
params: any,
139139
body: any,
140140
ts: TsModule,
141-
localSinks: Map<string, Sink[]>,
141+
localSinks: LocalSinks,
142142
bindings: Bindings,
143143
ctx: SinkContext,
144144
extra: { method?: string; route?: string; line?: number; start?: number; end?: number } = {},

‎src/map/extract.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
136136
const fn = functionNameFromPath(relFile);
137137
if (fn) ep.route = '/' + fn; // how the platform invokes it (…/functions/v1/<name>)
138138
}
139-
if (ep.route === undefined && (ep.entryKind === 'route-handler' || ep.entryKind === 'server-action')) {
139+
// A server action has no URL of its own: it is posted to whichever page renders it, so its file
140+
// location names no route, and a route scope derived from it would never match its traffic.
141+
if (ep.route === undefined && ep.entryKind === 'route-handler') {
140142
const derived = routeFromFilePath(relFile);
141143
if (derived.route) {
142144
ep.route = derived.route;

0 commit comments

Comments
 (0)