Skip to content

map: build-time input-flow (attack-surface) map command - #116

Merged
patchstackdave merged 6 commits into
mainfrom
feature/input-flow-map
Aug 13, 2026
Merged

map: build-time input-flow (attack-surface) map command#116
patchstackdave merged 6 commits into
mainfrom
feature/input-flow-map

Conversation

@patchstackdave

Copy link
Copy Markdown
Contributor

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).

patchstack-connect map [--dir <path>] [--out <file>]

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:

  • entry points: createServerFn (TanStack), exported GET/POST/… handlers (Next route handlers / SvelteKit +server), 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 helper functions.

Honesty

coverage.notes records 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

map parses the app's source with a TypeScript compiler resolved at runtime from the target app's own typescript (or the environment), and typescript is 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_map store (parallel to the manifest post) — is a follow-up.

Tests

tests/map-extract.test.ts validates 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.

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>
@coderbuds

coderbuds Bot commented Aug 13, 2026

Copy link
Copy Markdown

Robust new map command implements comprehensive static attack-surface analysis.

🎯 Quality: 91% Elite · 📦 Size: Extra Large — strongly consider breaking this down

🛡️ Standards: no pre-flight fit check ran for this change — wire assess-change-fit into your coding agents to catch size before opening.

📈 This month: Your 52nd PR — above team average · Averaging Excellent

See how your team is trending →

patchstackdave and others added 4 commits August 13, 2026 11:39
…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>
@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

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>
@patchstackdave
patchstackdave merged commit f942492 into main Aug 13, 2026
5 checks passed
@patchstackdave
patchstackdave deleted the feature/input-flow-map branch August 13, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants