From 6bb967347437adb9bd9e2db10539943bea1703d2 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 11:28:31 +0200 Subject: [PATCH 1/6] map: build-time input-flow (attack-surface) map command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `patchstack-connect map` — a build-time command that walks the app's source and emits its input-flow map: entry points → the inputs each reads → the sinks/dependencies they reach. It's both a user-facing attack-surface view and the coordinate source precise (param-pinned) vPatch rules bind against. Framework-AGNOSTIC by design — signal-driven, not stack-gated: - entry points: createServerFn (TanStack), exported GET/POST/… handlers (Next route handlers / SvelteKit), and app.post('/x', handler) route registrations (Express/Fastify/Hono). - inputs: zod z.object fields (name + type + min/max), and req.body/query/params member accesses. - sinks (provider-agnostic): db (supabase/knex .from().op, prisma, raw query), fs, child_process exec, http/fetch, eval — followed one level into same-file helpers. Add a stack by adding a recognizer. Honesty is a first-class field: `coverage.notes` records that this is the DETECTED surface (best-effort static analysis), never a guarantee. The compiler is resolved at RUNTIME from the target app's own `typescript` (marked external in tsup so it's never bundled into the CLI). Validated against the TanStack+Supabase reference app and TanStack/Express/Next fixtures. This is Leg 1 (extract + emit) of the build-time input-flow map; Leg 2 (POST to a SaaS `site_input_map` store) is a follow-up. Co-Authored-By: Claude Opus 4.8 --- src/cli.ts | 34 +++- src/map/extract.ts | 363 ++++++++++++++++++++++++++++++++++++++ src/map/index.ts | 24 +++ src/map/ts-loader.ts | 28 +++ src/map/types.ts | 61 +++++++ tests/map-extract.test.ts | 92 ++++++++++ tsup.config.ts | 4 + 7 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 src/map/extract.ts create mode 100644 src/map/index.ts create mode 100644 src/map/ts-loader.ts create mode 100644 src/map/types.ts create mode 100644 tests/map-extract.test.ts diff --git a/src/cli.ts b/src/cli.ts index d2df467..b04e68b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,7 @@ import { renderGuideChecklist, } from './guide.js'; import { runProtect, runVerify } from './protect/install/index.js'; +import { buildInputMap } from './map/index.js'; import { setupProtection, wireBuildScripts } from './setup.js'; import { detectStack, type StackDescriptor } from './stack.js'; import { PatchstackError } from './types.js'; @@ -56,6 +57,10 @@ Usage: manage the widget, install + verify runtime protection, and wire dependency/build scans. Never runs the project build + patchstack-connect map [--dir

] [--out ] Map the app's input surface (entry points → + inputs → sinks) for reachability + precise + rule pinning. Prints JSON (--out to write a + file). Uses the app's own TypeScript patchstack-connect init Optional: pre-seed .patchstackrc.json with an existing site UUID patchstack-connect status [options] Show current configuration and whether the @@ -128,7 +133,7 @@ Examples: npx @patchstack/connect demo-guide node-serialize `; -const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir', 'url']); +const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir', 'url', 'out']); interface ParsedArgs { command: string | null; @@ -193,6 +198,31 @@ async function runInit(args: ParsedArgs): Promise { return 0; } +async function runMap(args: ParsedArgs): Promise { + const cwd = getStringFlag(args.flags, 'dir') ?? process.cwd(); + const { map, error } = await buildInputMap(cwd); + if (!map) { + console.error(`patchstack: ${error}`); + return 1; + } + // Human summary → stderr; the JSON → stdout (so it can be piped / written). + const inputs = map.endpoints.reduce((n, e) => n + e.inputs.length, 0); + const sinks = map.endpoints.reduce((n, e) => n + e.sinks.length, 0); + console.error( + `patchstack: mapped ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s) ` + + `[${map.framework}]. This is the DETECTED surface — static analysis is best-effort.`, + ); + const json = JSON.stringify(map, null, 2); + const out = getStringFlag(args.flags, 'out'); + if (out) { + writeFileSync(out, json); + console.error(`patchstack: wrote ${out}`); + } else { + console.log(json); + } + return 0; +} + async function runScan( args: ParsedArgs, options: { showRemainingSetup?: boolean } = {}, @@ -771,6 +801,8 @@ async function main(): Promise { return runGuide(args); case 'setup': return runSetup(args); + case 'map': + return runMap(args); default: console.error(`Unknown command: ${args.command}\n`); console.error(HELP); diff --git a/src/map/extract.ts b/src/map/extract.ts new file mode 100644 index 0000000..6efb366 --- /dev/null +++ b/src/map/extract.ts @@ -0,0 +1,363 @@ +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import type { SiteInputMap, Endpoint, InputField, Sink, TsModule } from './types.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 +// across builders (TanStack Start, Next, SvelteKit, Express/Fastify/Hono, …) and providers, and +// degrades gracefully (recording what it couldn't see in `coverage.notes`). Add a stack by adding a +// recognizer, not a new adapter. + +const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']); +const ROUTE_REGISTER = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'all', 'head', 'use']); +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 ZOD_BASE = new Set(['string', 'number', 'boolean', 'array', 'object', 'enum', 'bigint', 'date', 'record']); + +export async function extractInputMap(cwd: string, ts: TsModule): Promise { + const notes: string[] = []; + const endpoints: Endpoint[] = []; + const srcDir = join(cwd, 'src'); + const root = existsSync(srcDir) ? srcDir : cwd; + + for (const file of collectSources(root)) { + const text = readFileSync(file, 'utf8'); + if (!hasEntrySignal(text)) continue; + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); + const localSinks = collectLocalSinks(sf, ts); + for (const ep of extractFromFile(sf, ts, localSinks)) { + endpoints.push({ ...ep, file: relative(cwd, file) }); + } + } + + notes.push('Static analysis is best-effort — this is the DETECTED surface, not a proof of completeness.'); + notes.push('Sinks are followed one level into same-file helpers; cross-file / dynamic indirection is not traced.'); + if (endpoints.length === 0) notes.push('No recognized server-side entry points found under the source root.'); + + return { version: 1, framework: detectFramework(cwd), endpoints, coverage: { adapter: 'agnostic-v1', notes } }; +} + +// Cheap textual pre-filter so we only parse files that could contain an entry point. +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) || + /\.(get|post|put|patch|delete|options|all)\s*\(/.test(text) || + text.includes("'use server'") || text.includes('"use server"') + ); +} + +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 */ } + 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; +} + +function collectSources(dir: string, out: string[] = []): string[] { + let entries; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + if (e.name === 'node_modules' || e.name === 'dist' || e.name === 'build' || e.name.startsWith('.')) continue; + const full = join(dir, e.name); + if (e.isDirectory()) collectSources(full, out); + else if (/\.(ts|tsx|js|jsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) out.push(full); + } + return out; +} + +// --- entry-point recognizers ----------------------------------------------- +function extractFromFile(sf: any, ts: TsModule, localSinks: Map): Omit[] { + const out: Omit[] = []; + + const visit = (node: any) => { + // (1) TanStack Start: `export const NAME = createServerFn({method}).inputValidator(fn).handler(fn)` + if (ts.isVariableStatement(node) && hasExport(node, ts)) { + for (const decl of node.declarationList.declarations) { + if (decl.initializer && ts.isCallExpression(decl.initializer)) { + const chain = unwindChain(decl.initializer, ts); + if (chain.baseName === 'createServerFn' && ts.isIdentifier(decl.name)) { + out.push({ + name: decl.name.text, + entryKind: 'server-fn', + method: methodFromObjectArg(chain.baseCall, ts), + inputs: inputsFromValidator(chain.calls['inputValidator'] ?? chain.calls['validator'], ts), + sinks: sinksFrom(chain.calls['handler']?.arguments?.[0], ts, localSinks), + }); + } + } + } + } + + // (2) Route handlers: `export (async) function GET/POST/…(req)` / `export const POST = (req) => …` + // (Next route handlers, SvelteKit +server, etc.) + if (ts.isFunctionDeclaration(node) && node.name && hasExport(node, ts) && HTTP_METHODS.has(node.name.text)) { + out.push(handlerEntry(node.name.text, node.name.text, node.parameters, node.body, ts, localSinks)); + } + if (ts.isVariableStatement(node) && hasExport(node, ts)) { + for (const decl of node.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && HTTP_METHODS.has(decl.name.text) && decl.initializer && isFnLike(decl.initializer, ts)) { + out.push(handlerEntry(decl.name.text, decl.name.text, decl.initializer.parameters, decl.initializer.body, ts, localSinks)); + } + } + } + + // (3) Route registrations: `app.post('/path', …, handler)` / `router.get('/x', handler)` (Express/Fastify/Hono/Koa) + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ROUTE_REGISTER.has(node.expression.name.text)) { + const args = node.arguments; + const pathArg = args[0]; + const handler = args[args.length - 1]; + if (pathArg && ts.isStringLiteralLike(pathArg) && handler && isFnLike(handler, ts)) { + out.push(handlerEntry(pathArg.text, `route-registration`, handler.parameters, handler.body, ts, localSinks, { + method: node.expression.name.text.toUpperCase(), + route: pathArg.text, + })); + } + } + + ts.forEachChild(node, visit); + }; + visit(sf); + return out; +} + +function handlerEntry( + name: string, + kindLabel: string, + params: any, + body: any, + ts: TsModule, + localSinks: Map, + extra: { method?: string; route?: string } = {}, +): Omit { + return { + name, + entryKind: kindLabel === 'route-registration' ? 'route-registration' : 'route-handler', + method: extra.method ?? (HTTP_METHODS.has(name) ? name : undefined), + route: extra.route, + inputs: inputsFromHandler(params, body, ts), + sinks: sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks), + }; +} + +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): InputField[] { + if (!validatorCall) return []; + return zodObjectFields(validatorCall, ts); +} + +// From a raw handler: zod schema fields it parses, plus request member-accesses (req.body/query/params.X). +function inputsFromHandler(params: any, body: any, ts: TsModule): InputField[] { + const fields = zodObjectFields(body, ts); + const names = new Set(fields.map((f) => f.name)); + for (const n of requestMemberAccesses(params, body, ts)) { + if (!names.has(n)) { names.add(n); fields.push({ name: n }); } + } + return fields; +} + +// Find the first `z.object({...})` in a subtree and read its fields (name + zod type/constraints). +function zodObjectFields(node: any, ts: TsModule): InputField[] { + if (!node) return []; + let objectLiteral: any = null; + const find = (n: any) => { + if (objectLiteral || !n) return; + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'object') { + const arg = n.arguments[0]; + if (arg && ts.isObjectLiteralExpression(arg)) { objectLiteral = arg; return; } + } + ts.forEachChild(n, find); + }; + find(node.body ?? node); + if (!objectLiteral) return []; + 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) fields.push({ name: fname, ...zodShape(p.initializer, ts) }); + } + return fields; +} + +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' && arg0 && ts.isNumericLiteral(arg0)) shape.min = Number(arg0.text); + if (method === 'max' && arg0 && ts.isNumericLiteral(arg0)) shape.max = Number(arg0.text); + if (method === 'optional' || method === 'nullish') shape.optional = true; + cur = cur.expression.expression; + } + return shape; +} + +// `req.body.X` / `req.query.X` / `req.params.X` where req is the handler's first param. +function requestMemberAccesses(params: any, body: any, ts: TsModule): string[] { + const reqName = params?.[0] && ts.isIdentifier(params[0].name) ? params[0].name.text : undefined; + const out = new Set(); + if (!body) return []; + const visit = (n: any) => { + // .. + if (ts.isPropertyAccessExpression(n) && ts.isPropertyAccessExpression(n.expression)) { + const mid = n.expression; + if (ts.isIdentifier(mid.expression) && (!reqName || mid.expression.text === reqName) && + ['body', 'query', 'params'].includes(mid.name.text)) { + out.add(n.name.text); + } + } + ts.forEachChild(n, visit); + }; + visit(body); + return [...out]; +} + +// --- sinks (agnostic) ------------------------------------------------------- +function collectLocalSinks(sf: any, ts: TsModule): 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)); + 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)); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return map; +} + +function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map): Sink[] { + if (!arrowOrNode) return []; + const body = arrowOrNode.isSyntheticBody ? arrowOrNode.body + : isFnLike(arrowOrNode, ts) ? arrowOrNode.body : arrowOrNode; + if (!body) return []; + const sinks = directSinks(body, ts); + for (const called of localCalls(body, ts)) for (const s of localSinks.get(called) ?? []) sinks.push(s); + return dedupeSinks(sinks); +} + +// Provider-agnostic sink recognizers over a subtree. +function directSinks(node: any, ts: TsModule): Sink[] { + const sinks: Sink[] = []; + const push = (s: Sink) => sinks.push(s); + const visit = (n: any) => { + if (ts.isCallExpression(n)) { + const callee = n.expression; + // db: `.from("t").()` (supabase/knex/kysely) + if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'from') { + const t = n.arguments[0]; + const table = t && ts.isStringLiteralLike(t) ? t.text : undefined; + const parent = n.parent; + if (parent && ts.isPropertyAccessExpression(parent) && DB_OPS.has(parent.name.text)) { + push({ kind: 'db', provider: 'sql', table, op: parent.name.text }); + } + } + if (ts.isPropertyAccessExpression(callee)) { + const method = callee.name.text; + // db: prisma-style `prisma..()` + if (PRISMA_OPS.has(method) && ts.isPropertyAccessExpression(callee.expression)) { + push({ kind: 'db', provider: 'prisma', table: callee.expression.name.text, op: method }); + } + // db: raw `.query(` / `.execute(` + if (method === 'query' || method === 'execute') push({ kind: 'db', provider: 'sql', op: method }); + // fs / exec via a namespace: `fs.writeFile(` / `child_process.exec(` + if (FS_CALLS.test(method)) push({ kind: 'fs', op: method }); + if (EXEC_CALLS.test(method)) push({ kind: 'exec', op: method }); + // http: `axios.get(` / `http.request(` + if ((method === 'get' || method === 'post' || method === 'request') && ts.isIdentifier(callee.expression) && + /^(axios|http|https|got)$/.test(callee.expression.text)) { + push({ kind: 'http', provider: callee.expression.text, op: method }); + } + } + // bare calls: fetch( / exec( / readFile( / eval( / new Function + if (ts.isIdentifier(callee)) { + const name = callee.text; + if (HTTP_CALLS.test(name)) push({ kind: 'http', provider: name, op: 'request' }); + if (FS_CALLS.test(name)) push({ kind: 'fs', op: name }); + if (EXEC_CALLS.test(name)) push({ kind: 'exec', op: name }); + if (name === 'eval') push({ kind: 'eval', op: 'eval' }); + } + } + if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'Function') { + push({ kind: 'eval', op: 'new Function' }); + } + ts.forEachChild(n, visit); + }; + visit(node); + return sinks; +} + +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; +} + +function dedupeSinks(sinks: Sink[]): Sink[] { + const seen = new Set(); + const out: Sink[] = []; + for (const s of sinks) { + const key = `${s.kind}:${s.provider}:${s.table}:${s.op}`; + if (!seen.has(key)) { seen.add(key); out.push(s); } + } + return out; +} diff --git a/src/map/index.ts b/src/map/index.ts new file mode 100644 index 0000000..839eb8a --- /dev/null +++ b/src/map/index.ts @@ -0,0 +1,24 @@ +import { loadTypeScript } from './ts-loader.js'; +import { extractInputMap } from './extract.js'; +import type { SiteInputMap } from './types.js'; + +export type { SiteInputMap, Endpoint, InputField, Sink } from './types.js'; + +/** + * Build the app's input-flow ("attack surface") map at build time. Resolves the target app's own + * TypeScript to parse the source; returns { map: null, error } (never throws) when TS can't be + * resolved, so the CLI can degrade with a clear message. + */ +export async function buildInputMap(cwd: string): Promise<{ map: SiteInputMap | null; error?: string }> { + const ts = await loadTypeScript(cwd); + if (!ts) { + return { + map: null, + error: + 'Could not resolve a TypeScript compiler to parse the app. `map` uses your project’s own ' + + '`typescript` (already a devDependency of a TypeScript app) — install it and retry.', + }; + } + const map = await extractInputMap(cwd, ts); + return { map }; +} diff --git a/src/map/ts-loader.ts b/src/map/ts-loader.ts new file mode 100644 index 0000000..612a3a4 --- /dev/null +++ b/src/map/ts-loader.ts @@ -0,0 +1,28 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { join } from 'node:path'; +import type { TsModule } from './types.js'; + +// Load a TypeScript compiler for parsing the target app's source. We do NOT bundle `typescript` into +// connect (it's a heavy dep and the runtime guard never needs it) — instead we resolve the TARGET +// app's own installed `typescript` (every TS app has it, at the exact version its code expects), then +// fall back to a `typescript` resolvable from connect's own context (dev/global). Returns null if +// neither is available, so the caller can degrade with a clear message rather than crash. +export async function loadTypeScript(cwd: string): Promise { + // 1. The target app's node_modules (the normal case). + try { + const req = createRequire(pathToFileURL(join(cwd, 'package.json'))); + const resolved = req.resolve('typescript'); + const mod = await import(pathToFileURL(resolved).href); + return (mod.default ?? mod) as TsModule; + } catch { + /* fall through */ + } + // 2. A `typescript` resolvable from here (connect dev / a global install). + try { + const mod = await import('typescript'); + return (mod.default ?? mod) as TsModule; + } catch { + return null; + } +} diff --git a/src/map/types.ts b/src/map/types.ts new file mode 100644 index 0000000..3ceb07b --- /dev/null +++ b/src/map/types.ts @@ -0,0 +1,61 @@ +// The build-time input-flow ("attack surface") map. connect's `map` command walks the app's source +// and emits this per-site: entry points → the inputs each reads → the sinks/dependencies they reach. +// It's both a user-facing surface view and the coordinate source dynamic vPatch templates bind against. +// +// Honesty is a first-class field: static analysis is best-effort, so `coverage` records what the +// adapter could and couldn't see. Never present the map as "complete". + +export interface InputField { + /** Parameter / body-field name — the coordinate a rule pins to. */ + name: string; + /** Coarse type when derivable (string | number | boolean | array | object | unknown). */ + type?: string; + /** Declared constraints, when a validator (e.g. zod) exposes them. */ + min?: number; + max?: number; + optional?: boolean; +} + +export interface Sink { + /** db | fs | http | exec | template | redirect | … */ + kind: string; + /** e.g. "supabase", "pg", "fetch". */ + provider?: string; + /** For db sinks: the table. */ + table?: string; + /** For db sinks: insert | update | delete | select | rpc. */ + op?: string; +} + +export interface Endpoint { + /** Exported server-fn name / route id / handler — the entry point. */ + name: string; + /** How it was recognized: server-fn | route-handler | route-registration | server-action. */ + entryKind: string; + /** HTTP method when known. */ + method?: string; + /** URL path when known (route registrations / file-based routes). */ + route?: string; + /** Repo-relative source file. */ + file: string; + inputs: InputField[]; + sinks: Sink[]; +} + +export interface Coverage { + /** Adapter that produced the map. */ + adapter: string; + /** Honest notes on what static analysis could not resolve (dynamic dispatch, indirection, …). */ + notes: string[]; +} + +export interface SiteInputMap { + version: 1; + /** e.g. "tanstack-start". */ + framework: string; + endpoints: Endpoint[]; + coverage: Coverage; +} + +/** The TypeScript module surface we use (a subset of `typescript`), resolved from the target app. */ +export type TsModule = typeof import('typescript'); diff --git a/tests/map-extract.test.ts b/tests/map-extract.test.ts new file mode 100644 index 0000000..1580fb9 --- /dev/null +++ b/tests/map-extract.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildInputMap } from '../src/map/index.js'; + +// The agnostic extractor across three stacks in one fixture app: a TanStack server fn (zod inputs + +// supabase sink, incl. a helper-indirected select), an Express route (req.body access + fs/exec +// sinks), and a Next-style route handler (supabase sink). + +let dir: string; +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'ps-map-')); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { '@tanstack/react-start': '1', express: '4' } })); + + writeFileSync(join(dir, 'src', 'tasks.functions.ts'), ` + import { createServerFn } from "@tanstack/react-start"; + import { z } from "zod"; + function listTasks() { + return supabase.from("tasks").select("id, title").order("created_at"); + } + export const getTasks = createServerFn({ method: "GET" }).handler(async () => listTasks()); + export const createTask = createServerFn({ method: "POST" }) + .inputValidator((input) => z.object({ title: z.string().trim().min(1).max(200) }).parse(input)) + .handler(async ({ data }) => { supabase.from("tasks").insert({ title: data.title }); return listTasks(); }); + `); + + writeFileSync(join(dir, 'src', 'server.ts'), ` + import express from "express"; + import { exec } from "node:child_process"; + import fs from "node:fs"; + const app = express(); + app.post("/api/convert", (req, res) => { + fs.writeFileSync(req.body.path, req.body.data); + exec("convert " + req.query.fmt); + res.end(); + }); + `); + + writeFileSync(join(dir, 'src', 'route.ts'), ` + export async function POST(request) { + const body = await request.json(); + return supabase.from("orders").insert({ note: body.note }); + } + `); +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe('agnostic input-flow extractor', () => { + it('extracts entry points across TanStack / Express / Next shapes', async () => { + const { map, error } = await buildInputMap(dir); + expect(error).toBeUndefined(); + expect(map).not.toBeNull(); + const byName = Object.fromEntries(map!.endpoints.map((e) => [e.name, e])); + + // TanStack server fn: zod input + direct + helper-indirected sinks. + expect(byName.createTask).toMatchObject({ entryKind: 'server-fn', method: 'POST' }); + expect(byName.createTask.inputs).toEqual([{ name: 'title', type: 'string', min: 1, max: 200 }]); + expect(byName.createTask.sinks).toEqual( + expect.arrayContaining([ + { kind: 'db', provider: 'sql', table: 'tasks', op: 'insert' }, + { kind: 'db', provider: 'sql', table: 'tasks', op: 'select' }, // via listTasks() one-level dataflow + ]), + ); + // getTasks has no input; sink reached only through the helper. + expect(byName.getTasks.inputs).toEqual([]); + expect(byName.getTasks.sinks).toEqual([{ kind: 'db', provider: 'sql', table: 'tasks', op: 'select' }]); + + // Express route registration: path + method + req.body/query inputs + fs/exec sinks. + const convert = map!.endpoints.find((e) => e.route === '/api/convert')!; + expect(convert.entryKind).toBe('route-registration'); + expect(convert.method).toBe('POST'); + expect(convert.inputs.map((i) => i.name).sort()).toEqual(['data', 'fmt', 'path']); + expect(convert.sinks).toEqual( + expect.arrayContaining([ + { kind: 'fs', op: 'writeFileSync' }, + { kind: 'exec', op: 'exec' }, + ]), + ); + + // Next-style route handler: method from the export name, supabase sink. + expect(byName.POST).toMatchObject({ entryKind: 'route-handler', method: 'POST' }); + expect(byName.POST.sinks).toEqual([{ kind: 'db', provider: 'sql', table: 'orders', op: 'insert' }]); + }); + + it('records honest coverage notes and a framework label', async () => { + const { map } = await buildInputMap(dir); + expect(map!.framework).toBe('tanstack-start'); + expect(map!.coverage.notes.join(' ')).toMatch(/best-effort/i); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 15ec4b9..b50bdfd 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -16,6 +16,10 @@ export default defineConfig([ sourcemap: true, target: 'node18', banner: { js: '#!/usr/bin/env node' }, + // `map` parses the target app's source with a TypeScript compiler resolved at RUNTIME (the app's + // own `typescript`, or the environment's). Never bundle the compiler into the CLI — it's a heavy + // devDependency and the runtime guard never needs it. + external: ['typescript'], }, { // Vendored runtime protection engine (node-waf + createProtection + Supabase guard), From 0ad6335bd45872422eb89fc1d96b61d6b9bc08d6 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 11:39:29 +0200 Subject: [PATCH 2/6] map: tag each sink with the npm package behind it; recognize server actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link that lets a site's vulnerable dependency (from the manifest / TI) be correlated to the exact input that reaches it: each sink now carries `package`, resolved from the file's imports — precisely from the call's base identifier (fs → node:fs, exec → node:child_process, axios → axios, and const-from-import / require / new bindings), or inferred from the file's import of a known provider for that sink kind when the client is built via a local factory (e.g. `const supabase = getClient()` still resolves to @supabase/supabase-js). Also fixes a dead branch: files with a `'use server'` directive passed the pre-filter but had no recognizer — Next server actions are now extracted as entry points (entryKind: server-action), and same-line const route-handler / server-action recognizers are consolidated. Validated on the reference app (all 7 supabase sinks tagged) and fixtures across TanStack / Express / Next / server-action shapes. Co-Authored-By: Claude Opus 4.8 --- src/map/extract.ts | 184 ++++++++++++++++++++++++++++++-------- src/map/types.ts | 7 ++ tests/map-extract.test.ts | 35 +++++--- 3 files changed, 181 insertions(+), 45 deletions(-) diff --git a/src/map/extract.ts b/src/map/extract.ts index 6efb366..3856815 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -16,6 +16,90 @@ const FS_CALLS = /^(readFile|writeFile|readFileSync|writeFileSync|appendFile|cre const EXEC_CALLS = /^(exec|execSync|spawn|spawnSync|execFile|execFileSync|fork)$/; const HTTP_CALLS = /^(fetch|got|request)$/; const ZOD_BASE = new Set(['string', 'number', 'boolean', 'array', 'object', 'enum', 'bigint', 'date', 'record']); +// 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']; + +// 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')`. `imports` is every module specifier the file imports (for the fallback). +interface Bindings { + resolve(name: string): string | undefined; + imports: Set; +} +function buildModuleBindings(sf: any, ts: TsModule): Bindings { + const nameToModule = new Map(); // local name → module specifier + const importedNames = new Map(); // imported binding → module (for the const-from-import step) + const imports = new Set(); + + const record = (local: string, mod: string) => { nameToModule.set(local, mod); imports.add(mod); }; + + 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); importedNames.set(clause.name.text, mod); } // default + const nb = clause?.namedBindings; + if (nb) { + if (ts.isNamespaceImport(nb)) { record(nb.name.text, mod); importedNames.set(nb.name.text, mod); } + else if (ts.isNamedImports(nb)) for (const el of nb.elements) { record(el.name.text, mod); importedNames.set(el.name.text, mod); } + } + } + // const x = require('mod') / const { a } = require('mod') + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + 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 = importedFactory(...) / const x = new ImportedClass(...) → x carries that package + 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 && importedNames.has(root)) record(decl.name.text, importedNames.get(root)!); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return { resolve: (name: string) => nameToModule.get(name), imports }; +} + +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 kept +// as-is (they signal a builtin, not an npm CVE); relative paths → undefined (local, not a package). +function npmPackageOf(spec: string | undefined): string | undefined { + if (!spec) return undefined; + if (spec.startsWith('.') || spec.startsWith('/')) return undefined; + if (spec.startsWith('node:')) return spec; + const parts = spec.split('/'); + return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; +} export async function extractInputMap(cwd: string, ts: TsModule): Promise { const notes: string[] = []; @@ -27,14 +111,16 @@ export async function extractInputMap(cwd: string, ts: TsModule): Promise): Omit[] { +function extractFromFile(sf: any, ts: TsModule, localSinks: Map, bindings: Bindings): Omit[] { const out: Omit[] = []; + const isServerActionsFile = fileHasUseServer(sf, ts); const visit = (node: any) => { - // (1) TanStack Start: `export const NAME = createServerFn({method}).inputValidator(fn).handler(fn)` 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)) { @@ -100,23 +187,28 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map) entryKind: 'server-fn', method: methodFromObjectArg(chain.baseCall, ts), inputs: inputsFromValidator(chain.calls['inputValidator'] ?? chain.calls['validator'], ts), - sinks: sinksFrom(chain.calls['handler']?.arguments?.[0], ts, localSinks), + sinks: sinksFrom(chain.calls['handler']?.arguments?.[0], ts, localSinks, bindings), }); + 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)); + } else if (isServerActionsFile) { + out.push(handlerEntry(decl.name.text, 'server-action', decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings)); } } } } - // (2) Route handlers: `export (async) function GET/POST/…(req)` / `export const POST = (req) => …` - // (Next route handlers, SvelteKit +server, etc.) - if (ts.isFunctionDeclaration(node) && node.name && hasExport(node, ts) && HTTP_METHODS.has(node.name.text)) { - out.push(handlerEntry(node.name.text, node.name.text, node.parameters, node.body, ts, localSinks)); - } - if (ts.isVariableStatement(node) && hasExport(node, ts)) { - for (const decl of node.declarationList.declarations) { - if (ts.isIdentifier(decl.name) && HTTP_METHODS.has(decl.name.text) && decl.initializer && isFnLike(decl.initializer, ts)) { - out.push(handlerEntry(decl.name.text, decl.name.text, decl.initializer.parameters, decl.initializer.body, ts, localSinks)); - } + // (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)); + } else if (isServerActionsFile || hasUseServerDirective(node, ts)) { + out.push(handlerEntry(node.name.text, 'server-action', node.parameters, node.body, ts, localSinks, bindings)); } } @@ -126,7 +218,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map) const pathArg = args[0]; const handler = args[args.length - 1]; if (pathArg && ts.isStringLiteralLike(pathArg) && handler && isFnLike(handler, ts)) { - out.push(handlerEntry(pathArg.text, `route-registration`, handler.parameters, handler.body, ts, localSinks, { + out.push(handlerEntry(pathArg.text, `route-registration`, handler.parameters, handler.body, ts, localSinks, bindings, { method: node.expression.name.text.toUpperCase(), route: pathArg.text, })); @@ -139,6 +231,16 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map) 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, @@ -146,15 +248,17 @@ function handlerEntry( body: any, ts: TsModule, localSinks: Map, + bindings: Bindings, extra: { method?: string; route?: string } = {}, ): Omit { + const entryKind = kindLabel === 'route-registration' ? 'route-registration' : kindLabel === 'server-action' ? 'server-action' : 'route-handler'; return { name, - entryKind: kindLabel === 'route-registration' ? 'route-registration' : 'route-handler', + entryKind, method: extra.method ?? (HTTP_METHODS.has(name) ? name : undefined), route: extra.route, inputs: inputsFromHandler(params, body, ts), - sinks: sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks), + sinks: sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings), }; } @@ -264,14 +368,14 @@ function requestMemberAccesses(params: any, body: any, ts: TsModule): string[] { } // --- sinks (agnostic) ------------------------------------------------------- -function collectLocalSinks(sf: any, ts: TsModule): Map { +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)); + 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)); + map.set(decl.name.text, directSinks(decl.initializer.body, ts, bindings)); } } } @@ -281,19 +385,29 @@ function collectLocalSinks(sf: any, ts: TsModule): Map { return map; } -function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map): Sink[] { +function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map, bindings: Bindings): Sink[] { if (!arrowOrNode) return []; const body = arrowOrNode.isSyntheticBody ? arrowOrNode.body : isFnLike(arrowOrNode, ts) ? arrowOrNode.body : arrowOrNode; if (!body) return []; - const sinks = directSinks(body, ts); + const sinks = directSinks(body, ts, bindings); for (const called of localCalls(body, ts)) for (const s of localSinks.get(called) ?? []) sinks.push(s); return dedupeSinks(sinks); } -// Provider-agnostic sink recognizers over a subtree. -function directSinks(node: any, ts: TsModule): Sink[] { +// 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 file uses one db/http client). +function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] { const sinks: Sink[] = []; + const pkgOf = (base: any, kind: string): string | undefined => { + const root = base ? rootIdentifier(base, ts) : undefined; + const precise = root ? npmPackageOf(bindings.resolve(root)) : undefined; + if (precise) return precise; + const table = kind === 'db' ? DB_PACKAGES : kind === 'http' ? HTTP_PACKAGES : null; + if (table) 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) => { if (ts.isCallExpression(n)) { @@ -304,32 +418,32 @@ function directSinks(node: any, ts: TsModule): Sink[] { const table = t && ts.isStringLiteralLike(t) ? t.text : undefined; const parent = n.parent; if (parent && ts.isPropertyAccessExpression(parent) && DB_OPS.has(parent.name.text)) { - push({ kind: 'db', provider: 'sql', table, op: parent.name.text }); + push({ kind: 'db', provider: 'sql', package: pkgOf(callee.expression, 'db'), table, op: parent.name.text }); } } if (ts.isPropertyAccessExpression(callee)) { const method = callee.name.text; // db: prisma-style `prisma..()` if (PRISMA_OPS.has(method) && ts.isPropertyAccessExpression(callee.expression)) { - push({ kind: 'db', provider: 'prisma', table: callee.expression.name.text, op: method }); + push({ kind: 'db', provider: 'prisma', package: pkgOf(callee.expression, 'db') ?? '@prisma/client', table: callee.expression.name.text, op: method }); } // db: raw `.query(` / `.execute(` - if (method === 'query' || method === 'execute') push({ kind: 'db', provider: 'sql', op: method }); + if (method === 'query' || method === 'execute') push({ kind: 'db', provider: 'sql', package: pkgOf(callee.expression, 'db'), op: method }); // fs / exec via a namespace: `fs.writeFile(` / `child_process.exec(` - if (FS_CALLS.test(method)) push({ kind: 'fs', op: method }); - if (EXEC_CALLS.test(method)) push({ kind: 'exec', op: method }); + if (FS_CALLS.test(method)) push({ kind: 'fs', package: pkgOf(callee.expression, 'fs'), op: method }); + if (EXEC_CALLS.test(method)) push({ kind: 'exec', package: pkgOf(callee.expression, 'exec'), op: method }); // http: `axios.get(` / `http.request(` if ((method === 'get' || method === 'post' || method === 'request') && ts.isIdentifier(callee.expression) && /^(axios|http|https|got)$/.test(callee.expression.text)) { - push({ kind: 'http', provider: callee.expression.text, op: method }); + push({ kind: 'http', provider: callee.expression.text, package: pkgOf(callee.expression, 'http'), op: method }); } } // bare calls: fetch( / exec( / readFile( / eval( / new Function if (ts.isIdentifier(callee)) { const name = callee.text; - if (HTTP_CALLS.test(name)) push({ kind: 'http', provider: name, op: 'request' }); - if (FS_CALLS.test(name)) push({ kind: 'fs', op: name }); - if (EXEC_CALLS.test(name)) push({ kind: 'exec', op: name }); + if (HTTP_CALLS.test(name)) push({ kind: 'http', provider: name, package: pkgOf(callee, 'http'), op: 'request' }); + if (FS_CALLS.test(name)) push({ kind: 'fs', package: pkgOf(callee, 'fs'), op: name }); + if (EXEC_CALLS.test(name)) push({ kind: 'exec', package: pkgOf(callee, 'exec'), op: name }); if (name === 'eval') push({ kind: 'eval', op: 'eval' }); } } @@ -356,7 +470,7 @@ function dedupeSinks(sinks: Sink[]): Sink[] { const seen = new Set(); const out: Sink[] = []; for (const s of sinks) { - const key = `${s.kind}:${s.provider}:${s.table}:${s.op}`; + const key = `${s.kind}:${s.provider}:${s.package}:${s.table}:${s.op}`; if (!seen.has(key)) { seen.add(key); out.push(s); } } return out; diff --git a/src/map/types.ts b/src/map/types.ts index 3ceb07b..f4b72fc 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -21,6 +21,13 @@ export interface Sink { kind: string; /** e.g. "supabase", "pg", "fetch". */ provider?: string; + /** + * The npm package (or `node:` builtin) backing this sink — the link that lets a site's vulnerable + * dependency (from the manifest / TI) be correlated to the exact input that reaches it. Resolved from + * the file's imports (e.g. `@supabase/supabase-js`, `express`, `node:child_process`); undefined when + * it can't be traced (a global like `fetch`, or an untraced indirection). + */ + package?: string; /** For db sinks: the table. */ table?: string; /** For db sinks: insert | update | delete | select | rpc. */ diff --git a/tests/map-extract.test.ts b/tests/map-extract.test.ts index 1580fb9..ab030c6 100644 --- a/tests/map-extract.test.ts +++ b/tests/map-extract.test.ts @@ -17,6 +17,8 @@ beforeAll(() => { writeFileSync(join(dir, 'src', 'tasks.functions.ts'), ` import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; + import { createClient } from "@supabase/supabase-js"; + const supabase = createClient(process.env.URL, process.env.KEY); function listTasks() { return supabase.from("tasks").select("id, title").order("created_at"); } @@ -39,11 +41,21 @@ beforeAll(() => { `); writeFileSync(join(dir, 'src', 'route.ts'), ` + import { createClient } from "@supabase/supabase-js"; + const supabase = createClient(process.env.URL, process.env.KEY); export async function POST(request) { const body = await request.json(); return supabase.from("orders").insert({ note: body.note }); } `); + + writeFileSync(join(dir, 'src', 'actions.ts'), ` + 'use server'; + import { exec } from "node:child_process"; + export async function runReport(input) { + exec("report " + input.name); + } + `); }); afterAll(() => rmSync(dir, { recursive: true, force: true })); @@ -54,34 +66,37 @@ describe('agnostic input-flow extractor', () => { expect(map).not.toBeNull(); const byName = Object.fromEntries(map!.endpoints.map((e) => [e.name, e])); - // TanStack server fn: zod input + direct + helper-indirected sinks. + // TanStack server fn: zod input + direct + helper-indirected sinks, each tagged with the package. expect(byName.createTask).toMatchObject({ entryKind: 'server-fn', method: 'POST' }); expect(byName.createTask.inputs).toEqual([{ name: 'title', type: 'string', min: 1, max: 200 }]); expect(byName.createTask.sinks).toEqual( expect.arrayContaining([ - { kind: 'db', provider: 'sql', table: 'tasks', op: 'insert' }, - { kind: 'db', provider: 'sql', table: 'tasks', op: 'select' }, // via listTasks() one-level dataflow + { kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'insert' }, + { kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'select' }, // via listTasks() ]), ); - // getTasks has no input; sink reached only through the helper. expect(byName.getTasks.inputs).toEqual([]); - expect(byName.getTasks.sinks).toEqual([{ kind: 'db', provider: 'sql', table: 'tasks', op: 'select' }]); + expect(byName.getTasks.sinks).toEqual([{ kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'select' }]); - // Express route registration: path + method + req.body/query inputs + fs/exec sinks. + // Express route registration: path + method + req.body/query inputs + fs/exec sinks with node: packages. const convert = map!.endpoints.find((e) => e.route === '/api/convert')!; expect(convert.entryKind).toBe('route-registration'); expect(convert.method).toBe('POST'); expect(convert.inputs.map((i) => i.name).sort()).toEqual(['data', 'fmt', 'path']); expect(convert.sinks).toEqual( expect.arrayContaining([ - { kind: 'fs', op: 'writeFileSync' }, - { kind: 'exec', op: 'exec' }, + { kind: 'fs', package: 'node:fs', op: 'writeFileSync' }, + { kind: 'exec', package: 'node:child_process', op: 'exec' }, ]), ); - // Next-style route handler: method from the export name, supabase sink. + // Next-style route handler: method from the export name, supabase sink with package. expect(byName.POST).toMatchObject({ entryKind: 'route-handler', method: 'POST' }); - expect(byName.POST.sinks).toEqual([{ kind: 'db', provider: 'sql', table: 'orders', op: 'insert' }]); + expect(byName.POST.sinks).toEqual([{ kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'orders', op: 'insert' }]); + + // Next `'use server'` action: recognized as an entry point, exec sink tagged with its package. + expect(byName.runReport).toMatchObject({ entryKind: 'server-action' }); + expect(byName.runReport.sinks).toEqual([{ kind: 'exec', package: 'node:child_process', op: 'exec' }]); }); it('records honest coverage notes and a framework label', async () => { From 390d66f895a1fbb6c869b14019a907f13adc8ed4 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 12:00:20 +0200 Subject: [PATCH 3/6] =?UTF-8?q?map:=20harden=20the=20extractor=20=E2=80=94?= =?UTF-8?q?=20binding-gated=20sinks,=20richer=20input=20tracing,=20honesty?= =?UTF-8?q?=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recall fixes (all previously produced silent false negatives): - the textual pre-filter and the route recognizer now derive from one list, so files registering only .head()/.use() routes are no longer skipped - inputs destructured from req.body/query/params and from destructured handler params are extracted; fetch-style bodies are traced through `const body = await request.json()` variables and destructuring - router.route('/x').get(handler) chains and Fastify's object-form app.route({method, url, handler}) are recognized (one endpoint per method) - symlinked source directories are followed (with a realpath cycle guard) Precision fixes (all previously produced false positives): - sink recognizers are gated on module bindings: calls on plain local objects/classes/functions are not dependency sinks; prisma-shaped ops require a real prisma signal - validator `.object({...})` is only read as a schema when its receiver traces to a known validator package - destructured handler params no longer wildcard-match unrelated *.body/query/params member accesses - bare builtin imports normalize to node:* (npm has a package named `fs`) New signal: - endpoints and sinks carry a 1-based source line (auditable coordinates) - nested validator fields flatten to dotted paths (address.city, tags[].label) with formats (.email(), …) and regex constraints captured - inputsResolved: false marks endpoints whose declared validator could not be parsed — inputs are unknown, not empty — and coverage notes now also report per-run facts (skipped files, unresolved validators) instead of boilerplate - per-file fail-open: one unparseable file no longer aborts the whole map - binding resolution is transitive (const conn = pool.promise()) Co-Authored-By: Claude Fable 5 --- src/map/extract.ts | 422 ++++++++++++++++++++++++------- src/map/types.ts | 20 +- tests/map-extract-strict.test.ts | 226 +++++++++++++++++ tests/map-extract.test.ts | 21 +- 4 files changed, 582 insertions(+), 107 deletions(-) create mode 100644 tests/map-extract-strict.test.ts diff --git a/src/map/extract.ts b/src/map/extract.ts index 3856815..75d1242 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -1,4 +1,5 @@ -import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { readFileSync, readdirSync, existsSync, realpathSync, statSync } from 'node:fs'; +import { builtinModules } from 'node:module'; import { join, relative } from 'node:path'; import type { SiteInputMap, Endpoint, InputField, Sink, TsModule } from './types.js'; @@ -7,33 +8,61 @@ import type { SiteInputMap, Endpoint, InputField, Sink, TsModule } from './types // across builders (TanStack Start, Next, SvelteKit, Express/Fastify/Hono, …) and providers, and // degrades gracefully (recording what it couldn't see in `coverage.notes`). Add a stack by adding a // recognizer, not a new adapter. +// +// False-positive control: sink and validator recognizers are gated on the file's module bindings — +// a call whose receiver is a plain local object/class/function is NOT a dependency sink, and +// `.object({…})` is only read as an input schema when its receiver traces to a known validator +// 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']); -const ROUTE_REGISTER = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'all', 'head', 'use']); +// 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')`. `imports` is every module specifier the file imports (for the fallback). +// `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; imports: Set; + locals: Set; } function buildModuleBindings(sf: any, ts: TsModule): Bindings { const nameToModule = new Map(); // local name → module specifier - const importedNames = new Map(); // imported binding → module (for the const-from-import step) + const declared = new Set(); // every name declared in this file 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' @@ -41,34 +70,39 @@ function buildModuleBindings(sf: any, ts: TsModule): Bindings { const mod = node.moduleSpecifier.text; imports.add(mod); const clause = node.importClause; - if (clause?.name) { record(clause.name.text, mod); importedNames.set(clause.name.text, mod); } // default + if (clause?.name) record(clause.name.text, mod); // default const nb = clause?.namedBindings; if (nb) { - if (ts.isNamespaceImport(nb)) { record(nb.name.text, mod); importedNames.set(nb.name.text, mod); } - else if (ts.isNamedImports(nb)) for (const el of nb.elements) { record(el.name.text, mod); importedNames.set(el.name.text, mod); } + 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); } } + 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 = importedFactory(...) / const x = new ImportedClass(...) → x carries that package + // const x = tracedFactory(...) / const x = new TracedClass(...) → x carries that package. + // Looking up nameToModule (not just direct imports) makes this transitive: pool → conn → …. 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 && importedNames.has(root)) record(decl.name.text, importedNames.get(root)!); + if (root && nameToModule.has(root)) record(decl.name.text, nameToModule.get(root)!); } } } ts.forEachChild(node, visit); }; visit(sf); - return { resolve: (name: string) => nameToModule.get(name), imports }; + const locals = new Set([...declared].filter((n) => !nameToModule.has(n))); + return { resolve: (name: string) => nameToModule.get(name), imports, locals }; } function requireSpecifier(init: any, ts: TsModule): string | undefined { @@ -91,47 +125,71 @@ function rootIdentifier(node: any, ts: TsModule): string | undefined { return undefined; } -// Normalize a module specifier to its npm package root (keep scope, drop subpath); node: builtins kept -// as-is (they signal a builtin, not an npm CVE); relative paths → undefined (local, not a package). +// 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]; } +// 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 async function extractInputMap(cwd: string, ts: TsModule): Promise { const notes: string[] = []; const endpoints: Endpoint[] = []; + const failed: string[] = []; const srcDir = join(cwd, 'src'); const root = existsSync(srcDir) ? srcDir : cwd; for (const file of collectSources(root)) { - const text = readFileSync(file, 'utf8'); - if (!hasEntrySignal(text)) continue; - const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); - const bindings = buildModuleBindings(sf, ts); - const localSinks = collectLocalSinks(sf, ts, bindings); - for (const ep of extractFromFile(sf, ts, localSinks, bindings)) { - endpoints.push({ ...ep, file: relative(cwd, file) }); + try { + const text = readFileSync(file, 'utf8'); + if (!hasEntrySignal(text)) continue; + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); + const bindings = buildModuleBindings(sf, ts); + const localSinks = collectLocalSinks(sf, ts, bindings); + for (const ep of extractFromFile(sf, ts, localSinks, bindings)) { + endpoints.push({ ...ep, file: relative(cwd, file) }); + } + } catch { + // Fail-open: one unreadable/unparseable file must never kill the whole map. + failed.push(relative(cwd, file)); } } notes.push('Static analysis is best-effort — this is the DETECTED surface, not a proof of completeness.'); notes.push('Sinks are followed one level into same-file helpers; cross-file / dynamic indirection is not traced.'); notes.push('A sink `package` is resolved from the file’s imports (precise) or inferred from a known provider import; an unresolved package means the backing dependency could not be traced.'); + if (failed.length > 0) { + const sample = failed.slice(0, 5).join(', '); + notes.push(`${failed.length} file(s) could not be analyzed and were skipped (fail-open): ${sample}${failed.length > 5 ? ', …' : ''}.`); + } + const unresolved = endpoints.filter((e) => e.inputsResolved === false).length; + if (unresolved > 0) { + notes.push(`${unresolved} endpoint(s) declare an input validator that could not be statically parsed — their inputs are UNKNOWN, not empty (marked inputsResolved: false).`); + } if (endpoints.length === 0) notes.push('No recognized server-side entry points found under the source root.'); return { version: 1, framework: detectFramework(cwd), endpoints, coverage: { adapter: 'agnostic-v1', notes } }; } -// Cheap textual pre-filter so we only parse files that could contain an entry point. +// 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) || - /\.(get|post|put|patch|delete|options|all)\s*\(/.test(text) || + ROUTE_CALL_RE.test(text) || text.includes("'use server'") || text.includes('"use server"') ); } @@ -158,14 +216,27 @@ function guessScriptKind(ts: TsModule, file: string) { return ts.ScriptKind.TS; } -function collectSources(dir: string, out: string[] = []): string[] { +const isSourceFile = (name: string) => /\.(ts|tsx|js|jsx|mjs)$/.test(name) && !name.endsWith('.d.ts'); + +// Walks the tree following symlinked directories/files too (monorepos link packages into src), with a +// realpath visited-set so link cycles can't loop. +function collectSources(dir: string, out: string[] = [], seen = new Set()): string[] { + let key: string; + try { key = realpathSync(dir); } catch { return out; } + if (seen.has(key)) return out; + seen.add(key); let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } for (const e of entries) { if (e.name === 'node_modules' || e.name === 'dist' || e.name === 'build' || e.name.startsWith('.')) continue; const full = join(dir, e.name); - if (e.isDirectory()) collectSources(full, out); - else if (/\.(ts|tsx|js|jsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) out.push(full); + if (e.isDirectory()) collectSources(full, out, seen); + else if (e.isSymbolicLink()) { + let st; + try { st = statSync(full); } catch { continue; } + if (st.isDirectory()) collectSources(full, out, seen); + else if (st.isFile() && isSourceFile(e.name)) out.push(full); + } else if (isSourceFile(e.name)) out.push(full); } return out; } @@ -182,22 +253,28 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, if (decl.initializer && ts.isCallExpression(decl.initializer)) { const chain = unwindChain(decl.initializer, ts); if (chain.baseName === 'createServerFn' && ts.isIdentifier(decl.name)) { - out.push({ + const validatorCall = chain.calls['inputValidator'] ?? chain.calls['validator']; + const inputs = inputsFromValidator(validatorCall, ts, bindings); + const ep: Omit = { name: decl.name.text, entryKind: 'server-fn', method: methodFromObjectArg(chain.baseCall, ts), - inputs: inputsFromValidator(chain.calls['inputValidator'] ?? chain.calls['validator'], ts), + line: lineOf(decl), + inputs, sinks: sinksFrom(chain.calls['handler']?.arguments?.[0], ts, localSinks, bindings), - }); + }; + // 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)); + out.push(handlerEntry(decl.name.text, decl.name.text, decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, { line: lineOf(decl) })); } else if (isServerActionsFile) { - out.push(handlerEntry(decl.name.text, 'server-action', decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings)); + out.push(handlerEntry(decl.name.text, 'server-action', decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, { line: lineOf(decl) })); } } } @@ -206,22 +283,41 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, // (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)); + out.push(handlerEntry(node.name.text, node.name.text, node.parameters, node.body, ts, localSinks, bindings, { line: lineOf(node) })); } else if (isServerActionsFile || hasUseServerDirective(node, ts)) { - out.push(handlerEntry(node.name.text, 'server-action', node.parameters, node.body, ts, localSinks, bindings)); + out.push(handlerEntry(node.name.text, 'server-action', node.parameters, node.body, ts, localSinks, bindings, { line: lineOf(node) })); } } - // (3) Route registrations: `app.post('/path', …, handler)` / `router.get('/x', handler)` (Express/Fastify/Hono/Koa) - if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ROUTE_REGISTER.has(node.expression.name.text)) { - const args = node.arguments; - const pathArg = args[0]; - const handler = args[args.length - 1]; - if (pathArg && ts.isStringLiteralLike(pathArg) && handler && isFnLike(handler, ts)) { - out.push(handlerEntry(pathArg.text, `route-registration`, handler.parameters, handler.body, ts, localSinks, bindings, { - method: node.expression.name.text.toUpperCase(), - route: pathArg.text, - })); + 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, { + // `use`/`all` register handlers but are not HTTP methods — leave method undefined. + method: HTTP_METHODS.has(mname.toUpperCase()) ? mname.toUpperCase() : undefined, + route, + line: lineOf(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, { method: m, route: reg.url, line: lineOf(node) })); + } + } + } } } @@ -231,6 +327,43 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, 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]; @@ -249,7 +382,7 @@ function handlerEntry( ts: TsModule, localSinks: Map, bindings: Bindings, - extra: { method?: string; route?: string } = {}, + extra: { method?: string; route?: string; line?: number } = {}, ): Omit { const entryKind = kindLabel === 'route-registration' ? 'route-registration' : kindLabel === 'server-action' ? 'server-action' : 'route-handler'; return { @@ -257,7 +390,8 @@ function handlerEntry( entryKind, method: extra.method ?? (HTTP_METHODS.has(name) ? name : undefined), route: extra.route, - inputs: inputsFromHandler(params, body, ts), + line: extra.line, + inputs: inputsFromHandler(params, body, ts, bindings), sinks: sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings), }; } @@ -294,14 +428,15 @@ function methodFromObjectArg(baseCall: any, ts: TsModule): string | undefined { } // --- inputs ----------------------------------------------------------------- -function inputsFromValidator(validatorCall: any, ts: TsModule): InputField[] { +function inputsFromValidator(validatorCall: any, ts: TsModule, bindings: Bindings): InputField[] { if (!validatorCall) return []; - return zodObjectFields(validatorCall, ts); + return zodObjectFields(validatorCall, ts, bindings); } -// From a raw handler: zod schema fields it parses, plus request member-accesses (req.body/query/params.X). -function inputsFromHandler(params: any, body: any, ts: TsModule): InputField[] { - const fields = zodObjectFields(body, ts); +// 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): InputField[] { + const fields = zodObjectFields(body, ts, bindings); const names = new Set(fields.map((f) => f.name)); for (const n of requestMemberAccesses(params, body, ts)) { if (!names.has(n)) { names.add(n); fields.push({ name: n }); } @@ -309,29 +444,57 @@ function inputsFromHandler(params: any, body: any, ts: TsModule): InputField[] { return fields; } -// Find the first `z.object({...})` in a subtree and read its fields (name + zod type/constraints). -function zodObjectFields(node: any, ts: TsModule): InputField[] { - if (!node) return []; - let objectLiteral: any = null; +// 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 (objectLiteral || !n) return; + if (found || !n) return; if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'object') { - const arg = n.arguments[0]; - if (arg && ts.isObjectLiteralExpression(arg)) { objectLiteral = arg; return; } + 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.body ?? node); - if (!objectLiteral) return []; + 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) fields.push({ name: fname, ...zodShape(p.initializer, ts) }); + 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; @@ -339,26 +502,63 @@ function zodShape(node: any, ts: TsModule): Omit { const method = cur.expression.name.text; const arg0 = cur.arguments[0]; if (ZOD_BASE.has(method) && !shape.type) shape.type = method; - if (method === 'min' && arg0 && ts.isNumericLiteral(arg0)) shape.min = Number(arg0.text); - if (method === 'max' && arg0 && ts.isNumericLiteral(arg0)) shape.max = Number(arg0.text); + 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; } -// `req.body.X` / `req.query.X` / `req.params.X` where req is the handler's first param. +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): string[] { - const reqName = params?.[0] && ts.isIdentifier(params[0].name) ? params[0].name.text : undefined; - const out = new Set(); if (!body) return []; + const out = new Set(); + 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(); + 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 isReqSourceExpr = (e: any): boolean => + (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) && ts.isPropertyAccessExpression(n.expression)) { - const mid = n.expression; - if (ts.isIdentifier(mid.expression) && (!reqName || mid.expression.text === reqName) && - ['body', 'query', 'params'].includes(mid.name.text)) { - out.add(n.name.text); + // . + if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression)) out.add(n.name.text); + 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))) { + for (const el of n.name.elements) { + const key = bindingKey(el, ts); + if (key) out.add(key); + } } } ts.forEachChild(n, visit); @@ -367,6 +567,12 @@ function requestMemberAccesses(params: any, body: any, ts: TsModule): string[] { return [...out]; } +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(); @@ -397,15 +603,20 @@ function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map { + const baseOf = (base: any): { pkg?: string; local?: boolean; root?: string } => { const root = base ? rootIdentifier(base, ts) : undefined; - const precise = root ? npmPackageOf(bindings.resolve(root)) : undefined; - if (precise) return precise; - const table = kind === 'db' ? DB_PACKAGES : kind === 'http' ? HTTP_PACKAGES : null; - if (table) for (const p of table) if (bindings.imports.has(p)) return p; + 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); @@ -414,41 +625,56 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] { 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 (parent && ts.isPropertyAccessExpression(parent) && DB_OPS.has(parent.name.text)) { - push({ kind: 'db', provider: 'sql', package: pkgOf(callee.expression, 'db'), table, op: parent.name.text }); + 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, line: lineOf(parent) }); } } if (ts.isPropertyAccessExpression(callee)) { const method = callee.name.text; - // db: prisma-style `prisma..()` - if (PRISMA_OPS.has(method) && ts.isPropertyAccessExpression(callee.expression)) { - push({ kind: 'db', provider: 'prisma', package: pkgOf(callee.expression, 'db') ?? '@prisma/client', table: callee.expression.name.text, op: method }); - } - // db: raw `.query(` / `.execute(` - if (method === 'query' || method === 'execute') push({ kind: 'db', provider: 'sql', package: pkgOf(callee.expression, 'db'), op: method }); - // fs / exec via a namespace: `fs.writeFile(` / `child_process.exec(` - if (FS_CALLS.test(method)) push({ kind: 'fs', package: pkgOf(callee.expression, 'fs'), op: method }); - if (EXEC_CALLS.test(method)) push({ kind: 'exec', package: pkgOf(callee.expression, 'exec'), op: method }); - // http: `axios.get(` / `http.request(` - if ((method === 'get' || method === 'post' || method === 'request') && ts.isIdentifier(callee.expression) && - /^(axios|http|https|got)$/.test(callee.expression.text)) { - push({ kind: 'http', provider: callee.expression.text, package: pkgOf(callee.expression, 'http'), op: method }); + 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, line: lineOf(n) }); + } + // db: raw `.query(` / `.execute(` + if (method === 'query' || method === 'execute') { + push({ kind: 'db', provider: 'sql', package: b.pkg ?? infer('db'), op: method, line: lineOf(n) }); + } + // fs / exec via a namespace: `fs.writeFile(` / `child_process.exec(` + if (FS_CALLS.test(method)) push({ kind: 'fs', package: b.pkg, op: method, line: lineOf(n) }); + if (EXEC_CALLS.test(method)) push({ kind: 'exec', package: b.pkg, op: method, line: lineOf(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, line: lineOf(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, line: lineOf(n) }); + } + } } } - // bare calls: fetch( / exec( / readFile( / eval( / new Function - if (ts.isIdentifier(callee)) { + // bare calls: fetch( / exec( / readFile( / eval( — unless the name is a plain local function. + if (ts.isIdentifier(callee) && !bindings.locals.has(callee.text)) { const name = callee.text; - if (HTTP_CALLS.test(name)) push({ kind: 'http', provider: name, package: pkgOf(callee, 'http'), op: 'request' }); - if (FS_CALLS.test(name)) push({ kind: 'fs', package: pkgOf(callee, 'fs'), op: name }); - if (EXEC_CALLS.test(name)) push({ kind: 'exec', package: pkgOf(callee, 'exec'), op: name }); - if (name === 'eval') push({ kind: 'eval', op: 'eval' }); + const pkg = npmPackageOf(bindings.resolve(name)); + // `fetch` is a global — never attribute it to an unrelated imported http client. + if (HTTP_CALLS.test(name)) push({ kind: 'http', provider: name, package: pkg ?? (name === 'fetch' ? undefined : infer('http')), op: 'request', line: lineOf(n) }); + if (FS_CALLS.test(name)) push({ kind: 'fs', package: pkg, op: name, line: lineOf(n) }); + if (EXEC_CALLS.test(name)) push({ kind: 'exec', package: pkg, op: name, line: lineOf(n) }); + if (name === 'eval') push({ kind: 'eval', op: 'eval', line: lineOf(n) }); } } if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'Function') { - push({ kind: 'eval', op: 'new Function' }); + push({ kind: 'eval', op: 'new Function', line: lineOf(n) }); } ts.forEachChild(n, visit); }; @@ -470,7 +696,7 @@ 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}`; + const key = `${s.kind}:${s.provider}:${s.package}:${s.table}:${s.op}:${s.line}`; if (!seen.has(key)) { seen.add(key); out.push(s); } } return out; diff --git a/src/map/types.ts b/src/map/types.ts index f4b72fc..9598529 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -6,7 +6,10 @@ // adapter could and couldn't see. Never present the map as "complete". export interface InputField { - /** Parameter / body-field name — the coordinate a rule pins to. */ + /** + * Parameter / body-field name — the coordinate a rule pins to. Nested validator fields are + * flattened to dotted paths (`address.city`, `tags[].label`), matching `array_key_value` paths. + */ name: string; /** Coarse type when derivable (string | number | boolean | array | object | unknown). */ type?: string; @@ -14,6 +17,10 @@ export interface InputField { min?: number; max?: number; optional?: boolean; + /** Declared string format when the validator names one (email | uuid | url | …). */ + format?: string; + /** Declared regex constraint (the regex literal's source text), when present. */ + pattern?: string; } export interface Sink { @@ -30,8 +37,10 @@ export interface Sink { package?: string; /** For db sinks: the table. */ table?: string; - /** For db sinks: insert | update | delete | select | rpc. */ + /** The operation at the sink (db: insert | select | …; fs/exec/http: the called function). */ op?: string; + /** 1-based line of the sink call in the endpoint's file — the auditable coordinate. */ + line?: number; } export interface Endpoint { @@ -45,8 +54,15 @@ export interface Endpoint { route?: string; /** Repo-relative source file. */ file: string; + /** 1-based line of the entry-point declaration in `file`. */ + line?: number; inputs: InputField[]; sinks: Sink[]; + /** + * false when the endpoint DECLARES an input validator that static analysis could not parse — its + * `inputs` are UNKNOWN rather than empty. Absent when the extracted inputs can be trusted as-is. + */ + inputsResolved?: boolean; } export interface Coverage { diff --git a/tests/map-extract-strict.test.ts b/tests/map-extract-strict.test.ts new file mode 100644 index 0000000..ca60959 --- /dev/null +++ b/tests/map-extract-strict.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildInputMap } from '../src/map/index.js'; +import type { SiteInputMap, Endpoint } from '../src/map/types.js'; + +// Regression suite for the strict-review hardening: destructured / request.json() inputs, pre-filter ↔ +// recognizer parity, chained + object route registration, bindings-gated sinks (local objects are not +// dependency sinks), validator gating + nested fields + formats, node: builtin normalization, source +// positions, and the honesty markers (inputsResolved, dynamic coverage notes). + +let dir: string; +let map: SiteInputMap; +const ep = (pred: (e: Endpoint) => boolean): Endpoint => { + const found = map.endpoints.find(pred); + expect(found).toBeDefined(); + return found!; +}; +const inputNames = (e: Endpoint) => e.inputs.map((i) => i.name).sort(); + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'ps-map-strict-')); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + dependencies: { express: '4', fastify: '5', '@tanstack/react-start': '1' }, + })); + + // Destructured req.body, destructured handler param, head/use registrations, chained .route().get(). + writeFileSync(join(dir, 'src', 'express.ts'), ` + import express from "express"; + import fs from "fs"; + import { exec } from "node:child_process"; + const app = express(); + app.post("/api/items", (req, res) => { + const { title, qty } = req.body; + fs.writeFileSync("/tmp/x", title + qty); + res.end(); + }); + app.post("/api/other", ({ body }, res) => { + const cache = { params: { zzz: 1 } }; + console.log(cache.params.zzz); + res.end(String(body.note)); + }); + app.head("/health", (req, res) => res.end()); + app.use("/legacy", (req, res) => { run(req.query.cmd); res.end(); }); + function run(cmd) { exec("legacy " + cmd); } + const r = express.Router(); + r.route("/chained").get((req, res) => { fs.readFile(req.query.f, () => res.end()); }); + `); + + // Fastify object-form registration with a method array. + writeFileSync(join(dir, 'src', 'fastify.ts'), ` + import Fastify from "fastify"; + const app = Fastify(); + app.route({ + method: ["POST", "PUT"], + url: "/upload", + handler: async (req, reply) => { + const { filename } = req.body; + reply.send(filename); + }, + }); + `); + + // fetch-style Request body reads + a non-validator ".object(" decoy. + writeFileSync(join(dir, 'src', 'next-route.ts'), ` + import { createClient } from "@supabase/supabase-js"; + const supabase = createClient("u", "k"); + const t = { object: (x) => x }; + export async function POST(request) { + const shape = t.object({ decoy: 1 }); + const body = await request.json(); + const { extra } = await request.json(); + await supabase.from("orders").insert({ note: body.note, qty: body.qty, extra }); + return new Response(String(shape)); + } + `); + + // Local objects/functions must not read as dependency sinks. + writeFileSync(join(dir, 'src', 'sinks-gate.ts'), ` + import express from "express"; + const app = express(); + class WorkQueue { open() { return this; } query(s) { return s; } execute() { return 1; } } + function request(x) { return x; } + app.post("/probe", (req, res) => { + const q = new WorkQueue(); + q.open(); q.query("a"); q.execute(); request("h"); + res.end(); + }); + `); + + // Zod: nested objects/arrays, formats, regex, negative bounds; plus an opaque validator. + writeFileSync(join(dir, 'src', 'zod.functions.ts'), ` + import { createServerFn } from "@tanstack/react-start"; + import { z } from "zod"; + export const saveProfile = createServerFn({ method: "POST" }) + .inputValidator((i) => z.object({ + email: z.string().email(), + slug: z.string().regex(/^[a-z0-9-]+$/), + offset: z.number().min(-10).max(50), + address: z.object({ city: z.string().min(1) }), + tags: z.array(z.object({ label: z.string() })), + }).parse(i)) + .handler(async ({ data }) => data); + export const opaque = createServerFn({ method: "POST" }) + .inputValidator((i) => checkSomehow(i)) + .handler(async ({ data }) => data); + `); + + // Transitive binding: conn ← pool.promise() ← createPool ← mysql2. + writeFileSync(join(dir, 'src', 'transitive.ts'), ` + import express from "express"; + import { createPool } from "mysql2"; + const app = express(); + const pool = createPool({}); + const conn = pool.promise(); + app.post("/sql", (req, res) => { conn.query(req.body.sql); res.end(); }); + `); + + // A route file reachable only through a symlinked directory. + mkdirSync(join(dir, 'linked-src'), { recursive: true }); + writeFileSync(join(dir, 'linked-src', 'ext.ts'), ` + import express from "express"; + const app = express(); + app.post("/linked", (req, res) => res.end(req.body.x)); + `); + symlinkSync(join(dir, 'linked-src'), join(dir, 'src', 'ext')); + + const res = await buildInputMap(dir); + expect(res.error).toBeUndefined(); + map = res.map!; +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe('inputs: destructuring and fetch-style bodies', () => { + it('extracts fields destructured from req.body', () => { + const e = ep((x) => x.route === '/api/items'); + expect(inputNames(e)).toEqual(['qty', 'title']); + expect(e.sinks).toEqual([ + expect.objectContaining({ kind: 'fs', package: 'node:fs', op: 'writeFileSync' }), + ]); + }); + + it('follows a destructured handler param without matching unrelated objects', () => { + const e = ep((x) => x.route === '/api/other'); + expect(inputNames(e)).toEqual(['note']); // body.note yes, cache.params.zzz no + }); + + it('traces `await request.json()` bodies through variables and destructuring', () => { + const e = ep((x) => x.name === 'POST' && x.entryKind === 'route-handler'); + expect(inputNames(e)).toEqual(['extra', 'note', 'qty']); // and never the t.object decoy + expect(e.sinks).toEqual([ + expect.objectContaining({ kind: 'db', package: '@supabase/supabase-js', table: 'orders', op: 'insert' }), + ]); + }); +}); + +describe('entry points: pre-filter parity and registration idioms', () => { + it('sees files that only register .head()/.use() routes', () => { + expect(ep((x) => x.route === '/health').method).toBe('HEAD'); + const legacy = ep((x) => x.route === '/legacy'); + expect(legacy.method).toBeUndefined(); // `use` is not an HTTP method + expect(inputNames(legacy)).toEqual(['cmd']); + expect(legacy.sinks).toEqual([ + expect.objectContaining({ kind: 'exec', package: 'node:child_process', op: 'exec' }), // via run() + ]); + }); + + it('recovers the path from router.route("/x").get(handler) chains', () => { + const e = ep((x) => x.route === '/chained'); + expect(e.method).toBe('GET'); + expect(inputNames(e)).toEqual(['f']); + expect(e.sinks).toEqual([expect.objectContaining({ kind: 'fs', package: 'node:fs', op: 'readFile' })]); + }); + + it('reads Fastify route objects, one endpoint per method', () => { + const uploads = map.endpoints.filter((x) => x.route === '/upload'); + expect(uploads.map((u) => u.method).sort()).toEqual(['POST', 'PUT']); + for (const u of uploads) expect(inputNames(u)).toEqual(['filename']); + }); + + it('follows symlinked source directories', () => { + expect(inputNames(ep((x) => x.route === '/linked'))).toEqual(['x']); + }); +}); + +describe('sinks: bindings gating and package attribution', () => { + it('does not report calls on local objects/functions as dependency sinks', () => { + expect(ep((x) => x.route === '/probe').sinks).toEqual([]); + }); + + it('resolves packages transitively through derived bindings', () => { + const e = ep((x) => x.route === '/sql'); + expect(inputNames(e)).toEqual(['sql']); + expect(e.sinks).toEqual([expect.objectContaining({ kind: 'db', package: 'mysql2', op: 'query' })]); + }); + + it('records source positions on endpoints and sinks', () => { + const e = ep((x) => x.route === '/api/items'); + expect(e.line).toBeGreaterThan(0); + expect(e.sinks[0].line).toBeGreaterThan(0); + }); +}); + +describe('validator schemas: gating, nesting, formats', () => { + it('extracts nested fields as dotted paths with constraints', () => { + const e = ep((x) => x.name === 'saveProfile'); + const byName = Object.fromEntries(e.inputs.map((i) => [i.name, i])); + expect(byName['email']).toEqual({ name: 'email', type: 'string', format: 'email' }); + expect(byName['slug']).toMatchObject({ type: 'string', pattern: '/^[a-z0-9-]+$/' }); + expect(byName['offset']).toEqual({ name: 'offset', type: 'number', min: -10, max: 50 }); + expect(byName['address']).toMatchObject({ type: 'object' }); + expect(byName['address.city']).toEqual({ name: 'address.city', type: 'string', min: 1 }); + expect(byName['tags']).toMatchObject({ type: 'array' }); + expect(byName['tags[].label']).toMatchObject({ type: 'string' }); + expect(e.inputsResolved).toBeUndefined(); + }); + + it('marks endpoints whose validator could not be parsed, and says so in coverage', () => { + const e = ep((x) => x.name === 'opaque'); + expect(e.inputs).toEqual([]); + expect(e.inputsResolved).toBe(false); + expect(map.coverage.notes.join(' ')).toMatch(/UNKNOWN, not empty/); + }); +}); diff --git a/tests/map-extract.test.ts b/tests/map-extract.test.ts index ab030c6..877f86b 100644 --- a/tests/map-extract.test.ts +++ b/tests/map-extract.test.ts @@ -71,12 +71,14 @@ describe('agnostic input-flow extractor', () => { expect(byName.createTask.inputs).toEqual([{ name: 'title', type: 'string', min: 1, max: 200 }]); expect(byName.createTask.sinks).toEqual( expect.arrayContaining([ - { kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'insert' }, - { kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'select' }, // via listTasks() + expect.objectContaining({ kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'insert' }), + expect.objectContaining({ kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'select' }), // via listTasks() ]), ); expect(byName.getTasks.inputs).toEqual([]); - expect(byName.getTasks.sinks).toEqual([{ kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'select' }]); + expect(byName.getTasks.sinks).toEqual([ + expect.objectContaining({ kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'select' }), + ]); // Express route registration: path + method + req.body/query inputs + fs/exec sinks with node: packages. const convert = map!.endpoints.find((e) => e.route === '/api/convert')!; @@ -85,18 +87,23 @@ describe('agnostic input-flow extractor', () => { expect(convert.inputs.map((i) => i.name).sort()).toEqual(['data', 'fmt', 'path']); expect(convert.sinks).toEqual( expect.arrayContaining([ - { kind: 'fs', package: 'node:fs', op: 'writeFileSync' }, - { kind: 'exec', package: 'node:child_process', op: 'exec' }, + expect.objectContaining({ kind: 'fs', package: 'node:fs', op: 'writeFileSync' }), + expect.objectContaining({ kind: 'exec', package: 'node:child_process', op: 'exec' }), ]), ); // Next-style route handler: method from the export name, supabase sink with package. expect(byName.POST).toMatchObject({ entryKind: 'route-handler', method: 'POST' }); - expect(byName.POST.sinks).toEqual([{ kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'orders', op: 'insert' }]); + expect(byName.POST.sinks).toEqual([ + expect.objectContaining({ kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'orders', op: 'insert' }), + ]); + expect(byName.POST.inputs.map((i) => i.name)).toEqual(['note']); // via `const body = await request.json()` // Next `'use server'` action: recognized as an entry point, exec sink tagged with its package. expect(byName.runReport).toMatchObject({ entryKind: 'server-action' }); - expect(byName.runReport.sinks).toEqual([{ kind: 'exec', package: 'node:child_process', op: 'exec' }]); + expect(byName.runReport.sinks).toEqual([ + expect.objectContaining({ kind: 'exec', package: 'node:child_process', op: 'exec' }), + ]); }); it('records honest coverage notes and a framework label', async () => { From aa5e89b4ca809ebae0628f1273f73c2dd7ce5fb6 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 13:16:41 +0200 Subject: [PATCH 4/6] =?UTF-8?q?map:=20prove=20input=E2=86=92sink=20flows,?= =?UTF-8?q?=20stop=20over-claiming,=20widen=20+=20bound=20the=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses an external review of the map command. The headline problem: we emitted endpoint-level inputs AND endpoint-level sinks but never established which input reaches which sink — while the CLI advertised "inputs → sinks … precise rule pinning". Anything consuming that for parameter pinning could pin the wrong input. - FLOWS. Each endpoint now carries `flows: [{input, sink, confidence, line}]`. A flow is `precise` only when the input identifier/path appears inside the sink call's arguments (tainting the handler params + local aliases such as `const body = await request.json()`); otherwise `heuristic` ("may reach"). On the reference app: `title -> insert [precise]`, while the helper-reached select that never receives the input is correctly `heuristic`. `inputs`/`sinks` are documented as INVENTORIES; only flows assert reachability. - FALSE POSITIVE. Sinks inside a declared-but-uncalled local function are no longer attributed to the endpoint (an unused helper that shells out used to make the endpoint look like it reaches exec). Inline callbacks / IIFEs still count. - MISSED CODE. Walk the whole project (minus node_modules/dist/build/.next/…) instead of `src` only, so root-level `server.ts` / `app/` / `functions/` entrypoints are seen; adds .cjs/.cts/.mts. - BOUNDARY. Symlinks are followed only while they stay inside the project; --follow-symlinks opts out (a link to an external repo used to pull in its code). - COVERAGE. `coverage` now reports filesDiscovered/filesParsed/filesSkipped + roots, and notes when endpoints have inputs+sinks but no proven link. Also fixes a pre-existing sink regression found while testing: a client built by a LOCAL factory (`const supabase = getClient()`, the common AI-generated shape) looked like a plain local, so every sink on it was dropped — the reference app reported ZERO sinks. Bindings now follow a local factory's return value to the package it wraps (fixpoint-resolved), restoring all 7 supabase sinks. Co-Authored-By: Claude Opus 4.8 --- src/cli.ts | 30 +++-- src/map/extract.ts | 263 ++++++++++++++++++++++++++++++++++---- src/map/index.ts | 12 +- src/map/types.ts | 34 +++++ tests/map-extract.test.ts | 85 +++++++++++- 5 files changed, 384 insertions(+), 40 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b04e68b..f158dea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -57,10 +57,14 @@ Usage: manage the widget, install + verify runtime protection, and wire dependency/build scans. Never runs the project build - patchstack-connect map [--dir

] [--out ] Map the app's input surface (entry points → - inputs → sinks) for reachability + precise - rule pinning. Prints JSON (--out to write a - file). Uses the app's own TypeScript + patchstack-connect map [--dir

] [--out ] Map the app's attack surface: entry points, the + inputs each reads, the sinks it can reach, and + evidence-backed input→sink flows (each marked + precise or heuristic). Best-effort static + analysis — reports the DETECTED surface, with + coverage counters. Prints JSON (--out writes a + file; --follow-symlinks leaves the project dir). + Uses the app's own TypeScript patchstack-connect init Optional: pre-seed .patchstackrc.json with an existing site UUID patchstack-connect status [options] Show current configuration and whether the @@ -200,17 +204,27 @@ async function runInit(args: ParsedArgs): Promise { async function runMap(args: ParsedArgs): Promise { const cwd = getStringFlag(args.flags, 'dir') ?? process.cwd(); - const { map, error } = await buildInputMap(cwd); + const { map, error } = await buildInputMap(cwd, { + followSymlinks: args.flags.get('follow-symlinks') === true, + }); if (!map) { console.error(`patchstack: ${error}`); return 1; } - // Human summary → stderr; the JSON → stdout (so it can be piped / written). + // Human summary → stderr; the JSON → stdout (so it can be piped / written). Report PRECISE flows + // separately from the inventories: only a precise flow is evidence that an input reaches a sink. const inputs = map.endpoints.reduce((n, e) => n + e.inputs.length, 0); const sinks = map.endpoints.reduce((n, e) => n + e.sinks.length, 0); + const precise = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => f.confidence === 'precise').length, 0); + const c = map.coverage; + console.error( + `patchstack: ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s), ` + + `${precise} proven input→sink flow(s) [${map.framework}].`, + ); console.error( - `patchstack: mapped ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s) ` + - `[${map.framework}]. This is the DETECTED surface — static analysis is best-effort.`, + `patchstack: ${c.filesParsed}/${c.filesDiscovered} file(s) parsed` + + (c.filesSkipped ? `, ${c.filesSkipped} skipped` : '') + + `. DETECTED surface only — static analysis is best-effort; unproven pairs are marked "heuristic".`, ); const json = JSON.stringify(map, null, 2); const out = getStringFlag(args.flags, 'out'); diff --git a/src/map/extract.ts b/src/map/extract.ts index 75d1242..85e5ad0 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -1,7 +1,7 @@ -import { readFileSync, readdirSync, existsSync, realpathSync, statSync } from 'node:fs'; +import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { builtinModules } from 'node:module'; -import { join, relative } from 'node:path'; -import type { SiteInputMap, Endpoint, InputField, Sink, TsModule } from './types.js'; +import { join, relative, isAbsolute } from 'node:path'; +import type { SiteInputMap, Endpoint, InputField, Sink, Flow, TsModule } from './types.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 @@ -91,20 +91,62 @@ function buildModuleBindings(sf: any, ts: TsModule): Bindings { } // 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 && nameToModule.has(root)) record(decl.name.text, nameToModule.get(root)!); + 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), 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]; @@ -144,17 +186,27 @@ function lineOf(node: any): number | undefined { try { return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; } catch { return undefined; } } -export async function extractInputMap(cwd: string, ts: TsModule): Promise { +export interface ExtractOptions { + /** Follow symlinks that leave the project directory (off by default — keeps analysis in-project). */ + followSymlinks?: boolean; +} + +export async function extractInputMap(cwd: string, ts: TsModule, options: ExtractOptions = {}): Promise { const notes: string[] = []; const endpoints: Endpoint[] = []; const failed: string[] = []; - const srcDir = join(cwd, 'src'); - const root = existsSync(srcDir) ? srcDir : cwd; + let boundary = cwd; + try { boundary = realpathSync(cwd); } catch { /* use cwd as-is */ } + + const stats: WalkStats = { discovered: 0 }; + const files = collectSources(cwd, boundary, { followOutside: options.followSymlinks }, [], new Set(), stats); + let parsed = 0; - for (const file of collectSources(root)) { + for (const file of files) { try { const text = readFileSync(file, 'utf8'); if (!hasEntrySignal(text)) continue; + parsed++; const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); const bindings = buildModuleBindings(sf, ts); const localSinks = collectLocalSinks(sf, ts, bindings); @@ -168,8 +220,10 @@ export async function extractInputMap(cwd: string, ts: TsModule): Promise 0) { const sample = failed.slice(0, 5).join(', '); notes.push(`${failed.length} file(s) could not be analyzed and were skipped (fail-open): ${sample}${failed.length > 5 ? ', …' : ''}.`); @@ -178,9 +232,25 @@ export async function extractInputMap(cwd: string, ts: TsModule): Promise 0) { notes.push(`${unresolved} endpoint(s) declare an input validator that could not be statically parsed — their inputs are UNKNOWN, not empty (marked inputsResolved: false).`); } - if (endpoints.length === 0) notes.push('No recognized server-side entry points found under the source root.'); + const heuristicOnly = endpoints.filter((e) => e.sinks.length > 0 && e.inputs.length > 0 && !e.flows.some((f) => f.confidence === 'precise')).length; + if (heuristicOnly > 0) { + notes.push(`${heuristicOnly} endpoint(s) have inputs and sinks but no PRECISE data link — their flows are "heuristic" (may reach), not proven.`); + } + if (endpoints.length === 0) notes.push('No recognized server-side entry points found under the analyzed roots.'); - return { version: 1, framework: detectFramework(cwd), endpoints, coverage: { adapter: 'agnostic-v1', notes } }; + return { + version: 1, + framework: detectFramework(cwd), + endpoints, + coverage: { + adapter: 'agnostic-v1', + filesDiscovered: stats.discovered, + filesParsed: parsed, + filesSkipped: failed.length, + roots: ['.'], + notes, + }, + }; } // Cheap textual pre-filter so we only parse files that could contain an entry point. Derived from the @@ -216,31 +286,61 @@ function guessScriptKind(ts: TsModule, file: string) { return ts.ScriptKind.TS; } -const isSourceFile = (name: string) => /\.(ts|tsx|js|jsx|mjs)$/.test(name) && !name.endsWith('.d.ts'); - -// Walks the tree following symlinked directories/files too (monorepos link packages into src), with a -// realpath visited-set so link cycles can't loop. -function collectSources(dir: string, out: string[] = [], seen = new Set()): string[] { +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 (e.name === 'node_modules' || e.name === 'dist' || e.name === 'build' || e.name.startsWith('.')) continue; + if (SKIP_DIRS.has(e.name) || (e.name.startsWith('.') && e.name !== '.')) continue; const full = join(dir, e.name); - if (e.isDirectory()) collectSources(full, out, seen); + if (e.isDirectory()) collectSources(full, boundary, opts, out, seen, stats); else if (e.isSymbolicLink()) { - let st; - try { st = statSync(full); } catch { continue; } - if (st.isDirectory()) collectSources(full, out, seen); - else if (st.isFile() && isSourceFile(e.name)) out.push(full); - } else if (isSourceFile(e.name)) out.push(full); + 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); +} + // --- entry-point recognizers ----------------------------------------------- function extractFromFile(sf: any, ts: TsModule, localSinks: Map, bindings: Bindings): Omit[] { const out: Omit[] = []; @@ -255,13 +355,17 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, if (chain.baseName === 'createServerFn' && ts.isIdentifier(decl.name)) { const validatorCall = chain.calls['inputValidator'] ?? chain.calls['validator']; const inputs = inputsFromValidator(validatorCall, ts, bindings); + const handlerFn = chain.calls['handler']?.arguments?.[0]; + const sinks = sinksFrom(handlerFn, ts, localSinks, bindings); + const handlerBody = handlerFn && isFnLike(handlerFn, ts) ? handlerFn.body : undefined; const ep: Omit = { name: decl.name.text, entryKind: 'server-fn', method: methodFromObjectArg(chain.baseCall, ts), line: lineOf(decl), inputs, - sinks: sinksFrom(chain.calls['handler']?.arguments?.[0], ts, localSinks, bindings), + sinks, + flows: linkFlows(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; @@ -385,14 +489,17 @@ function handlerEntry( extra: { method?: string; route?: string; line?: number } = {}, ): Omit { const entryKind = kindLabel === 'route-registration' ? 'route-registration' : kindLabel === 'server-action' ? 'server-action' : 'route-handler'; + const inputs = inputsFromHandler(params, body, ts, bindings); + const sinks = sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings); return { name, entryKind, method: extra.method ?? (HTTP_METHODS.has(name) ? name : undefined), route: extra.route, line: extra.line, - inputs: inputsFromHandler(params, body, ts, bindings), - sinks: sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings), + inputs, + sinks, + flows: linkFlows(body, params, inputs, sinks, ts), }; } @@ -621,6 +728,12 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] { }; 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) @@ -682,6 +795,104 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] { 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". +function linkFlows( + bodyNode: any, + params: any, + inputs: InputField[], + sinks: Sink[], + ts: TsModule, +): Flow[] { + if (!bodyNode || sinks.length === 0 || inputs.length === 0) return []; + const taintedRoots = new Set(); + for (const p of params ?? []) { + if (!p?.name) continue; + if (ts.isIdentifier(p.name)) taintedRoots.add(p.name.text); + else if (ts.isObjectBindingPattern(p.name)) { + for (const el of p.name.elements) if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) taintedRoots.add(el.name.text); + } + } + // Local aliases of tainted data: `const body = await request.json()`, `const { title } = data`. + const aliasVisit = (n: any) => { + if (ts.isVariableDeclaration(n) && n.initializer) { + const root = rootIdentifier(n.initializer, ts); + const fromTainted = root ? taintedRoots.has(root) : false; + const isRequestRead = /\b(json|formData|text|body|query|params)\b/.test(n.initializer.getText?.() ?? ''); + if (fromTainted || isRequestRead) { + if (ts.isIdentifier(n.name)) taintedRoots.add(n.name.text); + else if (ts.isObjectBindingPattern(n.name)) { + for (const el of n.name.elements) if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) taintedRoots.add(el.name.text); + } + } + } + ts.forEachChild(n, aliasVisit); + }; + aliasVisit(bodyNode); + + // Index sink call sites by line so a sink (which carries `line`) can be matched to its AST node. + const callsByLine = new Map(); + const callVisit = (n: any) => { + if (ts.isCallExpression(n)) { + const ln = lineOf(n); + if (ln !== undefined) { + const list = callsByLine.get(ln) ?? []; + list.push(n); + callsByLine.set(ln, list); + } + } + ts.forEachChild(n, callVisit); + }; + callVisit(bodyNode); + + const flows: Flow[] = []; + for (const sink of sinks) { + const candidates = sink.line !== undefined ? (callsByLine.get(sink.line) ?? []) : []; + // Text of every argument at this sink's call site(s) — where a tainted value would appear. + let argText = ''; + for (const c of candidates) { + for (const a of c.arguments ?? []) { + try { argText += ' ' + a.getText(); } catch { /* ignore */ } + } + } + for (const input of inputs) { + const leaf = input.name.split('.').pop()!.replace(/\[\]$/, ''); + // `data.title` / `{ title }` / `req.body.title` — the leaf name appearing in the sink's args, + // qualified by a tainted root when it's a member path. + const mentionsLeaf = argText.length > 0 && new RegExp(`\\b${escapeRe(leaf)}\\b`).test(argText); + const mentionsTaintedRoot = [...taintedRoots].some((r) => new RegExp(`\\b${escapeRe(r)}\\b`).test(argText)); + if (mentionsLeaf && (mentionsTaintedRoot || taintedRoots.has(leaf))) { + flows.push({ input: input.name, sink, confidence: 'precise', line: sink.line }); + } else { + flows.push({ input: input.name, sink, confidence: 'heuristic', line: sink.line }); + } + } + } + return flows; +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function localCalls(node: any, ts: TsModule): string[] { const names: string[] = []; const visit = (n: any) => { diff --git a/src/map/index.ts b/src/map/index.ts index 839eb8a..d6bd54d 100644 --- a/src/map/index.ts +++ b/src/map/index.ts @@ -1,15 +1,19 @@ import { loadTypeScript } from './ts-loader.js'; -import { extractInputMap } from './extract.js'; +import { extractInputMap, type ExtractOptions } from './extract.js'; import type { SiteInputMap } from './types.js'; -export type { SiteInputMap, Endpoint, InputField, Sink } from './types.js'; +export type { SiteInputMap, Endpoint, InputField, Sink, Flow, Coverage } from './types.js'; +export type { ExtractOptions } from './extract.js'; /** * Build the app's input-flow ("attack surface") map at build time. Resolves the target app's own * TypeScript to parse the source; returns { map: null, error } (never throws) when TS can't be * resolved, so the CLI can degrade with a clear message. */ -export async function buildInputMap(cwd: string): Promise<{ map: SiteInputMap | null; error?: string }> { +export async function buildInputMap( + cwd: string, + options: ExtractOptions = {}, +): Promise<{ map: SiteInputMap | null; error?: string }> { const ts = await loadTypeScript(cwd); if (!ts) { return { @@ -19,6 +23,6 @@ export async function buildInputMap(cwd: string): Promise<{ map: SiteInputMap | '`typescript` (already a devDependency of a TypeScript app) — install it and retry.', }; } - const map = await extractInputMap(cwd, ts); + const map = await extractInputMap(cwd, ts, options); return { map }; } diff --git a/src/map/types.ts b/src/map/types.ts index 9598529..c9c9fbb 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -56,8 +56,15 @@ export interface Endpoint { file: string; /** 1-based line of the entry-point declaration in `file`. */ line?: number; + /** Inventory: inputs the handler reads. Presence here does NOT mean an input reaches a sink. */ inputs: InputField[]; + /** Inventory: sinks reachable in the handler. Presence here does NOT mean an input flows into it. */ sinks: Sink[]; + /** + * Evidence-backed input→sink data links. This — not the `inputs`×`sinks` cross-product — is what a + * consumer should use to pin a rule to a parameter. Empty when no link could be established. + */ + flows: Flow[]; /** * false when the endpoint DECLARES an input validator that static analysis could not parse — its * `inputs` are UNKNOWN rather than empty. Absent when the extracted inputs can be trusted as-is. @@ -65,9 +72,36 @@ export interface Endpoint { inputsResolved?: boolean; } +/** + * A DATA LINK from one input to one sink — the only place the map asserts that an input actually + * *reaches* a sink. `inputs` and `sinks` on an endpoint are inventories (both present somewhere in the + * handler); a `Flow` is evidence-backed: + * - `precise` — the input identifier/path appears inside the sink call's arguments. + * - `heuristic` — the input and sink co-occur in the handler but no data link was found; treat as + * "may reach", never as proven. + * Consumers that pin a rule to a parameter should prefer `precise` flows and fall back to broad rules. + */ +export interface Flow { + /** Input field name (dotted path), matching an entry in `Endpoint.inputs`. */ + input: string; + /** The sink reached. */ + sink: Sink; + confidence: 'precise' | 'heuristic'; + /** 1-based line of the sink call — the auditable evidence location. */ + line?: number; +} + export interface Coverage { /** Adapter that produced the map. */ adapter: string; + /** Files the walker found under the analyzed roots. */ + filesDiscovered: number; + /** Files actually parsed (passed the entry-point pre-filter). */ + filesParsed: number; + /** Files skipped because they could not be read/parsed (fail-open). */ + filesSkipped: number; + /** Source roots analyzed, repo-relative. */ + roots: string[]; /** Honest notes on what static analysis could not resolve (dynamic dispatch, indirection, …). */ notes: string[]; } diff --git a/tests/map-extract.test.ts b/tests/map-extract.test.ts index 877f86b..bb61822 100644 --- a/tests/map-extract.test.ts +++ b/tests/map-extract.test.ts @@ -56,6 +56,40 @@ beforeAll(() => { exec("report " + input.name); } `); + + // ROOT-level entrypoint (not under src/) — common for server.ts / app.ts / worker entrypoints. + writeFileSync(join(dir, 'root-server.cjs'), ` + const express = require("express"); + const fs = require("node:fs"); + const app = express(); + app.post("/root/upload", (req, res) => { + fs.writeFileSync("/tmp/x", req.body.blob); + res.end(); + }); + `); + + // A handler with a DECLARED-BUT-UNCALLED local helper that shells out: its exec must NOT be + // attributed to the endpoint, while the helper it DOES call must be. + writeFileSync(join(dir, 'src', 'fp.ts'), ` + import { exec } from "node:child_process"; + import fs from "node:fs"; + export async function PUT(req) { + function neverCalled() { exec("rm -rf /"); } + const used = () => { fs.readFileSync("/etc/hosts"); }; + used(); + return new Response("ok"); + } + `); + + // A local factory returning an imported client — the client must still resolve to its package. + writeFileSync(join(dir, 'src', 'factory.ts'), ` + import { createClient } from "@supabase/supabase-js"; + function getClient() { return createClient(process.env.URL, process.env.KEY); } + export async function DELETE(req) { + const db = getClient(); + return db.from("audit").delete().eq("id", req.query.id); + } + `); }); afterAll(() => rmSync(dir, { recursive: true, force: true })); @@ -106,9 +140,56 @@ describe('agnostic input-flow extractor', () => { ]); }); - it('records honest coverage notes and a framework label', async () => { + it('records honest coverage notes, counters and a framework label', async () => { const { map } = await buildInputMap(dir); expect(map!.framework).toBe('tanstack-start'); - expect(map!.coverage.notes.join(' ')).toMatch(/best-effort/i); + const notes = map!.coverage.notes.join(' '); + expect(notes).toMatch(/best-effort/i); + // Must state that inputs/sinks are inventories and only `flows` asserts reachability. + expect(notes).toMatch(/INVENTOR/i); + expect(notes).toMatch(/flows/i); + expect(map!.coverage.filesDiscovered).toBeGreaterThan(0); + expect(map!.coverage.filesParsed).toBeGreaterThan(0); + expect(map!.coverage.filesParsed).toBeLessThanOrEqual(map!.coverage.filesDiscovered); + }); + + it('links input → sink flows with evidence, and only claims "precise" when the data reaches it', async () => { + const { map } = await buildInputMap(dir); + const createTask = map!.endpoints.find((e) => e.name === 'createTask')!; + const precise = createTask.flows.filter((f) => f.confidence === 'precise'); + // `title` is passed into the insert → proven. + expect(precise).toEqual([ + expect.objectContaining({ input: 'title', confidence: 'precise', sink: expect.objectContaining({ op: 'insert' }) }), + ]); + // The helper-reached select does NOT receive the input → heuristic, never precise. + expect(createTask.flows.some((f) => f.sink.op === 'select' && f.confidence === 'heuristic')).toBe(true); + expect(precise.every((f) => typeof f.line === 'number')).toBe(true); // evidence location + }); + + it('scans root-level entrypoints and .cjs files, not just src/', async () => { + const { map } = await buildInputMap(dir); + const rootEp = map!.endpoints.find((e) => e.route === '/root/upload'); + expect(rootEp).toBeDefined(); + expect(rootEp!.file).toBe('root-server.cjs'); + expect(rootEp!.sinks).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: 'fs', package: 'node:fs', op: 'writeFileSync' })]), + ); + }); + + it('does not attribute sinks from a declared-but-uncalled local helper', async () => { + const { map } = await buildInputMap(dir); + const put = map!.endpoints.find((e) => e.name === 'PUT' && e.file.endsWith('fp.ts'))!; + expect(put.sinks.some((s) => s.kind === 'exec')).toBe(false); // neverCalled() must not count + expect(put.sinks.some((s) => s.kind === 'fs' && s.op === 'readFileSync')).toBe(true); // used() does + }); + + it('resolves a client built by a local factory back to its package', async () => { + const { map } = await buildInputMap(dir); + const del = map!.endpoints.find((e) => e.name === 'DELETE')!; + expect(del.sinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'db', package: '@supabase/supabase-js', table: 'audit', op: 'delete' }), + ]), + ); }); }); From c7ad87a1dd7004b857b97d5cb9912863220e9952 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 13:31:35 +0200 Subject: [PATCH 5/6] docs: disclose the `map` capability and scope the "no source code" claim Shipped docs must disclose every capability in dist/, and an overbroad privacy claim is treated as misrepresentation by auditing agents. `map` reads the project's source files, which made two claims inaccurate: - AGENT-INSTALL said the package "reads the project's dependency list only". That was a READ claim; it is now scoped to what is TRANSMITTED (still only package names + versions), with an explicit line that `map` reads source locally and transmits nothing. - README's payload paragraph now notes the same. Adds a `map` entry to both command references (what it walks, that it uses the project's own TypeScript, that output is best-effort/detected-surface, that it writes nothing but --out and is never invoked by scan/setup/guide/protect). The install prompt is untouched. Co-Authored-By: Claude Opus 4.8 --- AGENT-INSTALL.md | 4 +++- README.md | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 6c7d3d3..c752b58 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -5,11 +5,13 @@ This versioned reference ships inside `@patchstack/connect` and documents each s ## Package and command behavior - Package: [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect), MIT-licensed, source at https://github.com/patchstack/connect. `npm view @patchstack/connect` shows the live registry metadata. -- It reads the project's **dependency list only** — from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — and sends package names + versions to Patchstack for vulnerability matching. No source code, no env var values, no file paths, no git history. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.) +- **What is sent to Patchstack is the dependency list only** — read from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — package names + versions, for vulnerability matching. No source code, no env var values, no file paths, no git history is ever transmitted. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.) +- **One command reads source files, locally:** `map` (see below) parses your server source to report your app's attack surface. It runs only when you invoke it, prints to stdout, and transmits nothing. No other command reads source (`protect` writes guard files but does not analyze your code). - **`scan` makes one source edit, and only after a successful post:** it adds (or updates) the disclosure widget's `