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
2 changes: 1 addition & 1 deletion AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
- 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.)
- **`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 edits only `package.json` build scripts:** it preserves existing commands, adds `scan` before builds and `mark-build` after builds, and uses a direct build chain for Bun. It never runs the project build or `protect`. If the widget needs a framework-specific source edit, it prints the exact remaining step instead of rewriting framework code.
- The package also bundles an **opt-in** `protect` command (runtime exploit guard, currently for TanStack Start + Supabase apps; its templates live under `dist/protect/`). It patches the app's Supabase client to route traffic through a same-origin guard — and it runs **only** when explicitly invoked; `scan`, `setup`, `guide`, `status`, and `mark-build` never invoke it. Passing `--demo` seeds a broad sample rule set (for demonstrations, not production) instead of the default starter rules; it writes only local files.
- The package also bundles an **opt-in** `protect` command (runtime exploit guard; its templates live under `dist/protect/`). It runs **only** when explicitly invoked; `scan`, `setup`, `guide`, `status`, and `mark-build` never invoke it, and it writes only local files. On a **TanStack Start + Supabase** app it auto-wires the guard (patches the Supabase client + `src/start.ts`). 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).
- Patchstack is not WordPress-only. This connector monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile.

## Before you start — never install twice
Expand Down
21 changes: 17 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
installCommand,
renderGuideChecklist,
} from './guide.js';
import { runProtect } from './protect/install/index.js';
import { runProtect, runVerify } from './protect/install/index.js';
import { wireBuildScripts } from './setup.js';
import { detectStack, type StackDescriptor } from './stack.js';
import { PatchstackError } from './types.js';
Expand All @@ -43,11 +43,14 @@ Usage:
patchstack-connect mark-build [options] Stamp built HTML with a production flag +
build fingerprint, and ensure the widget
tag in built pages (run as a postbuild step)
patchstack-connect protect [--demo] Install always-on runtime protection (the
guard) into a TanStack Start + Supabase app.
Covers the browser + server-function paths.
patchstack-connect protect [--demo|--check] Install always-on runtime protection (the
guard). Auto-wires known stacks (TanStack
Start + Supabase); for others it scaffolds a
generic guard + prints a wiring plan.
--demo seeds a broad sample rule set (for
demonstrations, not production).
--check verifies the guard is wired (exit 1
if not) — for the wire-then-verify loop.
patchstack-connect guide [--full] Show this project's setup status (what's done,
what's missing, with tailored commands), then
print the full setup guide. --full prints the
Expand Down Expand Up @@ -305,6 +308,16 @@ function reportSourceWidget(siteUuid: string): void {
}

async function runProtectCommand(args: ParsedArgs): Promise<number> {
// `--check`: verify the guard is wired (for the agent/CI loop). Non-zero exit if not.
if (args.flags.get('check') === true) {
const report = runVerify(process.cwd());
console.log(`patchstack protect --check (${report.stack}):`);
for (const c of report.checks) {
console.log(` ${c.ok ? '✓' : '✗'} ${c.label}${!c.ok && c.hint ? ` — ${c.hint}` : ''}`);
}
console.log(report.wired ? 'guard is wired ✓' : 'guard is NOT fully wired ✗');
return report.wired ? 0 : 1;
}
// Best-effort: like mark-build, this runs during builds and must never fail one.
const demo = args.flags.get('demo') === true;
try {
Expand Down
20 changes: 19 additions & 1 deletion src/protect/install/adapters/tanstack-supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';
import { read, log, templatesDir } from '../util.js';
import type { Adapter, WireOptions, WireResult } from '../types.js';
import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js';

const CLIENT_TUNNEL = [
'',
Expand Down Expand Up @@ -249,9 +249,27 @@ function wire(cwd: string, opts: WireOptions): WireResult {
return { ok: true, changed: [...new Set(changed)] };
}

function verify(cwd: string): VerifyResult {
const guardPath = join(cwd, GUARD_FILE);
const clientPath = join(cwd, 'src/integrations/supabase/client.ts');
const startPath = join(cwd, 'src/start.ts');
const guard = existsSync(guardPath) ? read(guardPath) : '';
const client = existsSync(clientPath) ? read(clientPath) : '';
const start = existsSync(startPath) ? read(startPath) : '';

const checks = [
{ label: 'guard.ts scaffolded', ok: guard.length > 0, hint: 'run `patchstack-connect protect`' },
{ label: 'Supabase client tunnels through the guard', ok: client.includes('x-ps-target'), hint: 'run `patchstack-connect protect` to re-patch src/integrations/supabase/client.ts' },
{ label: 'request middleware defined + registered', ok: start.includes('const patchstackGuard =') && start.includes('requestMiddleware: [patchstackGuard'), hint: 'run `patchstack-connect protect` to re-patch src/start.ts' },
{ label: 'server-function middleware defined + registered', ok: start.includes('const patchstackFunctionGuard =') && start.includes('functionMiddleware: [patchstackFunctionGuard'), hint: 'run `patchstack-connect protect` to re-patch src/start.ts' },
];
return { wired: checks.every((c) => c.ok), checks };
}

export const tanstackSupabaseAdapter: Adapter = {
name: 'tanstack-supabase',
label: 'TanStack Start + Supabase',
detect,
wire,
verify,
};
123 changes: 123 additions & 0 deletions src/protect/install/generic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Generic fallback for stacks no built-in adapter matches. Rather than silently skip, we scaffold
// a framework-agnostic guard, print a wiring plan (with best-effort entry-point detection), and
// provide a `--check` verifier — so the builder's own agent can finish the wiring and confirm it,
// entirely through the CLI (no server, no hosted infra).

import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { read, templatesDir } from './util.js';
import type { WireOptions, VerifyResult } from './types.js';

const GUARD_MARKER = 'patchstack/guard';

function genericDir(cwd: string): string {
return existsSync(join(cwd, 'src')) ? 'src/patchstack' : 'patchstack';
}

export function scaffoldGeneric(cwd: string, opts: WireOptions): { changed: string[]; dir: string } {
const templates = templatesDir();
const dir = genericDir(cwd);
const dst = join(cwd, dir);
mkdirSync(dst, { recursive: true });
copyFileSync(join(templates, 'generic-guard.ts'), join(dst, 'guard.ts'));
const changed = [`${dir}/guard.ts`];
const rulesDst = join(dst, 'rules.json');
if (opts.demo || !existsSync(rulesDst)) {
copyFileSync(join(templates, opts.demo ? 'demo-rules.json' : 'rules.json'), rulesDst);
changed.push(`${dir}/rules.json`);
}
return { changed, dir };
}

// Best-effort: likely server entry files + whether Express is a dependency.
function candidateEntries(cwd: string): string[] {
const names = [
'src/server.ts', 'src/server.js', 'src/index.ts', 'src/index.js', 'src/app.ts', 'src/app.js',
'src/main.ts', 'server.ts', 'server.js', 'index.ts', 'index.js', 'app.ts', 'app.js',
];
const hits = names.filter((n) => existsSync(join(cwd, n)));
try {
const pkg = JSON.parse(read(join(cwd, 'package.json')));
if (typeof pkg.main === 'string' && existsSync(join(cwd, pkg.main))) hits.push(pkg.main);
} catch {
/* ignore */
}
return [...new Set(hits)];
}

function usesExpress(cwd: string): boolean {
try {
const pkg = JSON.parse(read(join(cwd, 'package.json')));
return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }.express);
} catch {
return false;
}
}

export function wiringPlan(cwd: string, dir: string): string {
const entries = candidateEntries(cwd);
const express = usesExpress(cwd);
const lines = [
`no built-in adapter matched this stack — scaffolded a generic guard at ${dir}/guard.ts + ${dir}/rules.json.`,
'Finish by wiring it into your server (pick the one that fits):',
` • Web-Fetch entry: export default { fetch: protectFetch(yourHandler) } // import from "${dir}/guard"`,
` • Node / Express: app.use(patchstackMiddleware) // add before your routes`,
entries.length
? `Likely server ${entries.length === 1 ? 'entry' : 'entries'}: ${entries.join(', ')}${express ? ' (Express detected)' : ''}.`
: 'Could not locate a server entry — wire it wherever requests enter your app.',
'Then confirm it is hooked up: npx patchstack-connect protect --check',
];
return lines.join('\n');
}

export function genericVerify(cwd: string): VerifyResult {
const dir = genericDir(cwd);
const scaffolded = existsSync(join(cwd, dir, 'guard.ts'));
const imported = scaffolded && guardIsImported(cwd, join(cwd, dir, 'guard.ts'));
return {
wired: scaffolded && imported,
checks: [
{ label: 'generic guard scaffolded', ok: scaffolded, hint: 'run `patchstack-connect protect`' },
{
label: 'guard imported into a server entry',
ok: imported,
hint: `import { protectFetch } (or patchstackMiddleware) from "${dir}/guard" and wire it into your request path`,
},
],
};
}

function guardIsImported(cwd: string, guardPath: string): boolean {
const root = existsSync(join(cwd, 'src')) ? join(cwd, 'src') : cwd;
let found = false;
const walk = (d: string): void => {
if (found) return;
let entries: string[];
try {
entries = readdirSync(d);
} catch {
return;
}
for (const name of entries) {
if (found) return;
if (name === 'node_modules' || name.startsWith('.')) continue;
const p = join(d, name);
let st;
try {
st = statSync(p);
} catch {
continue;
}
if (st.isDirectory()) walk(p);
else if (/\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(name) && p !== guardPath) {
try {
if (readFileSync(p, 'utf8').includes(GUARD_MARKER)) found = true;
} catch {
/* ignore */
}
}
}
};
walk(root);
return found;
}
31 changes: 19 additions & 12 deletions src/protect/install/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,33 @@

import { log } from './util.js';
import { tanstackSupabaseAdapter } from './adapters/tanstack-supabase.js';
import type { Adapter, WireOptions, ProtectResult } from './types.js';
import { scaffoldGeneric, wiringPlan, genericVerify } from './generic.js';
import type { Adapter, WireOptions, ProtectResult, VerifyReport } from './types.js';

