From 29221c215ccb249b392380f2dcb98375d484ca9f Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 18:06:26 +0200 Subject: [PATCH] map: input identity is (address space, path); confidence becomes a taxonomy (schema v3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three wrong-pin bugs in a row came from one design choice: inputs were keyed by field NAME. Each was patched by REFUSING to emit a coordinate, which is the right reflex but the wrong layer — a refusal compensates for a lossy key instead of fixing it, and costs a legitimate candidate every time. This replaces the key. An input is now identified by `
:` — `get:id`, `post:billing.email`, `route-param:id`. Two reads that share a name in different regions are two inputs, each with its own coordinate, and `Flow.inputId` says which one a flow means (`Flow.input` stays as the display name and is explicitly NOT an identity). The address space is threaded through flow linking too: a tainted root carries the region it was bound from, and `pathFromTainted` captures the namespace segment it used to discard, so a read of `query.id` can no longer lend its evidence to the body field `id`. The refusals those bugs needed are gone, and the same code is now addressed correctly: ({ params: p, query: q }) => { fs.read(q.id); fs.read(p.id); } before: one input, coordinate decided by read order -> a rule pinned to get.id for path data after: get:id -> get.id candidate; route-param:id -> proven flow, no coordinate, refused z.object({ id }).parse(req.body); fs.read(req.query.id) before: refused as a namespace conflict after: get:id pinned correctly; the declared-but-unread post:id has no proven flow ({ query: q }) => q.doc // an alias before: visible but heuristic, no candidate after: exact-local -> get.doc That is 3 refusals turned into 3 correct candidates (corpus: 10 -> 13) with no new wrong pins. `confidence` becomes a taxonomy, so a consumer can tell WHY a link is weak instead of reading one word for four situations: `exact-local` (the input IS an argument of the sink call — the only tier that should ever be promoted to blocking automatically), `transformed-local` (it reaches the argument through an expression: the payload still arrives in the same parameter, so a rule compiles, but what reaches the sink is not what arrived), `imported` (the sink is in another module, so no argument-level evidence can exist here), `heuristic` (both present, no proven link), `unknown` (no source span at all). `isProvenFlow` is shared by every consumer so "proven" means one thing. Notable: `db.insert({ title: req.body.title })` is transformed-local, not exact — the sink receives an object containing the value. That distinction is the whole point of the tier. Breaking, hence `version: 3`. `withCoordinates` now attaches identity, so validator extraction returns a `FieldShape` (a name and constraints) and identity is assigned exactly where the request region becomes known — the type system enforces that ordering. The corpus harness itself was name-keyed and would have mis-reported a correct candidate's coordinate; it now correlates by id. The v2 -> v3 break is silent-failure shaped: `confidence === 'precise'` is now permanently FALSE rather than an error, so a consumer following stale docs sees every proven flow as unproven and quietly stops generating anything. Every reference to the old value is updated — including the internal boolean that was still NAMED `precise`, which is how the prose drifted — and two guards keep the docs honest: every emitted confidence must be one of the five declared tiers, and the coverage notes must not point a consumer at a removed value while failing to name the current ones. `SiteInputMap.version` now states the obligation directly: treat it as a wire contract, reject a version you do not implement rather than parsing optimistically, and only `exact-local` may feed an automatic promotion to blocking — `transformed-local` stays dry-run / review-only. The matching AGENT-INSTALL.md wording change is deliberately NOT here: that file is field-test-gated, so it ships in its own PR before the next publish (the doc travels with the package, not with main). 908 tests. Co-Authored-By: Claude Opus 4.8 --- src/cli.ts | 13 +- src/map/coordinates.ts | 47 ++++++- src/map/extract.ts | 11 +- src/map/flows.ts | 139 +++++++++++++------ src/map/inputs.ts | 68 ++++----- src/map/module-graph.ts | 2 +- src/map/sinks.ts | 2 +- src/map/types.ts | 71 ++++++++-- tests/map/aliased-namespace.test.ts | 16 +-- tests/map/argument-roles.test.ts | 10 +- tests/map/corpus.test.ts | 61 ++++++--- tests/map/edge-functions.test.ts | 5 +- tests/map/extract.test.ts | 15 +- tests/map/flow-paths.test.ts | 11 +- tests/map/flow-precision.test.ts | 12 +- tests/map/input-identity.test.ts | 152 +++++++++++++++++++++ tests/map/runtime-coordinates.test.ts | 16 ++- tests/map/sink-attribution-members.test.ts | 2 +- tests/map/sink-attribution.test.ts | 2 +- 19 files changed, 491 insertions(+), 164 deletions(-) create mode 100644 tests/map/input-identity.test.ts 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'); });