From bcc075b67b950ee97f8f6c849f09abd65ed0f29f Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 15:04:54 +0200 Subject: [PATCH 1/2] =?UTF-8?q?map:=20adapter=20summaries=20=E2=80=94=20ar?= =?UTF-8?q?gument=20roles=20and=20narrow=20candidate=20families?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track 2, step 1: the review's recommendation to use small per-library summaries BEFORE building a whole-program dataflow engine. Which ARGUMENT received the value decides which mitigation class applies, so this is the gate that lets `ruleGeneratable` ever be true. Every flow now reports `argumentRole` (command | file | args | url | init | body | options | path | content | sql | values | columns | column | value | code | unknown), taken from a per-sink-kind summary table so an overloaded name (`get`) can't be read as the wrong thing: exec(command) · execFile(file, args) · spawn(command, args) fetch(url, init) · axios.get(url, options) · axios.post(url, body) fs.readFile(path) · fs.writeFile(path, content) pool.query(sql, values) · .insert(values) · .eq(column, value) eval(code) `candidateFamily` is then assigned only for pairs where a request value arriving is inherently dangerous AND a rule can express it: http+url → ssrf, exec+command → command-injection, fs+path → path-traversal, db+sql → sql-injection, eval+code → code-injection. `ruleGeneratable` becomes true only with precise evidence, a non-null runtime parameter, a local sink call site, a modelled role, and such a family. The refusals matter as much as the acceptances, and are tested explicitly: fs.writeFileSync("/tmp/x", req.body.contents) role=content → NOT generatable db.from(t).insert({ title: req.body.title }) role=values → NOT generatable axios.post(url, req.body.payload) role=body → NOT generatable pool.query(sql, [req.body.id]) role=values → NOT generatable Each is a PROVEN flow — real reachability signal — but not a blockable pattern on its own, which is exactly the over-reach the review warned against (path vs contents, generic db values). The reason is reported rather than the flow being dropped. Illustrative: the real reference app yields 0 candidates (all its flows are db `values`), while an Express fixture with the high-risk sinks yields exactly ssrf + path-traversal + command-injection. Co-Authored-By: Claude Opus 4.8 --- src/map/extract.ts | 103 +++++++++++++++++++++++--- src/map/types.ts | 24 ++++++ tests/map-argument-roles.test.ts | 84 +++++++++++++++++++++ tests/map-runtime-coordinates.test.ts | 18 ++++- 4 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 tests/map-argument-roles.test.ts diff --git a/src/map/extract.ts b/src/map/extract.ts index 8d28509..0b7b56f 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -2,7 +2,7 @@ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { builtinModules } from 'node:module'; import { createHash } from 'node:crypto'; import { join, relative, isAbsolute, dirname, resolve as resolvePath } from 'node:path'; -import type { SiteInputMap, Endpoint, InputField, InputSource, Sink, Flow, TsModule } from './types.js'; +import type { SiteInputMap, Endpoint, InputField, InputSource, Sink, Flow, ArgumentRole, CandidateFamily, TsModule } from './types.js'; // Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS // source and applies recognizer tables for (1) entry points, (2) inputs, (3) sinks, so it generalizes @@ -1215,14 +1215,23 @@ function linkFlows( const node = sink.file === undefined && sink.start !== undefined && sink.end !== undefined ? callBySpan.get(`${sink.start}:${sink.end}`) : undefined; - const reads = new Set(); + // path → the argument ROLES it was read into. Per-argument attribution is what makes a candidate + // possible: the same value in `url` vs `body`, or `path` vs `content`, implies different mitigations. + const reads = new Map>(); if (node) { // ONLY this sink call's own arguments, plus other calls in the SAME fluent chain // (`.update({…}).eq('id', data.id)` is one operation). Never the enclosing statement: a sibling // expression such as `Promise.all([audit(data.title), db.insert({…})])` must not lend evidence. for (const call of fluentChainCalls(node, ts)) { - for (const arg of call.arguments ?? []) { - for (const path of taintedReadPaths(arg, ts, rootPath)) reads.add(path); + const method = calleeName(call, ts); + const args = call.arguments ?? []; + for (let i = 0; i < args.length; i++) { + const role = argumentRoleOf(sink.kind, method, i); + for (const path of taintedReadPaths(args[i], ts, rootPath)) { + const set = reads.get(path) ?? new Set(); + set.add(role); + reads.set(path, set); + } } } } @@ -1230,7 +1239,15 @@ function linkFlows( const inputPath = normalizePath(input.name); // Exact path, or the input is an ANCESTOR of what was read (`billing` covers `billing.email`). // A mere shared leaf name is NOT evidence: `billing.email` and `shipping.email` are different. - const precise = [...reads].some((r) => r === inputPath || r.startsWith(inputPath + '.')); + const matched = [...reads.entries()].filter(([r]) => r === inputPath || r.startsWith(inputPath + '.')); + const precise = matched.length > 0; + const roles = new Set(matched.flatMap(([, rs]) => [...rs])); + // Prefer a role that maps to a mitigation class over a generic one (a value can reach two args). + const family = [...roles].map((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]).find(Boolean); + const argumentRole = family + ? [...roles].find((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]) + : [...roles].find((r) => r !== 'unknown') ?? (precise ? 'unknown' : undefined); + // Deliberately SEPARATE from confidence: `precise` means "the source reaches the sink", which is // not authorization to block traffic. Every remaining obstacle is listed, so this doubles as the // queue for improving the extractor/adapters rather than silently losing the opportunity. @@ -1239,16 +1256,20 @@ function linkFlows( if (!input.runtimeParameter) reasons.push(input.runtimeParameterReason ?? 'input has no runtime parameter'); if (sink.file !== undefined) reasons.push('sink is in an imported module: no local call-site evidence'); if (sink.start === undefined) reasons.push('sink call could not be located in the source'); - // The sink ARGUMENT ROLE (command vs argument, URL vs body, path vs contents, raw SQL vs values) - // decides which mitigation class is even applicable, and it is not modelled yet — so nothing is - // rule-generatable today. This is the gate the candidate compiler opens. - reasons.push('sink argument role is not modelled yet'); + if (precise && argumentRole === 'unknown') reasons.push(`sink argument role is not modelled for ${sink.kind}.${sink.op ?? '?'}`); + if (precise && argumentRole && argumentRole !== 'unknown' && !family) { + // e.g. a request value in a parameterized db `values` object: real reachability, but not a + // pattern a generic blocking rule can express. + reasons.push(`argument role "${argumentRole}" on a ${sink.kind} sink is not a blockable pattern on its own`); + } flows.push({ input: input.name, sink, confidence: precise ? 'precise' : 'heuristic', line: sink.line, - ruleGeneratable: false, + ...(argumentRole ? { argumentRole } : {}), + ...(family ? { candidateFamily: family } : {}), + ruleGeneratable: reasons.length === 0, ruleGeneratableReasons: reasons, }); } @@ -1261,6 +1282,68 @@ function join2(base: string, seg: string): string { return base ? `${base}.${seg}` : seg; } +// --- adapter summaries: which argument means what --------------------------- +// Small, testable per-library summaries, keyed by sink kind so an overloaded name (`get`) can't be read +// as the wrong thing. This is the cheap foundation the review recommended BEFORE a whole-program +// dataflow engine: without argument roles, "the input reaches this sink" cannot be turned into a rule, +// because the mitigation class depends on WHICH argument received the value. +const ARGUMENT_ROLES: Record> = { + exec: { + exec: ['command'], execSync: ['command'], + execFile: ['file', 'args'], execFileSync: ['file', 'args'], + spawn: ['command', 'args'], spawnSync: ['command', 'args'], fork: ['file', 'args'], + }, + http: { + fetch: ['url', 'init'], request: ['url', 'options'], + get: ['url', 'options'], head: ['url', 'options'], delete: ['url', 'options'], + post: ['url', 'body'], put: ['url', 'body'], patch: ['url', 'body'], + }, + fs: { + readFile: ['path'], readFileSync: ['path'], open: ['path'], + writeFile: ['path', 'content'], writeFileSync: ['path', 'content'], + appendFile: ['path', 'content'], + unlink: ['path'], rm: ['path'], rmSync: ['path'], mkdir: ['path'], readdir: ['path'], stat: ['path'], + createReadStream: ['path'], createWriteStream: ['path'], + }, + db: { + query: ['sql', 'values'], execute: ['sql', 'values'], + insert: ['values'], update: ['values'], upsert: ['values'], select: ['columns'], + // Filters: the value half is still request data reaching the query, but as a bound parameter. + eq: ['column', 'value'], neq: ['column', 'value'], gt: ['column', 'value'], gte: ['column', 'value'], + lt: ['column', 'value'], lte: ['column', 'value'], like: ['column', 'value'], ilike: ['column', 'value'], + match: ['values'], filter: ['column', 'value'], + }, + eval: { eval: ['code'], Function: ['code'] }, +}; + +/** + * The (sink kind, argument role) pairs where a request value arriving is inherently dangerous AND a rule + * can express the mitigation. Deliberately narrow — notably `db`+`values` is absent: a request value in + * a parameterized insert is genuine reachability signal but not a blockable pattern by itself. + */ +const CANDIDATE_FAMILIES: Record>> = { + http: { url: 'ssrf' }, + exec: { command: 'command-injection', file: 'command-injection', args: 'command-injection' }, + fs: { path: 'path-traversal' }, + db: { sql: 'sql-injection' }, + eval: { code: 'code-injection' }, +}; + +/** Method name a call invokes (`db.from(t).insert(x)` → "insert", `exec(x)` → "exec"). */ +function calleeName(call: any, ts: TsModule): string | undefined { + const c = call?.expression; + if (!c) return undefined; + if (ts.isPropertyAccessExpression(c)) return c.name.text; + if (ts.isIdentifier(c)) return c.text; + return undefined; +} + +/** Role of argument `index` for this call, given the sink kind it was recognized as. */ +function argumentRoleOf(sinkKind: string, method: string | undefined, index: number): ArgumentRole { + const table = method ? ARGUMENT_ROLES[sinkKind]?.[method] : undefined; + return table?.[index] ?? 'unknown'; +} + /** * Canonical form for comparing paths: index/array tokens are erased and empty segments collapsed, so * `tags[0].label`, `tags[].label` and `tags.label` all compare equal, while DISTINCT paths such as diff --git a/src/map/types.ts b/src/map/types.ts index 5a45a0a..a9073a5 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -124,7 +124,31 @@ export interface Endpoint { * "may reach", never as proven. * Consumers that pin a rule to a parameter should prefer `precise` flows and fall back to broad rules. */ +/** + * Which argument of the sink call the tainted value landed in. This decides which mitigation class is + * even applicable, so a candidate compiler cannot work without it: `command` vs `args` for exec, + * `url` vs `body` for http, `path` vs `content` for the filesystem, `sql` vs `values` for a database. + */ +export type ArgumentRole = + | 'command' | 'file' | 'args' + | 'url' | 'init' | 'body' | 'options' + | 'path' | 'content' + | 'sql' | 'values' | 'columns' | 'column' | 'value' + | 'code' | 'unknown'; + +/** + * The mitigation class a flow could support. Deliberately narrow: only patterns where a request value + * reaching that argument is inherently dangerous and a rule can express it. A request value flowing into + * generic database *values* is real reachability signal but NOT a blockable pattern on its own, so it + * gets no family. + */ +export type CandidateFamily = 'ssrf' | 'command-injection' | 'path-traversal' | 'sql-injection' | 'code-injection'; + export interface Flow { + /** Which argument of the sink call received the value (see ArgumentRole). */ + argumentRole?: ArgumentRole; + /** The mitigation class this flow could support, when the (sink kind, argument role) pair maps to one. */ + candidateFamily?: CandidateFamily; /** * Whether a Patchstack rule can SAFELY be compiled from this flow — deliberately separate from * `confidence`. `precise` means "the source reaches the sink"; it is NOT authorization to block diff --git a/tests/map-argument-roles.test.ts b/tests/map-argument-roles.test.ts new file mode 100644 index 0000000..c0692dc --- /dev/null +++ b/tests/map-argument-roles.test.ts @@ -0,0 +1,84 @@ +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'; + +// Track 2, step 1 — adapter summaries. Which ARGUMENT received the value decides which mitigation class +// applies, so a candidate compiler cannot exist without it: `url` vs `body`, `path` vs `content`, +// `command` vs `args`, raw `sql` vs bound `values`. +let dir: string; +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'ps-roles-')); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { express: '4', axios: '1', pg: '8' } })); + writeFileSync(join(dir, 'src', 's.ts'), ` + import express from "express"; + import fs from "node:fs"; + import { exec } from "node:child_process"; + import axios from "axios"; + import { Pool } from "pg"; + import { createClient } from "@supabase/supabase-js"; + const db = createClient("u","k"); + const pool = new Pool(); + const app = express(); + app.post("/fetch", async (req,res) => { await axios.get(req.body.webhookUrl); res.end(); }); + app.post("/post", async (req,res) => { await axios.post("https://api.example.com", req.body.payload); res.end(); }); + app.post("/read", (req,res) => { res.end(fs.readFileSync(req.body.filename)); }); + app.post("/write", (req,res) => { fs.writeFileSync("/tmp/x", req.body.contents); res.end(); }); + app.post("/run", (req,res) => { exec(req.body.command); res.end(); }); + app.post("/sql", async (req,res) => { await pool.query(req.body.rawSql); res.end(); }); + app.post("/sqlparam", async (req,res) => { await pool.query("select 1 where a=$1", [req.body.id]); res.end(); }); + app.post("/save", async (req,res) => { await db.from("t").insert({ title: req.body.title }); res.end(); }); + `); +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const flow = async (route: string, input: string) => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints.find((e) => e.route === route)!; + return ep.flows.find((f) => f.input === input && f.confidence === 'precise')!; +}; + +describe('argument roles', () => { + it.each([ + ['/fetch', 'webhookUrl', 'url', 'ssrf'], + ['/read', 'filename', 'path', 'path-traversal'], + ['/run', 'command', 'command', 'command-injection'], + ['/sql', 'rawSql', 'sql', 'sql-injection'], + ])('%s: %s lands in the %s argument → %s candidate, rule-generatable', async (route, input, role, family) => { + const f = await flow(route, input); + expect(f.argumentRole).toBe(role); + expect(f.candidateFamily).toBe(family); + expect(f.ruleGeneratable).toBe(true); + expect(f.ruleGeneratableReasons).toEqual([]); + }); + + // The distinctions that stop a generator over-reaching. Each of these IS a proven flow, but none is a + // blockable pattern by itself — the review called out path-vs-contents and generic db values by name. + it.each([ + ['/write', 'contents', 'content', 'fs'], + ['/save', 'title', 'values', 'db'], + ['/post', 'payload', 'body', 'http'], + ['/sqlparam', 'id', 'values', 'db'], + ])('%s: %s lands in the %s argument of a %s sink → proven but NOT generatable', async (route, input, role, _kind) => { + const f = await flow(route, input); + expect(f.confidence).toBe('precise'); + expect(f.argumentRole).toBe(role); + expect(f.candidateFamily).toBeUndefined(); + expect(f.ruleGeneratable).toBe(false); + expect(f.ruleGeneratableReasons!.join(' ')).toMatch(/not a blockable pattern on its own/); + }); + + it('a generatable candidate carries everything a compiler needs', async () => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints.find((e) => e.route === '/fetch')!; + const f = ep.flows.find((x) => x.candidateFamily === 'ssrf')!; + expect(ep.method).toBe('POST'); // route + method + expect(ep.route).toBe('/fetch'); + expect(ep.fingerprint).toBeTruthy(); // staleness guard + expect(ep.inputs.find((i) => i.name === 'webhookUrl')!.runtimeParameter).toBe('post.webhookUrl'); + expect(f.sink.package).toBe('axios'); // the dependency behind the sink + expect(typeof f.sink.start).toBe('number'); // evidence span + }); +}); diff --git a/tests/map-runtime-coordinates.test.ts b/tests/map-runtime-coordinates.test.ts index 998d223..14ed2b9 100644 --- a/tests/map-runtime-coordinates.test.ts +++ b/tests/map-runtime-coordinates.test.ts @@ -80,10 +80,20 @@ describe('coordinates on a real project', () => { it('reports ruleGeneratable separately from confidence, with reasons', async () => { const { map } = await buildInputMap(dir); const ep = map!.endpoints[0]!; - for (const f of ep.flows) { - // Nothing is rule-generatable yet: argument roles are unmodelled (the Track-2 gate). - expect(f.ruleGeneratable).toBe(false); - expect(f.ruleGeneratableReasons).toContain('sink argument role is not modelled yet'); + // Since argument roles landed, a flow into a MITIGATABLE argument is generatable — `req.body.path` + // reaches the fs `path` argument (traversal). Everything else must still be refused, with reasons. + const pathFlow = ep.flows.find((f) => f.input === 'path' && f.confidence === 'precise')!; + expect(pathFlow.argumentRole).toBe('path'); + expect(pathFlow.candidateFamily).toBe('path-traversal'); + expect(pathFlow.ruleGeneratable).toBe(true); + // `req.query.data` lands in the fs CONTENT argument: proven, but not a blockable pattern. + const dataFlow = ep.flows.find((f) => f.input === 'data' && f.confidence === 'precise'); + if (dataFlow) { + expect(dataFlow.candidateFamily).toBeUndefined(); + expect(dataFlow.ruleGeneratable).toBe(false); + } + for (const f of ep.flows.filter((x) => x.ruleGeneratable === false)) { + expect(f.ruleGeneratableReasons!.length).toBeGreaterThan(0); } // A precise flow still must not be read as authorization to block. const routeParamFlow = ep.flows.find((f) => f.input === 'tenant'); From aeba7a7b615bb6de2e45daad3e5450a8f27b4c6c Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 15:10:19 +0200 Subject: [PATCH 2/2] map: add a golden corpus with the wrong-input metric; group map tests under tests/map/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus is what makes candidate generation measurable rather than merely tested. Six declarative cases across the stacks AI builders actually emit — Lovable/TanStack+Supabase server fns, Express+axios+fs+child_process, Next App Router + a server action, Fastify+pg (raw sql vs bound values), a Supabase Edge Function, and a case of shapes that must yield nothing (route param, dynamic computed key, spread into sink). Each case declares the candidates it expects as `family @ runtimeParameter` plus the proven flows that must be REFUSED. Two failure modes are measured separately: - WRONG-INPUT: a candidate nobody declared — the rule would pin the wrong parameter. Asserted to be ZERO. This is the metric that governs auto-promotion. - MISSED: a declared candidate absent — a recall gap; loud, but a lesser sin than a wrong pin. Plus two invariants across every case: a candidate never exists without a runtime coordinate, and every refusal carries a reason (silence is what makes a map untrustworthy). The corpus immediately earned its keep: a Next SERVER ACTION produced no inputs at all, because its first parameter IS the payload (`export async function report(input) { exec(input.job) }`) rather than a Request — so it could never yield a candidate. Payload- style entries now treat that parameter as the input container (source `server-fn-data`), which is also the shape TanStack server fns use without a validator. Also groups the nine map test files under tests/map/, mirroring tests/protect/. Co-Authored-By: Claude Opus 4.8 --- src/map/extract.ts | 34 ++- .../argument-roles.test.ts} | 2 +- tests/map/corpus.test.ts | 236 ++++++++++++++++++ .../edge-functions.test.ts} | 2 +- .../extract-strict.test.ts} | 4 +- .../extract.test.ts} | 2 +- .../flow-paths.test.ts} | 2 +- .../flow-precision.test.ts} | 2 +- .../imported.test.ts} | 2 +- .../runtime-coordinates.test.ts} | 4 +- 10 files changed, 274 insertions(+), 16 deletions(-) rename tests/{map-argument-roles.test.ts => map/argument-roles.test.ts} (98%) create mode 100644 tests/map/corpus.test.ts rename tests/{map-edge-functions.test.ts => map/edge-functions.test.ts} (98%) rename tests/{map-extract-strict.test.ts => map/extract-strict.test.ts} (98%) rename tests/{map-extract.test.ts => map/extract.test.ts} (99%) rename tests/{map-flow-paths.test.ts => map/flow-paths.test.ts} (98%) rename tests/{map-flow-precision.test.ts => map/flow-precision.test.ts} (98%) rename tests/{map-imported.test.ts => map/imported.test.ts} (97%) rename tests/{map-runtime-coordinates.test.ts => map/runtime-coordinates.test.ts} (98%) diff --git a/src/map/extract.ts b/src/map/extract.ts index 0b7b56f..10e202a 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -658,7 +658,12 @@ function handlerEntry( const entryKind = kindLabel === 'route-registration' || kindLabel === 'server-action' || kindLabel === 'edge-function' ? kindLabel : 'route-handler'; - const inputs = inputsFromHandler(params, body, ts, bindings); + // A server action receives its payload as the first argument; a route handler receives a Request. + const payloadStyle = kindLabel === 'server-action'; + const inputs = inputsFromHandler(params, body, ts, bindings, { + payloadParam: payloadStyle, + validatorSource: payloadStyle ? 'server-fn-data' : 'json-body', + }); const sinks = sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings, ctx); return { name, @@ -713,11 +718,18 @@ function inputsFromValidator(validatorCall: any, ts: TsModule, bindings: Binding // From a raw handler: validator schema fields it parses, plus the request fields it actually reads // (member accesses, destructuring, `await request.json()` bodies). -function inputsFromHandler(params: any, body: any, ts: TsModule, bindings: Bindings): InputField[] { - // A validated schema inside a handler describes the request body. - const fields = withCoordinates(zodObjectFields(body, ts, bindings), 'json-body'); +function inputsFromHandler( + params: any, + body: any, + ts: TsModule, + bindings: Bindings, + opts: { payloadParam?: boolean; validatorSource?: InputSource } = {}, +): InputField[] { + // A validated schema inside a handler describes the request body — except for a payload-style entry + // (a server action), where the schema describes the action's own argument. + const fields = withCoordinates(zodObjectFields(body, ts, bindings), opts.validatorSource ?? 'json-body'); const names = new Set(fields.map((f) => f.name)); - for (const { name, source } of requestMemberAccesses(params, body, ts)) { + for (const { name, source } of requestMemberAccesses(params, body, ts, opts)) { if (!names.has(name)) { names.add(name); fields.push({ name, source, ...runtimeCoordinate(source, name) }); @@ -801,13 +813,20 @@ const REQ_SOURCES = ['body', 'query', 'params']; // const { x } = req.body (destructuring) // ({ body }) => body.x / const { x } = body (destructured handler param) // const b = await request.json(); b.x / const { x } = await request.json() (fetch-style Request) -function requestMemberAccesses(params: any, body: any, ts: TsModule): Array<{ name: string; source: InputSource }> { +function requestMemberAccesses( + params: any, + body: any, + ts: TsModule, + opts: { payloadParam?: boolean } = {}, +): Array<{ name: string; source: InputSource }> { if (!body) return []; const out = new Map(); const p0 = params?.[0]; const reqName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : undefined; // Identifiers that ARE a request-input object (destructured `({ body })` param, `await req.json()`). const sourceNames = new Set(); + const payloadNames = new Set(); + if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name.text); if (p0 && !reqName && ts.isObjectBindingPattern(p0.name)) { for (const el of p0.name.elements) { const key = bindingKey(el, ts); @@ -819,7 +838,9 @@ function requestMemberAccesses(params: any, body: any, ts: TsModule): Array<{ na while (cur && (ts.isAwaitExpression(cur) || ts.isAsExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; return cur; }; + const isPayloadExpr = (e: any): boolean => ts.isIdentifier(e) && payloadNames.has(e.text); const isReqSourceExpr = (e: any): boolean => + isPayloadExpr(e) || (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === reqName && REQ_SOURCES.includes(e.name.text)) || (ts.isIdentifier(e) && sourceNames.has(e.text)); const isBodyReadCall = (e: any): boolean => { @@ -854,6 +875,7 @@ function requestMemberAccesses(params: any, body: any, ts: TsModule): Array<{ na // `req.body.x` / `req.query.x` / `req.params.x` — the namespace decides the runtime coordinate, and // route params notably have NONE, so this distinction is load-bearing rather than cosmetic. function sourceOfExpr(e: any): InputSource { + if (isPayloadExpr(e)) return 'server-fn-data'; if (ts.isPropertyAccessExpression(e)) { if (e.name.text === 'query') return 'query'; if (e.name.text === 'params') return 'route-param'; diff --git a/tests/map-argument-roles.test.ts b/tests/map/argument-roles.test.ts similarity index 98% rename from tests/map-argument-roles.test.ts rename to tests/map/argument-roles.test.ts index c0692dc..2c35a4b 100644 --- a/tests/map-argument-roles.test.ts +++ b/tests/map/argument-roles.test.ts @@ -2,7 +2,7 @@ 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'; +import { buildInputMap } from '../../src/map/index.js'; // Track 2, step 1 — adapter summaries. Which ARGUMENT received the value decides which mitigation class // applies, so a candidate compiler cannot exist without it: `url` vs `body`, `path` vs `content`, diff --git a/tests/map/corpus.test.ts b/tests/map/corpus.test.ts new file mode 100644 index 0000000..c3fdfa8 --- /dev/null +++ b/tests/map/corpus.test.ts @@ -0,0 +1,236 @@ +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'; +import type { SiteInputMap } from '../../src/map/types.js'; + +// GOLDEN CORPUS. Unit fixtures prove a mechanism; this measures BEHAVIOUR across the stacks AI builders +// actually generate, and enforces the metric that governs whether auto-generated rules are safe: +// +// an auto-generated parameter-pinned rule must target the wrong input at a rate of ZERO. +// +// Each case declares the candidates it expects (family + the exact runtime parameter) and the flows that +// must NOT become candidates. Two failure modes are then measured separately: +// - WRONG-INPUT: a candidate exists that nobody declared → the rule would pin the wrong parameter. +// This must be 0. It is the metric. +// - MISSED: a declared candidate is absent → recall gap. Reported, and asserted per-case so a +// regression is loud, but it is a lesser sin than a wrong pin. +// Every production false positive we ever find should become a permanent case here. + +interface Case { + name: string; + pkg: Record; + files: Record; + /** `family @ runtimeParameter` for every flow that SHOULD compile to a candidate. */ + expectCandidates: string[]; + /** Proven flows that must NOT be candidates, as `input -> reason-fragment`. */ + expectRefused?: Array<[string, RegExp]>; +} + +const CASES: Case[] = [ + { + name: 'lovable / tanstack start + supabase (server fns, validated payload)', + pkg: { dependencies: { '@tanstack/react-start': '1', zod: '3', '@supabase/supabase-js': '2' } }, + files: { + 'src/lib/tasks.functions.ts': ` + import { createServerFn } from "@tanstack/react-start"; + import { z } from "zod"; + import { createClient } from "@supabase/supabase-js"; + function getClient() { return createClient(process.env.URL, process.env.KEY); } + export const createTask = createServerFn({ method: "POST" }) + .inputValidator((i) => z.object({ title: z.string().min(1).max(200) }).parse(i)) + .handler(async ({ data }) => { await getClient().from("tasks").insert({ title: data.title }); }); + `, + }, + // A request value in a parameterized insert is reachability signal, not a blockable pattern. + expectCandidates: [], + expectRefused: [['title', /not a blockable pattern/]], + }, + { + name: 'express + axios + fs + child_process (the high-signal families)', + pkg: { dependencies: { express: '4', axios: '1' } }, + files: { + 'src/server.ts': ` + import express from "express"; + import fs from "node:fs"; + import { exec } from "node:child_process"; + import axios from "axios"; + const app = express(); + app.post("/proxy", async (req, res) => { await axios.get(req.body.target); res.end(); }); + app.post("/download", (req, res) => { res.end(fs.readFileSync(req.body.file)); }); + app.post("/convert", (req, res) => { exec(req.body.cmd); res.end(); }); + app.get("/search", (req, res) => { res.end(fs.readFileSync(req.query.doc)); }); + `, + }, + expectCandidates: [ + 'ssrf @ post.target', + 'path-traversal @ post.file', + 'command-injection @ post.cmd', + 'path-traversal @ get.doc', + ], + }, + { + name: 'next app router (file-based dynamic route) + server action', + pkg: { dependencies: { next: '15' } }, + files: { + 'app/api/render/route.ts': ` + import fs from "node:fs"; + export async function POST(request) { + const { template } = await request.json(); + return new Response(fs.readFileSync(template)); + } + `, + 'app/actions.ts': ` + 'use server'; + import { exec } from "node:child_process"; + export async function report(input) { exec(input.job); } + `, + }, + expectCandidates: ['path-traversal @ post.template', 'command-injection @ post.job'], + }, + { + name: 'fastify + pg (raw sql vs bound values)', + pkg: { dependencies: { fastify: '4', pg: '8' } }, + files: { + 'src/app.ts': ` + import Fastify from "fastify"; + import { Pool } from "pg"; + const pool = new Pool(); + const app = Fastify(); + app.post("/raw", async (req, reply) => { await pool.query(req.body.sql); reply.send(); }); + app.post("/safe", async (req, reply) => { await pool.query("select 1 where id=$1", [req.body.id]); reply.send(); }); + `, + }, + expectCandidates: ['sql-injection @ post.sql'], + expectRefused: [['id', /not a blockable pattern/]], + }, + { + name: 'supabase edge function (deno) with an outbound callback', + pkg: {}, + files: { + 'supabase/functions/notify/index.ts': ` + Deno.serve(async (req) => { + const { hook } = await req.json(); + await fetch(hook, { method: "POST" }); + return new Response("ok"); + }); + `, + }, + expectCandidates: ['ssrf @ post.hook'], + }, + { + name: 'unaddressable + unmodelled shapes (must yield nothing)', + pkg: { dependencies: { express: '4', '@supabase/supabase-js': '2' } }, + files: { + 'src/edge.ts': ` + import express from "express"; + import fs from "node:fs"; + import { createClient } from "@supabase/supabase-js"; + const db = createClient("u", "k"); + const app = express(); + // A route param has no runtime coordinate at all. + app.get("/t/:tenant/f", (req, res) => { res.end(fs.readFileSync(req.params.tenant)); }); + // A dynamic computed key cannot be pinned. + app.post("/dyn", async (req, res) => { const k = req.body.which; await db.from("t").insert({ v: req.body[k] }); res.end(); }); + // A spread hides which field reaches the sink. + app.post("/spread", async (req, res) => { await db.from("t").insert({ ...req.body }); res.end(); }); + `, + }, + expectCandidates: [], + }, +]; + +const maps = new Map(); +let dirs: string[] = []; + +beforeAll(async () => { + for (const c of CASES) { + const d = mkdtempSync(join(tmpdir(), 'ps-corpus-')); + dirs.push(d); + for (const [rel, body] of Object.entries(c.files)) { + const p = join(d, rel); + mkdirSync(join(p, '..'), { recursive: true }); + writeFileSync(p, body); + } + writeFileSync(join(d, 'package.json'), JSON.stringify(c.pkg)); + const { map, error } = await buildInputMap(d); + expect(error, `${c.name}: ${error}`).toBeUndefined(); + maps.set(c.name, map!); + } +}, 120_000); +afterAll(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true }))); + +/** Every compiled candidate as `family @ runtimeParameter`. */ +function candidatesOf(map: SiteInputMap): string[] { + const out: string[] = []; + for (const ep of map.endpoints) { + const coord = new Map(ep.inputs.map((i) => [i.name, i.runtimeParameter])); + for (const f of ep.flows) { + if (!f.ruleGeneratable) continue; + out.push(`${f.candidateFamily} @ ${coord.get(f.input)}`); + } + } + return out.sort(); +} + +describe('golden corpus', () => { + for (const c of CASES) { + describe(c.name, () => { + it('compiles exactly the expected candidates — no wrong-input pins', () => { + const got = candidatesOf(maps.get(c.name)!); + const want = [...c.expectCandidates].sort(); + // A candidate nobody declared is a WRONG-INPUT pin: the metric that must stay at zero. + expect(got.filter((g) => !want.includes(g)), 'unexpected candidate(s)').toEqual([]); + expect(got).toEqual(want); // and no silent recall loss + }); + + it('refuses the flows that are proven but not blockable, with a reason', () => { + const map = maps.get(c.name)!; + for (const [input, reason] of c.expectRefused ?? []) { + const flows = map.endpoints.flatMap((e) => e.flows).filter((f) => f.input === input); + expect(flows.length, `no flow for input ${input}`).toBeGreaterThan(0); + const refused = flows.filter((f) => f.ruleGeneratable === false); + expect(refused.length).toBeGreaterThan(0); + expect(refused.map((f) => (f.ruleGeneratableReasons ?? []).join(' ')).join(' ')).toMatch(reason); + } + }); + + it('never emits a candidate whose input lacks a runtime coordinate', () => { + const map = maps.get(c.name)!; + for (const ep of map.endpoints) { + const coord = new Map(ep.inputs.map((i) => [i.name, i.runtimeParameter])); + for (const f of ep.flows.filter((x) => x.ruleGeneratable)) { + expect(coord.get(f.input), `${f.input} is a candidate without a coordinate`).toBeTruthy(); + } + } + }); + }); + } + + it('reports corpus-wide metrics (the numbers that gate auto-promotion)', () => { + let candidates = 0, precise = 0, heuristic = 0, refusedWithReason = 0, noCoordinate = 0; + for (const c of CASES) { + for (const ep of maps.get(c.name)!.endpoints) { + for (const i of ep.inputs) if (!i.runtimeParameter) noCoordinate++; + for (const f of ep.flows) { + if (f.confidence === 'precise') precise++; else heuristic++; + if (f.ruleGeneratable) candidates++; + else if ((f.ruleGeneratableReasons ?? []).length > 0) refusedWithReason++; + } + } + } + // eslint-disable-next-line no-console + console.log(`corpus: ${CASES.length} projects · ${candidates} candidates · ${precise} precise / ${heuristic} heuristic flows · ${refusedWithReason} refused-with-reason · ${noCoordinate} inputs without a coordinate`); + expect(candidates).toBeGreaterThan(0); // the compiler does something + expect(refusedWithReason).toBeGreaterThan(0); // and refuses a lot, explicitly + // Every non-candidate must explain itself: silence is what makes a map untrustworthy. + for (const c of CASES) { + for (const ep of maps.get(c.name)!.endpoints) { + for (const f of ep.flows.filter((x) => x.ruleGeneratable === false)) { + expect(f.ruleGeneratableReasons?.length, `${c.name}/${f.input}: refused without a reason`).toBeGreaterThan(0); + } + } + } + }); +}); diff --git a/tests/map-edge-functions.test.ts b/tests/map/edge-functions.test.ts similarity index 98% rename from tests/map-edge-functions.test.ts rename to tests/map/edge-functions.test.ts index 3051b91..75b9758 100644 --- a/tests/map-edge-functions.test.ts +++ b/tests/map/edge-functions.test.ts @@ -2,7 +2,7 @@ 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'; +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 diff --git a/tests/map-extract-strict.test.ts b/tests/map/extract-strict.test.ts similarity index 98% rename from tests/map-extract-strict.test.ts rename to tests/map/extract-strict.test.ts index 9baf3a4..e18a279 100644 --- a/tests/map-extract-strict.test.ts +++ b/tests/map/extract-strict.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { buildInputMap } from '../src/map/index.js'; -import type { SiteInputMap, Endpoint } from '../src/map/types.js'; +import { buildInputMap } from '../../src/map/index.js'; +import type { SiteInputMap, Endpoint } from '../../src/map/types.js'; // Regression suite for the strict-review hardening: destructured / request.json() inputs, pre-filter ↔ // recognizer parity, chained + object route registration, bindings-gated sinks (local objects are not diff --git a/tests/map-extract.test.ts b/tests/map/extract.test.ts similarity index 99% rename from tests/map-extract.test.ts rename to tests/map/extract.test.ts index ea53f4a..effa71f 100644 --- a/tests/map-extract.test.ts +++ b/tests/map/extract.test.ts @@ -2,7 +2,7 @@ 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'; +import { buildInputMap } from '../../src/map/index.js'; // The agnostic extractor across three stacks in one fixture app: a TanStack server fn (zod inputs + // supabase sink, incl. a helper-indirected select), an Express route (req.body access + fs/exec diff --git a/tests/map-flow-paths.test.ts b/tests/map/flow-paths.test.ts similarity index 98% rename from tests/map-flow-paths.test.ts rename to tests/map/flow-paths.test.ts index 91fb0e0..20aa8e6 100644 --- a/tests/map-flow-paths.test.ts +++ b/tests/map/flow-paths.test.ts @@ -2,7 +2,7 @@ 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'; +import { buildInputMap } from '../../src/map/index.js'; // `precise` is the signal a rule-generator would pin a parameter on, so it must identify the RIGHT // parameter. Two ways it previously could not: diff --git a/tests/map-flow-precision.test.ts b/tests/map/flow-precision.test.ts similarity index 98% rename from tests/map-flow-precision.test.ts rename to tests/map/flow-precision.test.ts index 2f3c1b7..bda9f98 100644 --- a/tests/map-flow-precision.test.ts +++ b/tests/map/flow-precision.test.ts @@ -2,7 +2,7 @@ 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'; +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 diff --git a/tests/map-imported.test.ts b/tests/map/imported.test.ts similarity index 97% rename from tests/map-imported.test.ts rename to tests/map/imported.test.ts index 0eb8057..4890767 100644 --- a/tests/map-imported.test.ts +++ b/tests/map/imported.test.ts @@ -2,7 +2,7 @@ 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'; +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. diff --git a/tests/map-runtime-coordinates.test.ts b/tests/map/runtime-coordinates.test.ts similarity index 98% rename from tests/map-runtime-coordinates.test.ts rename to tests/map/runtime-coordinates.test.ts index 14ed2b9..6834dd2 100644 --- a/tests/map-runtime-coordinates.test.ts +++ b/tests/map/runtime-coordinates.test.ts @@ -2,8 +2,8 @@ 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'; -import { runtimeCoordinate } from '../src/map/extract.js'; +import { buildInputMap } from '../../src/map/index.js'; +import { runtimeCoordinate } from '../../src/map/extract.js'; // Track 1 — TRUSTED COORDINATES. A server compiling a map input into a rule must be handed the exact // engine parameter, or nothing at all: a coordinate the resolver cannot resolve compiles into a rule