Skip to content

Commit f942492

Browse files
map: build-time input-flow (attack-surface) map command (#116)
* map: build-time input-flow (attack-surface) map command 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> * map: tag each sink with the npm package behind it; recognize server actions 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> * map: harden the extractor — binding-gated sinks, richer input tracing, 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> * map: prove input→sink flows, stop over-claiming, widen + bound the walk 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> * docs: disclose the `map` capability and scope the "no source code" claim 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> * map: derive the URL path of file-based route handlers 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> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 19428c7 commit f942492

10 files changed

Lines changed: 1657 additions & 3 deletions

File tree

AGENT-INSTALL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
55
## Package and command behavior
66

77
- Package: [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect), MIT-licensed, source at https://github.com/patchstack/connect. `npm view @patchstack/connect` shows the live registry metadata.
8-
- It reads the project's **dependency list only** — from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — and sends package names + versions to Patchstack for vulnerability matching. No source code, no env var values, no file paths, no git history. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.)
8+
- **What is sent to Patchstack is the dependency list only** — read from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — package names + versions, for vulnerability matching. No source code, no env var values, no file paths, no git history is ever transmitted. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.)
9+
- **One command reads source files, locally:** `map` (see below) parses your server source to report your app's attack surface. It runs only when you invoke it, prints to stdout, and transmits nothing. No other command reads source (`protect` writes guard files but does not analyze your code).
910
- **`scan` makes one source edit, and only after a successful post:** it adds (or updates) the disclosure widget's `<script>` tag in the project's root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists. It touches no other file, never edits on `--dry-run` or after a failed post, leaves any pre-existing manual widget tag untouched, and is disabled entirely by `"widget": false` in `.patchstackrc.json`. `mark-build` writes to build output only (`dist/`, `build/`, `out/`, `.output/public`), never to source. `guide`, `status`, and `init` write nothing except `init`'s own `.patchstackrc.json`.
1011
- **`setup` runs `scan`, then `protect`, then edits `package.json` scripts:** provisioning happens first so the runtime guard can bake the real site UUID. It verifies the resulting framework seam, preserves existing commands, adds `scan` after dependency installs and before builds, adds `mark-build` after builds, and uses a direct build chain for Bun. It never runs the project build. If the widget or runtime guard needs a framework-specific manual merge, it prints the exact remaining step instead of overwriting user code.
1112
- The package also exposes **`protect`** directly (runtime exploit guard; its templates live under `dist/protect/`). `setup` invokes it automatically; `scan`, `guide`, `status`, and `mark-build` do not. It writes only local files and auto-wires known stacks — **TanStack Start + Supabase** (patches the Supabase client + `src/start.ts`), **Next.js** (scaffolds `middleware.ts`), **SvelteKit** (`src/hooks.server.ts`), **Astro** (`src/middleware.ts`), **Nuxt** (`server/middleware/`), **NestJS** (`app.use(patchstackMiddleware)` in the bootstrap), **Fastify** (`app.register(patchstackFastify)`), and **Express** (`app.use(patchstackMiddleware)`). On **any other stack** it scaffolds a framework-agnostic guard under `src/patchstack/` and prints a wiring plan — then you finish the install by importing that guard into your server entry (`protectFetch(handler)` for a Web-Fetch server, or `app.use(patchstackMiddleware)` for Node/Express) and running `patchstack-connect protect --check` to confirm it is wired (exit 1 until it is). Passing `--demo` seeds a broad sample rule set (for demonstrations, not production).
1213
- **`demo node-serialize` is an explicit production-backed walkthrough.** It requires `node-serialize@0.0.4` to already be present in the lockfile; it does not install the vulnerable dependency. It runs the same production `scan`, polls the configured site's public Pulse rules endpoint until rule `18843` is served, runs `protect`, verifies the generated guard, and prints exploit/benign test requests. It writes the same manifest/widget and guard files as those underlying commands. It does not start/restart the app and does not send the printed requests.
14+
- **`map` is a local, read-only analysis command.** It walks the project's server source (skipping `node_modules`, build output and dot-directories; it does not follow symlinks out of the project unless you pass `--follow-symlinks`), parses it with the project's **own** `typescript`, and prints JSON describing the attack surface: entry points, the inputs each reads, the sinks they can reach (database / file system / process / outbound HTTP) with the npm package behind each, and evidence-backed input→sink flows marked `precise` or `heuristic`. Static analysis is best-effort, so the output reports the *detected* surface with coverage counters — not a completeness guarantee. It writes nothing (except the file you name with `--out`), contacts nothing, and is never invoked by `scan`, `setup`, `guide`, `protect`, or `mark-build`.
1315
- **`demo-guide node-serialize` is the read-only companion.** It checks the Host-created site configuration and vulnerable lockfile entry, explains the complete local prepare/run/restart/prove/cleanup sequence, and prints the next exact command. It does not require a deployment and does not change files or contact Patchstack.
1416
- Patchstack is not WordPress-only. This connector monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile.
1517

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ patchstack-connect protect Install/reconcile the always-
6969
guard. Auto-wires supported server stacks;
7070
use --check to verify or --demo for local rules.
7171
Also run by setup; never run by scan/guide/mark-build.
72+
patchstack-connect map [--dir p] [--out f] Print a JSON map of this project's attack
73+
surface: server entry points, the inputs each
74+
reads, the sinks they can reach (database, file
75+
system, process, outbound HTTP) and the npm
76+
package behind each sink. READS YOUR SOURCE
77+
FILES locally and parses them with the
78+
project's own TypeScript; writes nothing except
79+
--out, and posts nothing. Never run by
80+
scan/setup/guide/protect — run it yourself.
7281
patchstack-connect demo node-serialize Production-backed walkthrough: require
7382
node-serialize@0.0.4, scan it, wait for live
7483
rule 18843, install + verify the runtime guard,
@@ -189,7 +198,7 @@ Lower-level pieces are also exported: `scanLockfile`, `buildWirePayload`, `postM
189198
}
190199
```
191200

