diff --git a/src/map/extract.ts b/src/map/extract.ts index 5a3780a..8d28509 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -1,7 +1,8 @@ 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, Sink, Flow, TsModule } from './types.js'; +import type { SiteInputMap, Endpoint, InputField, InputSource, Sink, Flow, TsModule } from './types.js'; // Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS // source and applies recognizer tables for (1) entry points, (2) inputs, (3) sinks, so it generalizes @@ -223,6 +224,8 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac const text = readFileSync(file, 'utf8'); if (!hasEntrySignal(text)) continue; parsed++; + // Coordinates are only valid for the exact file content they were derived from. + const fingerprint = createHash('sha256').update(text).digest('hex').slice(0, 16); const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); const bindings = buildModuleBindings(sf, ts); const localSinks = collectLocalSinks(sf, ts, bindings); @@ -241,9 +244,12 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac if (derived.dynamic) ep.routeDynamic = true; } } - endpoints.push({ ...ep, file: relFile }); + endpoints.push({ ...ep, file: relFile, fingerprint }); } - } catch { + } catch (e) { + // The fail-open below is right for production but hides bugs during development: a crash in the + // extractor looks identical to an unparseable file. PS_MAP_DEBUG surfaces it. + if (typeof process !== 'undefined' && process.env?.PS_MAP_DEBUG) console.error('[patchstack] map error', file, e); // Fail-open: one unreadable/unparseable file must never kill the whole map. failed.push(relative(cwd, file)); } @@ -269,7 +275,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac if (endpoints.length === 0) notes.push('No recognized server-side entry points found under the analyzed roots.'); return { - version: 1, + version: 2, framework: detectFramework(cwd), endpoints, coverage: { @@ -439,6 +445,51 @@ export function routeFromFilePath(relFile: string): { route?: string; dynamic?: return { route: route.length > 1 ? route.replace(/\/+$/, '') : '/', dynamic }; } +/** + * Map an input to the EXACT rule-engine parameter that addresses it, or null with a reason. Verified + * against the resolver in engine/request.js — a coordinate that the resolver cannot resolve would compile + * into a rule that silently never matches, which is worse than emitting nothing: + * - body / json / form / multipart / server-fn args → `post.` (createServerFnGuard feeds + * server-function arguments through as the JSON body, so `post.` resolves them) + * - query → `get.` + * - header → `server.HTTP_` (resolver lowercases and maps `_` → `-`) + * - cookie → `cookie.` + * - file → `files.` (`.content` / `.type` / `.filename` are separate) + * - route-param → NONE. The resolver exposes get/post/request/cookie/server/files — NOT `req.params`. + * - array path → NONE. `#getNestedValue` walks own properties, so `tags[].label` needs an + * `array_key_value` rule, not a dotted parameter. + */ +export function runtimeCoordinate(source: InputSource | undefined, path: string): { runtimeParameter: string | null; runtimeParameterReason?: string } { + if (/\[\d*\]/.test(path)) { + return { runtimeParameter: null, runtimeParameterReason: 'array traversal: needs an array_key_value rule, not a dotted parameter' }; + } + switch (source) { + case 'json-body': + case 'form-body': + case 'multipart': + case 'body': + case 'server-fn-data': + return { runtimeParameter: `post.${path}` }; + case 'query': + return { runtimeParameter: `get.${path}` }; + case 'cookie': + return { runtimeParameter: `cookie.${path}` }; + case 'file': + return { runtimeParameter: `files.${path}` }; + case 'header': + return { runtimeParameter: `server.HTTP_${path.toUpperCase().replace(/-/g, '_')}` }; + case 'route-param': + return { runtimeParameter: null, runtimeParameterReason: 'route parameters are not exposed by the runtime resolver' }; + default: + return { runtimeParameter: null, runtimeParameterReason: 'input source could not be determined' }; + } +} + +/** Attach `source` + the runtime coordinate to every extracted input. */ +function withCoordinates(fields: InputField[], source: InputSource): InputField[] { + return fields.map((f) => ({ ...f, source: f.source ?? source, ...runtimeCoordinate(f.source ?? source, f.name) })); +} + // --- entry-point recognizers ----------------------------------------------- function extractFromFile(sf: any, ts: TsModule, localSinks: Map, bindings: Bindings, ctx: { file: string; graph: ModuleGraph }): Omit[] { const out: Omit[] = []; @@ -452,7 +503,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, const chain = unwindChain(decl.initializer, ts); if (chain.baseName === 'createServerFn' && ts.isIdentifier(decl.name)) { const validatorCall = chain.calls['inputValidator'] ?? chain.calls['validator']; - const inputs = inputsFromValidator(validatorCall, ts, bindings); + const inputs = withCoordinates(inputsFromValidator(validatorCall, ts, bindings), 'server-fn-data'); const handlerFn = chain.calls['handler']?.arguments?.[0]; const sinks = sinksFrom(handlerFn, ts, localSinks, bindings, ctx); const handlerBody = handlerFn && isFnLike(handlerFn, ts) ? handlerFn.body : undefined; @@ -460,7 +511,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, name: decl.name.text, entryKind: 'server-fn', method: methodFromObjectArg(chain.baseCall, ts), - line: lineOf(decl), + ...spanOf(decl), inputs, sinks, flows: linkFlows(handlerBody, handlerFn?.parameters, inputs, sinks, ts), @@ -474,9 +525,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, ctx, { line: lineOf(decl) })); + out.push(handlerEntry(decl.name.text, decl.name.text, decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, spanOf(decl))); } else if (isServerActionsFile) { - out.push(handlerEntry(decl.name.text, 'server-action', decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, { line: lineOf(decl) })); + out.push(handlerEntry(decl.name.text, 'server-action', decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, spanOf(decl))); } } } @@ -485,9 +536,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, ctx, { line: lineOf(node) })); + out.push(handlerEntry(node.name.text, node.name.text, node.parameters, node.body, ts, localSinks, bindings, ctx, spanOf(node))); } else if (isServerActionsFile || hasUseServerDirective(node, ts)) { - out.push(handlerEntry(node.name.text, 'server-action', node.parameters, node.body, ts, localSinks, bindings, ctx, { line: lineOf(node) })); + out.push(handlerEntry(node.name.text, 'server-action', node.parameters, node.body, ts, localSinks, bindings, ctx, spanOf(node))); } } @@ -503,7 +554,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, 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) })); + out.push(handlerEntry(functionNameFromPath(ctx.file) ?? 'serve', 'edge-function', handler.parameters, handler.body, ts, localSinks, bindings, ctx, spanOf(node))); } } } @@ -522,7 +573,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map, // `use`/`all` register handlers but are not HTTP methods — leave method undefined. method: HTTP_METHODS.has(mname.toUpperCase()) ? mname.toUpperCase() : undefined, route, - line: lineOf(node), + ...spanOf(node), })); } } @@ -533,7 +584,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, ctx, { 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, ...spanOf(node) })); } } } @@ -602,7 +653,7 @@ function handlerEntry( localSinks: Map, bindings: Bindings, ctx: { file: string; graph: ModuleGraph }, - extra: { method?: string; route?: string; line?: number } = {}, + extra: { method?: string; route?: string; line?: number; start?: number; end?: number } = {}, ): Omit { const entryKind = kindLabel === 'route-registration' || kindLabel === 'server-action' || kindLabel === 'edge-function' ? kindLabel @@ -615,6 +666,8 @@ function handlerEntry( method: extra.method ?? (HTTP_METHODS.has(name) ? name : undefined), route: extra.route, line: extra.line, + start: extra.start, + end: extra.end, inputs, sinks, flows: linkFlows(body, params, inputs, sinks, ts), @@ -661,10 +714,14 @@ 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[] { - const fields = zodObjectFields(body, ts, bindings); + // A validated schema inside a handler describes the request body. + const fields = withCoordinates(zodObjectFields(body, ts, bindings), 'json-body'); const names = new Set(fields.map((f) => f.name)); - for (const n of requestMemberAccesses(params, body, ts)) { - if (!names.has(n)) { names.add(n); fields.push({ name: n }); } + for (const { name, source } of requestMemberAccesses(params, body, ts)) { + if (!names.has(name)) { + names.add(name); + fields.push({ name, source, ...runtimeCoordinate(source, name) }); + } } return fields; } @@ -744,9 +801,9 @@ 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): string[] { +function requestMemberAccesses(params: any, body: any, ts: TsModule): Array<{ name: string; source: InputSource }> { if (!body) return []; - const out = new Set(); + 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()`). @@ -773,23 +830,46 @@ function requestMemberAccesses(params: any, body: any, ts: TsModule): string[] { }; const visit = (n: any) => { // . - if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression)) out.add(n.name.text); + if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression)) { + out.set(n.name.text, sourceOfExpr(n.expression)); + } if (ts.isVariableDeclaration(n) && n.initializer) { const init = unwrap(n.initializer); // const b = await request.json() → b is a request-input object from here on. if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.add(n.name.text); // const { a, b } = | await request.json() if (ts.isObjectBindingPattern(n.name) && (isReqSourceExpr(init) || isBodyReadCall(n.initializer))) { + const src = isBodyReadCall(n.initializer) ? bodyReadSource(n.initializer) : sourceOfExpr(init); for (const el of n.name.elements) { const key = bindingKey(el, ts); - if (key) out.add(key); + if (key) out.set(key, src); } } } ts.forEachChild(n, visit); }; visit(body); - return [...out]; + return [...out].map(([name, source]) => ({ name, source })); + + // `req.body.x` / `req.query.x` / `req.params.x` — the namespace decides the runtime coordinate, and + // route params notably have NONE, so this distinction is load-bearing rather than cosmetic. + function sourceOfExpr(e: any): InputSource { + if (ts.isPropertyAccessExpression(e)) { + if (e.name.text === 'query') return 'query'; + if (e.name.text === 'params') return 'route-param'; + if (e.name.text === 'body') return 'body'; + } + if (ts.isIdentifier(e)) { + const key = [...sourceNames].includes(e.text) ? e.text : undefined; + if (key === 'query') return 'query'; + if (key === 'params') return 'route-param'; + } + return 'body'; + } + function bodyReadSource(init: any): InputSource { + const t = init?.getText?.() ?? ''; + return /formData\s*\(/.test(t) ? 'form-body' : 'json-body'; + } } function bindingKey(el: any, ts: TsModule): string | undefined { @@ -1151,7 +1231,26 @@ function linkFlows( // 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 + '.')); - flows.push({ input: input.name, sink, confidence: precise ? 'precise' : 'heuristic', line: sink.line }); + // Deliberately SEPARATE from confidence: `precise` means "the source reaches the sink", which is + // not authorization to block traffic. Every remaining obstacle is listed, so this doubles as the + // queue for improving the extractor/adapters rather than silently losing the opportunity. + const reasons: string[] = []; + if (!precise) reasons.push('flow evidence is heuristic, not precise'); + if (!input.runtimeParameter) reasons.push(input.runtimeParameterReason ?? 'input has no runtime parameter'); + if (sink.file !== undefined) reasons.push('sink is in an imported module: no local call-site evidence'); + if (sink.start === undefined) reasons.push('sink call could not be located in the source'); + // 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'); + flows.push({ + input: input.name, + sink, + confidence: precise ? 'precise' : 'heuristic', + line: sink.line, + ruleGeneratable: false, + ruleGeneratableReasons: reasons, + }); } } return flows; @@ -1243,6 +1342,12 @@ function pathFromTainted(node: any, ts: TsModule, rootPath: Map) if (!cur || !ts.isIdentifier(cur)) return undefined; const base = rootPath.get(cur.text); if (base === undefined) return undefined; + // Drop a leading NAMESPACE segment (`req.body.webhookUrl` → `webhookUrl`). Input names — and the + // runtime coordinates derived from them — are relative to their namespace (`post.webhookUrl`), so + // leaving `body.` in the read path would fail to match the very inputs it came from. Without this, + // the highest-value flows (`req.body.webhookUrl` → fetch, `req.body.command` → exec) never reach + // `precise`. + if (base === '' && segs.length > 1 && REQ_SOURCES.includes(segs[0]!)) segs.shift(); return normalizePath([base, ...segs].filter(Boolean).join('.')); } diff --git a/src/map/types.ts b/src/map/types.ts index 4d5cc30..5a45a0a 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -5,6 +5,12 @@ // Honesty is a first-class field: static analysis is best-effort, so `coverage` records what the // adapter could and couldn't see. Never present the map as "complete". +/** Where an input is read from — determines which runtime parameter namespace can address it. */ +export type InputSource = + | 'json-body' | 'form-body' | 'multipart' | 'body' + | 'query' | 'route-param' | 'header' | 'cookie' | 'file' + | 'server-fn-data' | 'unknown'; + export interface InputField { /** * Parameter / body-field name — the coordinate a rule pins to. Nested validator fields are @@ -21,6 +27,18 @@ export interface InputField { format?: string; /** Declared regex constraint (the regex literal's source text), when present. */ pattern?: string; + /** Where the value is read from. */ + source?: InputSource; + /** + * The EXACT rule-engine parameter that addresses this input (`post.shipping.email`, `get.q`, + * `server.HTTP_X_API_KEY`, `cookie.session`, `files.avatar`), or **null** when this input has no exact + * runtime representation — in which case `runtimeParameterReason` says why. A consumer must never + * synthesise a coordinate itself: an unaddressable input compiled into a rule produces a rule that + * silently never matches (e.g. an Express route param is NOT in `get.*`, and an array path needs an + * `array_key_value` rule rather than a dotted parameter). + */ + runtimeParameter?: string | null; + runtimeParameterReason?: string; } export interface Sink { @@ -73,6 +91,14 @@ export interface Endpoint { file: string; /** 1-based line of the entry-point declaration in `file`. */ line?: number; + /** UTF-16 offsets of the entry-point declaration in `file`. */ + start?: number; + end?: number; + /** + * Short content fingerprint (sha256 prefix) of `file` at analysis time. A server must treat this + * endpoint's spans/coordinates as STALE if the file no longer matches — deploys move code. + */ + fingerprint?: string; /** Inventory: inputs the handler reads. Presence here does NOT mean an input reaches a sink. */ inputs: InputField[]; /** Inventory: sinks reachable in the handler. Presence here does NOT mean an input flows into it. */ @@ -99,6 +125,13 @@ export interface Endpoint { * Consumers that pin a rule to a parameter should prefer `precise` flows and fall back to broad rules. */ export interface Flow { + /** + * 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 + * traffic. `ruleGeneratableReasons` lists what is missing, which doubles as the improvement queue. + */ + ruleGeneratable?: boolean; + ruleGeneratableReasons?: string[]; /** Input field name (dotted path), matching an entry in `Endpoint.inputs`. */ input: string; /** The sink reached. */ @@ -124,7 +157,13 @@ export interface Coverage { } export interface SiteInputMap { - version: 1; + /** + * Schema version of this document. 2 added: input `source` + `runtimeParameter`, sink/endpoint source + * spans, per-file `fingerprint`, and `ruleGeneratable` on flows. Spans are **UTF-16 code-unit offsets** + * (JavaScript string indices), not byte offsets; pair them with `fingerprint` so a server can reject + * stale coordinates after a deploy. + */ + version: 2; /** e.g. "tanstack-start". */ framework: string; endpoints: Endpoint[]; diff --git a/tests/map-extract-strict.test.ts b/tests/map-extract-strict.test.ts index ca60959..9baf3a4 100644 --- a/tests/map-extract-strict.test.ts +++ b/tests/map-extract-strict.test.ts @@ -207,11 +207,11 @@ describe('validator schemas: gating, nesting, formats', () => { it('extracts nested fields as dotted paths with constraints', () => { const e = ep((x) => x.name === 'saveProfile'); const byName = Object.fromEntries(e.inputs.map((i) => [i.name, i])); - expect(byName['email']).toEqual({ name: 'email', type: 'string', format: 'email' }); + expect(byName['email']).toMatchObject({ name: 'email', type: 'string', format: 'email' }); expect(byName['slug']).toMatchObject({ type: 'string', pattern: '/^[a-z0-9-]+$/' }); - expect(byName['offset']).toEqual({ name: 'offset', type: 'number', min: -10, max: 50 }); + expect(byName['offset']).toMatchObject({ name: 'offset', type: 'number', min: -10, max: 50 }); expect(byName['address']).toMatchObject({ type: 'object' }); - expect(byName['address.city']).toEqual({ name: 'address.city', type: 'string', min: 1 }); + expect(byName['address.city']).toMatchObject({ name: 'address.city', type: 'string', min: 1 }); expect(byName['tags']).toMatchObject({ type: 'array' }); expect(byName['tags[].label']).toMatchObject({ type: 'string' }); expect(e.inputsResolved).toBeUndefined(); diff --git a/tests/map-extract.test.ts b/tests/map-extract.test.ts index 843cabb..ea53f4a 100644 --- a/tests/map-extract.test.ts +++ b/tests/map-extract.test.ts @@ -113,7 +113,11 @@ describe('agnostic input-flow extractor', () => { // TanStack server fn: zod input + direct + helper-indirected sinks, each tagged with the package. expect(byName.createTask).toMatchObject({ entryKind: 'server-fn', method: 'POST' }); - expect(byName.createTask.inputs).toEqual([{ name: 'title', type: 'string', min: 1, max: 200 }]); + expect(byName.createTask.inputs).toEqual([ + // Includes the runtime coordinate: a server fn's validated args are delivered as the JSON body, + // so the engine addresses them with `post.`. + expect.objectContaining({ name: 'title', type: 'string', min: 1, max: 200, source: 'server-fn-data', runtimeParameter: 'post.title' }), + ]); expect(byName.createTask.sinks).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 'db', provider: 'sql', package: '@supabase/supabase-js', table: 'tasks', op: 'insert' }), diff --git a/tests/map-runtime-coordinates.test.ts b/tests/map-runtime-coordinates.test.ts new file mode 100644 index 0000000..998d223 --- /dev/null +++ b/tests/map-runtime-coordinates.test.ts @@ -0,0 +1,128 @@ +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'; + +// 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 +// that silently never matches, which is worse than emitting none. + +describe('runtimeCoordinate mapping', () => { + it.each([ + ['json-body', 'shipping.email', 'post.shipping.email'], + ['body', 'path', 'post.path'], + ['form-body', 'note', 'post.note'], + ['server-fn-data', 'title', 'post.title'], + ['query', 'q', 'get.q'], + ['cookie', 'session', 'cookie.session'], + ['file', 'avatar', 'files.avatar'], + ['header', 'x-api-key', 'server.HTTP_X_API_KEY'], + ] as const)('%s/%s → %s', (source, path, expected) => { + expect(runtimeCoordinate(source, path).runtimeParameter).toBe(expected); + }); + + it('refuses a coordinate for a route param — the resolver does not expose req.params', () => { + const r = runtimeCoordinate('route-param', 'tenant'); + expect(r.runtimeParameter).toBeNull(); + expect(r.runtimeParameterReason).toMatch(/route parameters are not exposed/i); + }); + + it('refuses a coordinate for an array path — that needs array_key_value, not a dotted parameter', () => { + const r = runtimeCoordinate('json-body', 'tags[].label'); + expect(r.runtimeParameter).toBeNull(); + expect(r.runtimeParameterReason).toMatch(/array_key_value/); + }); + + it('refuses a coordinate when the source is unknown', () => { + expect(runtimeCoordinate(undefined, 'x').runtimeParameter).toBeNull(); + }); +}); + +describe('coordinates on a real project', () => { + let dir: string; + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'ps-coord-')); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { express: '4' } })); + writeFileSync(join(dir, 'src', 's.ts'), ` + import express from "express"; + import fs from "node:fs"; + const app = express(); + app.post("/api/:tenant/files", (req, res) => { + fs.writeFileSync(req.body.path, req.query.data); + res.end(req.params.tenant); + }); + `); + }); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + it('labels each input with its source and the coordinate that addresses it', async () => { + const { map } = await buildInputMap(dir); + expect(map!.version).toBe(2); + const ep = map!.endpoints[0]!; + const by = Object.fromEntries(ep.inputs.map((i) => [i.name, i])); + expect(by.path).toMatchObject({ source: 'body', runtimeParameter: 'post.path' }); + expect(by.data).toMatchObject({ source: 'query', runtimeParameter: 'get.data' }); + // The safety case: a route param is reported, but WITHOUT a coordinate. + expect(by.tenant).toMatchObject({ source: 'route-param', runtimeParameter: null }); + expect(by.tenant.runtimeParameterReason).toBeTruthy(); + }); + + it('carries a content fingerprint so a server can reject stale coordinates after a deploy', async () => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints[0]!; + expect(ep.fingerprint).toMatch(/^[0-9a-f]{16}$/); + expect(typeof ep.start).toBe('number'); + }); + + 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'); + } + // A precise flow still must not be read as authorization to block. + const routeParamFlow = ep.flows.find((f) => f.input === 'tenant'); + expect(routeParamFlow?.ruleGeneratableReasons?.join(' ')).toMatch(/route parameters are not exposed/i); + }); +}); + +describe('high-signal candidate families reach precise', () => { + // These are the first candidate families a rule compiler would target (SSRF / traversal / command + // injection). They read straight off `req..` into the sink, and previously never + // reached `precise` because the namespace segment made the read path mismatch the input name. + let dir: string; + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'ps-fams-')); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { express: '4', axios: '1' } })); + writeFileSync(join(dir, '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("/api/fetch", async (req, res) => { await axios.get(req.body.webhookUrl); res.end(); }); + app.post("/api/read", (req, res) => { res.end(fs.readFileSync(req.body.filename)); }); + app.post("/api/run", (req, res) => { exec(req.body.command); res.end(); }); + app.get("/api/search", (req, res) => { res.end(fs.readFileSync(req.query.file)); }); + `); + }); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + it.each([ + ['/api/fetch', 'webhookUrl', 'http', 'post.webhookUrl'], + ['/api/read', 'filename', 'fs', 'post.filename'], + ['/api/run', 'command', 'exec', 'post.command'], + ['/api/search', 'file', 'fs', 'get.file'], + ])('%s: %s reaches the %s sink precisely with coordinate %s', async (route, input, kind, coord) => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints.find((e) => e.route === route)!; + expect(ep.inputs.find((i) => i.name === input)?.runtimeParameter).toBe(coord); + expect(ep.flows.some((f) => f.input === input && f.sink.kind === kind && f.confidence === 'precise')).toBe(true); + }); +});