diff --git a/package.json b/package.json index 74687ca..f79db2b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,11 @@ }, "./protect": { "types": "./dist/protect.d.ts", + "workerd": "./dist/protect.edge.js", + "worker": "./dist/protect.edge.js", + "edge-light": "./dist/protect.edge.js", + "deno": "./dist/protect.edge.js", + "browser": "./dist/protect.edge.js", "import": "./dist/protect.js", "require": "./dist/protect.cjs" } @@ -38,7 +43,7 @@ "LICENSE" ], "scripts": { - "build": "tsup && node scripts/copy-protect-templates.mjs", + "build": "tsup && node scripts/build-edge.mjs && node scripts/copy-protect-templates.mjs", "dev": "tsup --watch", "test": "vitest run", "test:manifest": "bun scripts/test-manifest.ts", diff --git a/scripts/build-edge.mjs b/scripts/build-edge.mjs new file mode 100644 index 0000000..1802cca --- /dev/null +++ b/scripts/build-edge.mjs @@ -0,0 +1,55 @@ +// Build the EDGE variant of the protect runtime: dist/protect.edge.js +// +// Why a separate artifact rather than one universal bundle: making the Node imports dynamic +// (`await import('node:fs')`) keeps the module *loadable* off Node, but bundlers FOLLOW dynamic +// imports, so an edge bundler (Next edge middleware, Cloudflare Workers, Deno, Supabase Functions) +// still tries to resolve `node:fs`/`node:path` and fails the build. The only way to be bundle-clean is +// for those modules to be absent from the graph entirely. +// +// So this build replaces every Node-only module with a stub that REJECTS on import. The runtime already +// treats a failed `await import('node:fs')` as "no filesystem on this runtime" and falls back to the +// memory / pluggable (`ruleCache`) tiers, so behaviour is preserved — the disk cache and the manifest +// re-post simply aren't available, which is correct on edge. +// +// We call esbuild directly instead of adding a tsup entry because tsup externalises Node builtins +// before a plugin can intercept them (and drops the `node:` prefix while doing so). +import * as esbuild from 'esbuild'; + +const NODE_ONLY = /^(node:)?(fs|fs\/promises|path|os|dns|net|crypto|http|https|child_process|worker_threads|module|url)$/; +// `refresh-manifest` pulls in the lockfile scanner (node:fs/promises) — Node-only by nature. +const NODE_ONLY_LOCAL = /refresh-manifest(\.js)?$/; + +const stubNodeOnly = { + name: 'ps-stub-node-only', + setup(build) { + const toStub = () => ({ path: 'ps-edge-stub', namespace: 'ps-edge' }); + build.onResolve({ filter: NODE_ONLY }, toStub); + build.onResolve({ filter: NODE_ONLY_LOCAL }, toStub); + build.onLoad({ filter: /.*/, namespace: 'ps-edge' }, () => ({ + // Throwing on import is exactly what the runtime's try/catch fallbacks expect. + contents: 'throw new Error("[patchstack] this module is Node-only and unavailable on an edge runtime");', + loader: 'js', + })); + }, +}; + +const result = await esbuild.build({ + entryPoints: { 'protect.edge': 'src/protect/runtime.js' }, + outdir: 'dist', + bundle: true, + format: 'esm', + platform: 'browser', // WinterCG: no Node globals assumed + target: 'es2022', + sourcemap: true, + // Keep the stub inline so the artifact is a single self-contained file (an edge bundler should not + // have to chase a chunk that only ever throws). + splitting: false, + plugins: [stubNodeOnly], + logLevel: 'warning', +}); + +if (result.errors.length) { + console.error('[patchstack] edge build failed'); + process.exit(1); +} +console.log('built dist/protect.edge.js (edge-safe: no Node modules in the graph)'); diff --git a/src/map/extract.ts b/src/map/extract.ts index ebb34b0..d87f7db 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { builtinModules } from 'node:module'; -import { join, relative, isAbsolute } from 'node:path'; +import { join, relative, isAbsolute, dirname, resolve as resolvePath } 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 @@ -48,12 +48,15 @@ const BUILTINS = new Set(builtinModules); // as local — an accepted miss.) interface Bindings { resolve(name: string): string | undefined; + /** For `import { saveOrder as write }`, maps the local name back to the EXPORTED name. */ + exportNameOf(name: string): string | undefined; imports: Set; locals: Set; } function buildModuleBindings(sf: any, ts: TsModule): Bindings { const nameToModule = new Map(); // local name → module specifier const declared = new Set(); // every name declared in this file + const exportNames = new Map(); // local alias → exported name const imports = new Set(); const record = (local: string, mod: string) => { nameToModule.set(local, mod); imports.add(mod); }; @@ -74,7 +77,11 @@ function buildModuleBindings(sf: any, ts: TsModule): Bindings { const nb = clause?.namedBindings; if (nb) { if (ts.isNamespaceImport(nb)) record(nb.name.text, mod); - else if (ts.isNamedImports(nb)) for (const el of nb.elements) record(el.name.text, mod); + else if (ts.isNamedImports(nb)) for (const el of nb.elements) { + record(el.name.text, mod); + // `import { saveOrder as write }` — looking up `write` in the target module would miss. + if (el.propertyName && ts.isIdentifier(el.propertyName)) exportNames.set(el.name.text, el.propertyName.text); + } } } if (ts.isFunctionDeclaration(node) && node.name) declared.add(node.name.text); @@ -129,7 +136,7 @@ function buildModuleBindings(sf: any, ts: TsModule): Bindings { if (!changed) break; } const locals = new Set([...declared].filter((n) => !nameToModule.has(n))); - return { resolve: (name: string) => nameToModule.get(name), imports, locals }; + return { resolve: (name: string) => nameToModule.get(name), exportNameOf: (name: string) => exportNames.get(name), imports, locals }; } // Root identifier of what a function body returns (`return createClient(…)` → "createClient"), for @@ -198,6 +205,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac let boundary = cwd; try { boundary = realpathSync(cwd); } catch { /* use cwd as-is */ } + const graph = createModuleGraph(ts, { cwd, boundary, followOutside: options.followSymlinks }); // shared cache const stats: WalkStats = { discovered: 0 }; const files = collectSources(cwd, boundary, { followOutside: options.followSymlinks }, [], new Set(), stats); let parsed = 0; @@ -210,10 +218,14 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac 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)) { + for (const ep of extractFromFile(sf, ts, localSinks, bindings, { file, graph })) { const relFile = relative(cwd, file); // A FILE-BASED route handler carries its URL path in its location, not in the code, so derive // it here — without this a rule can only be param-pinned, never route-scoped (`when.path`). + if (ep.route === undefined && ep.entryKind === 'edge-function') { + const fn = functionNameFromPath(relFile); + if (fn) ep.route = '/' + fn; // how the platform invokes it (…/functions/v1/) + } if (ep.route === undefined && (ep.entryKind === 'route-handler' || ep.entryKind === 'server-action')) { const derived = routeFromFilePath(relFile); if (derived.route) { @@ -231,7 +243,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac notes.push('Static analysis is best-effort — this is the DETECTED surface, not a proof of completeness.'); notes.push('`inputs` and `sinks` are INVENTORIES (both present in the handler). Only `flows` asserts that an input reaches a sink — prefer flows with confidence "precise" when pinning a rule to a parameter.'); - notes.push('Sinks are followed one level into same-file helpers; cross-file / dynamic indirection is not traced. Sinks inside declared-but-uncalled local functions are excluded.'); + notes.push('Sinks are followed into same-file helpers and ONE hop into an imported relative module (a dependency\u2019s internals are not followed); deeper or dynamic indirection is not traced. Sinks inside declared-but-uncalled local functions are excluded.'); 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 (!options.followSymlinks) notes.push('Symlinks leaving the project directory were not followed (use --follow-symlinks to include them).'); if (failed.length > 0) { @@ -270,7 +282,8 @@ function hasEntrySignal(text: string): boolean { text.includes('createServerFn') || /\bexport\s+(async\s+)?(function|const)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/.test(text) || ROUTE_CALL_RE.test(text) || - text.includes("'use server'") || text.includes('"use server"') + text.includes("'use server'") || text.includes('"use server"') || + text.includes('Deno.serve') || /\bserve\s*\(/.test(text) ); } @@ -286,6 +299,13 @@ function detectFramework(cwd: string): string { if (d['express']) return 'express'; if (d['hono']) return 'hono'; } catch { /* ignore */ } + // A Deno/edge functions project may have no package.json at all. + try { + if (statSync(join(cwd, 'supabase', 'functions')).isDirectory()) return 'supabase-functions'; + } catch { /* not a supabase project */ } + try { + if (statSync(join(cwd, 'functions')).isDirectory()) return 'deno-functions'; + } catch { /* ignore */ } return 'unknown'; } @@ -362,6 +382,19 @@ function isInside(candidate: string, boundary: string): boolean { // pages/api/[id].ts -> /api/:id (dynamic) // SvelteKit src/routes/api/items/+server.ts -> /api/items // Nuxt server/api/items.post.ts -> /api/items +// The deployed name of a platform function, from its conventional location: +// supabase/functions//index.ts (Supabase Edge Functions) +// functions//index.ts | functions/.ts (Base44 / generic Deno function dirs) +export function functionNameFromPath(relFile: string): string | undefined { + const parts = relFile.split(/[\\/]/).filter(Boolean); + const i = parts.lastIndexOf('functions'); + if (i === -1 || i === parts.length - 1) return undefined; + const next = parts[i + 1]; + if (!next) return undefined; + const base = next.replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, ''); + return base === 'index' ? undefined : base; +} + export function routeFromFilePath(relFile: string): { route?: string; dynamic?: boolean } { const parts = relFile.split(/[\\/]/).filter(Boolean); if (parts.length === 0) return {}; @@ -399,7 +432,7 @@ export function routeFromFilePath(relFile: string): { route?: string; dynamic?: } // --- entry-point recognizers ----------------------------------------------- -function extractFromFile(sf: any, ts: TsModule, localSinks: Map, bindings: Bindings): Omit[] { +function extractFromFile(sf: any, ts: TsModule, localSinks: Map, bindings: Bindings, ctx: { file: string; graph: ModuleGraph }): Omit[] { const out: Omit[] = []; const isServerActionsFile = fileHasUseServer(sf, ts); @@ -413,7 +446,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, 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 sinks = sinksFrom(handlerFn, ts, localSinks, bindings, ctx); const handlerBody = handlerFn && isFnLike(handlerFn, ts) ? handlerFn.body : undefined; const ep: Omit = { name: decl.name.text, @@ -433,9 +466,9 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, // (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, { line: lineOf(decl) })); + out.push(handlerEntry(decl.name.text, decl.name.text, decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, { line: lineOf(decl) })); } else if (isServerActionsFile) { - out.push(handlerEntry(decl.name.text, 'server-action', decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, { line: lineOf(decl) })); + out.push(handlerEntry(decl.name.text, 'server-action', decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, { line: lineOf(decl) })); } } } @@ -444,9 +477,26 @@ 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, { line: lineOf(node) })); + out.push(handlerEntry(node.name.text, node.name.text, node.parameters, node.body, ts, localSinks, bindings, ctx, { line: lineOf(node) })); } else if (isServerActionsFile || hasUseServerDirective(node, ts)) { - out.push(handlerEntry(node.name.text, 'server-action', node.parameters, node.body, ts, localSinks, bindings, { line: lineOf(node) })); + out.push(handlerEntry(node.name.text, 'server-action', node.parameters, node.body, ts, localSinks, bindings, ctx, { line: lineOf(node) })); + } + } + + // (2c) Deno / WinterCG function entry: `Deno.serve(handler)` or `serve(handler)` — Supabase Edge + // Functions, Base44 backend functions, Deno workers. These platforms have no router and no route + // file: one handler per module, invoked by the function's NAME, so the endpoint's identity comes + // from the file location. Without this recognizer such a project maps to nothing at all. + if (ts.isCallExpression(node)) { + const c = node.expression; + const denoServe = ts.isPropertyAccessExpression(c) && c.name.text === 'serve' && + ts.isIdentifier(c.expression) && c.expression.text === 'Deno'; + const bareServe = ts.isIdentifier(c) && c.text === 'serve'; + if (denoServe || bareServe) { + const handler = node.arguments.find((a: any) => isFnLike(a, ts)); + if (handler) { + out.push(handlerEntry(functionNameFromPath(ctx.file) ?? 'serve', 'edge-function', handler.parameters, handler.body, ts, localSinks, bindings, ctx, { line: lineOf(node) })); + } } } @@ -460,7 +510,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, 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, { + out.push(handlerEntry(route, 'route-registration', handler.parameters, handler.body, ts, localSinks, bindings, ctx, { // `use`/`all` register handlers but are not HTTP methods — leave method undefined. method: HTTP_METHODS.has(mname.toUpperCase()) ? mname.toUpperCase() : undefined, route, @@ -475,7 +525,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, 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) })); + out.push(handlerEntry(reg.url, 'route-registration', reg.handler.parameters, reg.handler.body, ts, localSinks, bindings, ctx, { method: m, route: reg.url, line: lineOf(node) })); } } } @@ -543,11 +593,14 @@ function handlerEntry( ts: TsModule, localSinks: Map, bindings: Bindings, + ctx: { file: string; graph: ModuleGraph }, extra: { method?: string; route?: string; line?: number } = {}, ): Omit { - const entryKind = kindLabel === 'route-registration' ? 'route-registration' : kindLabel === 'server-action' ? 'server-action' : 'route-handler'; + const entryKind = kindLabel === 'route-registration' || kindLabel === 'server-action' || kindLabel === 'edge-function' + ? kindLabel + : 'route-handler'; const inputs = inputsFromHandler(params, body, ts, bindings); - const sinks = sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings); + const sinks = sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings, ctx); return { name, entryKind, @@ -755,13 +808,123 @@ function collectLocalSinks(sf: any, ts: TsModule, bindings: Bindings): Map, bindings: Bindings): Sink[] { +// --- cross-file (imported) helper tracing ----------------------------------- +// AI-generated apps routinely put the data access in a sibling module (`import { saveOrder } from +// './db'`), so a handler's real sink lives one file away. Without following that, the endpoint looks +// sink-free and no rule can be correlated to it. We follow ONE cross-file hop (plus same-file helpers +// inside the target), which covers the common shape while keeping the walk bounded and cheap. +const RESOLVE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']; + +export interface ModuleGraph { + /** Sinks of `exportName` in the module `specifier` resolves to, relative to `fromFile`. */ + importedSinks(fromFile: string, specifier: string, exportName: string): Sink[]; +} + +function createModuleGraph(ts: TsModule, opts: { cwd: string; boundary: string; followOutside?: boolean }): ModuleGraph { + // file → { fnSinks, calleesOf } | null (unreadable/unparseable) + const cache = new Map; calleesOf: Map } | null>(); + + const load = (file: string) => { + if (cache.has(file)) return cache.get(file) ?? null; + let entry: { fnSinks: Map; calleesOf: Map } | null = null; + try { + const text = readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); + const bindings = buildModuleBindings(sf, ts); + entry = { fnSinks: collectLocalSinks(sf, ts, bindings), calleesOf: collectCallees(sf, ts) }; + } catch { + entry = null; // fail-open: an unreadable dependency must not break the map + } + cache.set(file, entry); + return entry; + }; + + return { + importedSinks(fromFile, specifier, exportName) { + const target = resolveRelativeModule(fromFile, specifier); + if (!target) return []; + // Stay inside the project: `../../other-repo/db` (or a symlink) would otherwise pull an unrelated + // codebase into this app's attack surface. The primary walker enforces this; so must the resolver. + if (!opts.followOutside) { + let real = target; + try { real = realpathSync(target); } catch { /* use as-is */ } + if (!isInside(real, opts.boundary)) return []; + } + const mod = load(target); + if (!mod) return []; + const collected = [...(mod.fnSinks.get(exportName) ?? [])]; + // One same-file hop inside the target: `export function saveOrder(){ return doInsert() }`. + for (const callee of mod.calleesOf.get(exportName) ?? []) { + for (const s of mod.fnSinks.get(callee) ?? []) collected.push(s); + } + // `line` refers to the HELPER's file, not the endpoint's — carry the file so the coordinate is + // interpretable (and so flow linking never claims `precise` for a sink it cannot see locally). + const rel = relative(opts.cwd, target); + return collected.map((s) => ({ ...s, file: rel })); + }, + }; +} + +// Resolve a RELATIVE specifier to a real file (extension + /index, and the TS-ESM `./db.js` → db.ts +// convention). Bare package specifiers are intentionally NOT followed — that's node_modules, and a +// dependency's internals are not this app's attack surface. +function resolveRelativeModule(fromFile: string, spec: string): string | undefined { + if (!spec.startsWith('.')) return undefined; + const dir = dirname(fromFile); + const base = resolvePath(dir, spec); + const candidates: string[] = []; + const jsLike = /\.(js|jsx|mjs|cjs)$/.exec(base); + if (jsLike) { + const stem = base.slice(0, -jsLike[0].length); + for (const e of RESOLVE_EXTS) candidates.push(stem + e); // ./db.js may mean db.ts + } + candidates.push(base); + for (const e of RESOLVE_EXTS) candidates.push(base + e); + for (const e of RESOLVE_EXTS) candidates.push(join(base, 'index' + e)); + for (const c of candidates) { + try { + if (statSync(c).isFile()) return c; + } catch { + /* next */ + } + } + return undefined; +} + +// name → the local function names it calls (for one same-file hop inside an imported module). +function collectCallees(sf: any, ts: TsModule): Map { + const map = new Map(); + const visit = (node: any) => { + let name: string | undefined; + let body: any; + if (ts.isFunctionDeclaration(node) && node.name && node.body) { name = node.name.text; body = node.body; } + else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isFnLike(node.initializer, ts)) { + name = node.name.text; body = node.initializer.body; + } + if (name && body) map.set(name, localCalls(body, ts)); + ts.forEachChild(node, visit); + }; + visit(sf); + return map; +} + +function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map, bindings: Bindings, ctx?: { file: string; graph: ModuleGraph }): Sink[] { if (!arrowOrNode) return []; const body = arrowOrNode.isSyntheticBody ? arrowOrNode.body : isFnLike(arrowOrNode, ts) ? arrowOrNode.body : arrowOrNode; if (!body) return []; const sinks = directSinks(body, ts, bindings); - for (const called of localCalls(body, ts)) for (const s of localSinks.get(called) ?? []) sinks.push(s); + for (const called of localCalls(body, ts)) { + // Same-file helper. + for (const s of localSinks.get(called) ?? []) sinks.push(s); + // Imported helper: the name resolves to a RELATIVE module → follow one hop into it. + if (ctx) { + const spec = bindings.resolve(called); + if (spec && spec.startsWith('.')) { + for (const s of ctx.graph.importedSinks(ctx.file, spec, bindings.exportNameOf(called) ?? called)) sinks.push(s); + } + } + } return dedupeSinks(sinks); } @@ -880,24 +1043,50 @@ function linkFlows( ts: TsModule, ): Flow[] { if (!bodyNode || sinks.length === 0 || inputs.length === 0) return []; + + // Roots that carry untrusted data: the handler's params, and locals aliased from them / from a + // request-body read. `leafOfLocal` maps a DESTRUCTURED local back to the field it came from, so + // `const { title: t } = await req.json()` links a read of `t` to the input `title`. const taintedRoots = new Set(); + const leafOfLocal = new Map(); 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); + for (const el of p.name.elements) { + if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; + taintedRoots.add(el.name.text); + const key = bindingKey(el, ts); + if (key) leafOfLocal.set(el.name.text, key); + } } } - // Local aliases of tainted data: `const body = await request.json()`, `const { title } = data`. + const isRequestRead = (init: any): boolean => { + let cur = init; + while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; + if (cur && ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { + const m = cur.expression.name.text; + if (['json', 'formData', 'text'].includes(m)) { + const root = rootIdentifier(cur.expression.expression, ts); + return root ? taintedRoots.has(root) : false; + } + } + if (cur && ts.isPropertyAccessExpression(cur) && REQ_SOURCES.includes(cur.name.text)) { + const root = rootIdentifier(cur.expression, ts); + return root ? taintedRoots.has(root) : false; + } + const root = cur ? rootIdentifier(cur, ts) : undefined; + return root ? taintedRoots.has(root) : false; + }; 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); + if (ts.isVariableDeclaration(n) && n.initializer && isRequestRead(n.initializer)) { + 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)) continue; + taintedRoots.add(el.name.text); + const key = bindingKey(el, ts); + if (key) leafOfLocal.set(el.name.text, key); } } } @@ -905,7 +1094,7 @@ function linkFlows( }; aliasVisit(bodyNode); - // Index sink call sites by line so a sink (which carries `line`) can be matched to its AST node. + // Index sink call sites by line so a sink (which carries `line`) can be matched back to its AST node. const callsByLine = new Map(); const callVisit = (n: any) => { if (ts.isCallExpression(n)) { @@ -922,30 +1111,83 @@ function linkFlows( 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 = ''; + // A sink from an imported module has no call site in THIS function — never claim precise for it. + const candidates = sink.file === undefined && sink.line !== undefined ? (callsByLine.get(sink.line) ?? []) : []; + const reads = new Set(); for (const c of candidates) { - for (const a of c.arguments ?? []) { - try { argText += ' ' + a.getText(); } catch { /* ignore */ } - } + // Collect from the enclosing statement so a chained builder counts as one operation: + // `db.from(t).update({…}).eq('id', data.id)` — both `…` and `data.id` feed the same update. + for (const leaf of taintedReadLeaves(enclosingStatement(c, ts) ?? c, ts, taintedRoots, leafOfLocal)) reads.add(leaf); } 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 }); - } + const precise = reads.has(leaf); + flows.push({ input: input.name, sink, confidence: precise ? 'precise' : 'heuristic', line: sink.line }); } } return flows; } +/** Nearest enclosing statement, so a whole fluent chain is considered one operation. */ +function enclosingStatement(node: any, ts: TsModule): any { + let cur = node; + while (cur && !ts.isStatement(cur)) cur = cur.parent; + return cur; +} + +/** + * Leaf names of values that are genuinely READ from a tainted source inside `node`. This is the + * evidence behind a `precise` flow, so it is deliberately strict about what counts as a read: + * - `data.title` / `req.body.title` → yields `title` (a member read off a tainted root) + * - `{ title }` (shorthand) → yields `title` (a read of the tainted local) + * - `fn(title)` → yields `title` + * and explicitly NOT: + * - `{ title: "system" }` → `title` here is a property KEY, not a read of anything + * - `x.title` where `x` is untainted → not tainted data + * (Text matching previously conflated these, so a key plus an unrelated tainted mention elsewhere in + * the same argument list produced a false `precise`.) + */ +function taintedReadLeaves(node: any, ts: TsModule, taintedRoots: Set, leafOfLocal: Map): Set { + const out = new Set(); + const visit = (n: any) => { + if (!n) return; + // A member read rooted in tainted data: take the accessed property as the leaf. + if (ts.isPropertyAccessExpression(n)) { + const root = rootIdentifier(n.expression, ts); + if (root && taintedRoots.has(root)) { + out.add(n.name.text); + return; // don't descend: the inner identifiers are the path, not separate reads + } + } + if (ts.isElementAccessExpression(n)) { + const root = rootIdentifier(n.expression, ts); + if (root && taintedRoots.has(root)) { + const arg = n.argumentExpression; + if (arg && ts.isStringLiteralLike(arg)) out.add(arg.text); + return; + } + } + if (ts.isIdentifier(n) && taintedRoots.has(n.text) && isValueRead(n, ts)) { + out.add(leafOfLocal.get(n.text) ?? n.text); + } + ts.forEachChild(n, visit); + }; + visit(node); + return out; +} + +/** Is this identifier occurrence a VALUE read (rather than a property key, a member name, a binding)? */ +function isValueRead(id: any, ts: TsModule): boolean { + const p = id.parent; + if (!p) return true; + if (ts.isPropertyAssignment(p) && p.name === id) return false; // { title: … } — a key + if (ts.isPropertyAccessExpression(p) && p.name === id) return false; // x.title — the member name + if (ts.isBindingElement(p) && p.propertyName === id) return false; // { title: t } — the source key + if ((ts.isVariableDeclaration(p) || ts.isParameter(p) || ts.isBindingElement(p)) && p.name === id) return false; + if (ts.isPropertySignature(p) || ts.isMethodSignature(p)) return false; + return true; // includes ShorthandPropertyAssignment `{ title }`, which IS a read +} + function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } diff --git a/src/map/types.ts b/src/map/types.ts index d787a32..09b1598 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -39,8 +39,13 @@ export interface Sink { table?: string; /** 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. */ + /** 1-based line of the sink call, in `file` when present, otherwise in the endpoint's own file. */ line?: number; + /** + * Repo-relative file of the sink call, set ONLY when the sink was reached through an imported module + * — i.e. it does not live in the endpoint's file. Without this, `line` would point at the wrong file. + */ + file?: string; } export interface Endpoint { diff --git a/tests/map-edge-functions.test.ts b/tests/map-edge-functions.test.ts new file mode 100644 index 0000000..3051b91 --- /dev/null +++ b/tests/map-edge-functions.test.ts @@ -0,0 +1,65 @@ +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'; + +// Platform function runtimes (Supabase Edge Functions, Base44 backend functions, Deno workers) have no +// route file and no framework router: one handler per module, invoked by the function's NAME. Without a +// recognizer these projects map to nothing at all. +let dir: string; +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'ps-edgefn-')); + mkdirSync(join(dir, 'supabase', 'functions', 'charge'), { recursive: true }); + mkdirSync(join(dir, 'functions'), { recursive: true }); + + // Supabase Edge Function: Deno.serve + destructured request read + a db sink. + writeFileSync(join(dir, 'supabase', 'functions', 'charge', 'index.ts'), ` + import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + const admin = createClient(Deno.env.get("URL"), Deno.env.get("KEY")); + Deno.serve(async (req) => { + const { orderId, amount } = await req.json(); + await admin.from("charges").insert({ orderId, amount }); + return new Response("ok"); + }); + `); + + // Generic Deno function dir (Base44 shape): bare serve() import + an outbound call. + writeFileSync(join(dir, 'functions', 'notify.ts'), ` + import { serve } from "https://deno.land/std/http/server.ts"; + serve(async (req) => { + const { hook } = await req.json(); + await fetch(hook, { method: "POST" }); + return new Response("sent"); + }); + `); +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe('platform function entry points', () => { + it('recognizes a Supabase Edge Function, its route, inputs and sink', async () => { + const { map } = await buildInputMap(dir); + expect(map!.framework).toBe('supabase-functions'); + const charge = map!.endpoints.find((e) => e.name === 'charge'); + expect(charge, 'Deno.serve handler should be an entry point').toBeDefined(); + expect(charge!.entryKind).toBe('edge-function'); + expect(charge!.route).toBe('/charge'); // how the platform invokes it + expect(charge!.inputs.map((i) => i.name).sort()).toEqual(['amount', 'orderId']); + expect(charge!.sinks).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: 'db', table: 'charges', op: 'insert' })]), + ); + // The insert receives the request data → a proven flow, so a rule can pin the parameter. + expect(charge!.flows.some((f) => f.confidence === 'precise' && f.input === 'orderId')).toBe(true); + }); + + it('recognizes a bare serve() function and its outbound (SSRF-relevant) sink', async () => { + const { map } = await buildInputMap(dir); + const notify = map!.endpoints.find((e) => e.name === 'notify')!; + expect(notify.entryKind).toBe('edge-function'); + expect(notify.route).toBe('/notify'); + expect(notify.inputs.map((i) => i.name)).toEqual(['hook']); + expect(notify.sinks).toEqual(expect.arrayContaining([expect.objectContaining({ kind: 'http' })])); + // hook -> fetch is the classic SSRF shape; it must be a PROVEN flow, not a co-occurrence. + expect(notify.flows.some((f) => f.input === 'hook' && f.sink.kind === 'http' && f.confidence === 'precise')).toBe(true); + }); +}); diff --git a/tests/map-flow-precision.test.ts b/tests/map-flow-precision.test.ts new file mode 100644 index 0000000..2f3c1b7 --- /dev/null +++ b/tests/map-flow-precision.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { buildInputMap } from '../src/map/index.js'; + +// `precise` is a claim a consumer may PIN A RULE ON, so it must be evidence-backed: the input has to be +// genuinely READ into the sink. A property key that merely shares the input's name, with an unrelated +// tainted value elsewhere in the same call, is NOT evidence. +let dir: string, outside: string; +beforeAll(() => { + outside = mkdtempSync(join(tmpdir(), 'ps-other-repo-')); + writeFileSync(join(outside, 'db.ts'), ` + import { createClient } from "@supabase/supabase-js"; + const c = createClient("u", "k"); + export function shouldNotBeSeen(x) { return c.from("secrets").delete().eq("id", x); } + `); + + dir = mkdtempSync(join(tmpdir(), 'ps-flow-')); + mkdirSync(join(dir, 'src', 'lib'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { express: '4' } })); + + // The counterexample: `title` appears only as a KEY; the tainted `req` appears in a DIFFERENT value. + writeFileSync(join(dir, 'src', 'keyonly.ts'), ` + import { createClient } from "@supabase/supabase-js"; + const db = createClient("u", "k"); + export async function POST(req) { + const { title } = await req.json(); + await db.from("items").insert({ title: "system", owner: req.user.id }); + return new Response("ok"); + } + `); + + // A genuine read of the input into the sink. + writeFileSync(join(dir, 'src', 'real.ts'), ` + import { createClient } from "@supabase/supabase-js"; + const db = createClient("u", "k"); + export async function PUT(req) { + const { title } = await req.json(); + await db.from("items").insert({ title }); + return new Response("ok"); + } + `); + + // Aliased import of a helper that owns the sink. + writeFileSync(join(dir, 'src', 'lib', 'db.ts'), ` + import { createClient } from "@supabase/supabase-js"; + const c = createClient("u", "k"); + export function saveOrder(o) { return c.from("orders").insert(o); } + `); + writeFileSync(join(dir, 'src', 'alias.ts'), ` + import { saveOrder as write } from "./lib/db"; + export async function PATCH(req) { + const body = await req.json(); + return write({ note: body.note }); + } + `); + + // An import that escapes the project directory. + writeFileSync(join(dir, 'src', 'escape.ts'), ` + import { shouldNotBeSeen } from "${join(outside, 'db').replace(/\\/g, '/')}"; + export async function DELETE(req) { return shouldNotBeSeen(req.query.id); } + `); +}); +afterAll(() => { rmSync(dir, { recursive: true, force: true }); rmSync(outside, { recursive: true, force: true }); }); + +describe('flow precision', () => { + it('does NOT claim precise when the input name is only a property key', async () => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints.find((e) => e.file.endsWith('keyonly.ts'))!; + const titleFlows = ep.flows.filter((f) => f.input === 'title'); + expect(titleFlows.length).toBeGreaterThan(0); + expect(titleFlows.every((f) => f.confidence === 'heuristic')).toBe(true); + }); + + it('does claim precise for a real read (shorthand property)', async () => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints.find((e) => e.file.endsWith('real.ts'))!; + expect(ep.flows.some((f) => f.input === 'title' && f.confidence === 'precise')).toBe(true); + }); + + it('resolves an ALIASED imported helper to its exported name', async () => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints.find((e) => e.file.endsWith('alias.ts'))!; + expect(ep.sinks).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: 'db', table: 'orders', op: 'insert' })]), + ); + }); + + it('labels an imported sink with ITS OWN file, and never calls it precise', async () => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints.find((e) => e.file.endsWith('alias.ts'))!; + const imported = ep.sinks.find((s) => s.table === 'orders')!; + expect(imported.file).toBe(join('src', 'lib', 'db.ts')); + expect(ep.flows.filter((f) => f.sink.table === 'orders').every((f) => f.confidence === 'heuristic')).toBe(true); + }); + + it('refuses to follow an import outside the project directory', async () => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints.find((e) => e.file.endsWith('escape.ts'))!; + expect(ep.sinks.some((s) => s.table === 'secrets')).toBe(false); + }); +}); diff --git a/tests/map-imported.test.ts b/tests/map-imported.test.ts new file mode 100644 index 0000000..0eb8057 --- /dev/null +++ b/tests/map-imported.test.ts @@ -0,0 +1,64 @@ +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'; + +// AI-generated apps put data access in a sibling module, so a handler's real sink is one file away. +// Following one cross-file hop is what keeps those endpoints from looking sink-free. +let dir: string; +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'ps-imp-')); + mkdirSync(join(dir, 'src', 'lib'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { next: '14' } })); + + // The helper module: exported fn hits supabase; a second exported fn delegates to a local helper. + writeFileSync(join(dir, 'src', 'lib', 'db.ts'), ` + import { createClient } from "@supabase/supabase-js"; + const client = createClient(process.env.URL, process.env.KEY); + export function saveOrder(o) { return client.from("orders").insert(o); } + function reallyPurge(id) { return client.from("orders").delete().eq("id", id); } + export function purgeOrder(id) { return reallyPurge(id); } + `); + + // Handler imports both — note the TS-ESM `.js` specifier for one of them. + writeFileSync(join(dir, 'src', 'route.ts'), ` + import { saveOrder } from "./lib/db.js"; + import { purgeOrder } from "./lib/db"; + export async function POST(request) { + const body = await request.json(); + return saveOrder({ note: body.note }); + } + export async function DELETE(request) { + return purgeOrder(request.query.id); + } + `); +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe('imported-helper tracing', () => { + it('attributes a sink reached through an imported module (incl. a .js specifier)', async () => { + const { map } = await buildInputMap(dir); + const post = map!.endpoints.find((e) => e.name === 'POST')!; + expect(post.sinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'db', package: '@supabase/supabase-js', table: 'orders', op: 'insert' }), + ]), + ); + }); + + it('follows one same-file hop inside the imported module', 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: 'orders', op: 'delete' }), + ]), + ); + }); + + it('states the hop limit in coverage notes', async () => { + const { map } = await buildInputMap(dir); + expect(map!.coverage.notes.join(' ')).toMatch(/ONE hop into an imported relative module/i); + }); +}); diff --git a/tests/protect/edge-bundle.test.ts b/tests/protect/edge-bundle.test.ts new file mode 100644 index 0000000..5b0018a --- /dev/null +++ b/tests/protect/edge-bundle.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +// A REAL edge build test. The source-level "no static node import" check (edge-safe.test.ts) is +// necessary but NOT sufficient: bundlers FOLLOW dynamic imports, so `await import('node:fs')` still +// fails to resolve in an edge build. Only bundling the shipped artifact the way Next edge middleware / +// Cloudflare Workers / Deno do proves it. This test does exactly that with esbuild +// (platform: 'browser', nothing external) and then RUNS the bundle to prove behaviour survives. + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const EDGE = root + 'dist/protect.edge.js'; + +async function bundlesForEdge(entry: string): Promise<{ ok: boolean; errors: string[] }> { + const esbuild = await import('esbuild'); + try { + await esbuild.build({ entryPoints: [entry], bundle: true, write: false, format: 'esm', platform: 'browser', logLevel: 'silent' }); + return { ok: true, errors: [] }; + } catch (e: any) { + return { ok: false, errors: (e.errors ?? []).map((x: any) => x.text) }; + } +} + +describe('edge bundle', () => { + beforeAll(() => { + if (!existsSync(EDGE)) { + // CI runs tests before the build; build just this artifact so the assertions are real. + execFileSync(process.execPath, ['scripts/build-edge.mjs'], { cwd: root, stdio: 'ignore' }); + } + }, 120_000); + + it('bundles for an edge runtime with no Node builtins available', async () => { + const { ok, errors } = await bundlesForEdge(EDGE); + expect(errors).toEqual([]); + expect(ok).toBe(true); + }, 60_000); + + it('contains no Node builtin import at all (static or dynamic)', () => { + const src = readFileSync(EDGE, 'utf8'); + const refs = src.match(/(?:^|[\s(])(?:import|require)\s*\(?\s*["'](?:node:)?(?:fs|fs\/promises|path|os|dns|net|crypto|child_process|worker_threads|module)["']/gm); + expect(refs ?? []).toEqual([]); + }); + + it('still enforces rules when imported (no filesystem, cacheDir ignored)', async () => { + const { createProtection } = await import(EDGE); + const rules = { + firewall: [{ id: 'edge-1', rule_v2: [{ parameter: 'raw', match: { type: 'contains', value: '__proto__' } }] }], + whitelists: [], + whitelist_keys: {}, + }; + // cacheDir is deliberately set: the disk tier must fail open on a filesystem-less runtime. + const p: any = await createProtection({ rules, mode: 'block', cacheDir: '/tmp/ignored-on-edge' }); + const post = (body: string) => + new Request('https://app.test/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body }); + expect((await p.fetch(() => new Response('ok'))(post('{"__proto__":{"x":1}}'))).status).toBe(403); + expect((await p.fetch(() => new Response('ok'))(post('{"a":1}'))).status).toBe(200); + }, 60_000); + + it('is selected by edge conditions in package.json exports', () => { + const pkg = JSON.parse(readFileSync(root + 'package.json', 'utf8')); + const protect = pkg.exports['./protect']; + for (const cond of ['workerd', 'worker', 'edge-light', 'deno', 'browser']) { + expect(protect[cond]).toBe('./dist/protect.edge.js'); + } + expect(protect.import).toBe('./dist/protect.js'); // Node still gets the full build + // Condition order matters: an edge condition must be matched before the generic `import`. + const keys = Object.keys(protect); + expect(keys.indexOf('workerd')).toBeLessThan(keys.indexOf('import')); + }); +});