From c800f7b6de8ddf2ea59f20973cf0910038d712fe Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 16:08:05 +0200 Subject: [PATCH] refactor(map): split extract.ts into cohesive modules `src/map/extract.ts` had grown to ~1.6k lines covering the whole extraction pipeline. Split it, moving code verbatim, into modules that follow the pipeline's own layering: ast dependency-free AST helpers bindings per-file module bindings (identifier -> npm package) routes file-path/route-shape derivation + the route-register list sources project walk, pre-filter, framework detection coordinates input -> runtime rule parameter inputs validator schemas + request reads sinks sink recognizers, argument roles, candidate families module-graph one-hop cross-file helper tracing flows input -> sink linking, limitations entries entry-point recognizers extract the orchestrator only, plus re-exports No behaviour change: every moved line is byte-identical, and `extract.ts` re-exports what consumers and tests import from it. Co-Authored-By: Claude Opus 5 (1M context) --- src/map/ast.ts | 148 ++++ src/map/bindings.ts | 140 ++++ src/map/coordinates.ts | 46 ++ src/map/entries.ts | 162 +++++ src/map/extract.ts | 1533 +-------------------------------------- src/map/flows.ts | 330 +++++++++ src/map/inputs.ts | 194 +++++ src/map/module-graph.ts | 102 +++ src/map/routes.ts | 105 +++ src/map/sinks.ts | 247 +++++++ src/map/sources.ts | 92 +++ 11 files changed, 1581 insertions(+), 1518 deletions(-) create mode 100644 src/map/ast.ts create mode 100644 src/map/bindings.ts create mode 100644 src/map/coordinates.ts create mode 100644 src/map/entries.ts create mode 100644 src/map/flows.ts create mode 100644 src/map/inputs.ts create mode 100644 src/map/module-graph.ts create mode 100644 src/map/routes.ts create mode 100644 src/map/sinks.ts create mode 100644 src/map/sources.ts diff --git a/src/map/ast.ts b/src/map/ast.ts new file mode 100644 index 0000000..22766cc --- /dev/null +++ b/src/map/ast.ts @@ -0,0 +1,148 @@ +import type { TsModule } from './types.js'; + +// Dependency-free AST helpers shared by every recognizer in this directory. + +// Leftmost identifier of a member/call chain (`supabase.from(x).insert` → "supabase", `fs.writeFile` → "fs"). +export function rootIdentifier(node: any, ts: TsModule): string | undefined { + let cur = node; + while (cur) { + if (ts.isIdentifier(cur)) return cur.text; + if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) || ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) { + cur = cur.expression; + } else return undefined; + } + return undefined; +} + +// Source span of a node: the auditable coordinate, AND the sink's identity for flow analysis (a line is +// not an identity — two sinks can share one, and an enclosing statement can hold unrelated expressions). +export function spanOf(node: any): { line?: number; start?: number; end?: number } { + const out: { line?: number; start?: number; end?: number } = { line: lineOf(node) }; + try { out.start = node.getStart(); out.end = node.getEnd(); } catch { /* synthetic node */ } + return out; +} + +// 1-based line of a node in its source file — the auditable coordinate rules and humans point at. +export function lineOf(node: any): number | undefined { + const sf = typeof node?.getSourceFile === 'function' ? node.getSourceFile() : undefined; + if (!sf) return undefined; + try { return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; } catch { return undefined; } +} + +export function guessScriptKind(ts: TsModule, file: string) { + if (file.endsWith('.tsx')) return ts.ScriptKind.TSX; + if (file.endsWith('.jsx')) return ts.ScriptKind.JSX; + if (file.endsWith('.js')) return ts.ScriptKind.JS; + return ts.ScriptKind.TS; +} + +export function isFnLike(n: any, ts: TsModule): n is import('typescript').ArrowFunction | import('typescript').FunctionExpression { + return ts.isArrowFunction(n) || ts.isFunctionExpression(n); +} +export function hasExport(node: any, ts: TsModule): boolean { + return Boolean(node.modifiers?.some((m: any) => m.kind === ts.SyntaxKind.ExportKeyword)); +} + +// --- call-chain + method ---------------------------------------------------- +export function unwindChain(node: any, ts: TsModule): { baseName?: string; baseCall?: any; calls: Record } { + const calls: Record = {}; + let cur = node; + while (cur && ts.isCallExpression(cur)) { + const callee = cur.expression; + if (ts.isPropertyAccessExpression(callee)) { calls[callee.name.text] = cur; cur = callee.expression; } + else if (ts.isIdentifier(callee)) return { baseName: callee.text, baseCall: cur, calls }; + else break; + } + return { calls }; +} + +export function methodFromObjectArg(baseCall: any, ts: TsModule): string | undefined { + const arg = baseCall?.arguments?.[0]; + if (!arg || !ts.isObjectLiteralExpression(arg)) return undefined; + for (const p of arg.properties) { + if (ts.isPropertyAssignment(p) && (p.name as any)?.text === 'method' && ts.isStringLiteralLike(p.initializer)) { + return p.initializer.text.toUpperCase(); + } + } + return undefined; +} + +export function bindingKey(el: any, ts: TsModule): string | undefined { + if (!ts.isBindingElement(el)) return undefined; + const prop = el.propertyName ?? el.name; + return prop && ts.isIdentifier(prop) ? prop.text : undefined; +} + +// A named function *declaration*, or a function bound to a variable/property — i.e. code that only runs +// if something calls it. An inline callback (an arrow passed as an argument), an IIFE, or a function +// used directly in an expression is NOT this: those execute where they appear. +export function isUninvokedFunctionDeclaration(n: any, ts: TsModule): boolean { + if (ts.isFunctionDeclaration(n)) return true; + if (isFnLike(n, ts)) { + const p = n.parent; + if (p && (ts.isVariableDeclaration(p) || ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p))) return true; + } + return false; +} + +/** + * Is `name` bound by an enclosing function parameter (or catch clause) at this call site? If so the call + * is NOT the global of that name — a callback parameter called `fetch` is the single most likely way to + * fake an SSRF candidate. Scoped to parameters/catch bindings: cheap, and it covers the shadowing shapes + * that occur in practice. Erring here loses a candidate rather than inventing one. + */ +export function isShadowedByEnclosingBinding(node: any, name: string, ts: TsModule): boolean { + for (let cur = node?.parent; cur; cur = cur.parent) { + if (ts.isCatchClause(cur) && cur.variableDeclaration && ts.isIdentifier(cur.variableDeclaration.name) + && cur.variableDeclaration.name.text === name) return true; + const params = (cur as any).parameters; + if (!params) continue; + for (const p of params) { + if (!p?.name) continue; + if (ts.isIdentifier(p.name) && p.name.text === name) return true; + if (ts.isObjectBindingPattern(p.name) || ts.isArrayBindingPattern(p.name)) { + for (const el of p.name.elements) { + if (ts.isBindingElement(el) && ts.isIdentifier(el.name) && el.name.text === name) return true; + } + } + } + } + return false; +} + +/** Method name a call invokes (`db.from(t).insert(x)` → "insert", `exec(x)` → "exec"). */ +export function calleeName(call: any, ts: TsModule): string | undefined { + const c = call?.expression; + if (!c) return undefined; + if (ts.isPropertyAccessExpression(c)) return c.name.text; + if (ts.isIdentifier(c)) return c.text; // also covers `new Function(...)` + return undefined; +} + +/** Is this identifier occurrence a VALUE read (rather than a property key, a member name, a binding)? */ +export function isValueRead(id: any, ts: TsModule): boolean { + const p = id.parent; + if (!p) return true; + if (ts.isPropertyAssignment(p) && p.name === id) return false; // { title: … } — a key + if (ts.isPropertyAccessExpression(p) && p.name === id) return false; // x.title — the member name + if (ts.isBindingElement(p) && p.propertyName === id) return false; // { title: t } — the source key + if ((ts.isVariableDeclaration(p) || ts.isParameter(p) || ts.isBindingElement(p)) && p.name === id) return false; + if (ts.isPropertySignature(p) || ts.isMethodSignature(p)) return false; + return true; // includes ShorthandPropertyAssignment `{ title }`, which IS a read +} + +// From a `.insert` property access, the CallExpression that invokes it — the sink's operation call. +export function opCallOf(propAccess: any, ts: TsModule): any { + const p = propAccess?.parent; + return p && ts.isCallExpression(p) && p.expression === propAccess ? p : propAccess; +} + +export function localCalls(node: any, ts: TsModule): string[] { + const names: string[] = []; + const visit = (n: any) => { + if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) names.push(n.expression.text); + ts.forEachChild(n, visit); + }; + visit(node); + return names; +} diff --git a/src/map/bindings.ts b/src/map/bindings.ts new file mode 100644 index 0000000..b619580 --- /dev/null +++ b/src/map/bindings.ts @@ -0,0 +1,140 @@ +import { builtinModules } from 'node:module'; +import type { TsModule } from './types.js'; +import { isFnLike, isUninvokedFunctionDeclaration, rootIdentifier } from './ast.js'; + +const BUILTINS = new Set(builtinModules); + +// Per-file module bindings: resolve a local identifier to the npm package (or node: builtin) it came +// from — directly (an import), or via `const x = (...)` / +// `const x = require('mod')`; derived names resolve transitively (`const conn = pool.promise()`). +// `imports` is every module specifier the file imports (for the fallback). `locals` is every name +// declared in-file that does NOT trace to a module — calls on those receivers are not dependency +// sinks. (Names assigned outside their declaration, e.g. `let fs; fs = require('fs')`, are treated +// as local — an accepted miss.) +export interface Bindings { + resolve(name: string): string | undefined; + /** For `import { saveOrder as write }`, maps the local name back to the EXPORTED name. */ + exportNameOf(name: string): string | undefined; + imports: Set; + locals: Set; +} +export function buildModuleBindings(sf: any, ts: TsModule): Bindings { + const nameToModule = new Map(); // local name → module specifier + const declared = new Set(); // every name declared in this file + const exportNames = new Map(); // local alias → exported name + const imports = new Set(); + + const record = (local: string, mod: string) => { nameToModule.set(local, mod); imports.add(mod); }; + const declareBound = (nameNode: any) => { + if (ts.isIdentifier(nameNode)) declared.add(nameNode.text); + else if (ts.isObjectBindingPattern(nameNode) || ts.isArrayBindingPattern(nameNode)) { + for (const el of nameNode.elements) if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) declared.add(el.name.text); + } + }; + + const visit = (node: any) => { + // import … from 'mod' + if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { + const mod = node.moduleSpecifier.text; + imports.add(mod); + const clause = node.importClause; + if (clause?.name) record(clause.name.text, mod); // default + const nb = clause?.namedBindings; + if (nb) { + if (ts.isNamespaceImport(nb)) record(nb.name.text, mod); + else if (ts.isNamedImports(nb)) for (const el of nb.elements) { + record(el.name.text, mod); + // `import { saveOrder as write }` — looking up `write` in the target module would miss. + if (el.propertyName && ts.isIdentifier(el.propertyName)) exportNames.set(el.name.text, el.propertyName.text); + } + } + } + if (ts.isFunctionDeclaration(node) && node.name) declared.add(node.name.text); + if (ts.isClassDeclaration(node) && node.name) declared.add(node.name.text); + // const x = require('mod') / const { a } = require('mod') + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + declareBound(decl.name); + const init = decl.initializer; + const reqMod = requireSpecifier(init, ts); + if (reqMod) { + if (ts.isIdentifier(decl.name)) record(decl.name.text, reqMod); + else if (ts.isObjectBindingPattern(decl.name)) for (const el of decl.name.elements) if (ts.isIdentifier(el.name)) record(el.name.text, reqMod); + } + // const x = tracedFactory(...) / const x = new TracedClass(...) → x carries that package. + // Looking up nameToModule (not just direct imports) makes this transitive: pool → conn → …. + // Recorded as pending too, so a factory resolved LATER (a local wrapper, below) still binds x. + if (init && ts.isIdentifier(decl.name)) { + const callee = ts.isCallExpression(init) ? init.expression : ts.isNewExpression(init) ? init.expression : undefined; + const root = callee ? rootIdentifier(callee, ts) : undefined; + if (root) { + pending.push([decl.name.text, root]); + if (nameToModule.has(root)) record(decl.name.text, nameToModule.get(root)!); + } + } + } + } + // A LOCAL factory that hands back a dependency object: `function getClient() { return createClient(…) }`. + // Without this, `const supabase = getClient()` looks like a plain local and every sink on it is + // dropped as "not a dependency" — silently losing the real client (the common AI-generated shape). + if ((ts.isFunctionDeclaration(node) || isFnLike(node, ts)) && node.body) { + const fnName = ts.isFunctionDeclaration(node) && node.name + ? node.name.text + : ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name) + ? node.parent.name.text + : undefined; + if (fnName) { + const root = returnedRootIdentifier(node.body, ts); + if (root) pending.push([fnName, root]); + } + } + ts.forEachChild(node, visit); + }; + const pending: Array<[string, string]> = []; // [localName, rootIdentifierItCameFrom] + visit(sf); + // Fixpoint: resolve chains like createClient → getClient → supabase (bounded; order-independent). + for (let i = 0; i < 4; i++) { + let changed = false; + for (const [name, root] of pending) { + if (!nameToModule.has(name) && nameToModule.has(root)) { record(name, nameToModule.get(root)!); changed = true; } + } + if (!changed) break; + } + const locals = new Set([...declared].filter((n) => !nameToModule.has(n))); + return { resolve: (name: string) => nameToModule.get(name), exportNameOf: (name: string) => exportNames.get(name), imports, locals }; +} + +// Root identifier of what a function body returns (`return createClient(…)` → "createClient"), for +// following a local factory to the dependency it wraps. Concise arrow bodies are the expression itself. +function returnedRootIdentifier(body: any, ts: TsModule): string | undefined { + if (!ts.isBlock(body)) return rootIdentifier(body, ts); // concise arrow body + let found: string | undefined; + const visit = (n: any) => { + if (found) return; + if (isUninvokedFunctionDeclaration(n, ts) && n !== body) return; // don't read a nested fn's return + if (ts.isReturnStatement(n) && n.expression) { found = rootIdentifier(n.expression, ts); return; } + ts.forEachChild(n, visit); + }; + visit(body); + return found; +} + +function requireSpecifier(init: any, ts: TsModule): string | undefined { + if (init && ts.isCallExpression(init) && ts.isIdentifier(init.expression) && init.expression.text === 'require') { + const a = init.arguments[0]; + if (a && ts.isStringLiteralLike(a)) return a.text; + } + return undefined; +} + +// Normalize a module specifier to its npm package root (keep scope, drop subpath). Node builtins are +// normalized to the `node:` form even when imported bare (`import fs from 'fs'`) — there IS an npm +// package named `fs`, and CVE correlation must never confuse the two. Relative paths → undefined. +export function npmPackageOf(spec: string | undefined): string | undefined { + if (!spec) return undefined; + if (spec.startsWith('.') || spec.startsWith('/')) return undefined; + if (spec.startsWith('node:')) return spec; + if (BUILTINS.has(spec)) return `node:${spec}`; + const parts = spec.split('/'); + return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; +} diff --git a/src/map/coordinates.ts b/src/map/coordinates.ts new file mode 100644 index 0000000..0404f9e --- /dev/null +++ b/src/map/coordinates.ts @@ -0,0 +1,46 @@ +import type { InputField, InputSource } from './types.js'; + +/** + * Map an input to the EXACT rule-engine parameter that addresses it, or null with a reason. Verified + * against the resolver in engine/request.js — a coordinate that the resolver cannot resolve would compile + * into a rule that silently never matches, which is worse than emitting nothing: + * - body / json / form / multipart / server-fn args → `post.` (createServerFnGuard feeds + * server-function arguments through as the JSON body, so `post.` resolves them) + * - query → `get.` + * - header → `server.HTTP_` (resolver lowercases and maps `_` → `-`) + * - cookie → `cookie.` + * - file → `files.` (`.content` / `.type` / `.filename` are separate) + * - route-param → NONE. The resolver exposes get/post/request/cookie/server/files — NOT `req.params`. + * - array path → NONE. `#getNestedValue` walks own properties, so `tags[].label` needs an + * `array_key_value` rule, not a dotted parameter. + */ +export function runtimeCoordinate(source: InputSource | undefined, path: string): { runtimeParameter: string | null; runtimeParameterReason?: string } { + if (/\[\d*\]/.test(path)) { + return { runtimeParameter: null, runtimeParameterReason: 'array traversal: needs an array_key_value rule, not a dotted parameter' }; + } + switch (source) { + case 'json-body': + case 'form-body': + case 'multipart': + case 'body': + case 'server-fn-data': + return { runtimeParameter: `post.${path}` }; + case 'query': + return { runtimeParameter: `get.${path}` }; + case 'cookie': + return { runtimeParameter: `cookie.${path}` }; + case 'file': + return { runtimeParameter: `files.${path}` }; + case 'header': + return { runtimeParameter: `server.HTTP_${path.toUpperCase().replace(/-/g, '_')}` }; + case 'route-param': + return { runtimeParameter: null, runtimeParameterReason: 'route parameters are not exposed by the runtime resolver' }; + default: + return { runtimeParameter: null, runtimeParameterReason: 'input source could not be determined' }; + } +} + +/** Attach `source` + the runtime coordinate to every extracted input. */ +export function withCoordinates(fields: InputField[], source: InputSource): InputField[] { + return fields.map((f) => ({ ...f, source: f.source ?? source, ...runtimeCoordinate(f.source ?? source, f.name) })); +} diff --git a/src/map/entries.ts b/src/map/entries.ts new file mode 100644 index 0000000..376633d --- /dev/null +++ b/src/map/entries.ts @@ -0,0 +1,162 @@ +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, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js'; +import { withCoordinates } from './coordinates.js'; +import { inputsFromHandler, inputsFromValidator } from './inputs.js'; +import { sinksFrom, type ModuleGraph } from './sinks.js'; +import { linkedFlows } from './flows.js'; + +const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']); + +// --- entry-point recognizers ----------------------------------------------- +export function extractFromFile(sf: any, ts: TsModule, localSinks: Map, bindings: Bindings, ctx: { file: string; graph: ModuleGraph }): Omit[] { + const out: Omit[] = []; + const isServerActionsFile = fileHasUseServer(sf, ts); + + const visit = (node: any) => { + if (ts.isVariableStatement(node) && hasExport(node, ts)) { + for (const decl of node.declarationList.declarations) { + // (1) TanStack Start: `export const NAME = createServerFn({method}).inputValidator(fn).handler(fn)` + if (decl.initializer && ts.isCallExpression(decl.initializer)) { + const chain = unwindChain(decl.initializer, ts); + if (chain.baseName === 'createServerFn' && ts.isIdentifier(decl.name)) { + const validatorCall = chain.calls['inputValidator'] ?? chain.calls['validator']; + const inputs = withCoordinates(inputsFromValidator(validatorCall, ts, bindings), 'server-fn-data'); + const handlerFn = chain.calls['handler']?.arguments?.[0]; + const sinks = sinksFrom(handlerFn, ts, localSinks, bindings, ctx); + const handlerBody = handlerFn && isFnLike(handlerFn, ts) ? handlerFn.body : undefined; + const ep: Omit = { + name: decl.name.text, + entryKind: 'server-fn', + method: methodFromObjectArg(chain.baseCall, ts), + ...spanOf(decl), + inputs, + sinks, + ...linkedFlows(handlerBody, handlerFn?.parameters, inputs, sinks, ts), + }; + // Honesty marker: a validator EXISTS but couldn't be read — inputs are unknown, not "none". + if (validatorCall && inputs.length === 0) ep.inputsResolved = false; + out.push(ep); + continue; + } + } + // (2b) `export const POST = (req) => …` route handler, or a `'use server'` action arrow. + if (ts.isIdentifier(decl.name) && decl.initializer && isFnLike(decl.initializer, ts)) { + if (HTTP_METHODS.has(decl.name.text)) { + out.push(handlerEntry(decl.name.text, decl.name.text, decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, spanOf(decl))); + } else if (isServerActionsFile) { + out.push(handlerEntry(decl.name.text, 'server-action', decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, spanOf(decl))); + } + } + } + } + + // (2a) Route handlers / server actions declared as functions. + if (ts.isFunctionDeclaration(node) && node.name && hasExport(node, ts)) { + if (HTTP_METHODS.has(node.name.text)) { + out.push(handlerEntry(node.name.text, node.name.text, node.parameters, node.body, ts, localSinks, bindings, ctx, spanOf(node))); + } else if (isServerActionsFile || hasUseServerDirective(node, ts)) { + out.push(handlerEntry(node.name.text, 'server-action', node.parameters, node.body, ts, localSinks, bindings, ctx, spanOf(node))); + } + } + + // (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 + // from the file location. Without this recognizer such a project maps to nothing at all. + if (ts.isCallExpression(node)) { + const c = node.expression; + const denoServe = ts.isPropertyAccessExpression(c) && c.name.text === 'serve' && + ts.isIdentifier(c.expression) && c.expression.text === 'Deno'; + const bareServe = ts.isIdentifier(c) && c.text === 'serve'; + if (denoServe || bareServe) { + const handler = node.arguments.find((a: any) => isFnLike(a, ts)); + if (handler) { + out.push(handlerEntry(functionNameFromPath(ctx.file) ?? 'serve', 'edge-function', handler.parameters, handler.body, ts, localSinks, bindings, ctx, spanOf(node))); + } + } + } + + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + const mname = node.expression.name.text; + // (3a) Route registrations: `app.post('/path', …, handler)` (Express/Fastify/Hono/Koa) and the + // chained `router.route('/x').get(handler)` idiom (path lives on the inner `.route()` call). + if (ROUTE_REGISTER.has(mname)) { + const args = node.arguments; + const first = args[0]; + const route = first && ts.isStringLiteralLike(first) ? first.text : routeFromChain(node.expression.expression, ts); + const handler = args[args.length - 1]; + if (route !== undefined && handler && isFnLike(handler, ts)) { + out.push(handlerEntry(route, 'route-registration', handler.parameters, handler.body, ts, localSinks, bindings, ctx, { + // `use`/`all` register handlers but are not HTTP methods — leave method undefined. + method: HTTP_METHODS.has(mname.toUpperCase()) ? mname.toUpperCase() : undefined, + route, + ...spanOf(node), + })); + } + } + // (3b) Fastify object form: `app.route({ method, url, handler })` — one endpoint per method. + if (mname === 'route') { + const arg = node.arguments[0]; + if (arg && ts.isObjectLiteralExpression(arg)) { + const reg = routeObject(arg, ts); + if (reg.url && reg.handler) { + for (const m of reg.methods.length ? reg.methods : [undefined]) { + out.push(handlerEntry(reg.url, 'route-registration', reg.handler.parameters, reg.handler.body, ts, localSinks, bindings, ctx, { method: m, route: reg.url, ...spanOf(node) })); + } + } + } + } + } + + ts.forEachChild(node, visit); + }; + visit(sf); + return out; +} + +// 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]; + return Boolean(first && ts.isExpressionStatement(first) && ts.isStringLiteralLike(first.expression) && first.expression.text === 'use server'); +} +function hasUseServerDirective(fn: any, ts: TsModule): boolean { + const first = fn.body?.statements?.[0]; + return Boolean(first && ts.isExpressionStatement(first) && ts.isStringLiteralLike(first.expression) && first.expression.text === 'use server'); +} + +function handlerEntry( + name: string, + kindLabel: string, + params: any, + body: any, + ts: TsModule, + localSinks: Map, + bindings: Bindings, + ctx: { file: string; graph: ModuleGraph }, + extra: { method?: string; route?: string; line?: number; start?: number; end?: number } = {}, +): Omit { + const entryKind = kindLabel === 'route-registration' || kindLabel === 'server-action' || kindLabel === 'edge-function' + ? kindLabel + : '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, { + payloadParam: payloadStyle, + validatorSource: payloadStyle ? 'server-fn-data' : 'json-body', + }); + const sinks = sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings, ctx); + return { + name, + entryKind, + method: extra.method ?? (HTTP_METHODS.has(name) ? name : undefined), + route: extra.route, + line: extra.line, + start: extra.start, + end: extra.end, + inputs, + sinks, + ...linkedFlows(body, params, inputs, sinks, ts), + }; +} diff --git a/src/map/extract.ts b/src/map/extract.ts index 6cd8cd2..0e4611c 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -1,8 +1,14 @@ -import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; -import { builtinModules } from 'node:module'; +import { readFileSync, realpathSync } from 'node:fs'; import { createHash } from 'node:crypto'; -import { join, relative, isAbsolute, dirname, resolve as resolvePath } from 'node:path'; -import type { SiteInputMap, Endpoint, InputField, InputSource, Sink, Flow, Limitation, ArgumentRole, CandidateFamily, TsModule } from './types.js'; +import { relative } from 'node:path'; +import type { SiteInputMap, Endpoint, TsModule } from './types.js'; +import { guessScriptKind } from './ast.js'; +import { buildModuleBindings } from './bindings.js'; +import { collectSources, detectFramework, hasEntrySignal, type WalkStats } from './sources.js'; +import { functionNameFromPath, routeFromFilePath } from './routes.js'; +import { collectLocalSinks } from './sinks.js'; +import { createModuleGraph } from './module-graph.js'; +import { extractFromFile } from './entries.js'; // Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS // source and applies recognizer tables for (1) entry points, (2) inputs, (3) sinks, so it generalizes @@ -16,192 +22,6 @@ import type { SiteInputMap, Endpoint, InputField, InputSource, Sink, Flow, Limit // package. Receivers that can't be traced (handler params, cross-file imports) stay heuristic, // favoring recall over precision. -const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']); -// One list drives BOTH the AST route-registration recognizer and the textual pre-filter — they must -// never diverge: a file the pre-filter skips is invisible to every recognizer. -const ROUTE_REGISTER_NAMES = ['get', 'post', 'put', 'patch', 'delete', 'options', 'all', 'head', 'use']; -const ROUTE_REGISTER = new Set(ROUTE_REGISTER_NAMES); -const ROUTE_CALL_RE = new RegExp(`\\.(${[...ROUTE_REGISTER_NAMES, 'route'].join('|')})\\s*\\(`); -const DB_OPS = new Set(['insert', 'update', 'delete', 'select', 'upsert', 'rpc']); -const PRISMA_OPS = new Set(['create', 'createMany', 'update', 'updateMany', 'delete', 'deleteMany', 'upsert', 'findFirst', 'findUnique', 'findMany', 'count', 'aggregate']); -const FS_CALLS = /^(readFile|writeFile|readFileSync|writeFileSync|appendFile|createReadStream|createWriteStream|unlink|rm|rmSync|mkdir|readdir|stat|open)$/; -const EXEC_CALLS = /^(exec|execSync|spawn|spawnSync|execFile|execFileSync|fork)$/; -const HTTP_CALLS = /^(fetch|got|request)$/; -const HTTP_MEMBER_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'request']); -const ZOD_BASE = new Set(['string', 'number', 'boolean', 'array', 'object', 'enum', 'bigint', 'date', 'record']); -// String-format refinements a validator can declare — kept on the field so a rule can pin the shape. -const STRING_FORMATS = new Set(['email', 'uuid', 'url', 'ip', 'ipv4', 'ipv6', 'cuid', 'cuid2', 'ulid', 'emoji', 'datetime', 'base64', 'jwt', 'nanoid']); -// Packages whose `.object({…})` calls describe an input schema. -const VALIDATOR_PACKAGES = new Set(['zod', 'valibot', 'yup', 'joi', '@hapi/joi', 'superstruct']); -// When a sink's base can't be traced precisely, infer its package from the file's imports of a known -// provider for that sink kind (a file almost always uses one db/http client). -const DB_PACKAGES = ['@supabase/supabase-js', '@prisma/client', 'drizzle-orm', 'knex', 'kysely', 'pg', 'mysql2', 'mysql', 'sequelize', 'typeorm', 'mongoose', 'better-sqlite3']; -const HTTP_PACKAGES = ['axios', 'got', 'node-fetch', 'undici', 'superagent', 'ky']; -const isHttpPackage = (pkg: string) => HTTP_PACKAGES.includes(pkg) || pkg === 'node:http' || pkg === 'node:https'; -const BUILTINS = new Set(builtinModules); - -// Per-file module bindings: resolve a local identifier to the npm package (or node: builtin) it came -// from — directly (an import), or via `const x = (...)` / -// `const x = require('mod')`; derived names resolve transitively (`const conn = pool.promise()`). -// `imports` is every module specifier the file imports (for the fallback). `locals` is every name -// declared in-file that does NOT trace to a module — calls on those receivers are not dependency -// sinks. (Names assigned outside their declaration, e.g. `let fs; fs = require('fs')`, are treated -// as local — an accepted miss.) -interface Bindings { - resolve(name: string): string | undefined; - /** For `import { saveOrder as write }`, maps the local name back to the EXPORTED name. */ - exportNameOf(name: string): string | undefined; - imports: Set; - locals: Set; -} -function buildModuleBindings(sf: any, ts: TsModule): Bindings { - const nameToModule = new Map(); // local name → module specifier - const declared = new Set(); // every name declared in this file - const exportNames = new Map(); // local alias → exported name - const imports = new Set(); - - const record = (local: string, mod: string) => { nameToModule.set(local, mod); imports.add(mod); }; - const declareBound = (nameNode: any) => { - if (ts.isIdentifier(nameNode)) declared.add(nameNode.text); - else if (ts.isObjectBindingPattern(nameNode) || ts.isArrayBindingPattern(nameNode)) { - for (const el of nameNode.elements) if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) declared.add(el.name.text); - } - }; - - const visit = (node: any) => { - // import … from 'mod' - if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { - const mod = node.moduleSpecifier.text; - imports.add(mod); - const clause = node.importClause; - if (clause?.name) record(clause.name.text, mod); // default - const nb = clause?.namedBindings; - if (nb) { - if (ts.isNamespaceImport(nb)) record(nb.name.text, mod); - else if (ts.isNamedImports(nb)) for (const el of nb.elements) { - record(el.name.text, mod); - // `import { saveOrder as write }` — looking up `write` in the target module would miss. - if (el.propertyName && ts.isIdentifier(el.propertyName)) exportNames.set(el.name.text, el.propertyName.text); - } - } - } - if (ts.isFunctionDeclaration(node) && node.name) declared.add(node.name.text); - if (ts.isClassDeclaration(node) && node.name) declared.add(node.name.text); - // const x = require('mod') / const { a } = require('mod') - if (ts.isVariableStatement(node)) { - for (const decl of node.declarationList.declarations) { - declareBound(decl.name); - const init = decl.initializer; - const reqMod = requireSpecifier(init, ts); - if (reqMod) { - if (ts.isIdentifier(decl.name)) record(decl.name.text, reqMod); - else if (ts.isObjectBindingPattern(decl.name)) for (const el of decl.name.elements) if (ts.isIdentifier(el.name)) record(el.name.text, reqMod); - } - // const x = tracedFactory(...) / const x = new TracedClass(...) → x carries that package. - // Looking up nameToModule (not just direct imports) makes this transitive: pool → conn → …. - // Recorded as pending too, so a factory resolved LATER (a local wrapper, below) still binds x. - if (init && ts.isIdentifier(decl.name)) { - const callee = ts.isCallExpression(init) ? init.expression : ts.isNewExpression(init) ? init.expression : undefined; - const root = callee ? rootIdentifier(callee, ts) : undefined; - if (root) { - pending.push([decl.name.text, root]); - if (nameToModule.has(root)) record(decl.name.text, nameToModule.get(root)!); - } - } - } - } - // A LOCAL factory that hands back a dependency object: `function getClient() { return createClient(…) }`. - // Without this, `const supabase = getClient()` looks like a plain local and every sink on it is - // dropped as "not a dependency" — silently losing the real client (the common AI-generated shape). - if ((ts.isFunctionDeclaration(node) || isFnLike(node, ts)) && node.body) { - const fnName = ts.isFunctionDeclaration(node) && node.name - ? node.name.text - : ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name) - ? node.parent.name.text - : undefined; - if (fnName) { - const root = returnedRootIdentifier(node.body, ts); - if (root) pending.push([fnName, root]); - } - } - ts.forEachChild(node, visit); - }; - const pending: Array<[string, string]> = []; // [localName, rootIdentifierItCameFrom] - visit(sf); - // Fixpoint: resolve chains like createClient → getClient → supabase (bounded; order-independent). - for (let i = 0; i < 4; i++) { - let changed = false; - for (const [name, root] of pending) { - if (!nameToModule.has(name) && nameToModule.has(root)) { record(name, nameToModule.get(root)!); changed = true; } - } - if (!changed) break; - } - const locals = new Set([...declared].filter((n) => !nameToModule.has(n))); - return { resolve: (name: string) => nameToModule.get(name), exportNameOf: (name: string) => exportNames.get(name), imports, locals }; -} - -// Root identifier of what a function body returns (`return createClient(…)` → "createClient"), for -// following a local factory to the dependency it wraps. Concise arrow bodies are the expression itself. -function returnedRootIdentifier(body: any, ts: TsModule): string | undefined { - if (!ts.isBlock(body)) return rootIdentifier(body, ts); // concise arrow body - let found: string | undefined; - const visit = (n: any) => { - if (found) return; - if (isUninvokedFunctionDeclaration(n, ts) && n !== body) return; // don't read a nested fn's return - if (ts.isReturnStatement(n) && n.expression) { found = rootIdentifier(n.expression, ts); return; } - ts.forEachChild(n, visit); - }; - visit(body); - return found; -} - -function requireSpecifier(init: any, ts: TsModule): string | undefined { - if (init && ts.isCallExpression(init) && ts.isIdentifier(init.expression) && init.expression.text === 'require') { - const a = init.arguments[0]; - if (a && ts.isStringLiteralLike(a)) return a.text; - } - return undefined; -} - -// Leftmost identifier of a member/call chain (`supabase.from(x).insert` → "supabase", `fs.writeFile` → "fs"). -function rootIdentifier(node: any, ts: TsModule): string | undefined { - let cur = node; - while (cur) { - if (ts.isIdentifier(cur)) return cur.text; - if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) || ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) { - cur = cur.expression; - } else return undefined; - } - return undefined; -} - -// Normalize a module specifier to its npm package root (keep scope, drop subpath). Node builtins are -// normalized to the `node:` form even when imported bare (`import fs from 'fs'`) — there IS an npm -// package named `fs`, and CVE correlation must never confuse the two. Relative paths → undefined. -function npmPackageOf(spec: string | undefined): string | undefined { - if (!spec) return undefined; - if (spec.startsWith('.') || spec.startsWith('/')) return undefined; - if (spec.startsWith('node:')) return spec; - if (BUILTINS.has(spec)) return `node:${spec}`; - const parts = spec.split('/'); - return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; -} - -// Source span of a node: the auditable coordinate, AND the sink's identity for flow analysis (a line is -// not an identity — two sinks can share one, and an enclosing statement can hold unrelated expressions). -function spanOf(node: any): { line?: number; start?: number; end?: number } { - const out: { line?: number; start?: number; end?: number } = { line: lineOf(node) }; - try { out.start = node.getStart(); out.end = node.getEnd(); } catch { /* synthetic node */ } - return out; -} - -// 1-based line of a node in its source file — the auditable coordinate rules and humans point at. -function lineOf(node: any): number | undefined { - const sf = typeof node?.getSourceFile === 'function' ? node.getSourceFile() : undefined; - if (!sf) return undefined; - try { return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; } catch { return undefined; } -} - export interface ExtractOptions { /** Follow symlinks that leave the project directory (off by default — keeps analysis in-project). */ followSymlinks?: boolean; @@ -291,1336 +111,13 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac }; } -// Cheap textual pre-filter so we only parse files that could contain an entry point. Derived from the -// same list as the AST recognizer (see ROUTE_REGISTER_NAMES). -function hasEntrySignal(text: string): boolean { - return ( - text.includes('createServerFn') || - /\bexport\s+(async\s+)?(function|const)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/.test(text) || - ROUTE_CALL_RE.test(text) || - text.includes("'use server'") || text.includes('"use server"') || - text.includes('Deno.serve') || /\bserve\s*\(/.test(text) - ); -} - -function detectFramework(cwd: string): string { - try { - const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); - const d = { ...pkg.dependencies, ...pkg.devDependencies }; - if (d['@tanstack/react-start'] || d['@tanstack/start'] || d['@tanstack/solid-start']) return 'tanstack-start'; - if (d['next']) return 'next'; - if (d['@sveltejs/kit']) return 'sveltekit'; - if (d['@nestjs/core']) return 'nestjs'; - if (d['fastify']) return 'fastify'; - if (d['express']) return 'express'; - if (d['hono']) return 'hono'; - } catch { /* ignore */ } - // A Deno/edge functions project may have no package.json at all. - try { - if (statSync(join(cwd, 'supabase', 'functions')).isDirectory()) return 'supabase-functions'; - } catch { /* not a supabase project */ } - try { - if (statSync(join(cwd, 'functions')).isDirectory()) return 'deno-functions'; - } catch { /* ignore */ } - return 'unknown'; -} - -function guessScriptKind(ts: TsModule, file: string) { - if (file.endsWith('.tsx')) return ts.ScriptKind.TSX; - if (file.endsWith('.jsx')) return ts.ScriptKind.JSX; - if (file.endsWith('.js')) return ts.ScriptKind.JS; - return ts.ScriptKind.TS; -} - -const isSourceFile = (name: string) => /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(name) && !name.endsWith('.d.ts'); - -// Directories that never hold app source, so walking the whole project stays cheap. (We walk the whole -// project rather than `src` only: server entrypoints, route dirs and platform function dirs commonly -// live at the root — `server.ts`, `app/`, `api/`, `routes/`, `functions/`, `netlify/`, `supabase/`.) -const SKIP_DIRS = new Set([ - 'node_modules', 'dist', 'build', 'out', 'coverage', 'public', 'static', 'assets', - '.git', '.next', '.nuxt', '.svelte-kit', '.output', '.vercel', '.wrangler', '.turbo', '.cache', - 'vendor', 'tmp', 'temp', '__pycache__', -]); - -export interface WalkStats { discovered: number } - -/** - * Walk the project for source files. Symlinks are followed ONLY while they stay inside the project - * boundary (`boundary`, a realpath) — a link to an external repo would otherwise pull unrelated code - * (and its paths) into the map. `followOutside` opts out of the boundary check. A realpath visited-set - * makes link cycles safe. - */ -function collectSources( - dir: string, - boundary: string, - opts: { followOutside?: boolean }, - out: string[] = [], - seen = new Set(), - stats: WalkStats = { discovered: 0 }, -): string[] { - let key: string; - try { key = realpathSync(dir); } catch { return out; } - if (seen.has(key)) return out; - if (!opts.followOutside && !isInside(key, boundary)) return out; - seen.add(key); - let entries; - try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } - for (const e of entries) { - if (SKIP_DIRS.has(e.name) || (e.name.startsWith('.') && e.name !== '.')) continue; - const full = join(dir, e.name); - if (e.isDirectory()) collectSources(full, boundary, opts, out, seen, stats); - else if (e.isSymbolicLink()) { - let st, real; - try { st = statSync(full); real = realpathSync(full); } catch { continue; } - if (!opts.followOutside && !isInside(real, boundary)) continue; // link escapes the project - if (st.isDirectory()) collectSources(full, boundary, opts, out, seen, stats); - else if (st.isFile() && isSourceFile(e.name)) { out.push(full); stats.discovered++; } - } else if (isSourceFile(e.name)) { out.push(full); stats.discovered++; } - } - return out; -} - -function isInside(candidate: string, boundary: string): boolean { - if (candidate === boundary) return true; - const rel = relative(boundary, candidate); - return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); -} - -// Derive the URL path of a FILE-BASED route handler from its location, across the conventions AI -// builders actually emit. Dynamic segments become `:name` and set `dynamic` so a consumer knows the -// route is a PATTERN (the engine's `when.path` takes a glob or /regex/, not an Express param), rather -// than mistaking `/api/:id` for a literal path. -// Next App Router app/api/items/route.ts -> /api/items -// app/api/items/[id]/route.ts -> /api/items/:id (dynamic) -// app/(marketing)/api/x/route.ts -> /api/x (route group stripped) -// Next Pages Router pages/api/items/index.ts -> /api/items -// pages/api/[id].ts -> /api/:id (dynamic) -// SvelteKit src/routes/api/items/+server.ts -> /api/items -// Nuxt server/api/items.post.ts -> /api/items -// The deployed name of a platform function, from its conventional location: -// supabase/functions//index.ts (Supabase Edge Functions) -// functions//index.ts | functions/.ts (Base44 / generic Deno function dirs) -export function functionNameFromPath(relFile: string): string | undefined { - const parts = relFile.split(/[\\/]/).filter(Boolean); - const i = parts.lastIndexOf('functions'); - if (i === -1 || i === parts.length - 1) return undefined; - const next = parts[i + 1]; - if (!next) return undefined; - const base = next.replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, ''); - return base === 'index' ? undefined : base; -} - -export function routeFromFilePath(relFile: string): { route?: string; dynamic?: boolean } { - const parts = relFile.split(/[\\/]/).filter(Boolean); - if (parts.length === 0) return {}; - const base = (parts[parts.length - 1] ?? '').replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, ''); - const dirs = parts.slice(0, -1); - const at = (name: string) => dirs.lastIndexOf(name); - - let segs: string[] | null = null; - if (base === 'route' && at('app') !== -1) { - segs = dirs.slice(at('app') + 1); // Next App Router - } else if (base === '+server' && at('routes') !== -1) { - segs = dirs.slice(at('routes') + 1); // SvelteKit - } else if (at('pages') !== -1) { - segs = [...dirs.slice(at('pages') + 1), ...(base === 'index' ? [] : [base])]; // Next Pages Router - } else if (at('server') !== -1) { - // Nuxt server routes; a `.post`/`.get` suffix encodes the method, not a path segment. - segs = [...dirs.slice(at('server') + 1), ...(base === 'index' ? [] : [base.replace(/\.(get|post|put|patch|delete|head|options)$/i, '')])]; - } - if (!segs) return {}; - - // Next route groups `(marketing)` and parallel/private segments don't appear in the URL. - segs = segs.filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('@') && !s.startsWith('_')); - - let dynamic = false; - const mapped = segs.map((s) => { - const m = /^\[+(\.{0,3})(.+?)\]+$/.exec(s); // [id], [...slug], [[...slug]] - if (m) { - dynamic = true; - return ':' + m[2]; - } - return s; - }); - const route = '/' + mapped.join('/'); - return { route: route.length > 1 ? route.replace(/\/+$/, '') : '/', dynamic }; -} - -/** - * Map an input to the EXACT rule-engine parameter that addresses it, or null with a reason. Verified - * against the resolver in engine/request.js — a coordinate that the resolver cannot resolve would compile - * into a rule that silently never matches, which is worse than emitting nothing: - * - body / json / form / multipart / server-fn args → `post.` (createServerFnGuard feeds - * server-function arguments through as the JSON body, so `post.` resolves them) - * - query → `get.` - * - header → `server.HTTP_` (resolver lowercases and maps `_` → `-`) - * - cookie → `cookie.` - * - file → `files.` (`.content` / `.type` / `.filename` are separate) - * - route-param → NONE. The resolver exposes get/post/request/cookie/server/files — NOT `req.params`. - * - array path → NONE. `#getNestedValue` walks own properties, so `tags[].label` needs an - * `array_key_value` rule, not a dotted parameter. - */ -export function runtimeCoordinate(source: InputSource | undefined, path: string): { runtimeParameter: string | null; runtimeParameterReason?: string } { - if (/\[\d*\]/.test(path)) { - return { runtimeParameter: null, runtimeParameterReason: 'array traversal: needs an array_key_value rule, not a dotted parameter' }; - } - switch (source) { - case 'json-body': - case 'form-body': - case 'multipart': - case 'body': - case 'server-fn-data': - return { runtimeParameter: `post.${path}` }; - case 'query': - return { runtimeParameter: `get.${path}` }; - case 'cookie': - return { runtimeParameter: `cookie.${path}` }; - case 'file': - return { runtimeParameter: `files.${path}` }; - case 'header': - return { runtimeParameter: `server.HTTP_${path.toUpperCase().replace(/-/g, '_')}` }; - case 'route-param': - return { runtimeParameter: null, runtimeParameterReason: 'route parameters are not exposed by the runtime resolver' }; - default: - return { runtimeParameter: null, runtimeParameterReason: 'input source could not be determined' }; - } -} - -/** Attach `source` + the runtime coordinate to every extracted input. */ -function withCoordinates(fields: InputField[], source: InputSource): InputField[] { - return fields.map((f) => ({ ...f, source: f.source ?? source, ...runtimeCoordinate(f.source ?? source, f.name) })); -} - -// --- entry-point recognizers ----------------------------------------------- -function extractFromFile(sf: any, ts: TsModule, localSinks: Map, bindings: Bindings, ctx: { file: string; graph: ModuleGraph }): Omit[] { - const out: Omit[] = []; - const isServerActionsFile = fileHasUseServer(sf, ts); - - const visit = (node: any) => { - if (ts.isVariableStatement(node) && hasExport(node, ts)) { - for (const decl of node.declarationList.declarations) { - // (1) TanStack Start: `export const NAME = createServerFn({method}).inputValidator(fn).handler(fn)` - if (decl.initializer && ts.isCallExpression(decl.initializer)) { - const chain = unwindChain(decl.initializer, ts); - if (chain.baseName === 'createServerFn' && ts.isIdentifier(decl.name)) { - const validatorCall = chain.calls['inputValidator'] ?? chain.calls['validator']; - const inputs = withCoordinates(inputsFromValidator(validatorCall, ts, bindings), 'server-fn-data'); - const handlerFn = chain.calls['handler']?.arguments?.[0]; - const sinks = sinksFrom(handlerFn, ts, localSinks, bindings, ctx); - const handlerBody = handlerFn && isFnLike(handlerFn, ts) ? handlerFn.body : undefined; - const ep: Omit = { - name: decl.name.text, - entryKind: 'server-fn', - method: methodFromObjectArg(chain.baseCall, ts), - ...spanOf(decl), - inputs, - sinks, - ...linkedFlows(handlerBody, handlerFn?.parameters, inputs, sinks, ts), - }; - // Honesty marker: a validator EXISTS but couldn't be read — inputs are unknown, not "none". - if (validatorCall && inputs.length === 0) ep.inputsResolved = false; - out.push(ep); - continue; - } - } - // (2b) `export const POST = (req) => …` route handler, or a `'use server'` action arrow. - if (ts.isIdentifier(decl.name) && decl.initializer && isFnLike(decl.initializer, ts)) { - if (HTTP_METHODS.has(decl.name.text)) { - out.push(handlerEntry(decl.name.text, decl.name.text, decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, spanOf(decl))); - } else if (isServerActionsFile) { - out.push(handlerEntry(decl.name.text, 'server-action', decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, spanOf(decl))); - } - } - } - } - - // (2a) Route handlers / server actions declared as functions. - if (ts.isFunctionDeclaration(node) && node.name && hasExport(node, ts)) { - if (HTTP_METHODS.has(node.name.text)) { - out.push(handlerEntry(node.name.text, node.name.text, node.parameters, node.body, ts, localSinks, bindings, ctx, spanOf(node))); - } else if (isServerActionsFile || hasUseServerDirective(node, ts)) { - out.push(handlerEntry(node.name.text, 'server-action', node.parameters, node.body, ts, localSinks, bindings, ctx, spanOf(node))); - } - } - - // (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 - // from the file location. Without this recognizer such a project maps to nothing at all. - if (ts.isCallExpression(node)) { - const c = node.expression; - const denoServe = ts.isPropertyAccessExpression(c) && c.name.text === 'serve' && - ts.isIdentifier(c.expression) && c.expression.text === 'Deno'; - const bareServe = ts.isIdentifier(c) && c.text === 'serve'; - if (denoServe || bareServe) { - const handler = node.arguments.find((a: any) => isFnLike(a, ts)); - if (handler) { - out.push(handlerEntry(functionNameFromPath(ctx.file) ?? 'serve', 'edge-function', handler.parameters, handler.body, ts, localSinks, bindings, ctx, spanOf(node))); - } - } - } - - if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { - const mname = node.expression.name.text; - // (3a) Route registrations: `app.post('/path', …, handler)` (Express/Fastify/Hono/Koa) and the - // chained `router.route('/x').get(handler)` idiom (path lives on the inner `.route()` call). - if (ROUTE_REGISTER.has(mname)) { - const args = node.arguments; - const first = args[0]; - const route = first && ts.isStringLiteralLike(first) ? first.text : routeFromChain(node.expression.expression, ts); - const handler = args[args.length - 1]; - if (route !== undefined && handler && isFnLike(handler, ts)) { - out.push(handlerEntry(route, 'route-registration', handler.parameters, handler.body, ts, localSinks, bindings, ctx, { - // `use`/`all` register handlers but are not HTTP methods — leave method undefined. - method: HTTP_METHODS.has(mname.toUpperCase()) ? mname.toUpperCase() : undefined, - route, - ...spanOf(node), - })); - } - } - // (3b) Fastify object form: `app.route({ method, url, handler })` — one endpoint per method. - if (mname === 'route') { - const arg = node.arguments[0]; - if (arg && ts.isObjectLiteralExpression(arg)) { - const reg = routeObject(arg, ts); - if (reg.url && reg.handler) { - for (const m of reg.methods.length ? reg.methods : [undefined]) { - out.push(handlerEntry(reg.url, 'route-registration', reg.handler.parameters, reg.handler.body, ts, localSinks, bindings, ctx, { method: m, route: reg.url, ...spanOf(node) })); - } - } - } - } - } - - ts.forEachChild(node, visit); - }; - visit(sf); - return out; -} - -// Unwind `router.route('/x').get(h).post(h2)` down to the `.route('/x')` call to recover the path. -function routeFromChain(expr: any, ts: TsModule): string | undefined { - let cur = expr; - while (cur && ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { - const nm = cur.expression.name.text; - if (nm === 'route') { - const a = cur.arguments[0]; - return a && ts.isStringLiteralLike(a) ? a.text : undefined; - } - if (!ROUTE_REGISTER.has(nm)) return undefined; - cur = cur.expression.expression; - } - return undefined; -} - -// Read `{ method, url|path, handler }` from a Fastify-style route object (handler as arrow/function -// property or as an object-method shorthand). -function routeObject(obj: any, ts: TsModule): { url?: string; methods: string[]; handler?: any } { - let url: string | undefined; - let handler: any; - const methods: string[] = []; - for (const p of obj.properties) { - const key = (p.name as any)?.text; - if (ts.isPropertyAssignment(p)) { - if ((key === 'url' || key === 'path') && ts.isStringLiteralLike(p.initializer)) url = p.initializer.text; - if (key === 'method') { - if (ts.isStringLiteralLike(p.initializer)) methods.push(p.initializer.text.toUpperCase()); - else if (ts.isArrayLiteralExpression(p.initializer)) { - for (const el of p.initializer.elements) if (ts.isStringLiteralLike(el)) methods.push(el.text.toUpperCase()); - } - } - if (key === 'handler' && isFnLike(p.initializer, ts)) handler = p.initializer; - } else if (ts.isMethodDeclaration(p) && key === 'handler') handler = p; - } - return { url, methods, handler }; -} - -// 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]; - return Boolean(first && ts.isExpressionStatement(first) && ts.isStringLiteralLike(first.expression) && first.expression.text === 'use server'); -} -function hasUseServerDirective(fn: any, ts: TsModule): boolean { - const first = fn.body?.statements?.[0]; - return Boolean(first && ts.isExpressionStatement(first) && ts.isStringLiteralLike(first.expression) && first.expression.text === 'use server'); -} - -function handlerEntry( - name: string, - kindLabel: string, - params: any, - body: any, - ts: TsModule, - localSinks: Map, - bindings: Bindings, - ctx: { file: string; graph: ModuleGraph }, - extra: { method?: string; route?: string; line?: number; start?: number; end?: number } = {}, -): Omit { - const entryKind = kindLabel === 'route-registration' || kindLabel === 'server-action' || kindLabel === 'edge-function' - ? kindLabel - : '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, { - payloadParam: payloadStyle, - validatorSource: payloadStyle ? 'server-fn-data' : 'json-body', - }); - const sinks = sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings, ctx); - return { - name, - entryKind, - method: extra.method ?? (HTTP_METHODS.has(name) ? name : undefined), - route: extra.route, - line: extra.line, - start: extra.start, - end: extra.end, - inputs, - sinks, - ...linkedFlows(body, params, inputs, sinks, ts), - }; -} - -function isFnLike(n: any, ts: TsModule): n is import('typescript').ArrowFunction | import('typescript').FunctionExpression { - return ts.isArrowFunction(n) || ts.isFunctionExpression(n); -} -function hasExport(node: any, ts: TsModule): boolean { - return Boolean(node.modifiers?.some((m: any) => m.kind === ts.SyntaxKind.ExportKeyword)); -} - -// --- call-chain + method ---------------------------------------------------- -function unwindChain(node: any, ts: TsModule): { baseName?: string; baseCall?: any; calls: Record } { - const calls: Record = {}; - let cur = node; - while (cur && ts.isCallExpression(cur)) { - const callee = cur.expression; - if (ts.isPropertyAccessExpression(callee)) { calls[callee.name.text] = cur; cur = callee.expression; } - else if (ts.isIdentifier(callee)) return { baseName: callee.text, baseCall: cur, calls }; - else break; - } - return { calls }; -} - -function methodFromObjectArg(baseCall: any, ts: TsModule): string | undefined { - const arg = baseCall?.arguments?.[0]; - if (!arg || !ts.isObjectLiteralExpression(arg)) return undefined; - for (const p of arg.properties) { - if (ts.isPropertyAssignment(p) && (p.name as any)?.text === 'method' && ts.isStringLiteralLike(p.initializer)) { - return p.initializer.text.toUpperCase(); - } - } - return undefined; -} - -// --- inputs ----------------------------------------------------------------- -function inputsFromValidator(validatorCall: any, ts: TsModule, bindings: Bindings): InputField[] { - if (!validatorCall) return []; - return zodObjectFields(validatorCall, ts, bindings); -} - -// From a raw handler: validator schema fields it parses, plus the request fields it actually reads -// (member accesses, destructuring, `await request.json()` bodies). -function inputsFromHandler( - params: any, - body: any, - ts: TsModule, - bindings: Bindings, - opts: { payloadParam?: boolean; validatorSource?: InputSource } = {}, -): InputField[] { - // 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 fields = withCoordinates(zodObjectFields(body, ts, bindings), opts.validatorSource ?? 'json-body'); - const names = new Set(fields.map((f) => f.name)); - for (const { name, source } of requestMemberAccesses(params, body, ts, opts)) { - if (!names.has(name)) { - names.add(name); - fields.push({ name, source, ...runtimeCoordinate(source, name) }); - } - } - return fields; -} - -// Find the first validator `.object({...})` in a subtree — gated on the receiver tracing to a known -// validator package (so an unrelated `.object(` never becomes a schema). An untraceable receiver -// literally named `z` is accepted as a heuristic (covers `z` re-exported from a local module). -function findValidatorObject(node: any, ts: TsModule, bindings: Bindings): any { - let found: any = null; - const find = (n: any) => { - if (found || !n) return; - if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'object') { - const root = rootIdentifier(n.expression.expression, ts); - const pkg = root ? npmPackageOf(bindings.resolve(root)) : undefined; - const isValidator = (pkg && VALIDATOR_PACKAGES.has(pkg)) || (!pkg && root === 'z' && !bindings.locals.has(root)); - if (isValidator) { - const arg = n.arguments[0]; - if (arg && ts.isObjectLiteralExpression(arg)) { found = arg; return; } - } - } - ts.forEachChild(n, find); - }; - find(node); - return found; -} - -// Read a validator object's fields (name + type/constraints). Nested objects/arrays are flattened to -// dotted paths — `address.city`, `tags[].label` — the same coordinates `array_key_value` rules use. -function zodObjectFields(node: any, ts: TsModule, bindings: Bindings): InputField[] { - if (!node) return []; - const lit = findValidatorObject(node.body ?? node, ts, bindings); - return lit ? fieldsOfObject(lit, ts, bindings, '') : []; -} - -function fieldsOfObject(objectLiteral: any, ts: TsModule, bindings: Bindings, prefix: string): InputField[] { - const fields: InputField[] = []; - for (const p of objectLiteral.properties) { - if (!ts.isPropertyAssignment(p) || !p.name) continue; - const fname = (p.name as any).text; - if (!fname) continue; - const shape = zodShape(p.initializer, ts); - fields.push({ name: prefix + fname, ...shape }); - const nested = findValidatorObject(p.initializer, ts, bindings); - if (nested) fields.push(...fieldsOfObject(nested, ts, bindings, prefix + fname + (shape.type === 'array' ? '[].' : '.'))); - } - return fields; -} - -function numericValue(arg: any, ts: TsModule): number | undefined { - if (!arg) return undefined; - if (ts.isNumericLiteral(arg)) return Number(arg.text); - if (ts.isPrefixUnaryExpression(arg) && arg.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(arg.operand)) return -Number(arg.operand.text); - return undefined; -} - -function zodShape(node: any, ts: TsModule): Omit { - const shape: Omit = {}; - let cur = node; - while (cur && ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { - const method = cur.expression.name.text; - const arg0 = cur.arguments[0]; - if (ZOD_BASE.has(method) && !shape.type) shape.type = method; - if (method === 'min') { const v = numericValue(arg0, ts); if (v !== undefined) shape.min = v; } - if (method === 'max') { const v = numericValue(arg0, ts); if (v !== undefined) shape.max = v; } - if (STRING_FORMATS.has(method) && !shape.format) shape.format = method; - if (method === 'regex' && arg0 && ts.isRegularExpressionLiteral(arg0) && !shape.pattern) shape.pattern = arg0.text; - if (method === 'optional' || method === 'nullish') shape.optional = true; - cur = cur.expression.expression; - } - return shape; -} - -const REQ_SOURCES = ['body', 'query', 'params']; - -// The request fields a handler reads, across the common idioms: -// req.body.x / req.query.x / req.params.x (member access) -// const { x } = req.body (destructuring) -// ({ body }) => body.x / const { x } = body (destructured handler param) -// const b = await request.json(); b.x / const { x } = await request.json() (fetch-style Request) -function requestMemberAccesses( - params: any, - body: any, - ts: TsModule, - opts: { payloadParam?: boolean } = {}, -): Array<{ name: string; source: InputSource }> { - if (!body) return []; - const out = new Map(); - const p0 = params?.[0]; - const reqName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : undefined; - // Identifiers that ARE a request-input object (destructured `({ body })` param, `await req.json()`). - const sourceNames = new Set(); - const payloadNames = new Set(); - if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name.text); - if (p0 && !reqName && 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.add(el.name.text); - } - } - const unwrap = (e: any): any => { - let cur = e; - while (cur && (ts.isAwaitExpression(cur) || ts.isAsExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; - return cur; - }; - const isPayloadExpr = (e: any): boolean => ts.isIdentifier(e) && payloadNames.has(e.text); - const isReqSourceExpr = (e: any): boolean => - isPayloadExpr(e) || - (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === reqName && REQ_SOURCES.includes(e.name.text)) || - (ts.isIdentifier(e) && sourceNames.has(e.text)); - const isBodyReadCall = (e: any): boolean => { - const inner = unwrap(e); - return Boolean(inner && ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) && - ['json', 'formData'].includes(inner.expression.name.text) && - ts.isIdentifier(inner.expression.expression) && inner.expression.expression.text === reqName); - }; - const visit = (n: any) => { - // . - if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression)) { - out.set(n.name.text, sourceOfExpr(n.expression)); - } - if (ts.isVariableDeclaration(n) && n.initializer) { - const init = unwrap(n.initializer); - // const b = await request.json() → b is a request-input object from here on. - if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.add(n.name.text); - // const { a, b } = | await request.json() - if (ts.isObjectBindingPattern(n.name) && (isReqSourceExpr(init) || isBodyReadCall(n.initializer))) { - const src = isBodyReadCall(n.initializer) ? bodyReadSource(n.initializer) : sourceOfExpr(init); - for (const el of n.name.elements) { - const key = bindingKey(el, ts); - if (key) out.set(key, src); - } - } - } - ts.forEachChild(n, visit); - }; - visit(body); - return [...out].map(([name, source]) => ({ name, source })); - - // `req.body.x` / `req.query.x` / `req.params.x` — the namespace decides the runtime coordinate, and - // route params notably have NONE, so this distinction is load-bearing rather than cosmetic. - function sourceOfExpr(e: any): InputSource { - if (isPayloadExpr(e)) return 'server-fn-data'; - if (ts.isPropertyAccessExpression(e)) { - if (e.name.text === 'query') return 'query'; - if (e.name.text === 'params') return 'route-param'; - if (e.name.text === 'body') return 'body'; - } - if (ts.isIdentifier(e)) { - const key = [...sourceNames].includes(e.text) ? e.text : undefined; - if (key === 'query') return 'query'; - if (key === 'params') return 'route-param'; - } - return 'body'; - } - function bodyReadSource(init: any): InputSource { - const t = init?.getText?.() ?? ''; - return /formData\s*\(/.test(t) ? 'form-body' : 'json-body'; - } -} - -function bindingKey(el: any, ts: TsModule): string | undefined { - if (!ts.isBindingElement(el)) return undefined; - const prop = el.propertyName ?? el.name; - return prop && ts.isIdentifier(prop) ? prop.text : undefined; -} - -// --- sinks (agnostic) ------------------------------------------------------- -function collectLocalSinks(sf: any, ts: TsModule, bindings: Bindings): Map { - const map = new Map(); - const visit = (node: any) => { - if (ts.isFunctionDeclaration(node) && node.name && node.body) map.set(node.name.text, directSinks(node.body, ts, bindings)); - else if (ts.isVariableStatement(node)) { - for (const decl of node.declarationList.declarations) { - if (ts.isIdentifier(decl.name) && decl.initializer && isFnLike(decl.initializer, ts)) { - map.set(decl.name.text, directSinks(decl.initializer.body, ts, bindings)); - } - } - } - ts.forEachChild(node, visit); - }; - visit(sf); - return map; -} - -// --- cross-file (imported) helper tracing ----------------------------------- -// AI-generated apps routinely put the data access in a sibling module (`import { saveOrder } from -// './db'`), so a handler's real sink lives one file away. Without following that, the endpoint looks -// sink-free and no rule can be correlated to it. We follow ONE cross-file hop (plus same-file helpers -// inside the target), which covers the common shape while keeping the walk bounded and cheap. -const RESOLVE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']; - -export interface ModuleGraph { - /** Sinks of `exportName` in the module `specifier` resolves to, relative to `fromFile`. */ - importedSinks(fromFile: string, specifier: string, exportName: string): Sink[]; -} - -function createModuleGraph(ts: TsModule, opts: { cwd: string; boundary: string; followOutside?: boolean }): ModuleGraph { - // file → { fnSinks, calleesOf } | null (unreadable/unparseable) - const cache = new Map; calleesOf: Map } | null>(); - - const load = (file: string) => { - if (cache.has(file)) return cache.get(file) ?? null; - let entry: { fnSinks: Map; calleesOf: Map } | null = null; - try { - const text = readFileSync(file, 'utf8'); - const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); - const bindings = buildModuleBindings(sf, ts); - entry = { fnSinks: collectLocalSinks(sf, ts, bindings), calleesOf: collectCallees(sf, ts) }; - } catch { - entry = null; // fail-open: an unreadable dependency must not break the map - } - cache.set(file, entry); - return entry; - }; - - return { - importedSinks(fromFile, specifier, exportName) { - const target = resolveRelativeModule(fromFile, specifier); - if (!target) return []; - // Stay inside the project: `../../other-repo/db` (or a symlink) would otherwise pull an unrelated - // codebase into this app's attack surface. The primary walker enforces this; so must the resolver. - if (!opts.followOutside) { - let real = target; - try { real = realpathSync(target); } catch { /* use as-is */ } - if (!isInside(real, opts.boundary)) return []; - } - const mod = load(target); - if (!mod) return []; - const collected = [...(mod.fnSinks.get(exportName) ?? [])]; - // One same-file hop inside the target: `export function saveOrder(){ return doInsert() }`. - for (const callee of mod.calleesOf.get(exportName) ?? []) { - for (const s of mod.fnSinks.get(callee) ?? []) collected.push(s); - } - // `line` refers to the HELPER's file, not the endpoint's — carry the file so the coordinate is - // interpretable (and so flow linking never claims `precise` for a sink it cannot see locally). - const rel = relative(opts.cwd, target); - return collected.map((s) => ({ ...s, file: rel })); - }, - }; -} - -// Resolve a RELATIVE specifier to a real file (extension + /index, and the TS-ESM `./db.js` → db.ts -// convention). Bare package specifiers are intentionally NOT followed — that's node_modules, and a -// dependency's internals are not this app's attack surface. -function resolveRelativeModule(fromFile: string, spec: string): string | undefined { - if (!spec.startsWith('.')) return undefined; - const dir = dirname(fromFile); - const base = resolvePath(dir, spec); - const candidates: string[] = []; - const jsLike = /\.(js|jsx|mjs|cjs)$/.exec(base); - if (jsLike) { - const stem = base.slice(0, -jsLike[0].length); - for (const e of RESOLVE_EXTS) candidates.push(stem + e); // ./db.js may mean db.ts - } - candidates.push(base); - for (const e of RESOLVE_EXTS) candidates.push(base + e); - for (const e of RESOLVE_EXTS) candidates.push(join(base, 'index' + e)); - for (const c of candidates) { - try { - if (statSync(c).isFile()) return c; - } catch { - /* next */ - } - } - return undefined; -} - -// name → the local function names it calls (for one same-file hop inside an imported module). -function collectCallees(sf: any, ts: TsModule): Map { - const map = new Map(); - const visit = (node: any) => { - let name: string | undefined; - let body: any; - if (ts.isFunctionDeclaration(node) && node.name && node.body) { name = node.name.text; body = node.body; } - else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isFnLike(node.initializer, ts)) { - name = node.name.text; body = node.initializer.body; - } - if (name && body) map.set(name, localCalls(body, ts)); - ts.forEachChild(node, visit); - }; - visit(sf); - return map; -} - -function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map, bindings: Bindings, ctx?: { file: string; graph: ModuleGraph }): Sink[] { - if (!arrowOrNode) return []; - const body = arrowOrNode.isSyntheticBody ? arrowOrNode.body - : isFnLike(arrowOrNode, ts) ? arrowOrNode.body : arrowOrNode; - if (!body) return []; - const sinks = directSinks(body, ts, bindings); - for (const called of localCalls(body, ts)) { - // Same-file helper. - for (const s of localSinks.get(called) ?? []) sinks.push(s); - // Imported helper: the name resolves to a RELATIVE module → follow one hop into it. - if (ctx) { - const spec = bindings.resolve(called); - if (spec && spec.startsWith('.')) { - for (const s of ctx.graph.importedSinks(ctx.file, spec, bindings.exportNameOf(called) ?? called)) sinks.push(s); - } - } - } - return dedupeSinks(sinks); -} - -// Provider-agnostic sink recognizers over a subtree. Each sink is tagged with the npm package behind -// it: resolved precisely from the call's base identifier via the file's imports, else inferred from -// the file's imports of a known provider for that sink kind. A receiver that traces to a plain local -// object/class/function is NOT a dependency sink and is dropped. -function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] { - const sinks: Sink[] = []; - const baseOf = (base: any): { pkg?: string; local?: boolean; root?: string } => { - const root = base ? rootIdentifier(base, ts) : undefined; - if (!root) return {}; - const pkg = npmPackageOf(bindings.resolve(root)); - if (pkg) return { pkg, root }; - return { local: bindings.locals.has(root), root }; - }; - const infer = (kind: 'db' | 'http'): string | undefined => { - const table = kind === 'db' ? DB_PACKAGES : HTTP_PACKAGES; - for (const p of table) if (bindings.imports.has(p)) return p; - return undefined; - }; - const push = (s: Sink) => sinks.push(s); - const visit = (n: any) => { - // A function that is DECLARED here but not invoked here is not reached by this endpoint — walking - // into it would report sinks the endpoint never touches (e.g. an unused local helper that shells - // out). Skip those subtrees; when the handler DOES call such a helper, `localCalls` + - // `collectLocalSinks` bring its sinks in by name. Inline callbacks / IIFEs are NOT skipped — those - // do run (`items.map(x => db.insert(x))`, `.then(...)`). - if (n !== node && isUninvokedFunctionDeclaration(n, ts)) return; - if (ts.isCallExpression(n)) { - const callee = n.expression; - // db: `.from("t").()` (supabase/knex/kysely) - if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'from') { - const b = baseOf(callee.expression); - const t = n.arguments[0]; - const table = t && ts.isStringLiteralLike(t) ? t.text : undefined; - const parent = n.parent; - if (!b.local && parent && ts.isPropertyAccessExpression(parent) && DB_OPS.has(parent.name.text)) { - push({ kind: 'db', provider: 'sql', package: b.pkg ?? infer('db'), table, op: parent.name.text, ...spanOf(opCallOf(parent, ts)) }); - } - } - if (ts.isPropertyAccessExpression(callee)) { - const method = callee.name.text; - const b = baseOf(callee.expression); - if (!b.local) { - // db: prisma-style `prisma..()` — the op names are generic (`delete`, `update`, …), - // so require a real prisma signal: a resolved binding, the import, or a prisma-named receiver. - if (PRISMA_OPS.has(method) && ts.isPropertyAccessExpression(callee.expression)) { - const prismaLikely = b.pkg === '@prisma/client' || - (!b.pkg && (bindings.imports.has('@prisma/client') || /prisma/i.test(b.root ?? ''))); - if (prismaLikely) push({ kind: 'db', provider: 'prisma', package: '@prisma/client', table: callee.expression.name.text, op: method, ...spanOf(n) }); - } - // db: raw `.query(` / `.execute(` - if (method === 'query' || method === 'execute') { - push({ kind: 'db', provider: 'sql', package: b.pkg ?? infer('db'), op: method, ...spanOf(n) }); - } - // fs / exec via a namespace: `fs.writeFile(` / `child_process.exec(` - if (FS_CALLS.test(method)) push({ kind: 'fs', package: b.pkg, op: method, ...spanOf(n) }); - if (EXEC_CALLS.test(method)) push({ kind: 'exec', package: b.pkg, op: method, ...spanOf(n) }); - // http: any client whose binding resolves to a known http package (`axios.get`, `ky.post`, - // `http.request`), else the classic identifiers by name as a heuristic. - if (HTTP_MEMBER_METHODS.has(method)) { - if (b.pkg && isHttpPackage(b.pkg)) { - push({ kind: 'http', provider: b.root, package: b.pkg, op: method, ...spanOf(n) }); - } else if (!b.pkg && ts.isIdentifier(callee.expression) && /^(axios|http|https|got|ky)$/.test(callee.expression.text)) { - push({ kind: 'http', provider: callee.expression.text, package: infer('http'), op: method, ...spanOf(n) }); - } - } - } - } - // Bare calls: `fetch(…)` / `exec(…)` / `readFile(…)` / `eval(…)`. A dangerous NAME is not a - // dangerous API: `import { fetch } from './util'` and a callback parameter named `fetch` both look - // identical here, and treating either as an HTTP request produced a FALSE SSRF candidate. So the - // call must be justified — either it resolves to a module that plausibly provides that API, or it - // is a genuine unresolved global (only `fetch`/`eval`/`Function` ever are). - if (ts.isIdentifier(callee) && !bindings.locals.has(callee.text)) { - const name = callee.text; - const spec = bindings.resolve(name); - const pkg = npmPackageOf(spec); - const shadowed = isShadowedByEnclosingBinding(n, name, ts); - // A relative import resolves to no package: it's app code, not the API it shares a name with. - const fromModule = spec !== undefined; - const trueGlobal = !fromModule && !shadowed; - - if (HTTP_CALLS.test(name)) { - if (pkg && isHttpPackage(pkg)) push({ kind: 'http', provider: name, package: pkg, op: 'request', ...spanOf(n) }); - else if (name === 'fetch' && trueGlobal) push({ kind: 'http', provider: 'fetch', op: 'request', ...spanOf(n) }); - } - // `readFile`/`exec` are never globals: without a matching module binding this is app code. - if (FS_CALLS.test(name) && pkg && /^node:fs(\/promises)?$/.test(pkg)) { - push({ kind: 'fs', package: pkg, op: name, ...spanOf(n) }); - } - if (EXEC_CALLS.test(name) && pkg === 'node:child_process') { - push({ kind: 'exec', package: pkg, op: name, ...spanOf(n) }); - } - if (name === 'eval' && trueGlobal) push({ kind: 'eval', op: 'eval', ...spanOf(n) }); - } - } - if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'Function' - && !bindings.locals.has('Function') && !isShadowedByEnclosingBinding(n, 'Function', ts)) { - push({ kind: 'eval', op: 'new Function', ...spanOf(n) }); - } - ts.forEachChild(n, visit); - }; - visit(node); - return sinks; -} - -// A named function *declaration*, or a function bound to a variable/property — i.e. code that only runs -// if something calls it. An inline callback (an arrow passed as an argument), an IIFE, or a function -// used directly in an expression is NOT this: those execute where they appear. -function isUninvokedFunctionDeclaration(n: any, ts: TsModule): boolean { - if (ts.isFunctionDeclaration(n)) return true; - if (isFnLike(n, ts)) { - const p = n.parent; - if (p && (ts.isVariableDeclaration(p) || ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p))) return true; - } - return false; -} - -// --- input → sink flow linking --------------------------------------------- -// Evidence-backed data links: for each sink, does an INPUT identifier/path appear inside the sink -// call's arguments? "Tainted" roots are the handler's own parameter names (`{ data }`, `req`) plus any -// local alias of them (`const body = await request.json()`, `const { title } = data`). -// -// Deliberately conservative: a match yields `precise`; no match yields `heuristic` (the input and sink -// merely co-occur). It never claims a flow it didn't see, which is the point — a consumer pinning a -// rule to a parameter should trust `precise` and treat `heuristic` as "may reach". -// Spread onto an endpoint: `flows`, plus `limitations` only when there are any (keeps the common case clean). -function linkedFlows(body: any, params: any, inputs: InputField[], sinks: Sink[], ts: TsModule): { flows: Flow[]; limitations?: Limitation[] } { - const { flows, limitations } = linkFlows(body, params, inputs, sinks, ts); - return limitations.length > 0 ? { flows, limitations } : { flows }; -} - -function linkFlows( - bodyNode: any, - params: any, - inputs: InputField[], - sinks: Sink[], - ts: TsModule, -): { flows: Flow[]; limitations: Limitation[] } { - if (!bodyNode || sinks.length === 0 || inputs.length === 0) return { flows: [], limitations: [] }; - - // Tainted roots and the PATH each one stands for. `req` → '' (its own members are the path); - // `const { billing } = await req.json()` → billing stands for 'billing', so a read of - // `billing.email` normalizes to 'billing.email' and can be compared with the input path. - // Bindings whose members ARE the input paths, so they contribute no segment: a request source - // (`{ body }`), and the validated-payload conventions of the server-fn frameworks (`{ data }` for - // TanStack). Getting this wrong shifts every path by one segment and silently kills all matching. - const CONTAINER_KEYS = new Set([...REQ_SOURCES, 'data', 'input', 'payload']); - const rootPath = new Map(); - const addRoot = (name: string, path: string) => { if (!rootPath.has(name)) rootPath.set(name, path); }; - for (const p of params ?? []) { - if (!p?.name) continue; - if (ts.isIdentifier(p.name)) addRoot(p.name.text, ''); - else if (ts.isObjectBindingPattern(p.name)) { - for (const el of p.name.elements) { - if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; - const key = bindingKey(el, ts); - // A destructured request source (`{ body }`) is a container: its members ARE the paths. - addRoot(el.name.text, key && CONTAINER_KEYS.has(key) ? '' : key ?? el.name.text); - } - } - } - - // Does this initializer carry request data? Includes `Schema.parse(await req.json())`: VALIDATION IS - // NOT SANITIZATION — a validated value is still attacker-controlled, and treating it as clean would - // silently drop every flow in a validated handler (the common TanStack/Next shape). - const requestReadPath = (init: any): string | undefined => { - let cur = init; - while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; - if (!cur) return undefined; - if (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { - const m = cur.expression.name.text; - if (['json', 'formData', 'text'].includes(m)) { - const root = rootIdentifier(cur.expression.expression, ts); - return root && rootPath.has(root) ? '' : undefined; - } - if (['parse', 'safeParse', 'validate', 'cast'].includes(m)) { - for (const a of cur.arguments) { - const inner = requestReadPath(a); - if (inner !== undefined) return inner; // taint survives validation - } - return undefined; - } - } - const p = pathFromTainted(cur, ts, rootPath); - return p; - }; - - const aliasVisit = (n: any) => { - if (ts.isVariableDeclaration(n) && n.initializer) { - const base = requestReadPath(n.initializer); - if (base !== undefined) { - if (ts.isIdentifier(n.name)) addRoot(n.name.text, base); - 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); - addRoot(el.name.text, join2(base, key ?? el.name.text)); - } - } - } - } - ts.forEachChild(n, aliasVisit); - }; - aliasVisit(bodyNode); - - // Index every call by its start offset so a sink's span identifies its EXACT call node. - // Keyed by start+end: in `db.from(t).insert(x)` BOTH calls start at `db`, so the start offset alone - // is ambiguous — the end distinguishes them. - const callBySpan = new Map(); - const callVisit = (n: any) => { - // NewExpression too, or `new Function(...)` — inventoried as an eval sink — could never be located, - // leaving its flows permanently heuristic and its argument-role entry unreachable. - if (ts.isCallExpression(n) || ts.isNewExpression(n)) { - try { callBySpan.set(`${n.getStart()}:${n.getEnd()}`, n); } catch { /* synthetic */ } - } - ts.forEachChild(n, callVisit); - }; - callVisit(bodyNode); - - const flows: Flow[] = []; - const allLimits: Limitation[] = []; - for (const sink of sinks) { - // A sink from an imported module has no call site here — never claim precise for it. - const node = sink.file === undefined && sink.start !== undefined && sink.end !== undefined - ? callBySpan.get(`${sink.start}:${sink.end}`) - : undefined; - // path → the argument ROLES it was read into. Per-argument attribution is what makes a candidate - // possible: the same value in `url` vs `body`, or `path` vs `content`, implies different mitigations. - const reads = new Map>(); - const sinkLimits: Limitation[] = []; - if (node) { - // ONLY this sink call's own arguments, plus other calls in the SAME fluent chain - // (`.update({…}).eq('id', data.id)` is one operation). Never the enclosing statement: a sibling - // expression such as `Promise.all([audit(data.title), db.insert({…})])` must not lend evidence. - for (const call of fluentChainCalls(node, ts)) { - const method = calleeName(call, ts); - const args = call.arguments ?? []; - for (const a of args) for (const l of sinkArgumentLimitations(a, ts, rootPath)) sinkLimits.push(l); - for (let i = 0; i < args.length; i++) { - const role = argumentRoleOf(sink.kind, method, i, args.length); - for (const path of taintedReadPaths(args[i], ts, rootPath)) { - const set = reads.get(path) ?? new Set(); - set.add(role); - reads.set(path, set); - } - } - } - } - for (const input of inputs) { - const inputPath = normalizePath(input.name); - // Exact path, or the input is an ANCESTOR of what was read (`billing` covers `billing.email`). - // A mere shared leaf name is NOT evidence: `billing.email` and `shipping.email` are different. - const matched = [...reads.entries()].filter(([r]) => r === inputPath || r.startsWith(inputPath + '.')); - const precise = matched.length > 0; - const roles = new Set(matched.flatMap(([, rs]) => [...rs])); - // Prefer a role that maps to a mitigation class over a generic one (a value can reach two args). - const family = [...roles].map((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]).find(Boolean); - const argumentRole = family - ? [...roles].find((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]) - : [...roles].find((r) => r !== 'unknown') ?? (precise ? 'unknown' : undefined); - - // Deliberately SEPARATE from confidence: `precise` means "the source reaches the sink", which is - // not authorization to block traffic. Every remaining obstacle is listed, so this doubles as the - // queue for improving the extractor/adapters rather than silently losing the opportunity. - const reasons: string[] = []; - if (!precise) reasons.push('flow evidence is heuristic, not precise'); - if (!input.runtimeParameter) reasons.push(input.runtimeParameterReason ?? 'input has no runtime parameter'); - if (sink.file !== undefined) reasons.push('sink is in an imported module: no local call-site evidence'); - if (sink.start === undefined) reasons.push('sink call could not be located in the source'); - if (precise && argumentRole === 'unknown') reasons.push(`sink argument role is not modelled for ${sink.kind}.${sink.op ?? '?'}`); - // A dynamic key or a spread in this sink's arguments means no coordinate can name the field that - // actually reaches it — report the specific cause rather than a generic "heuristic". - for (const l of sinkLimits) { - reasons.push(l.kind === 'dynamic-key' - ? `dynamic computed key reaches this sink (${l.detail}): the field cannot be named by a parameter` - : `spread reaches this sink (${l.detail}): the specific field is not identifiable`); - } - if (precise && argumentRole && argumentRole !== 'unknown' && !family) { - // e.g. a request value in a parameterized db `values` object: real reachability, but not a - // pattern a generic blocking rule can express. - reasons.push(`argument role "${argumentRole}" on a ${sink.kind} sink is not a blockable pattern on its own`); - } - flows.push({ - input: input.name, - sink, - confidence: precise ? 'precise' : 'heuristic', - line: sink.line, - ...(argumentRole ? { argumentRole } : {}), - ...(family ? { candidateFamily: family } : {}), - ruleGeneratable: reasons.length === 0, - ruleGeneratableReasons: reasons, - }); - } - for (const l of sinkLimits) allLimits.push(l); - } - return { flows, limitations: dedupeLimitations(allLimits) }; -} - -function dedupeLimitations(list: Limitation[]): Limitation[] { - const seen = new Set(); - return list.filter((l) => { - const k = `${l.kind}:${l.detail}:${l.line}`; - if (seen.has(k)) return false; - seen.add(k); - return true; - }); -} - -/** Join two path segments, tolerating an empty base. */ -function join2(base: string, seg: string): string { - return base ? `${base}.${seg}` : seg; -} - -// --- adapter summaries: which argument means what --------------------------- -// Small, testable per-library summaries, keyed by sink kind so an overloaded name (`get`) can't be read -// as the wrong thing. This is the cheap foundation the review recommended BEFORE a whole-program -// dataflow engine: without argument roles, "the input reaches this sink" cannot be turned into a rule, -// because the mitigation class depends on WHICH argument received the value. -const ARGUMENT_ROLES: Record> = { - exec: { - exec: ['command'], execSync: ['command'], - execFile: ['file', 'args'], execFileSync: ['file', 'args'], - spawn: ['command', 'args'], spawnSync: ['command', 'args'], fork: ['file', 'args'], - }, - http: { - fetch: ['url', 'init'], request: ['url', 'options'], - get: ['url', 'options'], head: ['url', 'options'], delete: ['url', 'options'], - post: ['url', 'body'], put: ['url', 'body'], patch: ['url', 'body'], - }, - fs: { - readFile: ['path'], readFileSync: ['path'], open: ['path'], - writeFile: ['path', 'content'], writeFileSync: ['path', 'content'], - appendFile: ['path', 'content'], - unlink: ['path'], rm: ['path'], rmSync: ['path'], mkdir: ['path'], readdir: ['path'], stat: ['path'], - createReadStream: ['path'], createWriteStream: ['path'], - }, - db: { - query: ['sql', 'values'], execute: ['sql', 'values'], - insert: ['values'], update: ['values'], upsert: ['values'], select: ['columns'], - // Filters: the value half is still request data reaching the query, but as a bound parameter. - eq: ['column', 'value'], neq: ['column', 'value'], gt: ['column', 'value'], gte: ['column', 'value'], - lt: ['column', 'value'], lte: ['column', 'value'], like: ['column', 'value'], ilike: ['column', 'value'], - match: ['values'], filter: ['column', 'value'], - }, - eval: { eval: ['code'], Function: ['code'] }, -}; - -/** - * The (sink kind, argument role) pairs where a request value arriving is inherently dangerous AND a rule - * can express the mitigation. Deliberately narrow — notably `db`+`values` is absent: a request value in - * a parameterized insert is genuine reachability signal but not a blockable pattern by itself. - */ -const CANDIDATE_FAMILIES: Record>> = { - http: { url: 'ssrf' }, - exec: { command: 'command-injection', file: 'command-injection', args: 'command-injection' }, - fs: { path: 'path-traversal' }, - db: { sql: 'sql-injection' }, - eval: { code: 'code-injection' }, -}; - -/** - * Is `name` bound by an enclosing function parameter (or catch clause) at this call site? If so the call - * is NOT the global of that name — a callback parameter called `fetch` is the single most likely way to - * fake an SSRF candidate. Scoped to parameters/catch bindings: cheap, and it covers the shadowing shapes - * that occur in practice. Erring here loses a candidate rather than inventing one. - */ -function isShadowedByEnclosingBinding(node: any, name: string, ts: TsModule): boolean { - for (let cur = node?.parent; cur; cur = cur.parent) { - if (ts.isCatchClause(cur) && cur.variableDeclaration && ts.isIdentifier(cur.variableDeclaration.name) - && cur.variableDeclaration.name.text === name) return true; - const params = (cur as any).parameters; - if (!params) continue; - for (const p of params) { - if (!p?.name) continue; - if (ts.isIdentifier(p.name) && p.name.text === name) return true; - if (ts.isObjectBindingPattern(p.name) || ts.isArrayBindingPattern(p.name)) { - for (const el of p.name.elements) { - if (ts.isBindingElement(el) && ts.isIdentifier(el.name) && el.name.text === name) return true; - } - } - } - } - return false; -} - -/** Method name a call invokes (`db.from(t).insert(x)` → "insert", `exec(x)` → "exec"). */ -function calleeName(call: any, ts: TsModule): string | undefined { - const c = call?.expression; - if (!c) return undefined; - if (ts.isPropertyAccessExpression(c)) return c.name.text; - if (ts.isIdentifier(c)) return c.text; // also covers `new Function(...)` - return undefined; -} - -/** Role of argument `index` for this call, given the sink kind it was recognized as. */ -function argumentRoleOf(sinkKind: string, method: string | undefined, index: number, total = 0): ArgumentRole { - // `new Function(a, b, "return a+b")` — every argument but the LAST declares a parameter name; only the - // last one is executable code. An index-based table cannot express that. - if (sinkKind === 'eval' && method === 'Function') return index === total - 1 ? 'code' : 'args'; - const table = method ? ARGUMENT_ROLES[sinkKind]?.[method] : undefined; - return table?.[index] ?? 'unknown'; -} - -/** - * Canonical form for comparing paths: index/array tokens are erased and empty segments collapsed, so - * `tags[0].label`, `tags[].label` and `tags.label` all compare equal, while DISTINCT paths such as - * `billing.email` and `shipping.email` stay distinct (the previous leaf-only comparison conflated them). - */ -function normalizePath(path: string): string { - return path - .replace(/\[\d*\]/g, '') - .split('.') - .filter(Boolean) - .join('.'); -} - -/** - * Calls belonging to the same fluent chain as `call` — the same logical operation. Walking UP stops at - * anything that is not a continuation of the chain (an array literal, an argument position), which is - * what keeps a sibling expression in the same statement from lending evidence. - */ -function fluentChainCalls(call: any, ts: TsModule): any[] { - let root = call; - for (;;) { - const p = root.parent; - if (p && ts.isPropertyAccessExpression(p) && p.expression === root) { root = p; continue; } - if (p && ts.isCallExpression(p) && p.expression === root) { root = p; continue; } - if (p && (ts.isAwaitExpression(p) || ts.isParenthesizedExpression(p) || ts.isNonNullExpression(p)) && p.expression === root) { root = p; continue; } - break; - } - const out: any[] = []; - const collect = (n: any) => { - if (!n) return; - if (ts.isCallExpression(n) || ts.isNewExpression(n)) out.push(n); - if (ts.isCallExpression(n) || ts.isPropertyAccessExpression(n) || ts.isAwaitExpression(n) || ts.isParenthesizedExpression(n) || ts.isNonNullExpression(n)) { - collect(n.expression); - } - }; - collect(root); - return out; -} - -/** - * The canonical PATHS of values genuinely read from a tainted source inside `node` — the evidence behind - * a `precise` flow. Full paths, not leaf names: `data.shipping.email` yields `shipping.email`, so it can - * never be mistaken for the distinct input `billing.email`. Array indices normalize to `[]`. - * Property KEYS, member names and binding names are not reads. - */ -function taintedReadPaths(node: any, ts: TsModule, rootPath: Map): Set { - const out = new Set(); - const visit = (n: any) => { - if (!n) return; - if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) { - const path = pathFromTainted(n, ts, rootPath); - if (path !== undefined) { out.add(path); return; } // the inner nodes are the path, not separate reads - } - if (ts.isIdentifier(n) && rootPath.has(n.text) && isValueRead(n, ts)) { - const p = rootPath.get(n.text)!; - if (p) out.add(normalizePath(p)); - } - ts.forEachChild(n, visit); - }; - visit(node); - return out; -} - -/** - * Shapes that defeat parameter pinning, found in a sink call's arguments. Reporting these is the point: - * "we could not model this" is far more useful to an operator than an endpoint that silently shows no - * flow, and it is the queue for improving the extractor. - * - `insert({ v: body[field] })` → the field is chosen at runtime; no coordinate can name it. - * - `insert({ ...body })` → the whole payload reaches the sink; which field is unidentifiable. - */ -function sinkArgumentLimitations(node: any, ts: TsModule, rootPath: Map): Limitation[] { - const out: Limitation[] = []; - const seen = new Set(); - const add = (kind: Limitation['kind'], detail: string, n: any) => { - const key = `${kind}:${detail}`; - if (seen.has(key)) return; - seen.add(key); - out.push({ kind, detail, line: lineOf(n) }); - }; - const text = (n: any) => { - try { return String(n.getText()).replace(/\s+/g, ' ').slice(0, 120); } catch { return ''; } - }; - const visit = (n: any) => { - if (!n) return; - // A computed member read off tainted data with a non-literal index. - if (ts.isElementAccessExpression(n)) { - const root = rootIdentifier(n.expression, ts); - const arg = n.argumentExpression; - if (root && rootPath.has(root) && arg && !ts.isStringLiteralLike(arg) && !ts.isNumericLiteral(arg)) { - add('dynamic-key', text(n), n); - } - } - // A spread of tainted data into the sink's argument. - if ((ts.isSpreadAssignment?.(n) || ts.isSpreadElement(n)) && n.expression) { - const root = rootIdentifier(n.expression, ts); - if (root && rootPath.has(root)) add('spread-into-sink', text(n.parent ?? n), n); - } - ts.forEachChild(n, visit); - }; - visit(node); - return out; -} - -/** Canonical path of a member/element access rooted in a tainted binding, or undefined if not tainted. */ -function pathFromTainted(node: any, ts: TsModule, rootPath: Map): string | undefined { - const segs: string[] = []; - let cur = node; - for (;;) { - if (ts.isPropertyAccessExpression(cur)) { segs.unshift(cur.name.text); cur = cur.expression; continue; } - if (ts.isElementAccessExpression(cur)) { - const a = cur.argumentExpression; - segs.unshift(a && ts.isStringLiteralLike(a) ? a.text : '[]'); - cur = cur.expression; - continue; - } - if (ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) { cur = cur.expression; continue; } - break; - } - if (!cur || !ts.isIdentifier(cur)) return undefined; - const base = rootPath.get(cur.text); - if (base === undefined) return undefined; - // Drop a leading NAMESPACE segment (`req.body.webhookUrl` → `webhookUrl`). Input names — and the - // runtime coordinates derived from them — are relative to their namespace (`post.webhookUrl`), so - // leaving `body.` in the read path would fail to match the very inputs it came from. Without this, - // the highest-value flows (`req.body.webhookUrl` → fetch, `req.body.command` → exec) never reach - // `precise`. - if (base === '' && segs.length > 1 && REQ_SOURCES.includes(segs[0]!)) segs.shift(); - return normalizePath([base, ...segs].filter(Boolean).join('.')); -} - -/** Is this identifier occurrence a VALUE read (rather than a property key, a member name, a binding)? */ -function isValueRead(id: any, ts: TsModule): boolean { - const p = id.parent; - if (!p) return true; - if (ts.isPropertyAssignment(p) && p.name === id) return false; // { title: … } — a key - if (ts.isPropertyAccessExpression(p) && p.name === id) return false; // x.title — the member name - if (ts.isBindingElement(p) && p.propertyName === id) return false; // { title: t } — the source key - if ((ts.isVariableDeclaration(p) || ts.isParameter(p) || ts.isBindingElement(p)) && p.name === id) return false; - if (ts.isPropertySignature(p) || ts.isMethodSignature(p)) return false; - return true; // includes ShorthandPropertyAssignment `{ title }`, which IS a read -} - function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -// From a `.insert` property access, the CallExpression that invokes it — the sink's operation call. -function opCallOf(propAccess: any, ts: TsModule): any { - const p = propAccess?.parent; - return p && ts.isCallExpression(p) && p.expression === propAccess ? p : propAccess; -} - -function localCalls(node: any, ts: TsModule): string[] { - const names: string[] = []; - const visit = (n: any) => { - if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) names.push(n.expression.text); - ts.forEachChild(n, visit); - }; - visit(node); - return names; -} - -// Deterministic identity for a sink, so `Flow.sink` (an embedded copy) can be correlated back to the -// inventory entry without deep-equality. -function sinkId(s: Sink): string { - return createHash('sha256') - .update([s.kind, s.provider, s.package, s.table, s.op, s.file, s.start, s.end].join('|')) - .digest('hex') - .slice(0, 12); -} - -function dedupeSinks(sinks: Sink[]): Sink[] { - const seen = new Set(); - const out: Sink[] = []; - for (const s of sinks) { - const key = `${s.kind}:${s.provider}:${s.package}:${s.table}:${s.op}:${s.line}:${s.start}`; - if (!seen.has(key)) { seen.add(key); out.push({ ...s, id: sinkId(s) }); } - } - return out; -} +// Re-exported so `./extract.js` stays the directory's entry point for consumers and tests. +export { runtimeCoordinate } from './coordinates.js'; +export { functionNameFromPath, routeFromFilePath } from './routes.js'; +export type { WalkStats } from './sources.js'; +export type { ModuleGraph } from './sinks.js'; diff --git a/src/map/flows.ts b/src/map/flows.ts new file mode 100644 index 0000000..68d07da --- /dev/null +++ b/src/map/flows.ts @@ -0,0 +1,330 @@ +import type { ArgumentRole, Flow, InputField, Limitation, Sink, TsModule } from './types.js'; +import { bindingKey, calleeName, isValueRead, lineOf, rootIdentifier } from './ast.js'; +import { REQ_SOURCES } from './inputs.js'; +import { argumentRoleOf, CANDIDATE_FAMILIES } from './sinks.js'; + +// --- input → sink flow linking --------------------------------------------- +// Evidence-backed data links: for each sink, does an INPUT identifier/path appear inside the sink +// call's arguments? "Tainted" roots are the handler's own parameter names (`{ data }`, `req`) plus any +// local alias of them (`const body = await request.json()`, `const { title } = data`). +// +// Deliberately conservative: a match yields `precise`; no match yields `heuristic` (the input and sink +// merely co-occur). It never claims a flow it didn't see, which is the point — a consumer pinning a +// rule to a parameter should trust `precise` and treat `heuristic` as "may reach". +// Spread onto an endpoint: `flows`, plus `limitations` only when there are any (keeps the common case clean). +export function linkedFlows(body: any, params: any, inputs: InputField[], sinks: Sink[], ts: TsModule): { flows: Flow[]; limitations?: Limitation[] } { + const { flows, limitations } = linkFlows(body, params, inputs, sinks, ts); + return limitations.length > 0 ? { flows, limitations } : { flows }; +} + +function linkFlows( + bodyNode: any, + params: any, + inputs: InputField[], + sinks: Sink[], + ts: TsModule, +): { flows: Flow[]; limitations: Limitation[] } { + if (!bodyNode || sinks.length === 0 || inputs.length === 0) return { flows: [], limitations: [] }; + + // Tainted roots and the PATH each one stands for. `req` → '' (its own members are the path); + // `const { billing } = await req.json()` → billing stands for 'billing', so a read of + // `billing.email` normalizes to 'billing.email' and can be compared with the input path. + // Bindings whose members ARE the input paths, so they contribute no segment: a request source + // (`{ body }`), and the validated-payload conventions of the server-fn frameworks (`{ data }` for + // TanStack). Getting this wrong shifts every path by one segment and silently kills all matching. + const CONTAINER_KEYS = new Set([...REQ_SOURCES, 'data', 'input', 'payload']); + const rootPath = new Map(); + const addRoot = (name: string, path: string) => { if (!rootPath.has(name)) rootPath.set(name, path); }; + for (const p of params ?? []) { + if (!p?.name) continue; + if (ts.isIdentifier(p.name)) addRoot(p.name.text, ''); + else if (ts.isObjectBindingPattern(p.name)) { + for (const el of p.name.elements) { + if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; + const key = bindingKey(el, ts); + // A destructured request source (`{ body }`) is a container: its members ARE the paths. + addRoot(el.name.text, key && CONTAINER_KEYS.has(key) ? '' : key ?? el.name.text); + } + } + } + + // Does this initializer carry request data? Includes `Schema.parse(await req.json())`: VALIDATION IS + // NOT SANITIZATION — a validated value is still attacker-controlled, and treating it as clean would + // silently drop every flow in a validated handler (the common TanStack/Next shape). + const requestReadPath = (init: any): string | undefined => { + let cur = init; + while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; + if (!cur) return undefined; + if (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { + const m = cur.expression.name.text; + if (['json', 'formData', 'text'].includes(m)) { + const root = rootIdentifier(cur.expression.expression, ts); + return root && rootPath.has(root) ? '' : undefined; + } + if (['parse', 'safeParse', 'validate', 'cast'].includes(m)) { + for (const a of cur.arguments) { + const inner = requestReadPath(a); + if (inner !== undefined) return inner; // taint survives validation + } + return undefined; + } + } + const p = pathFromTainted(cur, ts, rootPath); + return p; + }; + + const aliasVisit = (n: any) => { + if (ts.isVariableDeclaration(n) && n.initializer) { + const base = requestReadPath(n.initializer); + if (base !== undefined) { + if (ts.isIdentifier(n.name)) addRoot(n.name.text, base); + 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); + addRoot(el.name.text, join2(base, key ?? el.name.text)); + } + } + } + } + ts.forEachChild(n, aliasVisit); + }; + aliasVisit(bodyNode); + + // Index every call by its start offset so a sink's span identifies its EXACT call node. + // Keyed by start+end: in `db.from(t).insert(x)` BOTH calls start at `db`, so the start offset alone + // is ambiguous — the end distinguishes them. + const callBySpan = new Map(); + const callVisit = (n: any) => { + // NewExpression too, or `new Function(...)` — inventoried as an eval sink — could never be located, + // leaving its flows permanently heuristic and its argument-role entry unreachable. + if (ts.isCallExpression(n) || ts.isNewExpression(n)) { + try { callBySpan.set(`${n.getStart()}:${n.getEnd()}`, n); } catch { /* synthetic */ } + } + ts.forEachChild(n, callVisit); + }; + callVisit(bodyNode); + + const flows: Flow[] = []; + const allLimits: Limitation[] = []; + for (const sink of sinks) { + // A sink from an imported module has no call site here — never claim precise for it. + const node = sink.file === undefined && sink.start !== undefined && sink.end !== undefined + ? callBySpan.get(`${sink.start}:${sink.end}`) + : undefined; + // path → the argument ROLES it was read into. Per-argument attribution is what makes a candidate + // possible: the same value in `url` vs `body`, or `path` vs `content`, implies different mitigations. + const reads = new Map>(); + const sinkLimits: Limitation[] = []; + if (node) { + // ONLY this sink call's own arguments, plus other calls in the SAME fluent chain + // (`.update({…}).eq('id', data.id)` is one operation). Never the enclosing statement: a sibling + // expression such as `Promise.all([audit(data.title), db.insert({…})])` must not lend evidence. + for (const call of fluentChainCalls(node, ts)) { + const method = calleeName(call, ts); + const args = call.arguments ?? []; + for (const a of args) for (const l of sinkArgumentLimitations(a, ts, rootPath)) sinkLimits.push(l); + for (let i = 0; i < args.length; i++) { + const role = argumentRoleOf(sink.kind, method, i, args.length); + for (const path of taintedReadPaths(args[i], ts, rootPath)) { + const set = reads.get(path) ?? new Set(); + set.add(role); + reads.set(path, set); + } + } + } + } + for (const input of inputs) { + const inputPath = normalizePath(input.name); + // Exact path, or the input is an ANCESTOR of what was read (`billing` covers `billing.email`). + // A mere shared leaf name is NOT evidence: `billing.email` and `shipping.email` are different. + const matched = [...reads.entries()].filter(([r]) => r === inputPath || r.startsWith(inputPath + '.')); + const precise = matched.length > 0; + const roles = new Set(matched.flatMap(([, rs]) => [...rs])); + // Prefer a role that maps to a mitigation class over a generic one (a value can reach two args). + const family = [...roles].map((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]).find(Boolean); + const argumentRole = family + ? [...roles].find((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]) + : [...roles].find((r) => r !== 'unknown') ?? (precise ? 'unknown' : undefined); + + // Deliberately SEPARATE from confidence: `precise` means "the source reaches the sink", which is + // not authorization to block traffic. Every remaining obstacle is listed, so this doubles as the + // queue for improving the extractor/adapters rather than silently losing the opportunity. + const reasons: string[] = []; + if (!precise) reasons.push('flow evidence is heuristic, not precise'); + if (!input.runtimeParameter) reasons.push(input.runtimeParameterReason ?? 'input has no runtime parameter'); + if (sink.file !== undefined) reasons.push('sink is in an imported module: no local call-site evidence'); + if (sink.start === undefined) reasons.push('sink call could not be located in the source'); + if (precise && argumentRole === 'unknown') reasons.push(`sink argument role is not modelled for ${sink.kind}.${sink.op ?? '?'}`); + // A dynamic key or a spread in this sink's arguments means no coordinate can name the field that + // actually reaches it — report the specific cause rather than a generic "heuristic". + for (const l of sinkLimits) { + reasons.push(l.kind === 'dynamic-key' + ? `dynamic computed key reaches this sink (${l.detail}): the field cannot be named by a parameter` + : `spread reaches this sink (${l.detail}): the specific field is not identifiable`); + } + if (precise && argumentRole && argumentRole !== 'unknown' && !family) { + // e.g. a request value in a parameterized db `values` object: real reachability, but not a + // pattern a generic blocking rule can express. + reasons.push(`argument role "${argumentRole}" on a ${sink.kind} sink is not a blockable pattern on its own`); + } + flows.push({ + input: input.name, + sink, + confidence: precise ? 'precise' : 'heuristic', + line: sink.line, + ...(argumentRole ? { argumentRole } : {}), + ...(family ? { candidateFamily: family } : {}), + ruleGeneratable: reasons.length === 0, + ruleGeneratableReasons: reasons, + }); + } + for (const l of sinkLimits) allLimits.push(l); + } + return { flows, limitations: dedupeLimitations(allLimits) }; +} + +function dedupeLimitations(list: Limitation[]): Limitation[] { + const seen = new Set(); + return list.filter((l) => { + const k = `${l.kind}:${l.detail}:${l.line}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); +} + +/** Join two path segments, tolerating an empty base. */ +function join2(base: string, seg: string): string { + return base ? `${base}.${seg}` : seg; +} + +/** + * Canonical form for comparing paths: index/array tokens are erased and empty segments collapsed, so + * `tags[0].label`, `tags[].label` and `tags.label` all compare equal, while DISTINCT paths such as + * `billing.email` and `shipping.email` stay distinct (the previous leaf-only comparison conflated them). + */ +function normalizePath(path: string): string { + return path + .replace(/\[\d*\]/g, '') + .split('.') + .filter(Boolean) + .join('.'); +} + +/** + * Calls belonging to the same fluent chain as `call` — the same logical operation. Walking UP stops at + * anything that is not a continuation of the chain (an array literal, an argument position), which is + * what keeps a sibling expression in the same statement from lending evidence. + */ +function fluentChainCalls(call: any, ts: TsModule): any[] { + let root = call; + for (;;) { + const p = root.parent; + if (p && ts.isPropertyAccessExpression(p) && p.expression === root) { root = p; continue; } + if (p && ts.isCallExpression(p) && p.expression === root) { root = p; continue; } + if (p && (ts.isAwaitExpression(p) || ts.isParenthesizedExpression(p) || ts.isNonNullExpression(p)) && p.expression === root) { root = p; continue; } + break; + } + const out: any[] = []; + const collect = (n: any) => { + if (!n) return; + if (ts.isCallExpression(n) || ts.isNewExpression(n)) out.push(n); + if (ts.isCallExpression(n) || ts.isPropertyAccessExpression(n) || ts.isAwaitExpression(n) || ts.isParenthesizedExpression(n) || ts.isNonNullExpression(n)) { + collect(n.expression); + } + }; + collect(root); + return out; +} + +/** + * The canonical PATHS of values genuinely read from a tainted source inside `node` — the evidence behind + * a `precise` flow. Full paths, not leaf names: `data.shipping.email` yields `shipping.email`, so it can + * never be mistaken for the distinct input `billing.email`. Array indices normalize to `[]`. + * Property KEYS, member names and binding names are not reads. + */ +function taintedReadPaths(node: any, ts: TsModule, rootPath: Map): Set { + const out = new Set(); + const visit = (n: any) => { + if (!n) return; + if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) { + const path = pathFromTainted(n, ts, rootPath); + if (path !== undefined) { out.add(path); return; } // the inner nodes are the path, not separate reads + } + if (ts.isIdentifier(n) && rootPath.has(n.text) && isValueRead(n, ts)) { + const p = rootPath.get(n.text)!; + if (p) out.add(normalizePath(p)); + } + ts.forEachChild(n, visit); + }; + visit(node); + return out; +} + +/** + * Shapes that defeat parameter pinning, found in a sink call's arguments. Reporting these is the point: + * "we could not model this" is far more useful to an operator than an endpoint that silently shows no + * flow, and it is the queue for improving the extractor. + * - `insert({ v: body[field] })` → the field is chosen at runtime; no coordinate can name it. + * - `insert({ ...body })` → the whole payload reaches the sink; which field is unidentifiable. + */ +function sinkArgumentLimitations(node: any, ts: TsModule, rootPath: Map): Limitation[] { + const out: Limitation[] = []; + const seen = new Set(); + const add = (kind: Limitation['kind'], detail: string, n: any) => { + const key = `${kind}:${detail}`; + if (seen.has(key)) return; + seen.add(key); + out.push({ kind, detail, line: lineOf(n) }); + }; + const text = (n: any) => { + try { return String(n.getText()).replace(/\s+/g, ' ').slice(0, 120); } catch { return ''; } + }; + const visit = (n: any) => { + if (!n) return; + // A computed member read off tainted data with a non-literal index. + if (ts.isElementAccessExpression(n)) { + const root = rootIdentifier(n.expression, ts); + const arg = n.argumentExpression; + if (root && rootPath.has(root) && arg && !ts.isStringLiteralLike(arg) && !ts.isNumericLiteral(arg)) { + add('dynamic-key', text(n), n); + } + } + // A spread of tainted data into the sink's argument. + if ((ts.isSpreadAssignment?.(n) || ts.isSpreadElement(n)) && n.expression) { + const root = rootIdentifier(n.expression, ts); + if (root && rootPath.has(root)) add('spread-into-sink', text(n.parent ?? n), n); + } + ts.forEachChild(n, visit); + }; + visit(node); + return out; +} + +/** Canonical path of a member/element access rooted in a tainted binding, or undefined if not tainted. */ +function pathFromTainted(node: any, ts: TsModule, rootPath: Map): string | undefined { + const segs: string[] = []; + let cur = node; + for (;;) { + if (ts.isPropertyAccessExpression(cur)) { segs.unshift(cur.name.text); cur = cur.expression; continue; } + if (ts.isElementAccessExpression(cur)) { + const a = cur.argumentExpression; + segs.unshift(a && ts.isStringLiteralLike(a) ? a.text : '[]'); + cur = cur.expression; + continue; + } + if (ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) { cur = cur.expression; continue; } + break; + } + if (!cur || !ts.isIdentifier(cur)) return undefined; + const base = rootPath.get(cur.text); + if (base === undefined) return undefined; + // Drop a leading NAMESPACE segment (`req.body.webhookUrl` → `webhookUrl`). Input names — and the + // runtime coordinates derived from them — are relative to their namespace (`post.webhookUrl`), so + // leaving `body.` in the read path would fail to match the very inputs it came from. Without this, + // the highest-value flows (`req.body.webhookUrl` → fetch, `req.body.command` → exec) never reach + // `precise`. + if (base === '' && segs.length > 1 && REQ_SOURCES.includes(segs[0]!)) segs.shift(); + return normalizePath([base, ...segs].filter(Boolean).join('.')); +} diff --git a/src/map/inputs.ts b/src/map/inputs.ts new file mode 100644 index 0000000..36e758a --- /dev/null +++ b/src/map/inputs.ts @@ -0,0 +1,194 @@ +import type { InputField, InputSource, TsModule } from './types.js'; +import { bindingKey, rootIdentifier } from './ast.js'; +import { npmPackageOf, type Bindings } from './bindings.js'; +import { runtimeCoordinate, withCoordinates } from './coordinates.js'; + +const ZOD_BASE = new Set(['string', 'number', 'boolean', 'array', 'object', 'enum', 'bigint', 'date', 'record']); +// String-format refinements a validator can declare — kept on the field so a rule can pin the shape. +const STRING_FORMATS = new Set(['email', 'uuid', 'url', 'ip', 'ipv4', 'ipv6', 'cuid', 'cuid2', 'ulid', 'emoji', 'datetime', 'base64', 'jwt', 'nanoid']); +// Packages whose `.object({…})` calls describe an input schema. +const VALIDATOR_PACKAGES = new Set(['zod', 'valibot', 'yup', 'joi', '@hapi/joi', 'superstruct']); + +// --- inputs ----------------------------------------------------------------- +export function inputsFromValidator(validatorCall: any, ts: TsModule, bindings: Bindings): InputField[] { + if (!validatorCall) return []; + return zodObjectFields(validatorCall, ts, bindings); +} + +// From a raw handler: validator schema fields it parses, plus the request fields it actually reads +// (member accesses, destructuring, `await request.json()` bodies). +export function inputsFromHandler( + params: any, + body: any, + ts: TsModule, + bindings: Bindings, + opts: { payloadParam?: boolean; validatorSource?: InputSource } = {}, +): InputField[] { + // 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 fields = withCoordinates(zodObjectFields(body, ts, bindings), opts.validatorSource ?? 'json-body'); + const names = new Set(fields.map((f) => f.name)); + for (const { name, source } of requestMemberAccesses(params, body, ts, opts)) { + if (!names.has(name)) { + names.add(name); + fields.push({ name, source, ...runtimeCoordinate(source, name) }); + } + } + return fields; +} + +// Find the first validator `.object({...})` in a subtree — gated on the receiver tracing to a known +// validator package (so an unrelated `.object(` never becomes a schema). An untraceable receiver +// literally named `z` is accepted as a heuristic (covers `z` re-exported from a local module). +function findValidatorObject(node: any, ts: TsModule, bindings: Bindings): any { + let found: any = null; + const find = (n: any) => { + if (found || !n) return; + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'object') { + const root = rootIdentifier(n.expression.expression, ts); + const pkg = root ? npmPackageOf(bindings.resolve(root)) : undefined; + const isValidator = (pkg && VALIDATOR_PACKAGES.has(pkg)) || (!pkg && root === 'z' && !bindings.locals.has(root)); + if (isValidator) { + const arg = n.arguments[0]; + if (arg && ts.isObjectLiteralExpression(arg)) { found = arg; return; } + } + } + ts.forEachChild(n, find); + }; + find(node); + return found; +} + +// Read a validator object's fields (name + type/constraints). Nested objects/arrays are flattened to +// dotted paths — `address.city`, `tags[].label` — the same coordinates `array_key_value` rules use. +function zodObjectFields(node: any, ts: TsModule, bindings: Bindings): InputField[] { + if (!node) return []; + const lit = findValidatorObject(node.body ?? node, ts, bindings); + return lit ? fieldsOfObject(lit, ts, bindings, '') : []; +} + +function fieldsOfObject(objectLiteral: any, ts: TsModule, bindings: Bindings, prefix: string): InputField[] { + const fields: InputField[] = []; + for (const p of objectLiteral.properties) { + if (!ts.isPropertyAssignment(p) || !p.name) continue; + const fname = (p.name as any).text; + if (!fname) continue; + const shape = zodShape(p.initializer, ts); + fields.push({ name: prefix + fname, ...shape }); + const nested = findValidatorObject(p.initializer, ts, bindings); + if (nested) fields.push(...fieldsOfObject(nested, ts, bindings, prefix + fname + (shape.type === 'array' ? '[].' : '.'))); + } + return fields; +} + +function numericValue(arg: any, ts: TsModule): number | undefined { + if (!arg) return undefined; + if (ts.isNumericLiteral(arg)) return Number(arg.text); + if (ts.isPrefixUnaryExpression(arg) && arg.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(arg.operand)) return -Number(arg.operand.text); + return undefined; +} + +function zodShape(node: any, ts: TsModule): Omit { + const shape: Omit = {}; + let cur = node; + while (cur && ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { + const method = cur.expression.name.text; + const arg0 = cur.arguments[0]; + if (ZOD_BASE.has(method) && !shape.type) shape.type = method; + if (method === 'min') { const v = numericValue(arg0, ts); if (v !== undefined) shape.min = v; } + if (method === 'max') { const v = numericValue(arg0, ts); if (v !== undefined) shape.max = v; } + if (STRING_FORMATS.has(method) && !shape.format) shape.format = method; + if (method === 'regex' && arg0 && ts.isRegularExpressionLiteral(arg0) && !shape.pattern) shape.pattern = arg0.text; + if (method === 'optional' || method === 'nullish') shape.optional = true; + cur = cur.expression.expression; + } + return shape; +} + +export const REQ_SOURCES = ['body', 'query', 'params']; + +// The request fields a handler reads, across the common idioms: +// req.body.x / req.query.x / req.params.x (member access) +// const { x } = req.body (destructuring) +// ({ body }) => body.x / const { x } = body (destructured handler param) +// const b = await request.json(); b.x / const { x } = await request.json() (fetch-style Request) +function requestMemberAccesses( + params: any, + body: any, + ts: TsModule, + opts: { payloadParam?: boolean } = {}, +): Array<{ name: string; source: InputSource }> { + if (!body) return []; + const out = new Map(); + const p0 = params?.[0]; + const reqName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : undefined; + // Identifiers that ARE a request-input object (destructured `({ body })` param, `await req.json()`). + const sourceNames = new Set(); + const payloadNames = new Set(); + if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name.text); + if (p0 && !reqName && 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.add(el.name.text); + } + } + const unwrap = (e: any): any => { + let cur = e; + while (cur && (ts.isAwaitExpression(cur) || ts.isAsExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; + return cur; + }; + const isPayloadExpr = (e: any): boolean => ts.isIdentifier(e) && payloadNames.has(e.text); + const isReqSourceExpr = (e: any): boolean => + isPayloadExpr(e) || + (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === reqName && REQ_SOURCES.includes(e.name.text)) || + (ts.isIdentifier(e) && sourceNames.has(e.text)); + const isBodyReadCall = (e: any): boolean => { + const inner = unwrap(e); + return Boolean(inner && ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) && + ['json', 'formData'].includes(inner.expression.name.text) && + ts.isIdentifier(inner.expression.expression) && inner.expression.expression.text === reqName); + }; + const visit = (n: any) => { + // . + if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression)) { + out.set(n.name.text, sourceOfExpr(n.expression)); + } + if (ts.isVariableDeclaration(n) && n.initializer) { + const init = unwrap(n.initializer); + // const b = await request.json() → b is a request-input object from here on. + if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.add(n.name.text); + // const { a, b } = | await request.json() + if (ts.isObjectBindingPattern(n.name) && (isReqSourceExpr(init) || isBodyReadCall(n.initializer))) { + const src = isBodyReadCall(n.initializer) ? bodyReadSource(n.initializer) : sourceOfExpr(init); + for (const el of n.name.elements) { + const key = bindingKey(el, ts); + if (key) out.set(key, src); + } + } + } + ts.forEachChild(n, visit); + }; + visit(body); + return [...out].map(([name, source]) => ({ name, source })); + + // `req.body.x` / `req.query.x` / `req.params.x` — the namespace decides the runtime coordinate, and + // route params notably have NONE, so this distinction is load-bearing rather than cosmetic. + function sourceOfExpr(e: any): InputSource { + if (isPayloadExpr(e)) return 'server-fn-data'; + if (ts.isPropertyAccessExpression(e)) { + if (e.name.text === 'query') return 'query'; + if (e.name.text === 'params') return 'route-param'; + if (e.name.text === 'body') return 'body'; + } + if (ts.isIdentifier(e)) { + const key = [...sourceNames].includes(e.text) ? e.text : undefined; + if (key === 'query') return 'query'; + if (key === 'params') return 'route-param'; + } + return 'body'; + } + function bodyReadSource(init: any): InputSource { + const t = init?.getText?.() ?? ''; + return /formData\s*\(/.test(t) ? 'form-body' : 'json-body'; + } +} diff --git a/src/map/module-graph.ts b/src/map/module-graph.ts new file mode 100644 index 0000000..9f5b862 --- /dev/null +++ b/src/map/module-graph.ts @@ -0,0 +1,102 @@ +import { readFileSync, realpathSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve as resolvePath } from 'node:path'; +import type { Sink, TsModule } from './types.js'; +import { guessScriptKind, isFnLike, localCalls } from './ast.js'; +import { buildModuleBindings } from './bindings.js'; +import { isInside } from './sources.js'; +import { collectLocalSinks, type ModuleGraph } from './sinks.js'; + +// --- cross-file (imported) helper tracing ----------------------------------- +// AI-generated apps routinely put the data access in a sibling module (`import { saveOrder } from +// './db'`), so a handler's real sink lives one file away. Without following that, the endpoint looks +// sink-free and no rule can be correlated to it. We follow ONE cross-file hop (plus same-file helpers +// inside the target), which covers the common shape while keeping the walk bounded and cheap. +const RESOLVE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']; + +export function createModuleGraph(ts: TsModule, opts: { cwd: string; boundary: string; followOutside?: boolean }): ModuleGraph { + // file → { fnSinks, calleesOf } | null (unreadable/unparseable) + const cache = new Map; calleesOf: Map } | null>(); + + const load = (file: string) => { + if (cache.has(file)) return cache.get(file) ?? null; + let entry: { fnSinks: Map; calleesOf: Map } | null = null; + try { + const text = readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); + const bindings = buildModuleBindings(sf, ts); + entry = { fnSinks: collectLocalSinks(sf, ts, bindings), calleesOf: collectCallees(sf, ts) }; + } catch { + entry = null; // fail-open: an unreadable dependency must not break the map + } + cache.set(file, entry); + return entry; + }; + + return { + importedSinks(fromFile, specifier, exportName) { + const target = resolveRelativeModule(fromFile, specifier); + if (!target) return []; + // Stay inside the project: `../../other-repo/db` (or a symlink) would otherwise pull an unrelated + // codebase into this app's attack surface. The primary walker enforces this; so must the resolver. + if (!opts.followOutside) { + let real = target; + try { real = realpathSync(target); } catch { /* use as-is */ } + if (!isInside(real, opts.boundary)) return []; + } + const mod = load(target); + if (!mod) return []; + const collected = [...(mod.fnSinks.get(exportName) ?? [])]; + // One same-file hop inside the target: `export function saveOrder(){ return doInsert() }`. + for (const callee of mod.calleesOf.get(exportName) ?? []) { + for (const s of mod.fnSinks.get(callee) ?? []) collected.push(s); + } + // `line` refers to the HELPER's file, not the endpoint's — carry the file so the coordinate is + // interpretable (and so flow linking never claims `precise` for a sink it cannot see locally). + const rel = relative(opts.cwd, target); + return collected.map((s) => ({ ...s, file: rel })); + }, + }; +} + +// Resolve a RELATIVE specifier to a real file (extension + /index, and the TS-ESM `./db.js` → db.ts +// convention). Bare package specifiers are intentionally NOT followed — that's node_modules, and a +// dependency's internals are not this app's attack surface. +function resolveRelativeModule(fromFile: string, spec: string): string | undefined { + if (!spec.startsWith('.')) return undefined; + const dir = dirname(fromFile); + const base = resolvePath(dir, spec); + const candidates: string[] = []; + const jsLike = /\.(js|jsx|mjs|cjs)$/.exec(base); + if (jsLike) { + const stem = base.slice(0, -jsLike[0].length); + for (const e of RESOLVE_EXTS) candidates.push(stem + e); // ./db.js may mean db.ts + } + candidates.push(base); + for (const e of RESOLVE_EXTS) candidates.push(base + e); + for (const e of RESOLVE_EXTS) candidates.push(join(base, 'index' + e)); + for (const c of candidates) { + try { + if (statSync(c).isFile()) return c; + } catch { + /* next */ + } + } + return undefined; +} + +// name → the local function names it calls (for one same-file hop inside an imported module). +function collectCallees(sf: any, ts: TsModule): Map { + const map = new Map(); + const visit = (node: any) => { + let name: string | undefined; + let body: any; + if (ts.isFunctionDeclaration(node) && node.name && node.body) { name = node.name.text; body = node.body; } + else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isFnLike(node.initializer, ts)) { + name = node.name.text; body = node.initializer.body; + } + if (name && body) map.set(name, localCalls(body, ts)); + ts.forEachChild(node, visit); + }; + visit(sf); + return map; +} diff --git a/src/map/routes.ts b/src/map/routes.ts new file mode 100644 index 0000000..9b26494 --- /dev/null +++ b/src/map/routes.ts @@ -0,0 +1,105 @@ +import type { TsModule } from './types.js'; +import { isFnLike } from './ast.js'; + +// One list drives BOTH the AST route-registration recognizer and the textual pre-filter — they must +// never diverge: a file the pre-filter skips is invisible to every recognizer. +const ROUTE_REGISTER_NAMES = ['get', 'post', 'put', 'patch', 'delete', 'options', 'all', 'head', 'use']; +export const ROUTE_REGISTER = new Set(ROUTE_REGISTER_NAMES); +export const ROUTE_CALL_RE = new RegExp(`\\.(${[...ROUTE_REGISTER_NAMES, 'route'].join('|')})\\s*\\(`); + +// Derive the URL path of a FILE-BASED route handler from its location, across the conventions AI +// builders actually emit. Dynamic segments become `:name` and set `dynamic` so a consumer knows the +// route is a PATTERN (the engine's `when.path` takes a glob or /regex/, not an Express param), rather +// than mistaking `/api/:id` for a literal path. +// Next App Router app/api/items/route.ts -> /api/items +// app/api/items/[id]/route.ts -> /api/items/:id (dynamic) +// app/(marketing)/api/x/route.ts -> /api/x (route group stripped) +// Next Pages Router pages/api/items/index.ts -> /api/items +// pages/api/[id].ts -> /api/:id (dynamic) +// SvelteKit src/routes/api/items/+server.ts -> /api/items +// Nuxt server/api/items.post.ts -> /api/items +// The deployed name of a platform function, from its conventional location: +// supabase/functions//index.ts (Supabase Edge Functions) +// functions//index.ts | functions/.ts (Base44 / generic Deno function dirs) +export function functionNameFromPath(relFile: string): string | undefined { + const parts = relFile.split(/[\\/]/).filter(Boolean); + const i = parts.lastIndexOf('functions'); + if (i === -1 || i === parts.length - 1) return undefined; + const next = parts[i + 1]; + if (!next) return undefined; + const base = next.replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, ''); + return base === 'index' ? undefined : base; +} + +export function routeFromFilePath(relFile: string): { route?: string; dynamic?: boolean } { + const parts = relFile.split(/[\\/]/).filter(Boolean); + if (parts.length === 0) return {}; + const base = (parts[parts.length - 1] ?? '').replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, ''); + const dirs = parts.slice(0, -1); + const at = (name: string) => dirs.lastIndexOf(name); + + let segs: string[] | null = null; + if (base === 'route' && at('app') !== -1) { + segs = dirs.slice(at('app') + 1); // Next App Router + } else if (base === '+server' && at('routes') !== -1) { + segs = dirs.slice(at('routes') + 1); // SvelteKit + } else if (at('pages') !== -1) { + segs = [...dirs.slice(at('pages') + 1), ...(base === 'index' ? [] : [base])]; // Next Pages Router + } else if (at('server') !== -1) { + // Nuxt server routes; a `.post`/`.get` suffix encodes the method, not a path segment. + segs = [...dirs.slice(at('server') + 1), ...(base === 'index' ? [] : [base.replace(/\.(get|post|put|patch|delete|head|options)$/i, '')])]; + } + if (!segs) return {}; + + // Next route groups `(marketing)` and parallel/private segments don't appear in the URL. + segs = segs.filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('@') && !s.startsWith('_')); + + let dynamic = false; + const mapped = segs.map((s) => { + const m = /^\[+(\.{0,3})(.+?)\]+$/.exec(s); // [id], [...slug], [[...slug]] + if (m) { + dynamic = true; + return ':' + m[2]; + } + return s; + }); + const route = '/' + mapped.join('/'); + return { route: route.length > 1 ? route.replace(/\/+$/, '') : '/', dynamic }; +} + +// Unwind `router.route('/x').get(h).post(h2)` down to the `.route('/x')` call to recover the path. +export function routeFromChain(expr: any, ts: TsModule): string | undefined { + let cur = expr; + while (cur && ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { + const nm = cur.expression.name.text; + if (nm === 'route') { + const a = cur.arguments[0]; + return a && ts.isStringLiteralLike(a) ? a.text : undefined; + } + if (!ROUTE_REGISTER.has(nm)) return undefined; + cur = cur.expression.expression; + } + return undefined; +} + +// Read `{ method, url|path, handler }` from a Fastify-style route object (handler as arrow/function +// property or as an object-method shorthand). +export function routeObject(obj: any, ts: TsModule): { url?: string; methods: string[]; handler?: any } { + let url: string | undefined; + let handler: any; + const methods: string[] = []; + for (const p of obj.properties) { + const key = (p.name as any)?.text; + if (ts.isPropertyAssignment(p)) { + if ((key === 'url' || key === 'path') && ts.isStringLiteralLike(p.initializer)) url = p.initializer.text; + if (key === 'method') { + if (ts.isStringLiteralLike(p.initializer)) methods.push(p.initializer.text.toUpperCase()); + else if (ts.isArrayLiteralExpression(p.initializer)) { + for (const el of p.initializer.elements) if (ts.isStringLiteralLike(el)) methods.push(el.text.toUpperCase()); + } + } + if (key === 'handler' && isFnLike(p.initializer, ts)) handler = p.initializer; + } else if (ts.isMethodDeclaration(p) && key === 'handler') handler = p; + } + return { url, methods, handler }; +} diff --git a/src/map/sinks.ts b/src/map/sinks.ts new file mode 100644 index 0000000..f5bf9ef --- /dev/null +++ b/src/map/sinks.ts @@ -0,0 +1,247 @@ +import { createHash } from 'node:crypto'; +import type { ArgumentRole, CandidateFamily, Sink, TsModule } from './types.js'; +import { + isFnLike, + isShadowedByEnclosingBinding, + isUninvokedFunctionDeclaration, + localCalls, + opCallOf, + rootIdentifier, + spanOf, +} from './ast.js'; +import { npmPackageOf, type Bindings } from './bindings.js'; + +const DB_OPS = new Set(['insert', 'update', 'delete', 'select', 'upsert', 'rpc']); +const PRISMA_OPS = new Set(['create', 'createMany', 'update', 'updateMany', 'delete', 'deleteMany', 'upsert', 'findFirst', 'findUnique', 'findMany', 'count', 'aggregate']); +const FS_CALLS = /^(readFile|writeFile|readFileSync|writeFileSync|appendFile|createReadStream|createWriteStream|unlink|rm|rmSync|mkdir|readdir|stat|open)$/; +const EXEC_CALLS = /^(exec|execSync|spawn|spawnSync|execFile|execFileSync|fork)$/; +const HTTP_CALLS = /^(fetch|got|request)$/; +const HTTP_MEMBER_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'request']); +// When a sink's base can't be traced precisely, infer its package from the file's imports of a known +// provider for that sink kind (a file almost always uses one db/http client). +const DB_PACKAGES = ['@supabase/supabase-js', '@prisma/client', 'drizzle-orm', 'knex', 'kysely', 'pg', 'mysql2', 'mysql', 'sequelize', 'typeorm', 'mongoose', 'better-sqlite3']; +const HTTP_PACKAGES = ['axios', 'got', 'node-fetch', 'undici', 'superagent', 'ky']; +const isHttpPackage = (pkg: string) => HTTP_PACKAGES.includes(pkg) || pkg === 'node:http' || pkg === 'node:https'; + +export interface ModuleGraph { + /** Sinks of `exportName` in the module `specifier` resolves to, relative to `fromFile`. */ + importedSinks(fromFile: string, specifier: string, exportName: string): Sink[]; +} + +// --- sinks (agnostic) ------------------------------------------------------- +export function collectLocalSinks(sf: any, ts: TsModule, bindings: Bindings): Map { + const map = new Map(); + const visit = (node: any) => { + if (ts.isFunctionDeclaration(node) && node.name && node.body) map.set(node.name.text, directSinks(node.body, ts, bindings)); + else if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.initializer && isFnLike(decl.initializer, ts)) { + map.set(decl.name.text, directSinks(decl.initializer.body, ts, bindings)); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return map; +} + +export function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map, bindings: Bindings, ctx?: { file: string; graph: ModuleGraph }): Sink[] { + if (!arrowOrNode) return []; + const body = arrowOrNode.isSyntheticBody ? arrowOrNode.body + : isFnLike(arrowOrNode, ts) ? arrowOrNode.body : arrowOrNode; + if (!body) return []; + const sinks = directSinks(body, ts, bindings); + for (const called of localCalls(body, ts)) { + // Same-file helper. + for (const s of localSinks.get(called) ?? []) sinks.push(s); + // Imported helper: the name resolves to a RELATIVE module → follow one hop into it. + if (ctx) { + const spec = bindings.resolve(called); + if (spec && spec.startsWith('.')) { + for (const s of ctx.graph.importedSinks(ctx.file, spec, bindings.exportNameOf(called) ?? called)) sinks.push(s); + } + } + } + return dedupeSinks(sinks); +} + +// Provider-agnostic sink recognizers over a subtree. Each sink is tagged with the npm package behind +// it: resolved precisely from the call's base identifier via the file's imports, else inferred from +// the file's imports of a known provider for that sink kind. A receiver that traces to a plain local +// object/class/function is NOT a dependency sink and is dropped. +function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] { + const sinks: Sink[] = []; + const baseOf = (base: any): { pkg?: string; local?: boolean; root?: string } => { + const root = base ? rootIdentifier(base, ts) : undefined; + if (!root) return {}; + const pkg = npmPackageOf(bindings.resolve(root)); + if (pkg) return { pkg, root }; + return { local: bindings.locals.has(root), root }; + }; + const infer = (kind: 'db' | 'http'): string | undefined => { + const table = kind === 'db' ? DB_PACKAGES : HTTP_PACKAGES; + for (const p of table) if (bindings.imports.has(p)) return p; + return undefined; + }; + const push = (s: Sink) => sinks.push(s); + const visit = (n: any) => { + // A function that is DECLARED here but not invoked here is not reached by this endpoint — walking + // into it would report sinks the endpoint never touches (e.g. an unused local helper that shells + // out). Skip those subtrees; when the handler DOES call such a helper, `localCalls` + + // `collectLocalSinks` bring its sinks in by name. Inline callbacks / IIFEs are NOT skipped — those + // do run (`items.map(x => db.insert(x))`, `.then(...)`). + if (n !== node && isUninvokedFunctionDeclaration(n, ts)) return; + if (ts.isCallExpression(n)) { + const callee = n.expression; + // db: `.from("t").()` (supabase/knex/kysely) + if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'from') { + const b = baseOf(callee.expression); + const t = n.arguments[0]; + const table = t && ts.isStringLiteralLike(t) ? t.text : undefined; + const parent = n.parent; + if (!b.local && parent && ts.isPropertyAccessExpression(parent) && DB_OPS.has(parent.name.text)) { + push({ kind: 'db', provider: 'sql', package: b.pkg ?? infer('db'), table, op: parent.name.text, ...spanOf(opCallOf(parent, ts)) }); + } + } + if (ts.isPropertyAccessExpression(callee)) { + const method = callee.name.text; + const b = baseOf(callee.expression); + if (!b.local) { + // db: prisma-style `prisma..()` — the op names are generic (`delete`, `update`, …), + // so require a real prisma signal: a resolved binding, the import, or a prisma-named receiver. + if (PRISMA_OPS.has(method) && ts.isPropertyAccessExpression(callee.expression)) { + const prismaLikely = b.pkg === '@prisma/client' || + (!b.pkg && (bindings.imports.has('@prisma/client') || /prisma/i.test(b.root ?? ''))); + if (prismaLikely) push({ kind: 'db', provider: 'prisma', package: '@prisma/client', table: callee.expression.name.text, op: method, ...spanOf(n) }); + } + // db: raw `.query(` / `.execute(` + if (method === 'query' || method === 'execute') { + push({ kind: 'db', provider: 'sql', package: b.pkg ?? infer('db'), op: method, ...spanOf(n) }); + } + // fs / exec via a namespace: `fs.writeFile(` / `child_process.exec(` + if (FS_CALLS.test(method)) push({ kind: 'fs', package: b.pkg, op: method, ...spanOf(n) }); + if (EXEC_CALLS.test(method)) push({ kind: 'exec', package: b.pkg, op: method, ...spanOf(n) }); + // http: any client whose binding resolves to a known http package (`axios.get`, `ky.post`, + // `http.request`), else the classic identifiers by name as a heuristic. + if (HTTP_MEMBER_METHODS.has(method)) { + if (b.pkg && isHttpPackage(b.pkg)) { + push({ kind: 'http', provider: b.root, package: b.pkg, op: method, ...spanOf(n) }); + } else if (!b.pkg && ts.isIdentifier(callee.expression) && /^(axios|http|https|got|ky)$/.test(callee.expression.text)) { + push({ kind: 'http', provider: callee.expression.text, package: infer('http'), op: method, ...spanOf(n) }); + } + } + } + } + // Bare calls: `fetch(…)` / `exec(…)` / `readFile(…)` / `eval(…)`. A dangerous NAME is not a + // dangerous API: `import { fetch } from './util'` and a callback parameter named `fetch` both look + // identical here, and treating either as an HTTP request produced a FALSE SSRF candidate. So the + // call must be justified — either it resolves to a module that plausibly provides that API, or it + // is a genuine unresolved global (only `fetch`/`eval`/`Function` ever are). + if (ts.isIdentifier(callee) && !bindings.locals.has(callee.text)) { + const name = callee.text; + const spec = bindings.resolve(name); + const pkg = npmPackageOf(spec); + const shadowed = isShadowedByEnclosingBinding(n, name, ts); + // A relative import resolves to no package: it's app code, not the API it shares a name with. + const fromModule = spec !== undefined; + const trueGlobal = !fromModule && !shadowed; + + if (HTTP_CALLS.test(name)) { + if (pkg && isHttpPackage(pkg)) push({ kind: 'http', provider: name, package: pkg, op: 'request', ...spanOf(n) }); + else if (name === 'fetch' && trueGlobal) push({ kind: 'http', provider: 'fetch', op: 'request', ...spanOf(n) }); + } + // `readFile`/`exec` are never globals: without a matching module binding this is app code. + if (FS_CALLS.test(name) && pkg && /^node:fs(\/promises)?$/.test(pkg)) { + push({ kind: 'fs', package: pkg, op: name, ...spanOf(n) }); + } + if (EXEC_CALLS.test(name) && pkg === 'node:child_process') { + push({ kind: 'exec', package: pkg, op: name, ...spanOf(n) }); + } + if (name === 'eval' && trueGlobal) push({ kind: 'eval', op: 'eval', ...spanOf(n) }); + } + } + if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'Function' + && !bindings.locals.has('Function') && !isShadowedByEnclosingBinding(n, 'Function', ts)) { + push({ kind: 'eval', op: 'new Function', ...spanOf(n) }); + } + ts.forEachChild(n, visit); + }; + visit(node); + return sinks; +} + +// --- adapter summaries: which argument means what --------------------------- +// Small, testable per-library summaries, keyed by sink kind so an overloaded name (`get`) can't be read +// as the wrong thing. This is the cheap foundation the review recommended BEFORE a whole-program +// dataflow engine: without argument roles, "the input reaches this sink" cannot be turned into a rule, +// because the mitigation class depends on WHICH argument received the value. +const ARGUMENT_ROLES: Record> = { + exec: { + exec: ['command'], execSync: ['command'], + execFile: ['file', 'args'], execFileSync: ['file', 'args'], + spawn: ['command', 'args'], spawnSync: ['command', 'args'], fork: ['file', 'args'], + }, + http: { + fetch: ['url', 'init'], request: ['url', 'options'], + get: ['url', 'options'], head: ['url', 'options'], delete: ['url', 'options'], + post: ['url', 'body'], put: ['url', 'body'], patch: ['url', 'body'], + }, + fs: { + readFile: ['path'], readFileSync: ['path'], open: ['path'], + writeFile: ['path', 'content'], writeFileSync: ['path', 'content'], + appendFile: ['path', 'content'], + unlink: ['path'], rm: ['path'], rmSync: ['path'], mkdir: ['path'], readdir: ['path'], stat: ['path'], + createReadStream: ['path'], createWriteStream: ['path'], + }, + db: { + query: ['sql', 'values'], execute: ['sql', 'values'], + insert: ['values'], update: ['values'], upsert: ['values'], select: ['columns'], + // Filters: the value half is still request data reaching the query, but as a bound parameter. + eq: ['column', 'value'], neq: ['column', 'value'], gt: ['column', 'value'], gte: ['column', 'value'], + lt: ['column', 'value'], lte: ['column', 'value'], like: ['column', 'value'], ilike: ['column', 'value'], + match: ['values'], filter: ['column', 'value'], + }, + eval: { eval: ['code'], Function: ['code'] }, +}; + +/** + * The (sink kind, argument role) pairs where a request value arriving is inherently dangerous AND a rule + * can express the mitigation. Deliberately narrow — notably `db`+`values` is absent: a request value in + * a parameterized insert is genuine reachability signal but not a blockable pattern by itself. + */ +export const CANDIDATE_FAMILIES: Record>> = { + http: { url: 'ssrf' }, + exec: { command: 'command-injection', file: 'command-injection', args: 'command-injection' }, + fs: { path: 'path-traversal' }, + db: { sql: 'sql-injection' }, + eval: { code: 'code-injection' }, +}; + +/** Role of argument `index` for this call, given the sink kind it was recognized as. */ +export function argumentRoleOf(sinkKind: string, method: string | undefined, index: number, total = 0): ArgumentRole { + // `new Function(a, b, "return a+b")` — every argument but the LAST declares a parameter name; only the + // last one is executable code. An index-based table cannot express that. + if (sinkKind === 'eval' && method === 'Function') return index === total - 1 ? 'code' : 'args'; + const table = method ? ARGUMENT_ROLES[sinkKind]?.[method] : undefined; + return table?.[index] ?? 'unknown'; +} + +// Deterministic identity for a sink, so `Flow.sink` (an embedded copy) can be correlated back to the +// inventory entry without deep-equality. +function sinkId(s: Sink): string { + return createHash('sha256') + .update([s.kind, s.provider, s.package, s.table, s.op, s.file, s.start, s.end].join('|')) + .digest('hex') + .slice(0, 12); +} + +function dedupeSinks(sinks: Sink[]): Sink[] { + const seen = new Set(); + const out: Sink[] = []; + for (const s of sinks) { + const key = `${s.kind}:${s.provider}:${s.package}:${s.table}:${s.op}:${s.line}:${s.start}`; + if (!seen.has(key)) { seen.add(key); out.push({ ...s, id: sinkId(s) }); } + } + return out; +} diff --git a/src/map/sources.ts b/src/map/sources.ts new file mode 100644 index 0000000..a1091a1 --- /dev/null +++ b/src/map/sources.ts @@ -0,0 +1,92 @@ +import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import { join, relative, isAbsolute } from 'node:path'; +import { ROUTE_CALL_RE } from './routes.js'; + +// Cheap textual pre-filter so we only parse files that could contain an entry point. Derived from the +// same list as the AST recognizer (see ROUTE_REGISTER_NAMES). +export function hasEntrySignal(text: string): boolean { + return ( + text.includes('createServerFn') || + /\bexport\s+(async\s+)?(function|const)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/.test(text) || + ROUTE_CALL_RE.test(text) || + text.includes("'use server'") || text.includes('"use server"') || + text.includes('Deno.serve') || /\bserve\s*\(/.test(text) + ); +} + +export function detectFramework(cwd: string): string { + try { + const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); + const d = { ...pkg.dependencies, ...pkg.devDependencies }; + if (d['@tanstack/react-start'] || d['@tanstack/start'] || d['@tanstack/solid-start']) return 'tanstack-start'; + if (d['next']) return 'next'; + if (d['@sveltejs/kit']) return 'sveltekit'; + if (d['@nestjs/core']) return 'nestjs'; + if (d['fastify']) return 'fastify'; + if (d['express']) return 'express'; + if (d['hono']) return 'hono'; + } catch { /* ignore */ } + // A Deno/edge functions project may have no package.json at all. + try { + if (statSync(join(cwd, 'supabase', 'functions')).isDirectory()) return 'supabase-functions'; + } catch { /* not a supabase project */ } + try { + if (statSync(join(cwd, 'functions')).isDirectory()) return 'deno-functions'; + } catch { /* ignore */ } + return 'unknown'; +} + +const isSourceFile = (name: string) => /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(name) && !name.endsWith('.d.ts'); + +// Directories that never hold app source, so walking the whole project stays cheap. (We walk the whole +// project rather than `src` only: server entrypoints, route dirs and platform function dirs commonly +// live at the root — `server.ts`, `app/`, `api/`, `routes/`, `functions/`, `netlify/`, `supabase/`.) +const SKIP_DIRS = new Set([ + 'node_modules', 'dist', 'build', 'out', 'coverage', 'public', 'static', 'assets', + '.git', '.next', '.nuxt', '.svelte-kit', '.output', '.vercel', '.wrangler', '.turbo', '.cache', + 'vendor', 'tmp', 'temp', '__pycache__', +]); + +export interface WalkStats { discovered: number } + +/** + * Walk the project for source files. Symlinks are followed ONLY while they stay inside the project + * boundary (`boundary`, a realpath) — a link to an external repo would otherwise pull unrelated code + * (and its paths) into the map. `followOutside` opts out of the boundary check. A realpath visited-set + * makes link cycles safe. + */ +export function collectSources( + dir: string, + boundary: string, + opts: { followOutside?: boolean }, + out: string[] = [], + seen = new Set(), + stats: WalkStats = { discovered: 0 }, +): string[] { + let key: string; + try { key = realpathSync(dir); } catch { return out; } + if (seen.has(key)) return out; + if (!opts.followOutside && !isInside(key, boundary)) return out; + seen.add(key); + let entries; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + if (SKIP_DIRS.has(e.name) || (e.name.startsWith('.') && e.name !== '.')) continue; + const full = join(dir, e.name); + if (e.isDirectory()) collectSources(full, boundary, opts, out, seen, stats); + else if (e.isSymbolicLink()) { + let st, real; + try { st = statSync(full); real = realpathSync(full); } catch { continue; } + if (!opts.followOutside && !isInside(real, boundary)) continue; // link escapes the project + if (st.isDirectory()) collectSources(full, boundary, opts, out, seen, stats); + else if (st.isFile() && isSourceFile(e.name)) { out.push(full); stats.discovered++; } + } else if (isSourceFile(e.name)) { out.push(full); stats.discovered++; } + } + return out; +} + +export function isInside(candidate: string, boundary: string): boolean { + if (candidate === boundary) return true; + const rel = relative(boundary, candidate); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +}