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: 2 additions & 0 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
- **`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; 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. It 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`), **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.
- **`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.

## Before you start — never install twice
Expand Down
41 changes: 36 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,26 @@ patchstack-connect guide Show this project's setup sta
what's missing, with tailored commands), then
print the full setup guide
patchstack-connect protect Opt-in: install the always-on runtime exploit
guard (currently TanStack Start + Supabase; it
patches the app's Supabase client to route
traffic through a same-origin guard). Never
run by scan/setup/guide/mark-build.
guard. Auto-wires supported server stacks;
use --check to verify or --demo for local rules.
Never run by scan/setup/guide/mark-build.
patchstack-connect demo node-serialize Production-backed walkthrough: require
node-serialize@0.0.4, scan it, wait for live
rule 18843, install + verify the runtime guard,
and print exploit/benign test requests.
patchstack-connect demo-guide node-serialize Read-only, state-aware instructions for the
local demo, including the next exact command,
expected proof, and cleanup.
patchstack-connect help Print help

Options (for scan and status):
Options (for scan, setup, and status):
--site-uuid <uuid> Override the configured site UUID
--endpoint <url> Override the API endpoint
--dry-run (scan only) Print the payload without posting

Options (for demo and demo-guide):
--url <url> Test endpoint printed at the end
(default: http://localhost:3000/api/tasks)
```

## Configuration
Expand Down Expand Up @@ -101,6 +111,27 @@ Environment variables:

The site UUID identifies the site; it is not a secret — the disclosure widget ships the same UUID in client-side HTML, and committing `.patchstackrc.json` is the intended workflow so every developer and CI run reports to the same site. Possession of the UUID lets someone submit dependency manifests for that site (noise, not data access). In CI setups where the file isn't committed, set `PATCHSTACK_SITE_UUID` instead.

## Production virtual-patch demo

The `node-serialize` scenario demonstrates dependency detection and a live, version-scoped virtual patch against a throwaway Express application. Connect/provision the project first, deliberately add the known-vulnerable package, then run:

```bash
npm install --save-exact node-serialize@0.0.4
npx @patchstack/connect demo node-serialize
```

The demo command does not install the vulnerable package. It verifies the exact version in the lockfile, posts the production npm manifest to the configured site, polls the corresponding Pulse rules endpoint for rule `18843`, runs the normal `protect` installer, checks that the guard is wired, and prints one exploit request plus one benign control request. It never starts or restarts the application and never sends either test request itself.

For a read-only walkthrough that can be run before or during the demo, use:

```bash
npx @patchstack/connect demo-guide node-serialize
```

The guide inspects the Host-created site configuration and lockfile, explains that no deployment is required, shows the complete prepare → run → restart → prove → clean-up sequence, and ends with the next exact command for the project's current state. Pass the same `--url` option when the test endpoint differs from the default.

Use `--url http://localhost:PORT/api/tasks` when the app does not use the default `http://localhost:3000/api/tasks`. Remove the deliberately vulnerable dependency after the walkthrough.

## The disclosure widget

The widget is a floating "Report a vulnerability" button — a disclosure channel for anyone who spots a bug on the site. The connector manages its install so the UUID never has to be copied by hand:
Expand Down
135 changes: 132 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import { scanLockfile } from './parsers/index.js';
import { buildWirePayload } from './normalize.js';
import { computeManifestChecksum } from './checksum.js';
import { DEFAULT_ENDPOINT, buildClaimUrl, postManifest } from './client.js';
import {
assertDemoDependency,
assertPersistedSiteUuid,
DemoError,
inspectDemoDependency,
readPersistedSiteUuid,
renderDemoGuide,
renderDemoTestCommands,
resolveDemoScenario,
waitForDemoRule,
} from './demo.js';
import { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import {
buildInjectionSnippet,
Expand All @@ -14,6 +25,7 @@ import {
import {
collectGuideState,
countRemainingSteps,
detectPackageManager,
installCommand,
renderGuideChecklist,
} from './guide.js';
Expand Down Expand Up @@ -44,13 +56,21 @@ Usage:
build fingerprint, and ensure the widget
tag in built pages (run as a postbuild step)
patchstack-connect protect [--demo|--check] Install always-on runtime protection (the
guard). Auto-wires known stacks (TanStack
Start + Supabase); for others it scaffolds a
guard). Auto-wires supported server stacks;
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 demo node-serialize [--url URL] Run the production-backed node-serialize
walkthrough: verify the vulnerable package,
scan it, wait for live rule 18843, install +
verify the guard, and print test requests.
Does not install vulnerable dependencies.
patchstack-connect demo-guide node-serialize Show a read-only, state-aware walkthrough for
preparing, running, proving, and cleaning up
the production-backed local demo.
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 All @@ -66,6 +86,10 @@ Options (for mark-build):
--dir <path> Build output directory (default: auto-detect
dist/ build/ out/ .output/public)

Options (for demo and demo-guide):
--url <url> Test endpoint printed at the end
(default: http://localhost:3000/api/tasks)

Environment:
PATCHSTACK_SITE_UUID Site UUID
PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest)
Expand All @@ -82,9 +106,11 @@ Examples:
npx @patchstack/connect scan --dry-run
npx @patchstack/connect init 550e8400-e29b-41d4-a716-446655440000
npx @patchstack/connect scan --site-uuid 550e8400-...-446655440000
npx @patchstack/connect demo node-serialize
npx @patchstack/connect demo-guide node-serialize
`;

const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir']);
const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir', 'url']);

interface ParsedArgs {
command: string | null;
Expand Down Expand Up @@ -328,6 +354,105 @@ async function runProtectCommand(args: ParsedArgs): Promise<number> {
return 0;
}

async function runDemoCommand(args: ParsedArgs): Promise<number> {
try {
const scenario = resolveDemoScenario(args.positional[0]);
const cwd = process.cwd();
const config = await resolveConfig({
cwd,
cliSiteUuid: getStringFlag(args.flags, 'site-uuid'),
cliEndpoint: getStringFlag(args.flags, 'endpoint'),
requireSiteUuid: true,
});
if (config.environment !== 'production') {
throw new DemoError(
'The production-backed demo requires PATCHSTACK_ENVIRONMENT=production. Unset the sandbox override and try again.',
);
}

await assertPersistedSiteUuid(cwd, config.siteUuid!);
await assertDemoDependency(cwd, scenario);
console.log(`Patchstack production demo — ${scenario.packageName}@${scenario.packageVersion}`);
console.log(` Site: ${config.siteUuid}`);
console.log('');
console.log('1. Report the current npm manifest');
const scanCode = await runScan(args, { showRemainingSetup: false });
if (scanCode !== 0) return scanCode;

console.log('');
console.log(`2. Wait for live virtual-patch rule ${scenario.ruleId}`);
const rule = await waitForDemoRule(config.endpoint, config.siteUuid!, scenario, {
requestTimeoutMs: config.timeoutMs,
});
console.log(`Rule ready: ${rule.id}${rule.title ? ` — ${rule.title}` : ''}`);

console.log('');
console.log('3. Install and verify the runtime guard');
const result = runProtect(cwd);
if (result.status === 'unsupported') {
throw new DemoError(
`Runtime protection is not supported for this stack. Supported: ${result.supported.join(', ')}.`,
);
}
console.log(`Guard installer: ${result.adapter} (${result.status})`);
const report = runVerify(cwd);
for (const check of report.checks) {
console.log(` ${check.ok ? '✓' : '✗'} ${check.label}${!check.ok && check.hint ? ` — ${check.hint}` : ''}`);
}
if (!report.wired) {
throw new DemoError(
`The ${report.stack} guard is not fully wired. Complete the failed check above, then run this command again.`,
);
}

console.log('');
console.log('Production virtual patch is ready.');
console.log('');
console.log(
renderDemoTestCommands(
getStringFlag(args.flags, 'url') ?? 'http://localhost:3000/api/tasks',
scenario,
),
);
return 0;
} catch (error) {
if (error instanceof DemoError) {
console.error(`Demo error: ${error.message}`);
return 1;
}
throw error;
}
}

async function runDemoGuideCommand(args: ParsedArgs): Promise<number> {
try {
const scenario = resolveDemoScenario(args.positional[0]);
const cwd = process.cwd();
const config = await resolveConfig({ cwd });
const [siteUuid, dependency] = await Promise.all([
readPersistedSiteUuid(cwd),
inspectDemoDependency(cwd, scenario),
]);
console.log(
renderDemoGuide({
scenario,
packageManager: detectPackageManager(cwd),
siteUuid,
dependency,
environment: config.environment,
url: getStringFlag(args.flags, 'url') ?? 'http://localhost:3000/api/tasks',
}),
);
return 0;
} catch (error) {
if (error instanceof DemoError) {
console.error(`Demo guide error: ${error.message}`);
return 1;
}
throw error;
}
}

async function runGuide(args: ParsedArgs): Promise<number> {
// The live checklist first: what this project already has and what's missing,
// with commands tailored to it. Best-effort — a project we can't inspect
Expand Down Expand Up @@ -540,6 +665,10 @@ async function main(): Promise<number> {
return runMarkBuild(args);
case 'protect':
return runProtectCommand(args);
case 'demo':
return runDemoCommand(args);
case 'demo-guide':
return runDemoGuideCommand(args);
case 'guide':
return runGuide(args);
case 'setup':
Expand Down
12 changes: 12 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ export function buildEndpointUrl(base: string, siteUuid?: string | null): string
: trimmed;
}

/** Build the live Pulse rules URL corresponding to a manifest endpoint override. */
export function buildRulesUrl(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)}/rules/${encodeURIComponent(siteUuid)}`
: `/monitor/pulse/rules/${encodeURIComponent(siteUuid)}`;
url.search = '';
url.hash = '';
return url.toString();
}

/**
* Build the claim URL for a site. The claim page lives on the same origin as
* the API endpoint, at `/monitor/claim?site=<uuid>`. Using the API endpoint's
Expand Down
Loading
Loading