192-
That's the entire payload. No source code, no environment variable values, no file paths — just the package names and versions from your lockfile. Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed. (`mark-build` separately stamps built HTML with a stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL` — never their values.)
201+
That's the entire payload. No source code, no environment variable values, no file paths — just the package names and versions from your lockfile. (The `map` command reads source files locally to report your attack surface, but transmits nothing.) Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed. (`mark-build` separately stamps built HTML with a stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL` — never their values.)
193202

194203
## Supported lockfiles
195204

src/cli.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
renderGuideChecklist,
3737
} from './guide.js';
3838
import { runProtect, runVerify } from './protect/install/index.js';
39+
import { buildInputMap } from './map/index.js';
3940
import { setupProtection, wireBuildScripts } from './setup.js';
4041
import { detectStack, type StackDescriptor } from './stack.js';
4142
import { PatchstackError } from './types.js';
@@ -56,6 +57,14 @@ Usage:
5657
manage the widget, install + verify runtime
5758
protection, and wire dependency/build scans.
5859
Never runs the project build
60+
patchstack-connect map [--dir <p>] [--out <f>] Map the app's attack surface: entry points, the
61+
inputs each reads, the sinks it can reach, and
62+
evidence-backed input→sink flows (each marked
63+
precise or heuristic). Best-effort static
64+
analysis — reports the DETECTED surface, with
65+
coverage counters. Prints JSON (--out writes a
66+
file; --follow-symlinks leaves the project dir).
67+
Uses the app's own TypeScript
5968
patchstack-connect init <site-uuid> Optional: pre-seed .patchstackrc.json
6069
with an existing site UUID
6170
patchstack-connect status [options] Show current configuration and whether the
@@ -128,7 +137,7 @@ Examples:
128137
npx @patchstack/connect demo-guide node-serialize
129138
`;
130139

131-
const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir', 'url']);
140+
const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir', 'url', 'out']);
132141

133142
interface ParsedArgs {
134143
command: string | null;
@@ -193,6 +202,41 @@ async function runInit(args: ParsedArgs): Promise<number> {
193202
return 0;
194203
}
195204

205+
async function runMap(args: ParsedArgs): Promise<number> {
206+
const cwd = getStringFlag(args.flags, 'dir') ?? process.cwd();
207+
const { map, error } = await buildInputMap(cwd, {
208+
followSymlinks: args.flags.get('follow-symlinks') === true,
209+
});
210+
if (!map) {
211+
console.error(`patchstack: ${error}`);
212+
return 1;
213+
}
214+
// Human summary → stderr; the JSON → stdout (so it can be piped / written). Report PRECISE flows
215+
// separately from the inventories: only a precise flow is evidence that an input reaches a sink.
216+
const inputs = map.endpoints.reduce((n, e) => n + e.inputs.length, 0);
217+
const sinks = map.endpoints.reduce((n, e) => n + e.sinks.length, 0);
218+
const precise = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => f.confidence === 'precise').length, 0);
219+
const c = map.coverage;
220+
console.error(
221+
`patchstack: ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s), ` +
222+
`${precise} proven input→sink flow(s) [${map.framework}].`,
223+
);
224+
console.error(
225+
`patchstack: ${c.filesParsed}/${c.filesDiscovered} file(s) parsed` +
226+
(c.filesSkipped ? `, ${c.filesSkipped} skipped` : '') +
227+
`. DETECTED surface only — static analysis is best-effort; unproven pairs are marked "heuristic".`,
228+
);
229+
const json = JSON.stringify(map, null, 2);
230+
const out = getStringFlag(args.flags, 'out');
231+
if (out) {
232+
writeFileSync(out, json);
233+
console.error(`patchstack: wrote ${out}`);
234+
} else {
235+
console.log(json);
236+
}
237+
return 0;
238+
}
239+
196240
async function runScan(
197241
args: ParsedArgs,
198242
options: { showRemainingSetup?: boolean } = {},
@@ -771,6 +815,8 @@ async function main(): Promise<number> {
771815
return runGuide(args);
772816
case 'setup':
773817
return runSetup(args);
818+
case 'map':
819+
return runMap(args);
774820
default:
775821
console.error(`Unknown command: ${args.command}\n`);
776822
console.error(HELP);

0 commit comments

Comments
 (0)