diff --git a/src/cli.ts b/src/cli.ts
index bc74d4d..d26675a 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -37,6 +37,7 @@ import {
} from './guide.js';
import { runProtect, runVerify } from './protect/install/index.js';
import { buildInputMap } from './map/index.js';
+import { isProvenFlow } from './map/coordinates.js';
import { setupProtection, wireBuildScripts } from './setup.js';
import { detectStack, type StackDescriptor } from './stack.js';
import { PatchstackError } from './types.js';
@@ -59,8 +60,8 @@ Usage:
Never runs the project build
patchstack-connect map [--dir
] [--out ] Map the app's attack surface: entry points, the
inputs each reads, the sinks it can reach, and
- evidence-backed input→sink flows (each marked
- precise or heuristic). Best-effort static
+ evidence-backed input→sink flows (each labelled
+ with how the link was established). Best-effort static
analysis — reports the DETECTED surface, with
coverage counters. Prints JSON (--out writes a
file; --follow-symlinks leaves the project dir).
@@ -211,15 +212,15 @@ async function runMap(args: ParsedArgs): Promise {
console.error(`patchstack: ${error}`);
return 1;
}
- // Human summary → stderr; the JSON → stdout (so it can be piped / written). Report PRECISE flows
- // separately from the inventories: only a precise flow is evidence that an input reaches a sink.
+ // Human summary → stderr; the JSON → stdout (so it can be piped / written). Report PROVEN flows
+ // separately from the inventories: only a proven tier is evidence that an input reaches a sink.
const inputs = map.endpoints.reduce((n, e) => n + e.inputs.length, 0);
const sinks = map.endpoints.reduce((n, e) => n + e.sinks.length, 0);
- const precise = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => f.confidence === 'precise').length, 0);
+ const proven = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => isProvenFlow(f.confidence)).length, 0);
const c = map.coverage;
console.error(
`patchstack: ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s), ` +
- `${precise} proven input→sink flow(s) [${map.framework}].`,
+ `${proven} proven input→sink flow(s) [${map.framework}].`,
);
console.error(
// All three buckets, explicitly: "6/66 parsed" reads as "91% unanalysed" when the other 60 files
diff --git a/src/map/coordinates.ts b/src/map/coordinates.ts
index 844c8e1..b0d5ef7 100644
--- a/src/map/coordinates.ts
+++ b/src/map/coordinates.ts
@@ -1,4 +1,4 @@
-import type { InputField, InputSource } from './types.js';
+import type { AddressSpace, FieldShape, InputField, InputSource } from './types.js';
/**
* Map an input to the EXACT rule-engine parameter that addresses it, or null with a reason. Verified
@@ -40,6 +40,42 @@ export function runtimeCoordinate(source: InputSource | undefined, path: string)
}
}
+/**
+ * The request region an input lives in — its identity, independent of whether we can currently ADDRESS
+ * it. A route param has a space (`route-param`) but no coordinate; an array path has a space (`post`)
+ * but needs an `array_key_value` rule. Keep those two questions apart: conflating them made an
+ * unaddressable input indistinguishable from one in another region.
+ */
+export function addressSpaceOf(source: InputSource | undefined): AddressSpace {
+ switch (source) {
+ case 'json-body':
+ case 'form-body':
+ case 'multipart':
+ case 'body':
+ case 'server-fn-data':
+ return 'post';
+ case 'query': return 'get';
+ case 'cookie': return 'cookie';
+ case 'file': return 'files';
+ case 'header': return 'server';
+ case 'route-param': return 'route-param';
+ default: return 'unknown';
+ }
+}
+
+/**
+ * Is this flow backed by a read seen at the sink's own call site? True for the two `*-local` tiers.
+ * `imported` / `heuristic` / `unknown` all mean "we did not see the argument", for different reasons.
+ */
+export function isProvenFlow(confidence: string | undefined): boolean {
+ return confidence === 'exact-local' || confidence === 'transformed-local';
+}
+
+/** `:` — an input's identity within an endpoint. */
+export function inputIdOf(source: InputSource | undefined, path: string): string {
+ return `${addressSpaceOf(source)}:${path}`;
+}
+
/**
* 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
@@ -53,7 +89,10 @@ export function namespaceOf(source: InputSource | undefined, path: string): stri
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) }));
+/** Place extracted fields in a request region: attach `source`, the runtime coordinate, and the id. */
+export function withCoordinates(fields: FieldShape[], source: InputSource): InputField[] {
+ return fields.map((f) => {
+ const src = f.source ?? source;
+ return { ...f, source: src, id: inputIdOf(src, f.name), ...runtimeCoordinate(src, f.name) };
+ });
}
diff --git a/src/map/extract.ts b/src/map/extract.ts
index a6731a1..43e483c 100644
--- a/src/map/extract.ts
+++ b/src/map/extract.ts
@@ -8,6 +8,7 @@ import { collectSources, detectFramework, hasEntrySignal, type WalkStats } from
import { functionNameFromPath, routeFromFilePath } from './routes.js';
import { collectLocalSinks } from './sinks.js';
import { createModuleGraph } from './module-graph.js';
+import { isProvenFlow } from './coordinates.js';
import { extractFromFile } from './entries.js';
// Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS
@@ -77,9 +78,9 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
}
notes.push('Static analysis is best-effort — this is the DETECTED surface, not a proof of completeness.');
- notes.push('`inputs` and `sinks` are INVENTORIES (both present in the handler). Only `flows` asserts that an input reaches a sink — prefer flows with confidence "precise" when pinning a rule to a parameter.');
+ notes.push('`inputs` and `sinks` are INVENTORIES (both present in the handler). Only `flows` asserts that an input reaches a sink: require confidence "exact-local" or "transformed-local" before pinning a rule, and identify the input by `inputId` — a field NAME can occur in more than one request namespace.');
notes.push('Sinks are followed into same-file helpers and ONE hop into an imported relative module (a dependency\u2019s internals are not followed); deeper or dynamic indirection is not traced. Sinks inside declared-but-uncalled local functions are excluded.');
- notes.push('A sink `package` is resolved from the file’s imports (precise) or inferred from a known provider import; an unresolved package means the backing dependency could not be traced.');
+ notes.push('A sink `package` is resolved from the file’s imports (`attribution: "import"`) or inferred from another import in the same file (`"inferred"`); an inferred package is a hint for a reviewer, not evidence about the receiver, and never licenses a rule.');
if (!options.followSymlinks) notes.push('Symlinks leaving the project directory were not followed (use --follow-symlinks to include them).');
if (failed.length > 0) {
const sample = failed.slice(0, 5).join(', ');
@@ -89,14 +90,14 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
if (unresolved > 0) {
notes.push(`${unresolved} endpoint(s) declare an input validator that could not be statically parsed — their inputs are UNKNOWN, not empty (marked inputsResolved: false).`);
}
- const heuristicOnly = endpoints.filter((e) => e.sinks.length > 0 && e.inputs.length > 0 && !e.flows.some((f) => f.confidence === 'precise')).length;
+ const heuristicOnly = endpoints.filter((e) => e.sinks.length > 0 && e.inputs.length > 0 && !e.flows.some((f) => isProvenFlow(f.confidence))).length;
if (heuristicOnly > 0) {
- notes.push(`${heuristicOnly} endpoint(s) have inputs and sinks but no PRECISE data link — their flows are "heuristic" (may reach), not proven.`);
+ notes.push(`${heuristicOnly} endpoint(s) have inputs and sinks but no proven data link — their flows say "may reach", not "does reach" (see each flow's confidence).`);
}
if (endpoints.length === 0) notes.push('No recognized server-side entry points found under the analyzed roots.');
return {
- version: 2,
+ version: 3,
framework: detectFramework(cwd),
endpoints,
coverage: {
diff --git a/src/map/flows.ts b/src/map/flows.ts
index 3fbaaba..f64ce42 100644
--- a/src/map/flows.ts
+++ b/src/map/flows.ts
@@ -1,16 +1,32 @@
-import type { ArgumentRole, Flow, InputField, Limitation, Sink, TsModule } from './types.js';
+import type { AddressSpace, ArgumentRole, Flow, InputField, Limitation, Sink, TsModule } from './types.js';
import { bindingKey, calleeName, isValueRead, lineOf, rootIdentifier } from './ast.js';
import { REQ_SOURCES } from './inputs.js';
+import { addressSpaceOf } from './coordinates.js';
import { argumentRoleOf, CANDIDATE_FAMILIES } from './sinks.js';
+/** A tainted binding: the path prefix it stands for, and the request region it came from if known. */
+interface Root { path: string; space?: AddressSpace }
+
+/** The address space a request-namespace binding key implies (`query` → get, `params` → route-param). */
+function spaceOfKey(key: string | undefined): AddressSpace | undefined {
+ if (key === 'query') return 'get';
+ if (key === 'params') return 'route-param';
+ if (key === 'body') return 'post';
+ // `data` / `input` / `payload`: a server-function argument, which the guard feeds through as the body.
+ if (key === 'data' || key === 'input' || key === 'payload') return 'post';
+ return undefined;
+}
+
// --- input → sink flow linking ---------------------------------------------
// Evidence-backed data links: for each sink, does an INPUT identifier/path appear inside the sink
// call's arguments? "Tainted" roots are the handler's own parameter names (`{ data }`, `req`) plus any
// local alias of them (`const body = await request.json()`, `const { title } = data`).
//
-// Deliberately conservative: a match yields `precise`; no match yields `heuristic` (the input and sink
-// merely co-occur). It never claims a flow it didn't see, which is the point — a consumer pinning a
-// rule to a parameter should trust `precise` and treat `heuristic` as "may reach".
+// Deliberately conservative: a match yields one of the two `*-local` tiers; no match yields `imported` /
+// `heuristic` / `unknown` depending on WHY nothing was seen. It never claims a flow it didn't see, which
+// is the point — a consumer pinning a rule should require a proven tier (see `isProvenFlow`) and treat
+// the rest as "may reach". Matching is per (address space, path): a read of `query.id` is not evidence
+// about the body field `id`.
// Spread onto an endpoint: `flows`, plus `limitations` only when there are any (keeps the common case clean).
export function linkedFlows(body: any, params: any, inputs: InputField[], sinks: Sink[], ts: TsModule): { flows: Flow[]; limitations?: Limitation[] } {
const { flows, limitations } = linkFlows(body, params, inputs, sinks, ts);
@@ -33,8 +49,14 @@ function linkFlows(
// (`{ body }`), and the validated-payload conventions of the server-fn frameworks (`{ data }` for
// TanStack). Getting this wrong shifts every path by one segment and silently kills all matching.
const CONTAINER_KEYS = new Set([...REQ_SOURCES, 'data', 'input', 'payload']);
- const rootPath = new Map();
- const addRoot = (name: string, path: string) => { if (!rootPath.has(name)) rootPath.set(name, path); };
+ // Each tainted root also carries the ADDRESS SPACE it was bound from, when that is known. A bare `req`
+ // has none — its next segment decides (`req.query.x` vs `req.body.x`) — but `({ query: q })` fixes `q`
+ // in `get` for good. Without this the space was dropped along with the namespace segment, so a read of
+ // `query.id` was indistinguishable from a read of `body.id` and could match either input.
+ const rootPath = new Map();
+ const addRoot = (name: string, path: string, space?: AddressSpace) => {
+ if (!rootPath.has(name)) rootPath.set(name, { path, space });
+ };
for (const p of params ?? []) {
if (!p?.name) continue;
if (ts.isIdentifier(p.name)) addRoot(p.name.text, '');
@@ -43,7 +65,8 @@ function linkFlows(
if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
const key = bindingKey(el, ts);
// A destructured request source (`{ body }`) is a container: its members ARE the paths.
- addRoot(el.name.text, key && CONTAINER_KEYS.has(key) ? '' : key ?? el.name.text);
+ const container = key !== undefined && CONTAINER_KEYS.has(key);
+ addRoot(el.name.text, container ? '' : key ?? el.name.text, container ? spaceOfKey(key) : undefined);
}
}
}
@@ -51,7 +74,7 @@ function linkFlows(
// Does this initializer carry request data? Includes `Schema.parse(await req.json())`: VALIDATION IS
// NOT SANITIZATION — a validated value is still attacker-controlled, and treating it as clean would
// silently drop every flow in a validated handler (the common TanStack/Next shape).
- const requestReadPath = (init: any): string | undefined => {
+ const requestReadPath = (init: any): Root | undefined => {
let cur = init;
while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression;
if (!cur) return undefined;
@@ -59,7 +82,8 @@ function linkFlows(
const m = cur.expression.name.text;
if (['json', 'formData', 'text'].includes(m)) {
const root = rootIdentifier(cur.expression.expression, ts);
- return root && rootPath.has(root) ? '' : undefined;
+ // A body read: whatever the field names turn out to be, they are addressed in `post`.
+ return root && rootPath.has(root) ? { path: '', space: 'post' } : undefined;
}
if (['parse', 'safeParse', 'validate', 'cast'].includes(m)) {
for (const a of cur.arguments) {
@@ -77,12 +101,15 @@ function linkFlows(
if (ts.isVariableDeclaration(n) && n.initializer) {
const base = requestReadPath(n.initializer);
if (base !== undefined) {
- if (ts.isIdentifier(n.name)) addRoot(n.name.text, base);
+ if (ts.isIdentifier(n.name)) addRoot(n.name.text, base.path, base.space);
else if (ts.isObjectBindingPattern(n.name)) {
for (const el of n.name.elements) {
if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
const key = bindingKey(el, ts);
- addRoot(el.name.text, join2(base, key ?? el.name.text));
+ // `const { query: q } = req` — the binding KEY names the space when the base has none yet.
+ const space = base.space ?? (base.path === '' ? spaceOfKey(key) : undefined);
+ const container = base.path === '' && key !== undefined && CONTAINER_KEYS.has(key);
+ addRoot(el.name.text, container ? '' : join2(base.path, key ?? el.name.text), space);
}
}
}
@@ -108,13 +135,14 @@ function linkFlows(
const flows: Flow[] = [];
const allLimits: Limitation[] = [];
for (const sink of sinks) {
- // A sink from an imported module has no call site here — never claim precise for it.
+ // A sink from an imported module has no call site here — its flows can only be `imported`.
const node = sink.file === undefined && sink.start !== undefined && sink.end !== undefined
? callBySpan.get(`${sink.start}:${sink.end}`)
: undefined;
// 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>();
+ // Keyed by `:` so a read of `query.id` cannot lend its evidence to the body field `id`.
+ const reads = new Map; exact: boolean }>();
const sinkLimits: Limitation[] = [];
if (node) {
// ONLY this sink call's own arguments, plus other calls in the SAME fluent chain
@@ -126,32 +154,55 @@ function linkFlows(
for (const a of args) for (const l of sinkArgumentLimitations(a, ts, rootPath)) sinkLimits.push(l);
for (let i = 0; i < args.length; i++) {
const role = argumentRoleOf(sink.kind, method, i, args.length);
- for (const path of taintedReadPaths(args[i], ts, rootPath)) {
- const set = reads.get(path) ?? new Set();
- set.add(role);
- reads.set(path, set);
+ // Is the ARGUMENT ITSELF the read (`readFileSync(req.body.p)`), or does the read sit inside a
+ // larger expression (`readFileSync('/tmp/' + req.body.p)`)? Both mean the value arrives in the
+ // same parameter, but only the first says what reaches the sink is exactly what arrived — the
+ // distinction a server needs before promoting a rule to blocking without a human.
+ const whole = pathFromTainted(args[i], ts, rootPath);
+ for (const read of taintedReadPaths(args[i], ts, rootPath)) {
+ const key = `${read.space ?? '*'}:${read.path}`;
+ const exact = whole !== undefined && whole.path === read.path && whole.space === read.space;
+ const entry = reads.get(key) ?? { read, roles: new Set(), exact: false };
+ entry.roles.add(role);
+ entry.exact = entry.exact || exact;
+ reads.set(key, entry);
}
}
}
}
for (const input of inputs) {
const inputPath = normalizePath(input.name);
+ const inputSpace = addressSpaceOf(input.source);
// 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 matched = [...reads.entries()].filter(([r]) => r === inputPath || r.startsWith(inputPath + '.'));
- const precise = matched.length > 0;
- const roles = new Set(matched.flatMap(([, rs]) => [...rs]));
+ // The SPACE must agree too, or the two `id`s of `query.id` / `body.id` trade evidence and a rule
+ // gets pinned to whichever the extractor happened to keep. A read whose space is unknown (a bare
+ // `req` handed to a helper, say) still matches on path alone — recall, at heuristic strength.
+ const matched = [...reads.values()].filter(({ read }) =>
+ (read.space === undefined || read.space === inputSpace)
+ && (read.path === inputPath || read.path.startsWith(inputPath + '.')));
+ const proven = matched.length > 0;
+ // Weakest-honest tier that fits the evidence.
+ const confidence: Flow['confidence'] = proven
+ ? (matched.some((m) => m.exact) ? 'exact-local' : 'transformed-local')
+ : sink.file !== undefined ? 'imported'
+ // No span at all (a synthetic node) means no evidence is even possible. A sink whose span exists
+ // but sits outside this handler — reached through a same-file helper — is ordinary co-occurrence:
+ // located, just not attributable to an argument here. Calling that "unknown" would overstate it.
+ : sink.start === undefined ? 'unknown'
+ : 'heuristic';
+ const roles = new Set(matched.flatMap(({ roles: 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);
+ : [...roles].find((r) => r !== 'unknown') ?? (proven ? 'unknown' : undefined);
- // Deliberately SEPARATE from confidence: `precise` means "the source reaches the sink", which is
+ // Deliberately SEPARATE from confidence: a proven tier 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 (!proven) reasons.push(`flow evidence is "${confidence}": no proven local read of this input into the sink call`);
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');
@@ -165,7 +216,7 @@ function linkFlows(
? `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 ?? '?'}`);
+ if (proven && 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".
for (const l of sinkLimits) {
@@ -173,15 +224,16 @@ function linkFlows(
? `dynamic computed key reaches this sink (${l.detail}): the field cannot be named by a parameter`
: `spread reaches this sink (${l.detail}): the specific field is not identifiable`);
}
- if (precise && argumentRole && argumentRole !== 'unknown' && !family) {
+ if (proven && 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,
+ inputId: input.id,
sink,
- confidence: precise ? 'precise' : 'heuristic',
+ confidence,
line: sink.line,
...(argumentRole ? { argumentRole } : {}),
...(family ? { candidateFamily: family } : {}),
@@ -250,21 +302,26 @@ function fluentChainCalls(call: any, ts: TsModule): any[] {
/**
* The canonical PATHS of values genuinely read from a tainted source inside `node` — the evidence behind
- * a `precise` flow. Full paths, not leaf names: `data.shipping.email` yields `shipping.email`, so it can
+ * a proven flow. Full paths, not leaf names: `data.shipping.email` yields `shipping.email`, so it can
* never be mistaken for the distinct input `billing.email`. Array indices normalize to `[]`.
* Property KEYS, member names and binding names are not reads.
*/
-function taintedReadPaths(node: any, ts: TsModule, rootPath: Map): Set {
- const out = new Set();
+function taintedReadPaths(node: any, ts: TsModule, rootPath: Map): Root[] {
+ const out: Root[] = [];
+ const seen = new Set();
+ const add = (r: Root) => {
+ const key = `${r.space ?? '*'}:${r.path}`;
+ if (!seen.has(key)) { seen.add(key); out.push(r); }
+ };
const visit = (n: any) => {
if (!n) return;
if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) {
- const path = pathFromTainted(n, ts, rootPath);
- if (path !== undefined) { out.add(path); return; } // the inner nodes are the path, not separate reads
+ const read = pathFromTainted(n, ts, rootPath);
+ if (read !== undefined) { add(read); return; } // the inner nodes are the path, not separate reads
}
if (ts.isIdentifier(n) && rootPath.has(n.text) && isValueRead(n, ts)) {
- const p = rootPath.get(n.text)!;
- if (p) out.add(normalizePath(p));
+ const r = rootPath.get(n.text)!;
+ if (r.path) add({ path: normalizePath(r.path), space: r.space });
}
ts.forEachChild(n, visit);
};
@@ -279,7 +336,7 @@ function taintedReadPaths(node: any, ts: TsModule, rootPath: Map
* - `insert({ v: body[field] })` → the field is chosen at runtime; no coordinate can name it.
* - `insert({ ...body })` → the whole payload reaches the sink; which field is unidentifiable.
*/
-function sinkArgumentLimitations(node: any, ts: TsModule, rootPath: Map): Limitation[] {
+function sinkArgumentLimitations(node: any, ts: TsModule, rootPath: Map): Limitation[] {
const out: Limitation[] = [];
const seen = new Set();
const add = (kind: Limitation['kind'], detail: string, n: any) => {
@@ -313,7 +370,7 @@ function sinkArgumentLimitations(node: any, ts: TsModule, rootPath: Map): string | undefined {
+function pathFromTainted(node: any, ts: TsModule, rootPath: Map): Root | undefined {
const segs: string[] = [];
let cur = node;
for (;;) {
@@ -330,11 +387,17 @@ 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;
+ let space = base.space;
// 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('.'));
+ // proven.
+ // The dropped segment is exactly what names the address space, so capture it before discarding it —
+ // losing it is what made `req.query.id` and `req.body.id` the same read.
+ if (base.path === '' && segs.length > 1 && REQ_SOURCES.includes(segs[0]!)) {
+ space = spaceOfKey(segs[0]!) ?? space;
+ segs.shift();
+ }
+ return { path: normalizePath([base.path, ...segs].filter(Boolean).join('.')), space };
}
diff --git a/src/map/inputs.ts b/src/map/inputs.ts
index 5ada8d1..c6aeb78 100644
--- a/src/map/inputs.ts
+++ b/src/map/inputs.ts
@@ -1,7 +1,7 @@
-import type { InputField, InputSource, TsModule } from './types.js';
+import type { FieldShape, InputField, InputSource, TsModule } from './types.js';
import { bindingKey, rootIdentifier } from './ast.js';
import { npmPackageOf, type Bindings } from './bindings.js';
-import { namespaceOf, runtimeCoordinate, withCoordinates } from './coordinates.js';
+import { addressSpaceOf, inputIdOf, runtimeCoordinate } 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.
@@ -10,7 +10,7 @@ const STRING_FORMATS = new Set(['email', 'uuid', 'url', 'ip', 'ipv4', 'ipv6', 'c
const VALIDATOR_PACKAGES = new Set(['zod', 'valibot', 'yup', 'joi', '@hapi/joi', 'superstruct']);
// --- inputs -----------------------------------------------------------------
-export function inputsFromValidator(validatorCall: any, ts: TsModule, bindings: Bindings): InputField[] {
+export function inputsFromValidator(validatorCall: any, ts: TsModule, bindings: Bindings): FieldShape[] {
if (!validatorCall) return [];
return zodObjectFields(validatorCall, ts, bindings);
}
@@ -30,43 +30,25 @@ export function inputsFromHandler(
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();
- 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);
+ // Keyed by IDENTITY — `:` — not by field name. A handler that reads `query.id` and
+ // `params.id`, or validates a body field named `id` while the sink consumes `query.id`, has TWO inputs
+ // that merely share a name. Name-keying made one of them disappear, and since the survivor decided the
+ // coordinate, a rule could be pinned to a parameter the payload never travels in. Distinct identities
+ // remove that class outright: each input carries its own address, and a flow names which one it means.
+ // Same space + same path IS the same input, so a schema field and an `req.body` read of it merge
+ // (the schema entry wins, since it also carries the declared type/constraints).
+ const byId = new Map();
+ const put = (name: string, source: InputSource, extra: Omit = {}) => {
+ const id = inputIdOf(source, name);
+ if (byId.has(id)) return;
+ byId.set(id, { ...extra, id, name, source, ...runtimeCoordinate(source, 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) });
+ for (const f of schemaFields) {
+ const { name, source, ...shape } = f;
+ put(name, source ?? schemaSource, shape);
}
- return fields;
+ for (const { name, sources } of reads) for (const source of sources) put(name, source);
+ return [...byId.values()];
}
// Find the first validator `.object({...})` in a subtree — gated on the receiver tracing to a known
@@ -93,14 +75,14 @@ function findValidatorObject(node: any, ts: TsModule, bindings: Bindings): any {
// Read a validator object's fields (name + type/constraints). Nested objects/arrays are flattened to
// dotted paths — `address.city`, `tags[].label` — the same coordinates `array_key_value` rules use.
-function zodObjectFields(node: any, ts: TsModule, bindings: Bindings): InputField[] {
+function zodObjectFields(node: any, ts: TsModule, bindings: Bindings): FieldShape[] {
if (!node) return [];
const lit = findValidatorObject(node.body ?? node, ts, bindings);
return lit ? fieldsOfObject(lit, ts, bindings, '') : [];
}
-function fieldsOfObject(objectLiteral: any, ts: TsModule, bindings: Bindings, prefix: string): InputField[] {
- const fields: InputField[] = [];
+function fieldsOfObject(objectLiteral: any, ts: TsModule, bindings: Bindings, prefix: string): FieldShape[] {
+ const fields: FieldShape[] = [];
for (const p of objectLiteral.properties) {
if (!ts.isPropertyAssignment(p) || !p.name) continue;
const fname = (p.name as any).text;
@@ -120,8 +102,8 @@ function numericValue(arg: any, ts: TsModule): number | undefined {
return undefined;
}
-function zodShape(node: any, ts: TsModule): Omit {
- const shape: Omit = {};
+function zodShape(node: any, ts: TsModule): Omit {
+ const shape: Omit = {};
let cur = node;
while (cur && ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
const method = cur.expression.name.text;
diff --git a/src/map/module-graph.ts b/src/map/module-graph.ts
index 9f5b862..7ee50dc 100644
--- a/src/map/module-graph.ts
+++ b/src/map/module-graph.ts
@@ -51,7 +51,7 @@ export function createModuleGraph(ts: TsModule, opts: { cwd: string; boundary: s
for (const s of mod.fnSinks.get(callee) ?? []) collected.push(s);
}
// `line` refers to the HELPER's file, not the endpoint's — carry the file so the coordinate is
- // interpretable (and so flow linking never claims `precise` for a sink it cannot see locally).
+ // interpretable (and so flow linking marks it `imported` rather than proven — it cannot see the call).
const rel = relative(opts.cwd, target);
return collected.map((s) => ({ ...s, file: rel }));
},
diff --git a/src/map/sinks.ts b/src/map/sinks.ts
index f09ec45..050ca5f 100644
--- a/src/map/sinks.ts
+++ b/src/map/sinks.ts
@@ -161,7 +161,7 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] {
// `!b.relative` is the member-call twin of the bare-call justification below: a receiver that
// resolves to a RELATIVE module is app code, whatever its methods are named. Without it,
// `import * as helper from './util'; helper.exec(req.body.cmd)` was read as child_process and
- // produced a precise, auto-generatable command-injection candidate for harmless local code.
+ // produced a proven, auto-generatable command-injection candidate for harmless local code.
// Such a receiver is not dropped outright — `sinksFrom` follows it into its module instead.
if (!b.local && !b.relative) {
// db: prisma-style `prisma..()` — the op names are generic (`delete`, `update`, …),
diff --git a/src/map/types.ts b/src/map/types.ts
index c33e940..cead235 100644
--- a/src/map/types.ts
+++ b/src/map/types.ts
@@ -11,10 +11,30 @@ export type InputSource =
| 'query' | 'route-param' | 'header' | 'cookie' | 'file'
| 'server-fn-data' | 'unknown';
+/**
+ * Where an input lives in the request, as the rule engine addresses it. This — not the field name — is
+ * half of an input's identity: `query.id` and `body.id` are different inputs that happen to share a
+ * name, and keying by name alone let a rule be pinned to the wrong one.
+ */
+export type AddressSpace = 'post' | 'get' | 'cookie' | 'files' | 'server' | 'route-param' | 'unknown';
+
+/**
+ * A field's declared shape BEFORE it is placed in the request: validator extraction knows a name, a type
+ * and constraints, but not which region the value arrives in — so it cannot know the input's identity.
+ * `withCoordinates` attaches the space, the coordinate and the id in one step.
+ */
+export type FieldShape = Omit;
+
export interface InputField {
+ /**
+ * Stable identity: `:` (e.g. `get:id`, `post:billing.email`). Two inputs
+ * with the same NAME in different spaces are different inputs, and `Flow.inputId` refers to this.
+ */
+ id: string;
/**
* Parameter / body-field name — the coordinate a rule pins to. Nested validator fields are
* flattened to dotted paths (`address.city`, `tags[].label`), matching `array_key_value` paths.
+ * NOT unique within an endpoint: use `id` to correlate.
*/
name: string;
/** Coarse type when derivable (string | number | boolean | array | object | unknown). */
@@ -151,10 +171,9 @@ export interface Endpoint {
* A DATA LINK from one input to one sink — the only place the map asserts that an input actually
* *reaches* a sink. `inputs` and `sinks` on an endpoint are inventories (both present somewhere in the
* handler); a `Flow` is evidence-backed:
- * - `precise` — the input identifier/path appears inside the sink call's arguments.
- * - `heuristic` — the input and sink co-occur in the handler but no data link was found; treat as
- * "may reach", never as proven.
- * Consumers that pin a rule to a parameter should prefer `precise` flows and fall back to broad rules.
+ * see `confidence` for the tier, and `inputId` for WHICH input (a name is not unique). Consumers that pin
+ * a rule to a parameter must require a proven tier (`exact-local` / `transformed-local`) and fall back to
+ * broad rules otherwise — and only `exact-local` should ever be promoted to blocking automatically.
*/
/**
* Which argument of the sink call the tainted value landed in. This decides which mitigation class is
@@ -183,16 +202,36 @@ export interface Flow {
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
+ * `confidence`. A proven tier 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 field name (dotted path) — for display. Not an identity: an endpoint can read the same name
+ * from two spaces, so anything that pins a rule must use `inputId`.
+ */
input: string;
+ /** Identity of the input this flow starts from — matches `InputField.id`. */
+ inputId: string;
/** The sink reached. */
sink: Sink;
- confidence: 'precise' | 'heuristic';
+ /**
+ * How the link was established, from strongest to weakest:
+ * - `exact-local` the input IS an argument of the sink call, seen in this file. The only tier a
+ * server should consider for AUTOMATIC promotion to blocking.
+ * - `transformed-local` the input reaches the argument through an expression (concatenation, a
+ * template literal, a wrapper call). The value still arrives in the same
+ * parameter, so a rule can be compiled — but what reaches the sink is not
+ * exactly what arrived, so promotion deserves a human or a probe.
+ * - `imported` the sink lives in another module: the input co-occurs, and the call site is
+ * not visible here, so no argument-level evidence exists.
+ * - `heuristic` input and sink are both present in the handler, with no proven link.
+ * - `unknown` the sink call has no source span at all, so no evidence is even possible. A
+ * sink reached through a same-file helper is `heuristic`, not this: it was
+ * located, just not attributable to an argument at this call site.
+ */
+ confidence: 'exact-local' | 'transformed-local' | 'imported' | 'heuristic' | 'unknown';
/** 1-based line of the sink call — the auditable evidence location. */
line?: number;
}
@@ -220,12 +259,20 @@ export interface Coverage {
export interface SiteInputMap {
/**
- * 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.
+ * Schema version of this document. Treat it as a WIRE CONTRACT: a consumer must reject a version it does
+ * not implement rather than parse it optimistically. The v2 → v3 changes are silent-failure shaped —
+ * old code keeps running and quietly does the wrong thing:
+ * - flows identify their input by `inputId`, not `input` (a NAME is not unique within an endpoint, so
+ * keying by it can attribute a flow to the wrong parameter);
+ * - `confidence` is a five-tier taxonomy — `precise` no longer exists, so `=== 'precise'` is now
+ * permanently false and every proven flow reads as unproven (use `isProvenFlow`);
+ * - only `exact-local` should feed an automatic promotion to blocking; `transformed-local` is
+ * dry-run / review-only, because what reaches the sink is not exactly what arrived.
+ * v2 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;
+ version: 3;
/** e.g. "tanstack-start". */
framework: string;
endpoints: Endpoint[];
diff --git a/tests/map/aliased-namespace.test.ts b/tests/map/aliased-namespace.test.ts
index d83a2df..f235f94 100644
--- a/tests/map/aliased-namespace.test.ts
+++ b/tests/map/aliased-namespace.test.ts
@@ -67,10 +67,13 @@ describe('aliased request namespaces', () => {
});
it('captures the namespace when destructured from the request identifier itself', async () => {
- const { field } = await input('/fromreq', 'doc');
- // Previously invisible: no coordinate was mis-addressed, but the surface went unreported, which
- // reads as "nothing here" rather than "something we cannot address".
- expect(field).toMatchObject({ source: 'query', runtimeParameter: 'get.doc' });
+ const { ep, field } = await input('/fromreq', 'doc');
+ expect(field).toMatchObject({ source: 'query', runtimeParameter: 'get.doc', id: 'get:doc' });
+ // The flow is PRECISE through the alias too: the tainted root carries its address space, so the read
+ // off `q` is known to be a query read rather than merely "some request value".
+ const flow = ep.flows.find((f) => f.inputId === 'get:doc')!;
+ expect(flow.confidence).toBe('exact-local');
+ expect(flow.ruleGeneratable).toBe(true);
});
it('still refuses a coordinate for a route param destructured that way', async () => {
@@ -84,9 +87,6 @@ describe('aliased request namespaces', () => {
const got = map!.endpoints
.flatMap((e) => e.flows.filter((f) => f.ruleGeneratable).map((f) => `${e.route}:${f.input}`))
.sort();
- // `/fromreq` is absent by design: the input is now VISIBLE, but its flow evidence is heuristic
- // (the alias is not yet tracked in the taint paths), so it must not compile a rule. Visible and
- // non-generatable is the safe half of this fix; making such flows precise is a separate change.
- expect(got).toEqual(['/bodyalias:file', '/nested:doc', '/plain:doc', '/renamed:doc']);
+ expect(got).toEqual(['/bodyalias:file', '/fromreq:doc', '/nested:doc', '/plain:doc', '/renamed:doc']);
});
});
diff --git a/tests/map/argument-roles.test.ts b/tests/map/argument-roles.test.ts
index 2c35a4b..552899c 100644
--- a/tests/map/argument-roles.test.ts
+++ b/tests/map/argument-roles.test.ts
@@ -3,6 +3,7 @@ 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 { isProvenFlow } from '../../src/map/coordinates.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`,
@@ -37,7 +38,7 @@ 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')!;
+ return ep.flows.find((f) => f.input === input && isProvenFlow(f.confidence))!;
};
describe('argument roles', () => {
@@ -63,7 +64,12 @@ describe('argument roles', () => {
['/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');
+ // Proven either way, but the db rows are only `transformed-local`: `insert({ title: req.body.title })`
+ // hands the sink an OBJECT containing the value, not the value. The payload still travels in
+ // `post.title` — which is why a rule could be compiled at all — but what reaches the sink is not
+ // exactly what arrived, and that is precisely what the tier is there to tell a server.
+ expect(isProvenFlow(f.confidence)).toBe(true);
+ expect(f.confidence).toBe(role === 'values' ? 'transformed-local' : 'exact-local');
expect(f.argumentRole).toBe(role);
expect(f.candidateFamily).toBeUndefined();
expect(f.ruleGeneratable).toBe(false);
diff --git a/tests/map/corpus.test.ts b/tests/map/corpus.test.ts
index 775840c..8e5eb7b 100644
--- a/tests/map/corpus.test.ts
+++ b/tests/map/corpus.test.ts
@@ -3,6 +3,7 @@ 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 { isProvenFlow } from '../../src/map/coordinates.js';
import type { SiteInputMap } from '../../src/map/types.js';
// GOLDEN CORPUS. Unit fixtures prove a mechanism; this measures BEHAVIOUR across the stacks AI builders
@@ -101,13 +102,15 @@ const ADVERSARIAL: Case[] = [
expectCandidates: [],
},
{
- name: 'adversarial: one field name read from two request namespaces',
+ name: 'adversarial: one field name read from two request namespaces, addressed separately',
kind: 'adversarial',
pkg: { dependencies: { express: '4' } },
files: {
- // `params.id` and `query.id` share a NAME but not an address. Whichever read the walker saw last
- // used to decide the coordinate, so this handler compiled a rule pinned to `get.id` for data
- // arriving in the path segment. Both orders are covered because that is what made it a bug.
+ // `params.id` and `query.id` share a NAME but not an address, so they are two inputs. Name-keyed
+ // extraction kept one of them and let its coordinate stand for both, which pinned a rule to
+ // `get.id` for data arriving in the path segment. Now each is addressed on its own: the query read
+ // earns `get.id`, the route param earns nothing (the resolver cannot reach it). Both source orders
+ // are covered because the order dependence is what made it a bug.
'src/a.ts': `
import express from "express";
import fs from "node:fs";
@@ -121,8 +124,8 @@ const ADVERSARIAL: Case[] = [
app.get("/pq/:id", ({ params: p, query: q }, res) => { fs.readFileSync(p.id); fs.readFileSync(q.id); res.end(); });
`,
},
- expectCandidates: [],
- expectRefused: [['id', /more than one request namespace/]],
+ expectCandidates: ['path-traversal @ get.id', 'path-traversal @ get.id'],
+ expectRefused: [['id', /route parameters are not exposed/]],
},
{
name: 'adversarial: a validator field and the sink read address different namespaces',
@@ -131,8 +134,8 @@ const ADVERSARIAL: Case[] = [
files: {
// The schema describes the BODY; the sink consumes the QUERY. Both are called `id`, and grouping
// inputs by name made the schema's `post.id` the only surviving entry — so the candidate pinned a
- // parameter the payload never travels in. Namespace comparison (not source labels) is what catches
- // this: a schema field and an Express `req.body` read are both `post.*` and must NOT be a conflict.
+ // parameter the payload never travels in. As separate identities the query read is pinned correctly
+ // and the declared-but-unread body field simply has no proven flow.
'src/server.ts': `
import express from "express";
import fs from "node:fs";
@@ -148,9 +151,10 @@ const ADVERSARIAL: Case[] = [
});
`,
},
- // `/agree` must still compile: the point is to refuse conflicts, not to refuse validated bodies.
- expectCandidates: ['path-traversal @ post.doc'],
- expectRefused: [['id', /more than one request namespace/]],
+ // `/agree` must still compile: a validated body field read by the sink is the common good case.
+ expectCandidates: ['path-traversal @ get.id', 'path-traversal @ post.doc'],
+ // The schema's `post:id` is declared but never read by the sink — proven-nothing, not blockable.
+ expectRefused: [['id', /no proven local read/]],
},
{
name: 'adversarial: sibling expressions must not contaminate each other',
@@ -312,14 +316,18 @@ beforeAll(async () => {
}, 120_000);
afterAll(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true })));
-/** Every compiled candidate as `family @ runtimeParameter`. */
+/**
+ * Every compiled candidate as `family @ runtimeParameter`. Correlation is by input IDENTITY: keying this
+ * by name would collapse `get:id` and `post:id` into one entry — the same lossy key that caused the
+ * wrong-pin bugs, which would make the harness report the wrong coordinate for a correct candidate.
+ */
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]));
+ const coord = new Map(ep.inputs.map((i) => [i.id, i.runtimeParameter]));
for (const f of ep.flows) {
if (!f.ruleGeneratable) continue;
- out.push(`${f.candidateFamily} @ ${coord.get(f.input)}`);
+ out.push(`${f.candidateFamily} @ ${coord.get(f.inputId)}`);
}
}
return out.sort();
@@ -350,12 +358,21 @@ describe('golden corpus', () => {
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]));
+ const coord = new Map(ep.inputs.map((i) => [i.id, 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();
+ expect(coord.get(f.inputId), `${f.inputId} is a candidate without a coordinate`).toBeTruthy();
}
}
});
+
+ it('gives every input a unique identity, and every flow a real one to point at', () => {
+ const map = maps.get(c.name)!;
+ for (const ep of map.endpoints) {
+ const ids = ep.inputs.map((i) => i.id);
+ expect(new Set(ids).size, `${ep.route ?? ep.name}: duplicate input ids`).toBe(ids.length);
+ for (const f of ep.flows) expect(ids, `flow points at unknown input ${f.inputId}`).toContain(f.inputId);
+ }
+ });
});
}
@@ -370,8 +387,8 @@ describe('golden corpus', () => {
expect(inputs.length, `${c.name}: no inputs detected — the fixture proves nothing`).toBeGreaterThan(0);
const generatable = map.endpoints.flatMap((e) => e.flows).filter((f) => f.ruleGeneratable);
const declared = new Set(c.expectCandidates);
- const coord = new Map(map.endpoints.flatMap((e) => e.inputs).map((i) => [i.name, i.runtimeParameter]));
- expect(generatable.filter((f) => !declared.has(`${f.candidateFamily} @ ${coord.get(f.input)}`))).toEqual([]);
+ const coord = new Map(map.endpoints.flatMap((e) => e.inputs).map((i) => [i.id, i.runtimeParameter]));
+ expect(generatable.filter((f) => !declared.has(`${f.candidateFamily} @ ${coord.get(f.inputId)}`))).toEqual([]);
}
});
@@ -386,19 +403,21 @@ describe('golden corpus', () => {
});
it('reports corpus-wide metrics (the numbers that gate auto-promotion)', () => {
- let candidates = 0, precise = 0, heuristic = 0, refusedWithReason = 0, noCoordinate = 0;
+ let candidates = 0, refusedWithReason = 0, noCoordinate = 0;
+ const tiers = new Map();
for (const c of ALL) {
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++;
+ tiers.set(f.confidence, (tiers.get(f.confidence) ?? 0) + 1);
if (f.ruleGeneratable) candidates++;
else if ((f.ruleGeneratableReasons ?? []).length > 0) refusedWithReason++;
}
}
}
// eslint-disable-next-line no-console
- console.log(`corpus: ${CASES.length} stack + ${ADVERSARIAL.length} adversarial projects · ${candidates} candidates · ${precise} precise / ${heuristic} heuristic flows · ${refusedWithReason} refused-with-reason · ${noCoordinate} inputs without a coordinate`);
+ const byTier = [...tiers].sort().map(([t, n]) => `${n} ${t}`).join(', ');
+ console.log(`corpus: ${CASES.length} stack + ${ADVERSARIAL.length} adversarial projects · ${candidates} candidates · flows: ${byTier} · ${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.
diff --git a/tests/map/edge-functions.test.ts b/tests/map/edge-functions.test.ts
index 75b9758..1bea9f6 100644
--- a/tests/map/edge-functions.test.ts
+++ b/tests/map/edge-functions.test.ts
@@ -3,6 +3,7 @@ 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 { isProvenFlow } from '../../src/map/coordinates.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
@@ -49,7 +50,7 @@ describe('platform function entry points', () => {
expect.arrayContaining([expect.objectContaining({ kind: 'db', table: 'charges', op: 'insert' })]),
);
// The insert receives the request data → a proven flow, so a rule can pin the parameter.
- expect(charge!.flows.some((f) => f.confidence === 'precise' && f.input === 'orderId')).toBe(true);
+ expect(charge!.flows.some((f) => isProvenFlow(f.confidence) && f.input === 'orderId')).toBe(true);
});
it('recognizes a bare serve() function and its outbound (SSRF-relevant) sink', async () => {
@@ -60,6 +61,6 @@ describe('platform function entry points', () => {
expect(notify.inputs.map((i) => i.name)).toEqual(['hook']);
expect(notify.sinks).toEqual(expect.arrayContaining([expect.objectContaining({ kind: 'http' })]));
// hook -> fetch is the classic SSRF shape; it must be a PROVEN flow, not a co-occurrence.
- expect(notify.flows.some((f) => f.input === 'hook' && f.sink.kind === 'http' && f.confidence === 'precise')).toBe(true);
+ expect(notify.flows.some((f) => f.input === 'hook' && f.sink.kind === 'http' && isProvenFlow(f.confidence))).toBe(true);
});
});
diff --git a/tests/map/extract.test.ts b/tests/map/extract.test.ts
index effa71f..e76110b 100644
--- a/tests/map/extract.test.ts
+++ b/tests/map/extract.test.ts
@@ -3,6 +3,7 @@ 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 { isProvenFlow } from '../../src/map/coordinates.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
@@ -168,13 +169,19 @@ describe('agnostic input-flow extractor', () => {
expect(map!.coverage.filesParsed).toBeLessThanOrEqual(map!.coverage.filesDiscovered);
});
- it('links input → sink flows with evidence, and only claims "precise" when the data reaches it', async () => {
+ it('links input → sink flows with evidence, and only claims a proven tier when the data reaches it', async () => {
const { map } = await buildInputMap(dir);
const createTask = map!.endpoints.find((e) => e.name === 'createTask')!;
- const precise = createTask.flows.filter((f) => f.confidence === 'precise');
+ const precise = createTask.flows.filter((f) => isProvenFlow(f.confidence));
// `title` is passed into the insert → proven.
+ // `transformed-local`: the value is wrapped in the inserted row object rather than being the argument.
expect(precise).toEqual([
- expect.objectContaining({ input: 'title', confidence: 'precise', sink: expect.objectContaining({ op: 'insert' }) }),
+ expect.objectContaining({
+ input: 'title',
+ inputId: 'post:title',
+ confidence: 'transformed-local',
+ sink: expect.objectContaining({ op: 'insert' }),
+ }),
]);
// The helper-reached select does NOT receive the input → heuristic, never precise.
expect(createTask.flows.some((f) => f.sink.op === 'select' && f.confidence === 'heuristic')).toBe(true);
@@ -206,7 +213,7 @@ describe('agnostic input-flow extractor', () => {
expect(patch.routeDynamic).toBe(true); // a PATTERN, so when.path needs a glob/regex
expect(patch.method).toBe('PATCH');
expect(patch.inputs.map((i) => i.name).sort()).toEqual(['id', 'note']);
- expect(patch.flows.some((f) => f.confidence === 'precise' && f.sink.op === 'update')).toBe(true);
+ expect(patch.flows.some((f) => isProvenFlow(f.confidence) && f.sink.op === 'update')).toBe(true);
});
it('resolves a client built by a local factory back to its package', async () => {
diff --git a/tests/map/flow-paths.test.ts b/tests/map/flow-paths.test.ts
index 20aa8e6..f4f21d3 100644
--- a/tests/map/flow-paths.test.ts
+++ b/tests/map/flow-paths.test.ts
@@ -3,6 +3,7 @@ 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 { isProvenFlow } from '../../src/map/coordinates.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:
@@ -74,7 +75,7 @@ const flowsOf = async (file: string) => {
describe('flow paths and sink ownership', () => {
it('distinguishes nested paths that share a leaf name', async () => {
const ep = await flowsOf('nested.ts');
- const precise = ep.flows.filter((f) => f.confidence === 'precise').map((f) => f.input).sort();
+ const precise = ep.flows.filter((f) => isProvenFlow(f.confidence)).map((f) => f.input).sort();
// shipping.email is read (and `shipping` is its ancestor, so it covers the flow).
expect(precise).toEqual(['shipping', 'shipping.email']);
// The collision case: billing.* must NOT be precise.
@@ -84,23 +85,23 @@ describe('flow paths and sink ownership', () => {
it('does not let a sibling expression in the same statement lend evidence', async () => {
const ep = await flowsOf('sibling.ts');
- expect(ep.flows.filter((f) => f.confidence === 'precise')).toEqual([]);
+ expect(ep.flows.filter((f) => isProvenFlow(f.confidence))).toEqual([]);
});
it('treats a fluent chain as one operation', async () => {
const ep = await flowsOf('chain.ts');
- const precise = ep.flows.filter((f) => f.confidence === 'precise').map((f) => f.input).sort();
+ const precise = ep.flows.filter((f) => isProvenFlow(f.confidence)).map((f) => f.input).sort();
expect(precise).toEqual(['id', 'note']); // the values object AND the .eq filter
});
it('normalizes array indices so tags[].label matches a read of tags[0].label', async () => {
const ep = await flowsOf('arrays.ts');
- expect(ep.flows.some((f) => f.input === 'tags[].label' && f.confidence === 'precise')).toBe(true);
+ expect(ep.flows.some((f) => f.input === 'tags[].label' && isProvenFlow(f.confidence))).toBe(true);
});
it('keeps taint through a validator (validation is not sanitization) and records sink spans', async () => {
const ep = await flowsOf('nested.ts');
- expect(ep.flows.some((f) => f.confidence === 'precise')).toBe(true); // would be none if validation cleaned
+ expect(ep.flows.some((f) => isProvenFlow(f.confidence))).toBe(true); // would be none if validation cleaned
const sink = ep.sinks[0]!;
expect(typeof sink.start).toBe('number');
expect(typeof sink.end).toBe('number');
diff --git a/tests/map/flow-precision.test.ts b/tests/map/flow-precision.test.ts
index bda9f98..16384e4 100644
--- a/tests/map/flow-precision.test.ts
+++ b/tests/map/flow-precision.test.ts
@@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { buildInputMap } from '../../src/map/index.js';
+import { isProvenFlow } from '../../src/map/coordinates.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
@@ -76,7 +77,7 @@ describe('flow precision', () => {
it('does claim precise for a real read (shorthand property)', async () => {
const { map } = await buildInputMap(dir);
const ep = map!.endpoints.find((e) => e.file.endsWith('real.ts'))!;
- expect(ep.flows.some((f) => f.input === 'title' && f.confidence === 'precise')).toBe(true);
+ expect(ep.flows.some((f) => f.input === 'title' && isProvenFlow(f.confidence))).toBe(true);
});
it('resolves an ALIASED imported helper to its exported name', async () => {
@@ -87,12 +88,17 @@ describe('flow precision', () => {
);
});
- it('labels an imported sink with ITS OWN file, and never calls it precise', async () => {
+ it('labels an imported sink with ITS OWN file, and never claims a proven flow for it', async () => {
const { map } = await buildInputMap(dir);
const ep = map!.endpoints.find((e) => e.file.endsWith('alias.ts'))!;
const imported = ep.sinks.find((s) => s.table === 'orders')!;
expect(imported.file).toBe(join('src', 'lib', 'db.ts'));
- expect(ep.flows.filter((f) => f.sink.table === 'orders').every((f) => f.confidence === 'heuristic')).toBe(true);
+ // `imported`, not the generic `heuristic`: the call site is in another module, so no argument-level
+ // evidence can exist here. Naming the reason is the difference between "no link" and "cannot see".
+ const flows = ep.flows.filter((f) => f.sink.table === 'orders');
+ expect(flows.length).toBeGreaterThan(0);
+ expect(flows.every((f) => f.confidence === 'imported')).toBe(true);
+ expect(flows.every((f) => !isProvenFlow(f.confidence))).toBe(true);
});
it('refuses to follow an import outside the project directory', async () => {
diff --git a/tests/map/input-identity.test.ts b/tests/map/input-identity.test.ts
new file mode 100644
index 0000000..b996ed5
--- /dev/null
+++ b/tests/map/input-identity.test.ts
@@ -0,0 +1,152 @@
+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 { addressSpaceOf, inputIdOf, isProvenFlow } from '../../src/map/coordinates.js';
+
+// An input's identity is (address space, full path) — NOT its name. Three separate wrong-pin bugs came
+// from name-keying: two field names colliding across namespaces, and a validator field colliding with a
+// read. Each was previously patched by REFUSING to emit a coordinate; with real identities the same code
+// is addressed correctly instead, so this file asserts the pins rather than the refusals.
+let dir: string;
+beforeAll(() => {
+ dir = mkdtempSync(join(tmpdir(), 'ps-ident-'));
+ mkdirSync(join(dir, 'src'), { recursive: true });
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { express: '4', zod: '3' } }));
+ writeFileSync(join(dir, 'src', 'app.ts'), `
+ import express from "express";
+ import fs from "node:fs";
+ import { z } from "zod";
+ const app = express();
+ // One name, two namespaces, two sinks: each read must pin its own address.
+ app.get("/both/:id", ({ params: p, query: q }, res) => {
+ fs.readFileSync(q.id);
+ fs.readFileSync(p.id);
+ res.end();
+ });
+ // The schema declares a BODY field; the sink consumes the QUERY field of the same name.
+ app.post("/mismatch", (req, res) => {
+ z.object({ id: z.string() }).parse(req.body);
+ res.end(fs.readFileSync(req.query.id));
+ });
+ // Transformation: the value reaches the sink inside a larger expression.
+ app.post("/joined", (req, res) => { res.end(fs.readFileSync("/tmp/" + req.body.name)); });
+ `);
+});
+afterAll(() => rmSync(dir, { recursive: true, force: true }));
+
+const ep = async (route: string) => {
+ const { map } = await buildInputMap(dir);
+ return map!.endpoints.find((e) => e.route === route)!;
+};
+
+describe('input identity', () => {
+ it('is (space, path), so one name in two namespaces is two inputs', async () => {
+ const e = await ep('/both/:id');
+ expect(e.inputs.map((i) => i.id).sort()).toEqual(['get:id', 'route-param:id']);
+ expect(e.inputs.filter((i) => i.name === 'id')).toHaveLength(2);
+ });
+
+ it('addresses each one on its own terms — the query field is pinned, the route param is not', async () => {
+ const e = await ep('/both/:id');
+ const byId = new Map(e.inputs.map((i) => [i.id, i]));
+ expect(byId.get('get:id')!.runtimeParameter).toBe('get.id');
+ expect(byId.get('route-param:id')!.runtimeParameter).toBeNull();
+ // The candidate exists AND points at the query field only. Before identities, this endpoint either
+ // pinned `get.id` for the path-segment read or refused both.
+ const gen = e.flows.filter((f) => f.ruleGeneratable);
+ expect(gen).toHaveLength(1);
+ expect(gen[0]!.inputId).toBe('get:id');
+ expect(gen[0]!.candidateFamily).toBe('path-traversal');
+ });
+
+ it('keeps a proven route-param flow visible while refusing to address it', async () => {
+ const e = await ep('/both/:id');
+ const flow = e.flows.find((f) => f.inputId === 'route-param:id' && isProvenFlow(f.confidence))!;
+ expect(flow).toBeDefined();
+ expect(flow.ruleGeneratable).toBe(false);
+ expect(flow.ruleGeneratableReasons!.join(' ')).toMatch(/route parameters are not exposed/);
+ });
+
+ it('does not let a read in one space lend evidence to an input in another', async () => {
+ const e = await ep('/mismatch');
+ const post = e.flows.filter((f) => f.inputId === 'post:id');
+ const get = e.flows.filter((f) => f.inputId === 'get:id');
+ // The declared body field is never read by the sink…
+ expect(post.every((f) => !isProvenFlow(f.confidence))).toBe(true);
+ expect(post.every((f) => f.ruleGeneratable === false)).toBe(true);
+ // …while the query field it shares a name with is proven and correctly pinned.
+ expect(get.some((f) => isProvenFlow(f.confidence) && f.ruleGeneratable)).toBe(true);
+ });
+
+ it('every flow names an input that exists, and ids are unique per endpoint', async () => {
+ const { map } = await buildInputMap(dir);
+ for (const e of map!.endpoints) {
+ const ids = e.inputs.map((i) => i.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const f of e.flows) expect(ids).toContain(f.inputId);
+ }
+ });
+});
+
+describe('confidence taxonomy', () => {
+ it('calls a direct argument read exact-local', async () => {
+ const e = await ep('/both/:id');
+ const f = e.flows.find((x) => x.inputId === 'get:id' && x.ruleGeneratable)!;
+ expect(f.confidence).toBe('exact-local');
+ });
+
+ it('calls a read inside a larger expression transformed-local — still pinnable, not promotable', async () => {
+ const e = await ep('/joined');
+ const f = e.flows.find((x) => x.inputId === 'post:name')!;
+ expect(f.confidence).toBe('transformed-local');
+ expect(isProvenFlow(f.confidence)).toBe(true);
+ // A rule can still be compiled: the payload arrives in `post.name` regardless of the concatenation.
+ // The tier is the signal a server uses to require a probe or a human before blocking.
+ expect(f.ruleGeneratable).toBe(true);
+ });
+
+ it('reports the schema version so a consumer can reject a shape it does not understand', async () => {
+ const { map } = await buildInputMap(dir);
+ expect(map!.version).toBe(3);
+ });
+});
+
+// The v2 -> v3 changes are silent-failure shaped: `confidence === "precise"` is now permanently false
+// rather than an error, so stale prose in the CLI or in the map's own notes would send a consumer down
+// exactly that path. These guard the DOCUMENTED contract, which drifted from the code once already.
+describe('the v3 contract describes itself accurately', () => {
+ const TIERS = ['exact-local', 'transformed-local', 'imported', 'heuristic', 'unknown'];
+
+ it('emits only tiers the schema declares', async () => {
+ const { map } = await buildInputMap(dir);
+ const seen = map!.endpoints.flatMap((e) => e.flows.map((f) => f.confidence));
+ expect(seen.length).toBeGreaterThan(0);
+ expect(seen.filter((c) => !TIERS.includes(c))).toEqual([]);
+ });
+
+ it('never tells a consumer to look for a confidence value that no longer exists', async () => {
+ const { map } = await buildInputMap(dir);
+ const notes = map!.coverage.notes.join('\n');
+ expect(notes).not.toMatch(/confidence "precise"|marked precise/);
+ // …and does say what to require instead, including which field identifies the input.
+ expect(notes).toMatch(/exact-local/);
+ expect(notes).toMatch(/inputId/);
+ });
+});
+
+describe('the identity helpers agree with the schema', () => {
+ it('maps every body-ish source to the post space', () => {
+ for (const s of ['json-body', 'form-body', 'multipart', 'body', 'server-fn-data'] as const) {
+ expect(addressSpaceOf(s)).toBe('post');
+ }
+ expect(inputIdOf('json-body', 'a.b')).toBe('post:a.b');
+ expect(inputIdOf('query', 'a')).toBe('get:a');
+ });
+
+ it('treats only the two local tiers as proven', () => {
+ expect(['exact-local', 'transformed-local'].every(isProvenFlow)).toBe(true);
+ expect(['imported', 'heuristic', 'unknown'].some(isProvenFlow)).toBe(false);
+ });
+});
diff --git a/tests/map/runtime-coordinates.test.ts b/tests/map/runtime-coordinates.test.ts
index 6834dd2..262fd4e 100644
--- a/tests/map/runtime-coordinates.test.ts
+++ b/tests/map/runtime-coordinates.test.ts
@@ -3,6 +3,7 @@ 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 { isProvenFlow } from '../../src/map/coordinates.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
@@ -60,13 +61,14 @@ describe('coordinates on a real project', () => {
it('labels each input with its source and the coordinate that addresses it', async () => {
const { map } = await buildInputMap(dir);
- expect(map!.version).toBe(2);
+ expect(map!.version).toBe(3); // identity + confidence taxonomy are a breaking schema change
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' });
+ expect(by.path).toMatchObject({ source: 'body', runtimeParameter: 'post.path', id: 'post:path' });
+ expect(by.data).toMatchObject({ source: 'query', runtimeParameter: 'get.data', id: 'get:data' });
// The safety case: a route param is reported, but WITHOUT a coordinate.
- expect(by.tenant).toMatchObject({ source: 'route-param', runtimeParameter: null });
+ // A route param has an identity — so a flow can point at it — even with no coordinate.
+ expect(by.tenant).toMatchObject({ source: 'route-param', runtimeParameter: null, id: 'route-param:tenant' });
expect(by.tenant.runtimeParameterReason).toBeTruthy();
});
@@ -82,12 +84,12 @@ describe('coordinates on a real project', () => {
const ep = map!.endpoints[0]!;
// 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')!;
+ const pathFlow = ep.flows.find((f) => f.input === 'path' && isProvenFlow(f.confidence))!;
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');
+ const dataFlow = ep.flows.find((f) => f.input === 'data' && isProvenFlow(f.confidence));
if (dataFlow) {
expect(dataFlow.candidateFamily).toBeUndefined();
expect(dataFlow.ruleGeneratable).toBe(false);
@@ -133,6 +135,6 @@ describe('high-signal candidate families reach precise', () => {
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);
+ expect(ep.flows.some((f) => f.input === input && f.sink.kind === kind && isProvenFlow(f.confidence))).toBe(true);
});
});
diff --git a/tests/map/sink-attribution-members.test.ts b/tests/map/sink-attribution-members.test.ts
index b6dd071..0fd6dfc 100644
--- a/tests/map/sink-attribution-members.test.ts
+++ b/tests/map/sink-attribution-members.test.ts
@@ -176,7 +176,7 @@ describe('an inferred package does not license a rule', () => {
expect(sink.package).toBe('@supabase/supabase-js');
expect(sink.attribution).toBe('inferred');
const flow = e.flows.find((f) => f.sink.kind === 'db')!;
- expect(flow.confidence).toBe('precise'); // the data really does reach it
+ expect(flow.confidence).toBe('exact-local'); // the data really does reach it
expect(flow.ruleGeneratable).toBe(false); // and it still must not be auto-ruled
expect(flow.ruleGeneratableReasons!.join(' ')).toMatch(/inferred from the file's other imports/);
});
diff --git a/tests/map/sink-attribution.test.ts b/tests/map/sink-attribution.test.ts
index 01143fe..224ed3a 100644
--- a/tests/map/sink-attribution.test.ts
+++ b/tests/map/sink-attribution.test.ts
@@ -81,7 +81,7 @@ describe('sink attribution', () => {
// Previously impossible: NewExpression was inventoried but never indexed, so it could not be located
// and every flow into it stayed heuristic.
const f = ep.flows.find((x) => x.input === 'code')!;
- expect(f.confidence).toBe('precise');
+ expect(f.confidence).toBe('exact-local');
expect(f.argumentRole).toBe('code');
expect(f.candidateFamily).toBe('code-injection');
});