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
148 changes: 148 additions & 0 deletions src/map/ast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { TsModule } from './types.js';

// Dependency-free AST helpers shared by every recognizer in this directory.

// Leftmost identifier of a member/call chain (`supabase.from(x).insert` → "supabase", `fs.writeFile` → "fs").
export function rootIdentifier(node: any, ts: TsModule): string | undefined {
let cur = node;
while (cur) {
if (ts.isIdentifier(cur)) return cur.text;
if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) || ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) {
cur = cur.expression;
} else return undefined;
}
return undefined;
}

// Source span of a node: the auditable coordinate, AND the sink's identity for flow analysis (a line is
// not an identity — two sinks can share one, and an enclosing statement can hold unrelated expressions).
export function spanOf(node: any): { line?: number; start?: number; end?: number } {
const out: { line?: number; start?: number; end?: number } = { line: lineOf(node) };
try { out.start = node.getStart(); out.end = node.getEnd(); } catch { /* synthetic node */ }
return out;
}

// 1-based line of a node in its source file — the auditable coordinate rules and humans point at.
export function lineOf(node: any): number | undefined {
const sf = typeof node?.getSourceFile === 'function' ? node.getSourceFile() : undefined;
if (!sf) return undefined;
try { return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; } catch { return undefined; }
}

export function guessScriptKind(ts: TsModule, file: string) {
if (file.endsWith('.tsx')) return ts.ScriptKind.TSX;
if (file.endsWith('.jsx')) return ts.ScriptKind.JSX;
if (file.endsWith('.js')) return ts.ScriptKind.JS;
return ts.ScriptKind.TS;
}

export function isFnLike(n: any, ts: TsModule): n is import('typescript').ArrowFunction | import('typescript').FunctionExpression {
return ts.isArrowFunction(n) || ts.isFunctionExpression(n);
}
export function hasExport(node: any, ts: TsModule): boolean {
return Boolean(node.modifiers?.some((m: any) => m.kind === ts.SyntaxKind.ExportKeyword));
}

// --- call-chain + method ----------------------------------------------------
export function unwindChain(node: any, ts: TsModule): { baseName?: string; baseCall?: any; calls: Record<string, any> } {
const calls: Record<string, any> = {};
let cur = node;
while (cur && ts.isCallExpression(cur)) {
const callee = cur.expression;
if (ts.isPropertyAccessExpression(callee)) { calls[callee.name.text] = cur; cur = callee.expression; }
else if (ts.isIdentifier(callee)) return { baseName: callee.text, baseCall: cur, calls };
else break;
}
return { calls };
}

export function methodFromObjectArg(baseCall: any, ts: TsModule): string | undefined {
const arg = baseCall?.arguments?.[0];
if (!arg || !ts.isObjectLiteralExpression(arg)) return undefined;
for (const p of arg.properties) {
if (ts.isPropertyAssignment(p) && (p.name as any)?.text === 'method' && ts.isStringLiteralLike(p.initializer)) {
return p.initializer.text.toUpperCase();
}
}
return undefined;
}

export function bindingKey(el: any, ts: TsModule): string | undefined {
if (!ts.isBindingElement(el)) return undefined;
const prop = el.propertyName ?? el.name;
return prop && ts.isIdentifier(prop) ? prop.text : undefined;
}

// A named function *declaration*, or a function bound to a variable/property — i.e. code that only runs
// if something calls it. An inline callback (an arrow passed as an argument), an IIFE, or a function
// used directly in an expression is NOT this: those execute where they appear.
export function isUninvokedFunctionDeclaration(n: any, ts: TsModule): boolean {
if (ts.isFunctionDeclaration(n)) return true;
if (isFnLike(n, ts)) {
const p = n.parent;
if (p && (ts.isVariableDeclaration(p) || ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p))) return true;
}
return false;
}

