map: build-time input-flow (attack-surface) map command - #116
Merged
Conversation
Adds `patchstack-connect map` — a build-time command that walks the app's source
and emits its input-flow map: entry points → the inputs each reads → the
sinks/dependencies they reach. It's both a user-facing attack-surface view and
the coordinate source precise (param-pinned) vPatch rules bind against.
Framework-AGNOSTIC by design — signal-driven, not stack-gated:
- entry points: createServerFn (TanStack), exported GET/POST/… handlers (Next
route handlers / SvelteKit), and app.post('/x', handler) route registrations
(Express/Fastify/Hono).
- inputs: zod z.object fields (name + type + min/max), and req.body/query/params
member accesses.
- sinks (provider-agnostic): db (supabase/knex .from().op, prisma, raw query),
fs, child_process exec, http/fetch, eval — followed one level into same-file
helpers. Add a stack by adding a recognizer.
Honesty is a first-class field: `coverage.notes` records that this is the
DETECTED surface (best-effort static analysis), never a guarantee.
The compiler is resolved at RUNTIME from the target app's own `typescript`
(marked external in tsup so it's never bundled into the CLI). Validated against
the TanStack+Supabase reference app and TanStack/Express/Next fixtures.
This is Leg 1 (extract + emit) of the build-time input-flow map; Leg 2 (POST to
a SaaS `site_input_map` store) is a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Robust new 🎯 Quality: 91% Elite · 📦 Size: Extra Large — strongly consider breaking this down 🛡️ Standards: no pre-flight fit check ran for this change — wire 📈 This month: Your 52nd PR — above team average · Averaging Excellent |
…ctions The link that lets a site's vulnerable dependency (from the manifest / TI) be correlated to the exact input that reaches it: each sink now carries `package`, resolved from the file's imports — precisely from the call's base identifier (fs → node:fs, exec → node:child_process, axios → axios, and const-from-import / require / new bindings), or inferred from the file's import of a known provider for that sink kind when the client is built via a local factory (e.g. `const supabase = getClient()` still resolves to @supabase/supabase-js). Also fixes a dead branch: files with a `'use server'` directive passed the pre-filter but had no recognizer — Next server actions are now extracted as entry points (entryKind: server-action), and same-line const route-handler / server-action recognizers are consolidated. Validated on the reference app (all 7 supabase sinks tagged) and fixtures across TanStack / Express / Next / server-action shapes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, honesty markers
Recall fixes (all previously produced silent false negatives):
- the textual pre-filter and the route recognizer now derive from one list,
so files registering only .head()/.use() routes are no longer skipped
- inputs destructured from req.body/query/params and from destructured
handler params are extracted; fetch-style bodies are traced through
`const body = await request.json()` variables and destructuring
- router.route('/x').get(handler) chains and Fastify's object-form
app.route({method, url, handler}) are recognized (one endpoint per method)
- symlinked source directories are followed (with a realpath cycle guard)
Precision fixes (all previously produced false positives):
- sink recognizers are gated on module bindings: calls on plain local
objects/classes/functions are not dependency sinks; prisma-shaped ops
require a real prisma signal
- validator `.object({...})` is only read as a schema when its receiver
traces to a known validator package
- destructured handler params no longer wildcard-match unrelated
*.body/query/params member accesses
- bare builtin imports normalize to node:* (npm has a package named `fs`)
New signal:
- endpoints and sinks carry a 1-based source line (auditable coordinates)
- nested validator fields flatten to dotted paths (address.city, tags[].label)
with formats (.email(), …) and regex constraints captured
- inputsResolved: false marks endpoints whose declared validator could not be
parsed — inputs are unknown, not empty — and coverage notes now also report
per-run facts (skipped files, unresolved validators) instead of boilerplate
- per-file fail-open: one unparseable file no longer aborts the whole map
- binding resolution is transitive (const conn = pool.promise())
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses an external review of the map command. The headline problem: we emitted
endpoint-level inputs AND endpoint-level sinks but never established which input
reaches which sink — while the CLI advertised "inputs → sinks … precise rule
pinning". Anything consuming that for parameter pinning could pin the wrong input.
- FLOWS. Each endpoint now carries `flows: [{input, sink, confidence, line}]`.
A flow is `precise` only when the input identifier/path appears inside the sink
call's arguments (tainting the handler params + local aliases such as
`const body = await request.json()`); otherwise `heuristic` ("may reach"). On the
reference app: `title -> insert [precise]`, while the helper-reached select that
never receives the input is correctly `heuristic`. `inputs`/`sinks` are documented
as INVENTORIES; only flows assert reachability.
- FALSE POSITIVE. Sinks inside a declared-but-uncalled local function are no longer
attributed to the endpoint (an unused helper that shells out used to make the
endpoint look like it reaches exec). Inline callbacks / IIFEs still count.
- MISSED CODE. Walk the whole project (minus node_modules/dist/build/.next/…)
instead of `src` only, so root-level `server.ts` / `app/` / `functions/`
entrypoints are seen; adds .cjs/.cts/.mts.
- BOUNDARY. Symlinks are followed only while they stay inside the project;
--follow-symlinks opts out (a link to an external repo used to pull in its code).
- COVERAGE. `coverage` now reports filesDiscovered/filesParsed/filesSkipped + roots,
and notes when endpoints have inputs+sinks but no proven link.
Also fixes a pre-existing sink regression found while testing: a client built by a
LOCAL factory (`const supabase = getClient()`, the common AI-generated shape) looked
like a plain local, so every sink on it was dropped — the reference app reported ZERO
sinks. Bindings now follow a local factory's return value to the package it wraps
(fixpoint-resolved), restoring all 7 supabase sinks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shipped docs must disclose every capability in dist/, and an overbroad privacy
claim is treated as misrepresentation by auditing agents. `map` reads the project's
source files, which made two claims inaccurate:
- AGENT-INSTALL said the package "reads the project's dependency list only".
That was a READ claim; it is now scoped to what is TRANSMITTED (still only
package names + versions), with an explicit line that `map` reads source
locally and transmits nothing.
- README's payload paragraph now notes the same.
Adds a `map` entry to both command references (what it walks, that it uses the
project's own TypeScript, that output is best-effort/detected-surface, that it
writes nothing but --out and is never invoked by scan/setup/guide/protect).
The install prompt is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
Author
|
/review |
devlob
approved these changes
Aug 13, 2026
A file-based route carries its URL in its LOCATION, not its code, so these endpoints
had no `route` — meaning a generated rule could only be param-pinned, never
route-scoped (`when.path`). Derive it across the conventions AI builders emit:
Next App Router app/api/items/route.ts -> /api/items
app/api/items/[id]/route.ts -> /api/items/:id
app/(marketing)/api/x/route.ts -> /api/x (group stripped)
app/api/files/[...path]/route.ts -> /api/files/:path
Next Pages Router pages/api/items/index.ts -> /api/items
pages/api/[id].ts -> /api/:id
SvelteKit src/routes/api/items/+server.ts -> /api/items
Nuxt server/api/items.post.ts -> /api/items (method suffix dropped)
Dynamic segments become `:name` and set `routeDynamic: true`, so a consumer knows the
route is a PATTERN and must scope with a glob/regex `when.path` rather than treating
`/api/orders/:id` as a literal path.
Net effect: a Next route handler now yields FULL pinning coordinates — route +
method + inputs + a precise input→sink flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
patchstack-connect map— a build-time command that walks the app's source and emits its input-flow map: entry points → the inputs each reads → the sinks/dependencies they reach. It's both a user-facing attack-surface view and the coordinate source precise (param-pinned) vPatch rules bind against (the proactive complement to runtime coordinate reporting).Example (our TanStack + Supabase reference app):
{ "framework": "tanstack-start", "endpoints": [ { "name": "createTask", "entryKind": "server-fn", "method": "POST", "inputs": [{"name":"title","type":"string","min":1,"max":200}], "sinks": [{"kind":"db","provider":"sql","table":"tasks","op":"insert"}, {"kind":"db","provider":"sql","table":"tasks","op":"select"}] } ]}Agnostic by design
Signal-driven, not stack-gated — add a stack by adding a recognizer:
createServerFn(TanStack), exportedGET/POST/…handlers (Next route handlers / SvelteKit+server), andapp.post('/x', handler)route registrations (Express / Fastify / Hono).z.objectfields (name + type +min/max) andreq.body/query/paramsmember accesses.supabase/knex.from().op, prisma, raw.query), fs,child_processexec, http/fetch,eval— followed one level into same-file helper functions.Honesty
coverage.notesrecords that this is the DETECTED surface (best-effort static analysis: dynamic dispatch / cross-file indirection aren't traced), never a completeness guarantee — the framing to keep when this is shown in a dashboard.Compiler resolution
mapparses the app's source with a TypeScript compiler resolved at runtime from the target app's owntypescript(or the environment), andtypescriptis marked external in tsup so the heavy compiler is never bundled into the CLI (the runtime guard never needs it).Scope
Leg 1 (extract + emit). Leg 2 — POST the map to a SaaS
site_input_mapstore (parallel to the manifest post) — is a follow-up.Tests
tests/map-extract.test.tsvalidates extraction across TanStack / Express / Next shapes in one fixture app (zod inputs,req.*accesses, fs/exec/db sinks, one-level helper dataflow, framework label, honesty notes). Validated against the real reference app. Full suite green (653), typecheck + build clean.