// Registry — order = match priority. Add new stacks here.
const ADAPTERS: Adapter[] = [tanstackSupabaseAdapter];

/** Scaffold + wire the runtime guard into the app at `cwd`. Best-effort — never throws. */
export function runProtect(cwd: string, opts: WireOptions = {}): ProtectResult {
const adapter = ADAPTERS.find((a) => a.detect(cwd));

if (!adapter) {
log(
`no built-in adapter matched this app's stack. Auto-wire currently supports: ` +
`${ADAPTERS.map((a) => a.label).join(', ')}. The guard engine is stack-agnostic; ` +
`wiring for this stack needs a new adapter or an agent-assisted install (see AGENT-INSTALL.md) — not skipped silently.`,
);
return { status: 'unsupported', supported: ADAPTERS.map((a) => a.name) };
if (adapter) {
const result = adapter.wire(cwd, opts);
return { status: 'wired', adapter: adapter.name, changed: result.changed };
}

const result = adapter.wire(cwd, opts);
return { status: 'wired', adapter: adapter.name, changed: result.changed };
// No adapter matched — DON'T silently skip. Scaffold the framework-agnostic guard and print a
// wiring plan the builder's agent (or user) can finish, then verify with `protect --check`.
const { changed, dir } = scaffoldGeneric(cwd, opts);
const plan = wiringPlan(cwd, dir);
log(plan);
return { status: 'scaffolded', adapter: 'generic', changed, plan };
}

/** Verify the guard is correctly wired (backs `protect --check`). Fail-open — never throws. */
export function runVerify(cwd: string): VerifyReport {
const adapter = ADAPTERS.find((a) => a.detect(cwd));
if (adapter) return { stack: adapter.label, ...adapter.verify(cwd) };
return { stack: 'generic', ...genericVerify(cwd) };
}

export type { Adapter, WireOptions, WireResult, ProtectResult } from './types.js';
export type { Adapter, WireOptions, WireResult, VerifyResult, VerifyReport, ProtectResult } from './types.js';
20 changes: 20 additions & 0 deletions src/protect/install/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ export interface WireResult {
changed: string[];
}

export interface VerifyCheck {
label: string;
ok: boolean;
/** How to fix it, shown when `ok` is false. */
hint?: string;
}

export interface VerifyResult {
wired: boolean;
checks: VerifyCheck[];
}

export interface Adapter {
/** Stable id, e.g. "tanstack-supabase". */
name: string;
Expand All @@ -25,8 +37,16 @@ export interface Adapter {
detect(cwd: string): boolean;
/** Scaffold + wire the guard. Only called when `detect()` returned true. */
wire(cwd: string, opts: WireOptions): WireResult;
/** Inspect the app and report whether the guard is correctly wired (for `protect --check`). */
verify(cwd: string): VerifyResult;
}

export type ProtectResult =
| { status: 'wired'; adapter: string; changed: string[] }
| { status: 'scaffolded'; adapter: string; changed: string[]; plan: string }
| { status: 'unsupported'; supported: string[] };

export interface VerifyReport extends VerifyResult {
/** Which adapter/stack the verification ran against (or "generic"). */
stack: string;
}
48 changes: 48 additions & 0 deletions src/protect/templates/generic-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Patchstack runtime protection — GENERIC guard (framework-agnostic).
//
// Scaffolded by `patchstack-connect protect` when no built-in adapter matched your stack. Wire
// whichever helper below fits your server into your request path (see the plan the CLI printed),
// then run `patchstack-connect protect --check` to confirm it's hooked up. The engine ships inside
// @patchstack/connect — nothing else to install.
import { createProtection } from "@patchstack/connect/protect";
import fallbackRules from "./rules.json";

let _protection: Awaited<ReturnType<typeof createProtection>> | undefined;

/** One memoized protection policy. Rules come from the Patchstack API per-site (cached); the
* bundled rules.json is the fallback until a site UUID / token is configured. */
export async function getProtection() {
if (!_protection) {
const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
const token = process.env.PATCHSTACK_WAF_TOKEN;
const siteUuid = process.env.PATCHSTACK_SITE_UUID;
const common = { mode, egress: true } as const;
_protection = await createProtection(
siteUuid
? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
: token
? { ...common, token, cacheDir: ".patchstack" }
: { ...common, rules: fallbackRules as never },
);
}
return _protection;
}

// --- Web Fetch (Cloudflare Workers, Bun, Deno, Hono, Next edge, TanStack server.ts) ---------
// Wrap your fetch handler: export default { fetch: protectFetch(originalFetch) }
export function protectFetch<H extends (request: Request, ...rest: unknown[]) => unknown>(handler: H): H {
return (async (request: Request, ...rest: unknown[]) => {
const protection = await getProtection();
const blocked = await protection.fetchGuard()(request);
if (blocked) return blocked;
return protection.screenResponse(await handler(request, ...rest) as Response);
}) as H;
}

// --- Node / Express -------------------------------------------------------------------------
// Add before your routes: app.use(patchstackMiddleware)
export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: unknown) => void) {
getProtection()
.then((protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, next))
.catch(() => next()); // fail open
}
Loading
Loading