/**
* Is `name` bound by an enclosing function parameter (or catch clause) at this call site? If so the call
* is NOT the global of that name — a callback parameter called `fetch` is the single most likely way to
* fake an SSRF candidate. Scoped to parameters/catch bindings: cheap, and it covers the shadowing shapes
* that occur in practice. Erring here loses a candidate rather than inventing one.
*/
export function isShadowedByEnclosingBinding(node: any, name: string, ts: TsModule): boolean {
for (let cur = node?.parent; cur; cur = cur.parent) {
if (ts.isCatchClause(cur) && cur.variableDeclaration && ts.isIdentifier(cur.variableDeclaration.name)
&& cur.variableDeclaration.name.text === name) return true;
const params = (cur as any).parameters;
if (!params) continue;
for (const p of params) {
if (!p?.name) continue;
if (ts.isIdentifier(p.name) && p.name.text === name) return true;
if (ts.isObjectBindingPattern(p.name) || ts.isArrayBindingPattern(p.name)) {
for (const el of p.name.elements) {
if (ts.isBindingElement(el) && ts.isIdentifier(el.name) && el.name.text === name) return true;
}
}
}
}
return false;
}

/** Method name a call invokes (`db.from(t).insert(x)` → "insert", `exec(x)` → "exec"). */
export 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; // also covers `new Function(...)`
return undefined;
}

/** Is this identifier occurrence a VALUE read (rather than a property key, a member name, a binding)? */
export 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
}

// From a `.insert` property access, the CallExpression that invokes it — the sink's operation call.
export function opCallOf(propAccess: any, ts: TsModule): any {
const p = propAccess?.parent;
return p && ts.isCallExpression(p) && p.expression === propAccess ? p : propAccess;
}

export function localCalls(node: any, ts: TsModule): string[] {
const names: string[] = [];
const visit = (n: any) => {
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) names.push(n.expression.text);
ts.forEachChild(n, visit);
};
visit(node);
return names;
}
140 changes: 140 additions & 0 deletions src/map/bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { builtinModules } from 'node:module';
import type { TsModule } from './types.js';
import { isFnLike, isUninvokedFunctionDeclaration, rootIdentifier } from './ast.js';

const BUILTINS = new Set(builtinModules);

// Per-file module bindings: resolve a local identifier to the npm package (or node: builtin) it came
// from — directly (an import), or via `const x = <importedFn|new ImportedClass>(...)` /
// `const x = require('mod')`; derived names resolve transitively (`const conn = pool.promise()`).
// `imports` is every module specifier the file imports (for the fallback). `locals` is every name
// declared in-file that does NOT trace to a module — calls on those receivers are not dependency
// sinks. (Names assigned outside their declaration, e.g. `let fs; fs = require('fs')`, are treated
// as local — an accepted miss.)
export 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<string>;
locals: Set<string>;
}
export function buildModuleBindings(sf: any, ts: TsModule): Bindings {
const nameToModule = new Map<string, string>(); // local name → module specifier
const declared = new Set<string>(); // every name declared in this file
const exportNames = new Map<string, string>(); // local alias → exported name
const imports = new Set<string>();

const record = (local: string, mod: string) => { nameToModule.set(local, mod); imports.add(mod); };
const declareBound = (nameNode: any) => {
if (ts.isIdentifier(nameNode)) declared.add(nameNode.text);
else if (ts.isObjectBindingPattern(nameNode) || ts.isArrayBindingPattern(nameNode)) {
for (const el of nameNode.elements) if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) declared.add(el.name.text);
}
};

