Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ This versioned reference ships inside `@patchstack/connect` and documents each s

- 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.
- **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.)
- **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).
- **One command reads source files:** `map` (see below) parses your server source to report your app's attack surface. It runs only when you invoke it and prints to stdout. It transmits nothing unless you explicitly pass `--upload`, which sends that description of your app's structure to your own site's Patchstack endpoint — never source code, and never without that flag. No other command reads source (`protect` writes guard files but does not analyze your code).
- **`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`.
- **`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.
- 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).
- **`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.
- **`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, each labelled with how the link was established — from an exact read at the sink's own call site, through a transformed or cross-module link, down to the two being present together with no proven link. 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`.
- **`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, each labelled with how the link was established — from an exact read at the sink's own call site, through a transformed or cross-module link, down to the two being present together with no proven link. 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`) and is never invoked by `scan`, `setup`, `guide`, `protect`, or `mark-build`.
- **`map --upload` is the one opt-in that sends anything derived from your source.** It POSTs the same JSON document to `monitor/pulse/input-map/<your site uuid>` so Patchstack can pin protection rules to your app's own parameter names instead of guessing them. What is sent is exactly what `map` prints — a structural description: route paths, parameter/field names, the dependency behind each sink, and file paths with line numbers. **No source code, no file contents, no environment variable values.** It never runs without the flag, it is skipped when no entry points are detected, and a failure to reach Patchstack is reported and ignored rather than failing your build. Omit the flag and the command stays entirely local.
- **`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.
- Patchstack is not WordPress-only. This connector monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile.

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ patchstack-connect protect Install/reconcile the always-
guard. Auto-wires supported server stacks;
use --check to verify or --demo for local rules.
Also run by setup; never run by scan/guide/mark-build.
patchstack-connect map [--dir p] [--out f] Print a JSON map of this project's attack
patchstack-connect map [--dir p] [--out f] [--upload]
Print a JSON map of this project's attack
surface: server entry points, the inputs each
reads, the sinks they can reach (database, file
system, process, outbound HTTP) and the npm
Expand Down Expand Up @@ -198,7 +199,7 @@ Lower-level pieces are also exported: `scanLockfile`, `buildWirePayload`, `postM
}
```

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.)
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; it transmits nothing unless you pass `--upload`, which sends that structural description — route paths, parameter names, the dependency behind each sink, and file/line locations, never file contents — to your own site's endpoint so rules can be pinned to your real parameter names.) 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.)

## Supported lockfiles

Expand Down
34 changes: 32 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { scanLockfile } from './parsers/index.js';
import { buildWirePayload } from './normalize.js';
import { computeManifestChecksum } from './checksum.js';
import {
postInputMap,
DEFAULT_ENDPOINT,
buildClaimUrl,
fetchSiteStatus,
Expand Down Expand Up @@ -58,7 +59,8 @@ Usage:
manage the widget, install + verify runtime
protection, and wire dependency/build scans.
Never runs the project build
patchstack-connect map [--dir <p>] [--out <f>] Map the app's attack surface: entry points, the
patchstack-connect map [--dir <p>] [--out <f>] [--upload]
Map the app's attack surface: entry points, the
inputs each reads, the sinks it can reach, and
evidence-backed input→sink flows (each labelled
with how the link was established). Best-effort static
Expand Down Expand Up @@ -236,9 +238,37 @@ async function runMap(args: ParsedArgs): Promise<number> {
if (out) {
writeFileSync(out, json);
console.error(`patchstack: wrote ${out}`);
} else {
} else if (args.flags.get('upload') !== true) {
// With --upload the map goes to Patchstack instead of stdout: printing a full structural document
// AND sending it is noise, and the interesting output becomes what the server did with it.
console.log(json);
}

// Opt-in, never implied. This is the only path that sends anything derived from source code, so it
// takes an explicit flag rather than happening because a site UUID exists.
if (args.flags.get('upload') === true) {
if (map.endpoints.length === 0) {
console.error('patchstack: nothing to upload — no server entry points were detected.');
return 0;
}
// Same resolution order as every other network path: CLI flags, then env, then `.patchstackrc.json`.
const config = await resolveConfig({
cwd,
cliSiteUuid: getStringFlag(args.flags, 'site-uuid'),
cliEndpoint: getStringFlag(args.flags, 'endpoint'),
});
const outcome = await postInputMap(config, map);
if (outcome.result === 'stored') {
console.error(`patchstack: uploaded the attack surface (revision ${outcome.revision}).`);
} else if (outcome.result === 'unchanged') {
console.error(`patchstack: attack surface unchanged since revision ${outcome.revision} — nothing to store.`);
} else if (outcome.result === 'skipped') {
console.error(`patchstack: did not upload the attack surface — ${outcome.message}`);
} else {
// Fail-open: this runs inside someone's build, so a Patchstack problem must not fail it.
console.error(`patchstack: could not upload the attack surface — ${outcome.message}`);
}
}
return 0;
}

Expand Down
73 changes: 73 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,79 @@ export function buildPackageRemovedUrl(manifestEndpoint: string, siteUuid: strin
return url.toString();
}

/** Build the input-map ingest URL corresponding to a manifest endpoint override. */
export function buildInputMapUrl(manifestEndpoint: string, siteUuid: string): string {
const url = new URL(manifestEndpoint);
const path = url.pathname.replace(/\/$/, '');
url.pathname = path.endsWith('/manifest')
? `${path.slice(0, -'/manifest'.length)}/input-map/${encodeURIComponent(siteUuid)}`
: `/monitor/pulse/input-map/${encodeURIComponent(siteUuid)}`;
url.search = '';
url.hash = '';
return url.toString();
}

/**
* Outcome of uploading an attack-surface map. `unchanged` is a first-class result, not a failure: most
* builds do not change the surface, and the server keeps one revision per distinct surface rather than
* one per deploy. `skipped` covers the cases where there is nothing to send or nowhere to send it.
*/
export type InputMapUploadOutcome =
| { result: 'stored'; revision: number }
| { result: 'unchanged'; revision: number }
| { result: 'skipped'; message: string }
| { result: 'failed'; message: string };

/**
* Upload an attack-surface map for this site.
*
* Fail-open by construction: every failure path returns a result rather than throwing, because this runs
* during someone's build and a Patchstack outage must never break it. The caller decides what to print.
*/
export async function postInputMap(
config: Config,
map: { version: number; endpoints: unknown[] },
): Promise<InputMapUploadOutcome> {
if (config.siteUuid === null) {
return { result: 'skipped', message: 'No site UUID configured — run `patchstack-connect scan` first.' };
}

const url = buildInputMapUrl(config.endpoint, config.siteUuid);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': '@patchstack/connect',
},
body: JSON.stringify(map),
signal: AbortSignal.timeout(config.timeoutMs),
});
if (response.status === 404) {
return { result: 'failed', message: 'Site not found — the configured site UUID is unknown to Patchstack.' };
}
if (response.status === 422) {
// The server implements a different map schema than this client emits. Say so plainly: it means
// one side is out of date, and guessing at compatibility is how a consumer misreads a document.
return { result: 'failed', message: `Patchstack does not accept this map schema (version ${map.version}). Update @patchstack/connect.` };
}
if (!response.ok) {
return { result: 'failed', message: `Patchstack returned ${response.status}.` };
}
const body = (await response.json()) as { result?: string; revision?: number };
// The revision is part of the contract, not decoration: "stored, revision 0" is not a state the server
// can be in, so accepting it would report a successful upload that cannot be pointed at afterwards.
const revision = Number(body.revision);
if ((body.result === 'stored' || body.result === 'unchanged') && Number.isInteger(revision) && revision > 0) {
return { result: body.result, revision };
}
return { result: 'failed', message: 'Patchstack returned an unexpected response.' };
} catch {
return { result: 'failed', message: `Could not reach Patchstack at ${url}.` };
}
}

/**
* Outcome of the package-removed signal. 'deleted' — the site was unclaimed
* and its record was removed; 'flagged' — the site is claimed, so it was only
Expand Down
Loading
Loading