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
13 changes: 13 additions & 0 deletions src/map/coordinates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ export function runtimeCoordinate(source: InputSource | undefined, path: string)
}
}

/**
* The rule-engine NAMESPACE an input lands in (`post`, `get`, `cookie`, `files`, `server`), or null when
* it has no address. Derived from `runtimeCoordinate` on purpose: comparing raw source labels would call
* `json-body` and an Express `req.body` read different places when both resolve to `post.*`, and would
* miss that `post.id` and `get.id` are genuinely different places.
*/
export function namespaceOf(source: InputSource | undefined, path: string): string | null {
const { runtimeParameter } = runtimeCoordinate(source, path);
if (!runtimeParameter) return null;
const dot = runtimeParameter.indexOf('.');
return dot === -1 ? runtimeParameter : runtimeParameter.slice(0, dot);
}

/** 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) }));
Expand Down
6 changes: 3 additions & 3 deletions src/map/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import type { Bindings } from './bindings.js';
import { functionNameFromPath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js';
import { withCoordinates } from './coordinates.js';
import { inputsFromHandler, inputsFromValidator } from './inputs.js';
import { sinksFrom, type ModuleGraph } from './sinks.js';
import { sinksFrom, type SinkContext } from './sinks.js';
import { linkedFlows } from './flows.js';

const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);

// --- entry-point recognizers -----------------------------------------------
export function extractFromFile(sf: any, ts: TsModule, localSinks: Map<string, Sink[]>, bindings: Bindings, ctx: { file: string; graph: ModuleGraph }): Omit<Endpoint, 'file'>[] {
export function extractFromFile(sf: any, ts: TsModule, localSinks: Map<string, Sink[]>, bindings: Bindings, ctx: SinkContext): Omit<Endpoint, 'file'>[] {
const out: Omit<Endpoint, 'file'>[] = [];
const isServerActionsFile = fileHasUseServer(sf, ts);

Expand Down Expand Up @@ -134,7 +134,7 @@ function handlerEntry(
ts: TsModule,
localSinks: Map<string, Sink[]>,
bindings: Bindings,
ctx: { file: string; graph: ModuleGraph },
ctx: SinkContext,
extra: { method?: string; route?: string; line?: number; start?: number; end?: number } = {},
): Omit<Endpoint, 'file'> {
const entryKind = kindLabel === 'route-registration' || kindLabel === 'server-action' || kindLabel === 'edge-function'
Expand Down
4 changes: 2 additions & 2 deletions src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file));
const bindings = buildModuleBindings(sf, ts);
const localSinks = collectLocalSinks(sf, ts, bindings);
for (const ep of extractFromFile(sf, ts, localSinks, bindings, { file, graph })) {
const relFile = relative(cwd, file);
const relFile = relative(cwd, file);
for (const ep of extractFromFile(sf, ts, localSinks, bindings, { file, owner: relFile, graph })) {
// A FILE-BASED route handler carries its URL path in its location, not in the code, so derive
// it here — without this a rule can only be param-pinned, never route-scoped (`when.path`).
if (ep.route === undefined && ep.entryKind === 'edge-function') {
Expand Down
10 changes: 10 additions & 0 deletions src/map/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,16 @@ 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');
// Only a receiver traced to a dependency ('import') or a genuine runtime global earns a rule.
// 'inferred' is deliberately NOT enough: the package came from some OTHER import in the file, not
// from the receiver, so `res.locals.db.query(x)` in a file that happens to import `pg` looks
// identical to a real pool — and `res.locals.db` may be any app object. Such sinks stay in the
// inventory for review; they just cannot compile a rule that blocks live traffic on a guess.
if (sink.attribution !== 'import' && sink.attribution !== 'global') {
reasons.push(sink.attribution === 'inferred'
? `sink package "${sink.package}" was inferred from the file's other imports, not from the receiver (${sink.kind}.${sink.op ?? '?'}): the receiver may be any app object`
: `sink receiver could not be traced to a dependency (${sink.kind}.${sink.op ?? '?'} on an unresolved receiver): a rule here would be a guess`);
}
if (precise && argumentRole === 'unknown') reasons.push(`sink argument role is not modelled for ${sink.kind}.${sink.op ?? '?'}`);
// A dynamic key or a spread in this sink's arguments means no coordinate can name the field that
// actually reaches it — report the specific cause rather than a generic "heuristic".
Expand Down
75 changes: 63 additions & 12 deletions src/map/inputs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { InputField, InputSource, TsModule } from './types.js';
import { bindingKey, rootIdentifier } from './ast.js';
import { npmPackageOf, type Bindings } from './bindings.js';
import { runtimeCoordinate, withCoordinates } from './coordinates.js';
import { namespaceOf, runtimeCoordinate, withCoordinates } from './coordinates.js';

const ZOD_BASE = new Set(['string', 'number', 'boolean', 'array', 'object', 'enum', 'bigint', 'date', 'record']);
// String-format refinements a validator can declare — kept on the field so a rule can pin the shape.
Expand All @@ -26,13 +26,45 @@ export function inputsFromHandler(
): 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, opts)) {
if (!names.has(name)) {
names.add(name);
fields.push({ name, source, ...runtimeCoordinate(source, name) });
const schemaSource = opts.validatorSource ?? 'json-body';
const schemaFields = zodObjectFields(body, ts, bindings);
const reads = requestMemberAccesses(params, body, ts, opts);

// ONE collision pass over both origins. A schema field and a request read can share a name while
// addressing different places — `z.object({ id }).parse(req.body)` next to `fs.read(req.query.id)`
// used to yield the schema's `post.id` while the sink actually consumed `get.id`, so a rule inspected
// a parameter the payload never travels in. Grouping by name is what makes that invisible, so the
// grouping now carries every source, and the verdict compares effective NAMESPACES: `json-body` and
// an Express `req.body` read are both `post.*` (not a conflict), `post.id` vs `get.id` is.
const sourcesByName = new Map<string, InputSource[]>();
const add = (name: string, source: InputSource) => {
const list = sourcesByName.get(name) ?? [];
if (!list.includes(source)) list.push(source);
sourcesByName.set(name, list);
};
for (const f of schemaFields) add(f.name, f.source ?? schemaSource);
for (const r of reads) for (const s of r.sources) add(r.name, s);

const coordFor = (name: string, primary: InputSource) => {
const sources = sourcesByName.get(name) ?? [primary];
if (new Set(sources.map((s) => namespaceOf(s, name))).size > 1) {
return {
runtimeParameter: null,
runtimeParameterReason: `field is read from more than one request namespace (${sources.join(', ')}): no single parameter addresses it`,
};
}
return runtimeCoordinate(primary, name);
};

const fields: InputField[] = withCoordinates(schemaFields, schemaSource)
.map((f) => ({ ...f, ...coordFor(f.name, f.source ?? schemaSource) }));
const names = new Set(fields.map((f) => f.name));
for (const { name, sources } of reads) {
if (names.has(name)) continue;
const primary = sources[0]; // first-seen, so the reported source is deterministic
if (!primary) continue;
names.add(name);
fields.push({ name, source: primary, ...coordFor(name, primary) });
}
return fields;
}
Expand Down Expand Up @@ -117,9 +149,19 @@ function requestMemberAccesses(
body: any,
ts: TsModule,
opts: { payloadParam?: boolean } = {},
): Array<{ name: string; source: InputSource }> {
): Array<{ name: string; sources: InputSource[] }> {
if (!body) return [];
const out = new Map<string, InputSource>();
// Keyed by FIELD NAME, which two namespaces can share (`params.id` and `query.id` in one handler).
// Last-write-wins silently picked one, and since the pick decided the coordinate, a handler reading
// both compiled a rule pinned to `get.id` for data that arrives in the path segment — a wrong-input
// pin, the one failure this whole layer exists to prevent. Collisions are now recorded, and the
// first-seen source wins so the record is at least deterministic.
const out = new Map<string, InputSource[]>();
const record = (name: string, source: InputSource) => {
const list = out.get(name) ?? [];
if (!list.includes(source)) list.push(source);
out.set(name, list);
};
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()`),
Expand Down Expand Up @@ -157,25 +199,34 @@ function requestMemberAccesses(
const visit = (n: any) => {
// <source>.<field>
if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression)) {
out.set(n.name.text, sourceOfExpr(n.expression));
record(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.set(n.name.text, bodyReadSource(n.initializer));
// const { query: q } = req → the SAME namespace capture as a destructured handler param, just one
// statement later. Without this the fields read off `q` are invisible: no coordinate is emitted
// (so nothing is mis-addressed) but the surface goes unreported, which reads as "nothing here".
if (ts.isObjectBindingPattern(n.name) && ts.isIdentifier(init) && reqName && init.text === reqName) {
for (const el of n.name.elements) {
const key = bindingKey(el, ts);
if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) sourceNames.set(el.name.text, namespaceSource(key));
}
}
// const { a, b } = <source> | 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.set(key, src);
if (key) record(key, src);
}
}
}
ts.forEachChild(n, visit);
};
visit(body);
return [...out].map(([name, source]) => ({ name, source }));
return [...out].map(([name, sources]) => ({ name, sources }));

// `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.
Expand Down
Loading
Loading