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: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ jobs:
- name: Consumer shapes against a packed tarball
run: node scripts/compat-matrix.mjs --manager ${{ matrix.manager }}

- name: Setup hooks execute through the real package manager
run: npx vitest run tests/setup-build-execution.test.ts
env:
PATCHSTACK_TEST_MANAGER: ${{ matrix.manager }}

# Most consumers of the runtime guard are BUNDLED — a Worker through wrangler, a Next edge middleware,
# a SvelteKit adapter build — and all of them tree-shake. A guard that has lost the part which screens
# requests still starts, still logs, and still looks installed, so the failure arrives through the
Expand Down
28 changes: 14 additions & 14 deletions AGENT-INSTALL.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions GETTING-STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ The fastest path from "I have a JS/Node project" to "Patchstack is monitoring it

The prompt below is for an existing JS/Node project in a hosted workspace that can install npm packages and run project commands. For Gemini CLI, OpenCode, Codex CLI, or Claude Code on your own machine, use the [local coding CLI workflow](README.md#local-coding-clis): an ordinary laptop reports `local`, while the eventual deployment gets its tier from its host or an explicit build setting. A coding tool's permission sandbox does not make the app a sandbox deployment. A standalone HTML/CSS/JavaScript site without a package-managed app uses the [plain HTML widget instructions](AGENT-INSTALL.md#plain-html-sites) instead; it does not need a new Node project, build hooks, or a runtime guard.

> I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, and runtime protection source edits. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. The Patchstack Connector widget is part of this install and on by default: add it, and do not ask me whether to. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the Patchstack widget is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself.
> I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, runtime protection source edits, structural attack-surface map uploads (not source code), and live-rule retrieval. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. The Patchstack Connector widget is part of this install and on by default: add it, and do not ask me whether to. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the Patchstack widget is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself.

When setup finishes it shows you a **dashboard URL**. Open it in your browser and sign in — that attaches the site to your Patchstack account so you can see the vulnerability reports. That's the only manual step.
When setup finishes it shows you a **dashboard URL**. Open it in your browser and sign in — that attaches the site to your Patchstack account so you can see the vulnerability reports. The command installs protection, uploads the structural map and fetches rules together. Restart an already-running preview/server to load the changes; unsupported or custom server entries are reported for manual review. Deployment remains your choice.

Then look at your preview. The widget loads with the page, so a preview you already had open still shows the page from before setup — refresh it once if the widget isn't there. Until the site is attached to your account it shows a "Connect this website" panel.

Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ Connect a JavaScript / Node.js application to [Patchstack](https://patchstack.co

For an existing JS/Node project in a hosted workspace, copy this request into a coding assistant, or run the same command yourself. For Gemini CLI, OpenCode, Codex CLI, or Claude Code on your own machine, use [Local coding CLIs](#local-coding-clis) below. For a standalone HTML/CSS/JavaScript site without a package-managed app, use the [plain HTML widget instructions](AGENT-INSTALL.md#plain-html-sites); do not add Node tooling just for the widget.

> I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, and runtime protection source edits. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. The Patchstack Connector widget is part of this install and on by default: add it, and do not ask me whether to. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the Patchstack widget is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself.
> I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, runtime protection source edits, structural attack-surface map uploads (not source code), and live-rule retrieval. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. The Patchstack Connector widget is part of this install and on by default: add it, and do not ask me whether to. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the Patchstack widget is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself.

`setup` is state-aware and idempotent: it scans dependencies, provisions or reuses the site, manages the Patchstack Connector, installs and verifies the runtime guard, adds a dependency-install scan, wires the existing build command without replacing it, and prints the remaining setup status. It never runs the project build. `guide` provides the same project-specific status without changing files.
`setup` is state-aware and idempotent: it scans dependencies, provisions or reuses the site, manages the Patchstack Connector, installs and verifies the runtime guard, uploads a structural map, fetches live rules, and wires dependency/build checks without replacing the build command. It prints what succeeded and what remains; it never starts the app or runs the build. `guide` provides project-specific status without changing files.

### Local coding CLIs

Expand Down Expand Up @@ -126,8 +126,11 @@ That's it. `setup`:
5. Connect installs the Patchstack Connector's `<script>` tag into your root HTML shell (see *The Patchstack Connector* below) so the widget shows up on the next preview reload — as the "Connect this website" panel until the site is claimed, then as the "Report a vulnerability" button. On a server-rendered root it also adds the production marker, which is what tells the widget to switch from build mode to visitor report intake on the published site.
6. Installs the runtime guard after provisioning, bakes the site UUID into it, and verifies the framework seam. Known server stacks are auto-wired; unmatched or conflicting layouts get a generic scaffold and exact manual checks.
7. Adds `postinstall: patchstack-connect scan`, preserving any existing command, so dependencies added during a sandbox session and build-less production installs are reported immediately.
8. Wires `scan` before builds and `mark-build` after builds, preserving existing commands and using direct build chaining for Bun.
9. Prints a dashboard link — open it in a browser to attach the new site to your Patchstack account. You can re-display it any time with `npx @patchstack/connect status`.
8. Uploads a structural attack-surface map (routes, input names, package attribution, relative file:line locations and coverage notes; no source text or environment values), stamps its identity into the guard for the next startup/build, and fetches live request/response rules. Empty policy, upload failures and rule-fetch failures are reported separately.
9. Wires `scan` followed by `map --upload` before builds and `mark-build` after builds, preserving existing commands. npm uses lifecycle hooks; Yarn, pnpm and Bun use explicit build chains independent of lifecycle settings.
10. Prints a dashboard link — open it in a browser to attach the new site to your Patchstack account. You can re-display it any time with `npx @patchstack/connect status`.

If the server is already running, **restart it** to load the new guard and map identity. Setup does not start, build or deploy your app. Rule delivery does not prove runtime enforcement: scoped rules still need a matching server verdict, and unsupported/custom entries remain reported gaps.

Then **refresh your preview**. The widget loads with the page, so a preview that was already open still shows the HTML from before setup. Builders that hot reload will have refreshed it for you; if the widget is missing, refresh it once. Until the site is claimed it shows the "Connect this website" panel. `setup` prints the same reminder, and the CLI has no way to reload a browser itself.

Expand Down Expand Up @@ -156,7 +159,8 @@ patchstack-connect scan [options] Scan the lockfile and POST to
.patchstackrc.json)
patchstack-connect setup [options] Run scan, manage the widget, and idempotently
install + verify runtime protection and wire
dependency/build scans. Never runs the build
dependency/build scans + map uploads. Uploads the map and
fetches live rules; never starts the app or runs the build
patchstack-connect init <site-uuid> Optional: pre-seed .patchstackrc.json with
an existing site UUID
patchstack-connect status [options] Show current configuration
Expand Down Expand Up @@ -423,7 +427,7 @@ During a build, the `prebuild` scan removes any previous map stamp. A later `map

### `scan` as a build hook

`setup` wires `scan` into `postinstall`, `prebuild`, or the Bun `build` chain. Run from one of those, a report Patchstack cannot accept — no credential in the build environment, a rejected credential, a site that no longer exists, an outage — is printed on stderr and `scan` exits 0, so the install or build it is attached to carries on. Patchstack keeps the last manifest it accepted for the site until a scan that can report. Run directly (`npx @patchstack/connect scan`), the same failure exits 1.
`setup` wires `scan` into `postinstall`, npm's `prebuild`, or an explicit `build` chain for Yarn, pnpm and Bun. Run from one of those, a report Patchstack cannot accept — no credential in the build environment, a rejected credential, a site that no longer exists, an outage — is printed on stderr and `scan` exits 0, so the install or build it is attached to carries on. Patchstack keeps the last manifest it accepted for the site until a scan that can report. Run directly (`npx @patchstack/connect scan`), the same failure exits 1.

A deploy never has `.patchstackrc.local.json`, so the usual cause is a missing `PATCHSTACK_API_KEY` in the platform's environment (see *Configuration*). The hook is recognised through `npm_lifecycle_event`, which npm, pnpm, Yarn and `bun run` set to the running script's name. `bun install` does not set it, so a `postinstall` scan under Bun still fails the install when it cannot report.

Expand Down
24 changes: 22 additions & 2 deletions field-test/mock-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,36 @@ import { randomUUID } from 'node:crypto';
*/
export function startMockApi({ port = 0, uuid = randomUUID() } = {}) {
const requests = [];
let mappedBuild = null;

const server = createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
requests.push({ method: req.method, url: req.url, body: body.slice(0, 4000) });
requests.push({ method: req.method, url: req.url, body: body.slice(0, 4000), buildId: req.headers['x-patchstack-build'] ?? null });

if (req.method === 'POST' && req.url === '/monitor/pulse/token') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ access_token: 'synthetic-field-token', expires_in: 3600 }));
return;
}
if (req.method === 'POST' && req.url === `/monitor/pulse/input-map/${uuid}`) {
try { mappedBuild = JSON.parse(body).build_id ?? null; } catch { mappedBuild = null; }
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ result: 'stored', revision: 1 }));
return;
}
if (req.method === 'GET' && req.url === `/monitor/pulse/rules/${uuid}`) {
const matching = mappedBuild && req.headers['x-patchstack-build'] === mappedBuild;
res.writeHead(200, { 'Content-Type': 'application/json',
...(matching ? { 'X-Patchstack-Build-Match': 'match', 'X-Patchstack-Build-ID': mappedBuild } : {}) });
res.end(JSON.stringify({ firewall: [], whitelists: [], whitelist_keys: {} }));
return;
}

if (req.method === 'POST' && req.url === '/monitor/pulse/manifest') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ uuid, stored: true, manifest_id: 101, checksum: 'deadbeefcafe' }));
res.end(JSON.stringify({ uuid, stored: true, manifest_id: 101, checksum: 'deadbeefcafe', api_key: 'synthetic-field-secret-1' }));
return;
}
if (req.method === 'POST' && req.url?.startsWith('/monitor/pulse/manifest/')) {
Expand Down
2 changes: 1 addition & 1 deletion field-test/prompt.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, and runtime protection source edits. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. The Patchstack Connector widget is part of this install and on by default: add it, and do not ask me whether to. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the Patchstack widget is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself.
I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, runtime protection source edits, structural attack-surface map uploads (not source code), and live-rule retrieval. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. The Patchstack Connector widget is part of this install and on by default: add it, and do not ask me whether to. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the Patchstack widget is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself.
6 changes: 6 additions & 0 deletions field-test/setup-demo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ try {

mock = await startMockApi();
const env = { ...process.env, PATCHSTACK_ENDPOINT: mock.endpoint, NO_COLOR: '1' };
for (const name of ['PATCHSTACK_SITE_UUID','PATCHSTACK_API_KEY','PATCHSTACK_PULSE_AUTH','PATCHSTACK_CLAIM_TOKEN','PATCHSTACK_PULSE_RULES_URL']) delete env[name];

console.log('\n2. Run the single bounded setup command');
if ((await run('npx', ['--no-install', 'patchstack-connect', 'setup'], { cwd: fixture, env })) !== 0) {
Expand All @@ -83,6 +84,11 @@ try {
['one site provisioned and reused', rc.siteUuid === mock.uuid && mock.requests[0]?.url === '/monitor/pulse/manifest'],
['scan wired once', count(scanScript, 'patchstack-connect scan') === 1],
['mark-build wired once', count(markScript, 'patchstack-connect mark-build') === 1],
['map upload wired once', count(scanScript, 'patchstack-connect map --upload') === 1],
['map uploaded on both setups', mock.requests.filter(r => r.url === `/monitor/pulse/input-map/${mock.uuid}`).length === 2],
['rules pulled after both uploads', mock.requests.filter(r => r.url === `/monitor/pulse/rules/${mock.uuid}`).length === 2],
['rule cache saved', existsSync(path.join(fixture,'.patchstack/patchstack-rules.json'))],
['rule lookup presented the new map identity', template !== 'express-npm' || mock.requests.filter(r => r.url === `/monitor/pulse/rules/${mock.uuid}`).every(r => /^[a-f0-9]{64}$/.test(r.buildId ?? ''))],
['widget installed once with the site UUID', count(html, 'patchstack-widget.js') === 1 && html.includes(mock.uuid)],
...Object.entries(verdict.checks)
.filter(([name]) => name !== 'claimUrlSurfaced' && name !== 'noProductionLeak')
Expand Down
16 changes: 10 additions & 6 deletions src/build-hook.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { SECRET_CONFIG_FILENAME } from './config.js';
import type { Config, PatchstackError } from './types.js';

Expand Down Expand Up @@ -45,16 +47,18 @@ export function isInstallOrBuildHook(env: NodeJS.ProcessEnv = process.env): bool
* `postbuild` is excluded for the opposite reason: it is a build, but the bundler has already run, so a
* value written there could never reach the artifact.
*/
export function isPreBundleBuildHook(env: NodeJS.ProcessEnv = process.env): boolean {
export function isPreBundleBuildHook(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): boolean {
const event = env.npm_lifecycle_event;
if (event === 'prebuild') return true;
if (event !== 'build') return false;

// Bun does not run npm's `prebuild` hook, so setup places scan at the start of `build` itself. The
// lifecycle name alone is not enough: a manually appended scan would run after the bundler and stamp
// source too late to reach the artifact. npm exposes the complete running script here; accept only the
// exact command at its beginning.
return /^\s*patchstack-connect\s+scan(?:\s*(?:&&|;)|\s*$)/.test(env.npm_lifecycle_script ?? '');
// Lifecycle script text and manifest paths can be missing or inherited from a parent process.
// Read the current project's declared chain; never evaluate application configuration.
try {
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) as { scripts?: { build?: unknown } };
return typeof pkg.scripts?.build === 'string'
&& /^\s*patchstack-connect\s+scan(?:\s*(?:&&|;)|\s*$)/.test(pkg.scripts.build);
} catch { return false; }
}

/**
Expand Down
Loading
Loading