Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 121 additions & 16 deletions src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) });
Expand Down Expand Up @@ -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<string, InputSource>();
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<string>();
const payloadNames = new Set<string>();
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);
Expand All @@ -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 => {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -1215,22 +1237,39 @@ function linkFlows(
const node = sink.file === undefined && sink.start !== undefined && sink.end !== undefined
? callBySpan.get(`${sink.start}:${sink.end}`)
: undefined;
const reads = new Set<string>();
// 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<string, Set<ArgumentRole>>();
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<ArgumentRole>();
set.add(role);
reads.set(path, set);
}
}
}
}
for (const input of inputs) {
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<ArgumentRole>(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.
Expand All @@ -1239,16 +1278,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,
});
}
Expand All @@ -1261,6 +1304,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<string, Record<string, ArgumentRole[]>> = {
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<string, Partial<Record<ArgumentRole, CandidateFamily>>> = {
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
Expand Down
24 changes: 24 additions & 0 deletions src/map/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions tests/map/argument-roles.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
Loading
Loading