From 459d0cf158207aad18a48ae0c6bde76cf72db75a Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 14:04:32 +0200 Subject: [PATCH 1/3] map: trace imported helpers; protect: ship a real edge build (dist/protect.edge.js) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups, both driven by tests that prove the behaviour rather than assert a proxy for it. 1. IMPORTED-HELPER TRACING (map). AI-generated apps put data access in a sibling module, so a handler's real sink lives one file away and the endpoint looked sink-free — nothing to correlate a CVE against. The extractor now follows ONE cross-file hop into a relative import (resolving extensionless, /index and the TS-ESM `./db.js` -> db.ts convention), plus one same-file hop inside that module, with a shared parse cache. Bare package specifiers are deliberately NOT followed: a dependency's internals are not this app's attack surface. Coverage notes state the hop limit. 2. A REAL EDGE BUILD. #117 made the Node imports dynamic, which keeps the module loadable off Node — but bundlers FOLLOW dynamic imports, so an edge build still failed to resolve `node:fs`/`node:path`, and the shipped dist/protect.js could NOT be bundled for Next edge middleware / Workers / Deno. (tsup also strips the `node:` prefix, and bare `fs` resolves nowhere on Workers while `node:fs` does under nodejs_compat.) So the source-level "no static node import" check was necessary but not sufficient. Adds scripts/build-edge.mjs: an esbuild build (platform browser) that replaces every Node-only module — and refresh-manifest, which pulls in the lockfile scanner — with a stub that rejects on import. The runtime already treats a failed `import('node:fs')` as "no filesystem here" and falls back to the memory/pluggable rule cache, so behaviour is preserved; the disk cache and manifest re-post are simply unavailable, which is correct on edge. Wired into `npm run build` and selected automatically via package.json exports conditions (workerd / worker / edge-light / deno / browser), with Node still getting the full build. tests/protect/edge-bundle.test.ts is the test that would have caught the original gap: it bundles the shipped artifact the way an edge bundler does (nothing external), asserts zero Node builtin references static OR dynamic, IMPORTS the bundle and verifies it still blocks an exploit with cacheDir set, and pins the exports condition order. Co-Authored-By: Claude Opus 4.8 --- package.json | 7 +- scripts/build-edge.mjs | 55 +++++++++++++ src/map/extract.ts | 130 ++++++++++++++++++++++++++---- tests/map-imported.test.ts | 64 +++++++++++++++ tests/protect/edge-bundle.test.ts | 71 ++++++++++++++++ 5 files changed, 312 insertions(+), 15 deletions(-) create mode 100644 scripts/build-edge.mjs create mode 100644 tests/map-imported.test.ts create mode 100644 tests/protect/edge-bundle.test.ts 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..f2b54b9 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 @@ -198,6 +198,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); // shared cache across files const stats: WalkStats = { discovered: 0 }; const files = collectSources(cwd, boundary, { followOutside: options.followSymlinks }, [], new Set(), stats); let parsed = 0; @@ -210,7 +211,7 @@ 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`). @@ -231,7 +232,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) { @@ -399,7 +400,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 +414,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 +434,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 +445,9 @@ 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) })); } } @@ -460,7 +461,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 +476,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 +544,12 @@ 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 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 +757,113 @@ 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): 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 []; + const mod = load(target); + if (!mod) return []; + const out = [...(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) ?? []) out.push(s); + } + return out; + }, + }; +} + +// 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, called)) sinks.push(s); + } + } + } return dedupeSinks(sinks); } 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')); + }); +}); From 8504fe063aa4cfc70eff7011f13593e64ce18eb2 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 14:07:19 +0200 Subject: [PATCH 2/3] map: recognize Deno / edge platform functions (Supabase Edge Functions, Base44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform function runtimes have no router and no route file: one handler per module, invoked by the function's NAME. Without a recognizer, a Supabase Edge Functions or Base44 backend-functions project mapped to NOTHING — no entry points, so no reachability signal and nothing to correlate a vulnerability against. Adds a `Deno.serve(handler)` / `serve(handler)` entry recognizer (entryKind `edge-function`), derives the deployed function name and route from the conventional location (`supabase/functions//index.ts`, `functions/.ts` → `/`), extends the textual pre-filter so those files are parsed at all, and detects the project shape (`supabase-functions` / `deno-functions`) even when there is no package.json — which is normal for a Deno project. The existing request-read extraction already covers the idiomatic `const { a, b } = await req.json()`, so these endpoints get real inputs, sinks and PROVEN flows: the fixture's `hook -> fetch` (the classic SSRF shape) comes out as a precise flow, which is exactly what a rule needs to pin. Co-Authored-By: Claude Opus 4.8 --- src/map/extract.ts | 48 ++++++++++++++++++++++- tests/map-edge-functions.test.ts | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/map-edge-functions.test.ts diff --git a/src/map/extract.ts b/src/map/extract.ts index f2b54b9..ca114bf 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -215,6 +215,10 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac 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) { @@ -271,7 +275,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) ); } @@ -287,6 +292,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'; } @@ -363,6 +375,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 {}; @@ -451,6 +476,23 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, } } + // (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) })); + } + } + } + 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 @@ -547,7 +589,9 @@ function handlerEntry( 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, ctx); return { 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); + }); +}); From 766fa0db8664ef9fb8623d5c4df1f1195eb3220c Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 14:20:20 +0200 Subject: [PATCH 3/3] map: prove flows with AST evidence; keep imported helpers in-project and locatable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review fixes. Both are cases where the previous implementation could make a claim it hadn't earned. 1. FALSE `precise` FLOWS (high). linkFlows concatenated all argument source text and then checked, independently, that (a) the input's leaf name appeared anywhere and (b) any tainted root appeared anywhere. So: const { title } = await req.json(); db.from("items").insert({ title: "system", owner: req.user.id }); was reported `title -> insert [precise]` — `title` matched a property KEY and `req` matched a different value. Since a consumer may PIN A RULE on `precise`, that is the exact false positive the designation exists to avoid. Replaced with AST evidence: collect leaves genuinely READ from a tainted source (`data.title`, `req.body.title`, `{ title }` shorthand, `fn(title)`, `x[\"title\"]`), explicitly excluding property keys, member names and binding names; a flow is `precise` only if the input's leaf is among them, else `heuristic`. Evidence is gathered from the enclosing statement so a fluent chain (`.update({…}).eq('id', data.id)`) counts as one operation. 2. IMPORTED HELPERS (medium). - The resolver did not enforce the project boundary the walker enforces, so `import '../../other-repo/db'` (or a symlink) could pull an unrelated codebase into this app's attack surface. It now rejects anything whose realpath leaves the project unless --follow-symlinks. - An imported sink kept the HELPER's line number while the endpoint reported the handler's file, so the coordinate pointed at the wrong file. Sinks reached through an import now carry their own `file`; flow linking also refuses to call such a sink `precise`, since its call site isn't visible in the handler. - `import { saveOrder as write } from './db'` looked up `write` in the target module and missed the helper. Bindings now track the exported name behind an alias. Co-Authored-By: Claude Opus 4.8 --- src/map/extract.ts | 164 ++++++++++++++++++++++++------- src/map/types.ts | 7 +- tests/map-flow-precision.test.ts | 103 +++++++++++++++++++ 3 files changed, 239 insertions(+), 35 deletions(-) create mode 100644 tests/map-flow-precision.test.ts diff --git a/src/map/extract.ts b/src/map/extract.ts index ca114bf..d87f7db 100644 --- a/src/map/extract.ts +++ b/src/map/extract.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,7 +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); // shared cache across files + 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; @@ -813,7 +820,7 @@ export interface ModuleGraph { importedSinks(fromFile: string, specifier: string, exportName: string): Sink[]; } -function createModuleGraph(ts: TsModule): ModuleGraph { +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>(); @@ -836,14 +843,24 @@ function createModuleGraph(ts: TsModule): ModuleGraph { 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 out = [...(mod.fnSinks.get(exportName) ?? [])]; + 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) ?? []) out.push(s); + for (const s of mod.fnSinks.get(callee) ?? []) collected.push(s); } - return out; + // `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 })); }, }; } @@ -904,7 +921,7 @@ function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map(); + 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); } } } @@ -1051,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)) { @@ -1068,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-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); + }); +});