const visit = (node: any) => {
// import … from 'mod'
if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
const mod = node.moduleSpecifier.text;
imports.add(mod);
const clause = node.importClause;
if (clause?.name) record(clause.name.text, mod); // default
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);
// `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);
if (ts.isClassDeclaration(node) && node.name) declared.add(node.name.text);
// const x = require('mod') / const { a } = require('mod')
if (ts.isVariableStatement(node)) {
for (const decl of node.declarationList.declarations) {
declareBound(decl.name);
const init = decl.initializer;
const reqMod = requireSpecifier(init, ts);
if (reqMod) {
if (ts.isIdentifier(decl.name)) record(decl.name.text, reqMod);
else if (ts.isObjectBindingPattern(decl.name)) for (const el of decl.name.elements) if (ts.isIdentifier(el.name)) record(el.name.text, reqMod);
}
// const x = tracedFactory(...) / const x = new TracedClass(...) → x carries that package.
// Looking up nameToModule (not just direct imports) makes this transitive: pool → conn → ….
// Recorded as pending too, so a factory resolved LATER (a local wrapper, below) still binds x.
if (init && ts.isIdentifier(decl.name)) {
const callee = ts.isCallExpression(init) ? init.expression : ts.isNewExpression(init) ? init.expression : undefined;
const root = callee ? rootIdentifier(callee, ts) : undefined;
if (root) {
pending.push([decl.name.text, root]);
if (nameToModule.has(root)) record(decl.name.text, nameToModule.get(root)!);
}
}
}
}
// A LOCAL factory that hands back a dependency object: `function getClient() { return createClient(…) }`.
// Without this, `const supabase = getClient()` looks like a plain local and every sink on it is
// dropped as "not a dependency" — silently losing the real client (the common AI-generated shape).
if ((ts.isFunctionDeclaration(node) || isFnLike(node, ts)) && node.body) {
const fnName = ts.isFunctionDeclaration(node) && node.name
? node.name.text
: ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)
? node.parent.name.text
: undefined;
if (fnName) {
const root = returnedRootIdentifier(node.body, ts);
if (root) pending.push([fnName, root]);
}
}
ts.forEachChild(node, visit);
};
const pending: Array<[string, string]> = []; // [localName, rootIdentifierItCameFrom]
visit(sf);
// Fixpoint: resolve chains like createClient → getClient → supabase (bounded; order-independent).
for (let i = 0; i < 4; i++) {
let changed = false;
for (const [name, root] of pending) {
if (!nameToModule.has(name) && nameToModule.has(root)) { record(name, nameToModule.get(root)!); changed = true; }
}
if (!changed) break;
}
const locals = new Set([...declared].filter((n) => !nameToModule.has(n)));
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
// following a local factory to the dependency it wraps. Concise arrow bodies are the expression itself.
function returnedRootIdentifier(body: any, ts: TsModule): string | undefined {
if (!ts.isBlock(body)) return rootIdentifier(body, ts); // concise arrow body
let found: string | undefined;
const visit = (n: any) => {
if (found) return;
if (isUninvokedFunctionDeclaration(n, ts) && n !== body) return; // don't read a nested fn's return
if (ts.isReturnStatement(n) && n.expression) { found = rootIdentifier(n.expression, ts); return; }
ts.forEachChild(n, visit);
};
visit(body);
return found;
}

function requireSpecifier(init: any, ts: TsModule): string | undefined {
if (init && ts.isCallExpression(init) && ts.isIdentifier(init.expression) && init.expression.text === 'require') {
const a = init.arguments[0];
if (a && ts.isStringLiteralLike(a)) return a.text;
}
return undefined;
}

// Normalize a module specifier to its npm package root (keep scope, drop subpath). Node builtins are
// normalized to the `node:` form even when imported bare (`import fs from 'fs'`) — there IS an npm
// package named `fs`, and CVE correlation must never confuse the two. Relative paths → undefined.
export function npmPackageOf(spec: string | undefined): string | undefined {
if (!spec) return undefined;
if (spec.startsWith('.') || spec.startsWith('/')) return undefined;
if (spec.startsWith('node:')) return spec;
if (BUILTINS.has(spec)) return `node:${spec}`;
const parts = spec.split('/');
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
}
46 changes: 46 additions & 0 deletions src/map/coordinates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { InputField, InputSource } from './types.js';

/**
* 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.<dotted path>` (createServerFnGuard feeds
* server-function arguments through as the JSON body, so `post.<field>` resolves them)
* - query → `get.<name>`
* - header → `server.HTTP_<UPPER_SNAKE>` (resolver lowercases and maps `_` → `-`)
* - cookie → `cookie.<name>`
* - file → `files.<field>` (`.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. */
export function withCoordinates(fields: InputField[], source: InputSource): InputField[] {
return fields.map((f) => ({ ...f, source: f.source ?? source, ...runtimeCoordinate(f.source ?? source, f.name) }));
}
Loading
Loading