From a723b8b73b7d5564424649b29db9987b99a40855 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 1 Oct 2026 20:45:17 +0200 Subject: [PATCH 1/3] feat(setup): upload the map and retrieve rules in one command --- AGENT-INSTALL.md | 28 +++---- GETTING-STARTED.md | 4 +- README.md | 14 ++-- field-test/mock-api.mjs | 24 +++++- field-test/prompt.txt | 2 +- field-test/setup-demo.mjs | 6 ++ src/cli.ts | 22 ++++- src/config.ts | 4 +- src/map-command.ts | 72 ++++++++++------ src/protect/rules/source.d.ts | 10 +++ src/protect/rules/store.d.ts | 5 ++ src/setup-sync.ts | 72 ++++++++++++++++ src/setup.ts | 16 +++- tests/setup-sync.test.ts | 152 ++++++++++++++++++++++++++++++++++ tests/setup.test.ts | 20 ++++- 15 files changed, 393 insertions(+), 58 deletions(-) create mode 100644 src/protect/rules/source.d.ts create mode 100644 src/protect/rules/store.d.ts create mode 100644 src/setup-sync.ts create mode 100644 tests/setup-sync.test.ts diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 1f36c020..4a79d527 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -43,7 +43,7 @@ The pages get a build step, even though nothing is compiled. The build is what r npx @patchstack/connect setup ``` - `setup` creates the site, writes its `siteUuid` to `.patchstackrc.json`, adds the Patchstack widget to `public/index.html`, adds `"postinstall": "patchstack-connect scan"`, and wires `"prebuild": "patchstack-connect scan"` and `"postbuild": "patchstack-connect mark-build"` around the build. In a hosted builder, scope `PATCHSTACK_ENVIRONMENT=sandbox` to the `setup` command, as in "Automated setup". + `setup` creates the site, writes its `siteUuid` to `.patchstackrc.json`, adds the Patchstack widget to `public/index.html`, adds `"postinstall": "patchstack-connect scan"`, and wires `"prebuild": "patchstack-connect scan && patchstack-connect map --upload"` and `"postbuild": "patchstack-connect mark-build"` around the build. In a hosted builder, scope `PATCHSTACK_ENVIRONMENT=sandbox` to the `setup` command, as in "Automated setup". 2. Add `dist` to `.gitignore` next to the entries `setup` wrote. 3. Put the widget on the other pages. `setup` adds the tag only to `index.html`, `public/index.html` or `src/app.html`. For any other page it lists the widget under `Missing` and prints the tag to add. Add one tag before `` on each page, or in the shared layout, exactly as printed — no `data-build-mode`. 4. When the person names where the site is published, add that host's build settings so it publishes `dist/` and runs the build. See "Deploying" below; for Netlify that is a `netlify.toml` with `command = "npm run build"` and `publish = "dist"`. @@ -69,8 +69,8 @@ Every command at a glance — what it does, whether it reads your source, what i | Command | What it does | Reads your source? | Writes to your project | Sends over the network | |---|---|---|---|---| | `scan` | Provision (or reuse) the site and POST the dependency list for vulnerability matching. Also runs automatically via `setup` and the install/build hooks. | No source analysis — lockfile only; `node_modules/` is enumerated when no lockfile can be read (e.g. `bun.lockb`) or when the lockfiles present disagree. It also reads the `` of the root `index.html` and the `name` in `package.json`, to report what the site is called. During `prebuild` only, it reads the scaffolded guard and its co-located rules JSON to remove a previous map stamp. | `.patchstackrc.json` (public: site UUID + settings); `.patchstackrc.local.json` (the API key, created owner-only) and a `.gitignore` entry for it — the CLI says so if it could not add one; during `prebuild`, removal of a previous `_patchstack.build_id` from the guard's own rules file so a later build cannot carry stale coordinates; the widget `<script>` tag in the root HTML shell — only after a successful post; the production marker in a code root shell — before the post, since it needs no site UUID | Package names + versions; this site's public address and name, where the project or build environment states them | -| `setup` | One bounded command: `scan` → manage the widget → install + verify `protect` → wire the install/build scans. Never runs the project build. | Local integration reads via `scan` and `protect` | Config, widget tag, production marker, guard/framework/route files, `package.json` scripts | Package names + versions and the site's public address and name (via `scan`); a claim token as a request header, only when you pass one | -| `map` | Local attack-surface analysis (entry points → inputs → sinks → evidence-backed flows). Never run by another command. | **Yes** — via the app's own TypeScript; a pre-bundle `--upload` also locates the co-located rules file imported by the scaffolded guard | Only the file named by `--out`; during a pre-bundle `--upload`, `_patchstack.build_id` in that existing rules file | Nothing — **unless `--upload`**: structure only (routes, parameter names, the package behind each sink, file:line) plus a SHA-256 identity derived from its policy content. Never source code or env values | +| `setup` | One bounded command: scan, widget, install + verify guard, upload map, pull live rules, wire build hooks. Never starts the app or runs a build. | Local integration and structural source analysis | Config, widget, guard/framework files, scripts, map identity in the guard rules JSON, git-ignored `.patchstack/` cache | Package inventory and site identity; structural map (routes, input names, packages, file:line, identity); authenticated Pulse rules lookup. No source code or environment values | +| `map` | Local attack-surface analysis; setup invokes it with upload, and wires prebuild uploads. | Server source parsed with TypeScript (app compiler or CLI dependency) | `--out` file if requested; an uploaded map is stamped into the guard rules JSON during setup or a pre-bundle build hook | Standalone command sends nothing unless `--upload`. Setup and its build hook upload structure only, never source text or environment values | | `protect` | Install the always-on runtime guard; auto-wire known stacks, or scaffold a generic guard + print a wiring plan. `--check` verifies the guard is wired (exit 1 if not); `--demo` seeds a broad sample rule set. Runs automatically **only** via `setup` — never by `scan`, `guide`, `status`, or `mark-build`. | Reads local wiring/route source for integration; does not produce an attack-surface map | Guard/framework files (e.g. `middleware.ts`, `src/patchstack/`, supported Next App Router handlers) | Nothing | | `demo node-serialize` | Production-backed walkthrough: confirm the vulnerable package is present, scan, wait for live rule `18843`, install + verify the guard, print test requests. Does not install the package or start/restart the app. | Local integration reads via `scan` and `protect` | Same files as `scan` + `protect` | `scan` payload; polls the public Pulse rules endpoint (never the printed test requests) | | `demo-guide node-serialize` | Read-only companion: explains the prepare/run/prove/cleanup sequence and prints the next command. | Reads local protection wiring for verification | Nothing | Nothing | @@ -82,27 +82,27 @@ Every command at a glance — what it does, whether it reads your source, what i | `login` | Recover a lost credential for an existing site: print an owner-approval link and poll (10 min). Approving **rotates** the credential. Not usable in CI. | No | New credential into `.patchstackrc.local.json` on approval | Device-code request + approval poll | | `uninstall` | Signal Patchstack that the package is being removed: an unclaimed record is deleted, a claimed one is flagged. Does **not** touch local files. | No | Nothing local | Removal signal | -Only `map` produces an attack-surface analysis, and only `map --upload` sends that description. `protect` reads and edits local source to integrate the guard; its verifier, also used by setup guides, reads the wiring without executing the app. These integration reads transmit nothing. `scan` additionally reads two declarations the project makes about itself — the `<title>` in the root `index.html` and the `name` in `package.json` — to report what the site is called; during `prebuild` it also reads the scaffolded guard and its co-located rules JSON solely to remove a previous map stamp. `scan` transmits package names + versions, plus the site's own public address and name where the project states them — never source code, file paths, git history, or any environment variable value other than the published URL of this site. `scan --install-paths` additionally sends where each package sits in the dependency tree; it is off unless you pass it. +`setup` uploads a structural attack-surface map after installing the guard; the build hooks it installs upload a fresh map before bundling. Standalone `map` remains local without `--upload`. `protect` reads and edits integration source but transmits nothing. `scan` sends package names + versions and the declared public site identity, not source text. Map uploads additionally include routes, parameter names, package attribution, relative file:line locations, coverage limitations, and a policy-map digest. `scan --install-paths` remains separately opt-in for package installation locations. ## Package and command behavior - 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, plus this site's public address and name** — the dependencies are 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 file paths, no git history is ever transmitted. +- **`scan` sends the dependency list plus this site's public address and name** — read from lockfiles or installed package metadata. It sends no source code or git history. Setup additionally uploads the structural map described below. - **`scan --install-paths` is the one exception, and it is opt-in.** It adds where each package sits in the dependency tree — repo-relative paths made of `node_modules` segments, plus a workspace directory name when a workspace pins its own copy. They are read from the lockfile's own keys or from the `node_modules` walk, **never from your source tree**: no path to a file you wrote is sent by either form of `scan`. - Why it exists: the same package is routinely installed twice at different versions, and without the locations an advisory affecting only one of them cannot be matched to the copy your code actually loads. Node resolves an import by walking up from the importing file, so the location is what distinguishes "you are running the vulnerable copy" from "the vulnerable copy is installed but nothing reaches it". Absent them, every installed version has to be treated as if the app used it — warnings about code you never call, and protection rules pinned to routes that run the safe copy. - Why it is off by default: it widens what leaves the machine, so it is your explicit choice and not a consequence of upgrading the package. (`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.) - **Only `scan` looks for the address and the name.** They are resolved in the one code path that reports them, so `guide`, `status`, `login`, `uninstall`, `mark-build`, `init`, `protect`, `demo-guide` and `map` neither read the host's URL variables nor open `index.html` or `package.json` for this. `setup` and `demo` do, because both run `scan`. - **The address is the one your visitors use, and, apart from the tool's own `PATCHSTACK_*` settings, it is the only env var value read.** A site provisioned by a scan from a developer machine has no address, so the dashboard shows a placeholder and Patchstack cannot check that the published page still carries what was scanned. `scan` therefore sends `url` when — and only when — it can know it: `url` in `.patchstackrc.json` or `PATCHSTACK_SITE_URL` if you set one, otherwise the single variable a host publishes to name its own **production** URL (`VERCEL_PROJECT_PRODUCTION_URL` on a Vercel production deployment, Netlify's `URL` in the production context, `RENDER_EXTERNAL_URL`, `RAILWAY_PUBLIC_DOMAIN` in a production environment). Preview and branch deployments are excluded, as are hosts that publish no production signal. An address that is not how the public reaches a website is dropped: any IP address (in either family, however it is written), any single-label host such as `localhost` or `production`, and the reserved suffixes (`.local`, `.internal`, `.test`, `.invalid`, `.home.arpa`, …). A `url` you set explicitly that fails those checks is refused with an error rather than replaced by a guess. When nothing qualifies, `url` is omitted from the payload rather than guessed. Patchstack only ever applies it to a site that still has no address; it never re-points a site whose address is already real. - **The name is read from your project, never from the host environment.** `name` in `.patchstackrc.json` (or `PATCHSTACK_SITE_NAME`) if you set one; otherwise the `<title>` of the project's root `index.html` (`public/index.html` if there is no root one), read from the file as text — a title your app sets from script is not seen; otherwise the `name` in `package.json`, unless it is a template placeholder such as `vite_react_shadcn_ts`. It is omitted when nothing qualifies, and it only ever fills in a site that has no name yet — a name set in the dashboard is never replaced. -- **Only `map` produces an attack-surface analysis.** It 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. `protect` separately parses supported wiring and route files for local integration, without producing or uploading a map. A `prebuild` scan reads the scaffolded guard and rules JSON only to identify and clear the reserved map stamp; it does not analyse them or transmit their contents. +- **Mapping is automatic in `setup`, and in its prebuild hook.** It parses server source and sends structure, not source text. A standalone `map` command prints locally and uploads only with `--upload`. `protect`, `scan`, `guide`, `status` and `mark-build` do not invoke mapping. - **`scan` makes up to three source edits:** the Patchstack Connector's `<script>` tag, the production marker, and — during `prebuild` only — removal of a previous `_patchstack.build_id` from the existing guard rules file. None runs on `--dry-run`; all are idempotent. `"widget": false` disables the first two, while stale-stamp removal is independent because it prevents old coordinates being attributed to a new build. - The **widget tag** goes in the root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists — and only after a successful post, because it carries the site UUID. - The **production marker** goes in a root shell that is JSX rather than HTML (e.g. `src/routes/__root.tsx`, `app/layout.tsx`), inside a `{/* #region patchstack */}` block placed above the widget tag. It is written *before* the post: it carries no site UUID and needs no network, and build scripts commonly chain `patchstack-connect scan || true`, where waiting on the server would mean an offline build silently ships without the flag. The marker is guarded by the framework's own production expression (`import.meta.env.PROD`, or `process.env.NODE_ENV === 'production'`), so it is inert in dev and preview builds. Without it a server-rendered site has no built HTML for `mark-build` to stamp, and the widget treats the published site as build mode. `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. +- **`setup` runs `scan` → `protect` → map upload → live-rule lookup**, then reports the outcome. Provisioning precedes guard installation. The map is stamped into the guard source for the NEXT startup/build; restart an already-running preview/server to load it. The rule lookup uses the runtime validator and source-scoped local cache without creating a running guard or installing global hooks. A successful empty policy is reported as zero assigned rules, not proof of protection. Failed uploads/pulls appear under Missing and can be retried by rerunning setup. It also wires install scans, prebuild scans + map uploads, and postbuild marking (direct build chain for Bun), preserving existing commands. It never starts, builds or deploys the app. Ambiguous/custom integration code still requires review rather than being overwritten. - 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`), **TanStack Start** (documented server Fetch entry without requiring Supabase), **Next.js** (scaffolds or composes middleware/proxy and adds request/response checks to supported App Router handlers), **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 local unless you pass `--upload`.** 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. Without `--upload` it writes nothing except the file named by `--out`, and it is never invoked by `scan`, `setup`, `guide`, `protect`, or `mark-build`. -- **`map --upload` is the only command that sends a description of your source.** (The runtime guard can also report rule detections, which carry route paths and parameter names — see "Runtime guard reporting" below.) 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. During a pre-bundle build hook it hashes the policy-relevant document (all fields except analyser timing and memory observations), writes the SHA-256 value as `_patchstack.build_id` in the existing rules file imported by the scaffolded guard, and sends the same value as `build_id`. Outside that lifecycle it sends no identity and changes no file, so any generated scoped rule remains detect-only. **No source code, no file contents, no environment variable values.** A map with no recognised entry points is still uploaded because its import inventory and coverage limits are evidence; a failure to reach Patchstack is reported and ignored rather than failing your build. Omit the flag and the command stays entirely local. +- **Standalone `map` is local unless you pass `--upload`.** It skips dependencies, build output and hidden directories and does not follow external symlinks by default. It reports detected entry points, inputs, sinks, dependency calls and evidence tiers with coverage limitations. Static analysis is best-effort, not a completeness guarantee. `setup` runs this analysis after guard integration and uploads it automatically. +- **`map --upload` and `setup` send structural analysis** to `monitor/pulse/input-map/<your site uuid>`. The upload includes no source code, file contents or environment variable values. Setup and pre-bundle uploads derive `_patchstack.build_id` from the policy map and write it into the existing guard rules file. A standalone upload outside a build remains unbound and changes no guard file. No recognized routes still means useful import/coverage evidence and is uploaded. API failures are reported without failing a production build. Setup then retrieves Pulse rules, including delivered response-phase policies, using the same build identity. Only a matching server verdict permits scoped enforcement. The local cache is not a client bundle or a replacement for runtime rule refresh; filesystem-less deployments fetch at runtime and need server-side credentials. - **`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. Connect monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile. @@ -293,16 +293,16 @@ Handle it in this order: ```jsonc { "scripts": { - "prebuild": "patchstack-connect scan", + "prebuild": "patchstack-connect scan && patchstack-connect map --upload", "postbuild": "patchstack-connect mark-build", "postinstall": "patchstack-connect scan" } } ``` - If a lifecycle hook already exists, chain instead of replacing it, e.g. `"prebuild": "existing-command && patchstack-connect scan"`. The `postinstall` scan reports dependencies added during an iterative sandbox session and covers applications with no build command. + If a lifecycle hook already exists, chain instead of replacing it, e.g. `"prebuild": "patchstack-connect scan && existing-command && patchstack-connect map --upload"`. The `postinstall` scan reports dependencies added during an iterative sandbox session and covers applications with no build command. - **Bun-managed projects:** `bun run` does not execute npm-style `pre`/`post` scripts, so wire the build script directly instead: `"build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build"`. + **Bun-managed projects:** `bun run` does not execute npm-style `pre`/`post` scripts, so wire the build script directly instead: `"build": "patchstack-connect scan && patchstack-connect map --upload && <existing build command> && patchstack-connect mark-build"`. **Checking a build yourself:** run it through the package manager (`npm run build`), never the framework's own CLI (`astro build`, `vite build`, `next build`). Calling the CLI directly skips the `prebuild`/`postbuild` hooks, so the build is not scanned, not marked and not reported, and it tells you nothing about what the deployed build will carry. @@ -518,7 +518,7 @@ read from: rename the field two deploys later and the rule addresses something t while still reporting as active protection. Coverage that is not there is worse than a known gap. Such a rule carries a `build_scope` naming the policy map its coordinate came from, and it blocks only -when Patchstack **confirms** those coordinates belong to the map carried by the guard now running. Three moving +when Patchstack **confirms** those coordinates belong to the map carried by the guard now running. Setup performs mapping and rule retrieval for the next startup too; it does not update an already-running process. Three moving parts: - **`scan`, during `prebuild`**, removes any previous `_patchstack.build_id` from the guard's own rules @@ -551,7 +551,7 @@ rule locally establishes that you intend it, not that its coordinate still descr A scoped rule you supply blocks when it names the map identity this guard reports (`buildId`), or when you set **`trustLocalRuleScope: true`** to take responsibility for the match. -Nothing here is sent unless you have already opted into `map --upload`. The identifier is a one-way +Running `setup` authorizes this workflow, including its prebuild map uploads. Standalone mapping sends nothing without `--upload`. The identifier is a one-way digest of the map — never a message, an author, a diff, a branch name, source text, or environment value. ## Runtime guard reporting diff --git a/GETTING-STARTED.md b/GETTING-STARTED.md index db81c79a..e3283371 100644 --- a/GETTING-STARTED.md +++ b/GETTING-STARTED.md @@ -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. diff --git a/README.md b/README.md index 49cd3cb0..e280ea11 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 and using direct build chaining for Bun. +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. @@ -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 diff --git a/field-test/mock-api.mjs b/field-test/mock-api.mjs index ecaa3282..61d15654 100644 --- a/field-test/mock-api.mjs +++ b/field-test/mock-api.mjs @@ -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/')) { diff --git a/field-test/prompt.txt b/field-test/prompt.txt index df0db36e..ba2f7ebe 100644 --- a/field-test/prompt.txt +++ b/field-test/prompt.txt @@ -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. diff --git a/field-test/setup-demo.mjs b/field-test/setup-demo.mjs index d0810105..2acfe036 100644 --- a/field-test/setup-demo.mjs +++ b/field-test/setup-demo.mjs @@ -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) { @@ -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') diff --git a/src/cli.ts b/src/cli.ts index 578b442a..64e4a4b1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -86,6 +86,7 @@ import { runMap } from './map-command.js'; import { getStringFlag } from './flags.js'; import { isCanonicalUuid } from './endpoint-policy.js'; import { setupProtection, wireBuildScripts } from './setup.js'; +import { syncSetupProtection } from './setup-sync.js'; import { isInstallOrBuildHook, isPreBundleBuildHook, undeliveredReportLines } from './build-hook.js'; import { applyBuildStamp } from './build-stamp.js'; import { detectStack, type StackDescriptor } from './stack.js'; @@ -109,7 +110,8 @@ Usage: directly, the same failure exits 1 patchstack-connect setup [options] Finish the bounded project setup: run scan, manage the widget, install + verify runtime - protection, and wire dependency/build scans. + protection, upload the attack-surface map, pull + live rules, and wire dependency/build scans. Never runs the project build patchstack-connect map [--dir <p>] [--out <f>] [--upload] Map the app's attack surface: entry points, the @@ -1308,6 +1310,24 @@ async function runSetup(args: ParsedArgs): Promise<number> { detail(`Build hooks: ${wired.detail}`); const config = await resolveCliConfig(args); + const synced = await syncSetupProtection(process.cwd(), config); + for (const line of synced.notices) detail(line); + for (const [index, warning] of synced.warnings.entries()) { + report.missing.push({ key: `setup-sync-${index}`, text: warning }); + } + const uploaded = synced.map.upload; + if (uploaded?.result === 'stored' || uploaded?.result === 'unchanged') { + report.done.push(`Attack-surface map uploaded (revision ${uploaded.revision}; ${synced.map.endpoints ?? 0} detected entry points)`); + if (!synced.map.buildId) report.missing.push({ key: 'map-binding', text: 'Map is not linked to runtime protection', hint: ['Finish the reported protection integration, then rerun setup. Rules for specific inputs can only detect, not block, until linked.'] }); + } else { + report.missing.push({ key: 'map-upload', text: 'Attack-surface map was not uploaded', hint: [synced.map.error ?? uploaded?.message ?? 'Check connectivity and rerun setup.'] }); + } + if (synced.rules.ok) { + report.done.push(`Live rule lookup succeeded (${synced.rules.count} delivered rules; ${synced.rules.count === 0 ? 'no rules currently assigned' : 'not proof of runtime enforcement'})`); + } else { + report.missing.push({ key: 'rules-pull', text: 'Live rule lookup is incomplete', hint: [synced.rules.error ?? 'Check authentication and rerun setup.'] }); + } + report.missing.push({ key: 'runtime-restart', text: 'Load the new protection in your running app', hint: ['Restart the preview/server if it is already running. Deploy a new build to update the live app. Setup does not start or deploy it.'] }); const after = await collectGuideState(process.cwd()); const missing = mergeMissing(report.missing, guideMissing(after, reported)); const context = scanNextStepContext(config, after, after.siteUuid); diff --git a/src/config.ts b/src/config.ts index 0f13ce38..f002e5fc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -347,7 +347,7 @@ async function writeAtomicFile(target: string, content: string, mode: number): P * there but negated further down — comes back as `ignored: false` with a reason, because the caller's next * line is either an assurance or a warning and it has to be the right one. */ -async function ensureIgnored(cwd: string, entry: string): Promise<{ ignored: boolean; reason?: string }> { +export async function ensureIgnored(cwd: string, entry: string, label = 'Patchstack credential — never commit this'): Promise<{ ignored: boolean; reason?: string }> { const target = path.join(cwd, '.gitignore'); let existing = ''; try { @@ -362,7 +362,7 @@ async function ensureIgnored(cwd: string, entry: string): Promise<{ ignored: boo if (ignoresEntry(existing, entry)) return { ignored: true }; const separator = existing === '' || existing.endsWith('\n') ? '' : '\n'; - const block = `${separator}\n# Patchstack credential — never commit this\n${entry}\n`; + const block = `${separator}\n# ${label}\n${entry}\n`; try { await writeAtomicFile(target, existing + block, 0o644); } catch (err) { diff --git a/src/map-command.ts b/src/map-command.ts index 4658db56..ff8dd35c 100644 --- a/src/map-command.ts +++ b/src/map-command.ts @@ -9,6 +9,22 @@ import { applyBuildStamp } from './build-stamp.js'; import { isPreBundleBuildHook } from './build-hook.js'; import { inputMapBuildId } from './input-map-id.js'; import { atomicWriteFileSync } from './safe-file.js'; +import type { Config } from './types.js'; + +export interface MapResult { + code: number; + endpoints?: number; + buildId?: string | null; + upload?: Awaited<ReturnType<typeof postInputMap>>; + error?: string; +} + +interface MapOptions { + config?: Config; + /** Setup edits source for the NEXT server start, never the already-running process. */ + setup?: boolean; + log?: (line: string) => void; +} /** * `patchstack-connect map` — build the attack-surface map and, with `--upload`, send it. @@ -17,13 +33,20 @@ import { atomicWriteFileSync } from './safe-file.js'; * to call this without importing the entry point, which runs the CLI on import. */ export async function runMap(flags: Flags): Promise<number> { + return (await runMapDetailed(flags)).code; +} + +export async function runMapDetailed(flags: Flags, options: MapOptions = {}): Promise<MapResult> { + const log = options.log ?? console.error; const cwd = getStringFlag(flags, 'dir') ?? process.cwd(); + // Setup may be rerun after a source change or an unsuccessful analysis. Never leave its old identity. + if (options.setup) applyBuildStamp(cwd, null); const { map, error } = await buildInputMap(cwd, { followSymlinks: flags.get('follow-symlinks') === true, }); if (!map) { - console.error(`patchstack: ${error}`); - return 1; + log(`patchstack: ${error}`); + return { code: isPreBundleBuildHook() ? 0 : 1, error: error ?? 'could not analyse the project' }; } // Human summary → stderr; the JSON → stdout (so it can be piped / written). Report PROVEN flows // separately from the inventories: only a proven tier is evidence that an input reaches a sink. @@ -31,11 +54,11 @@ export async function runMap(flags: Flags): Promise<number> { const sinks = map.endpoints.reduce((n, e) => n + e.sinks.length, 0); const proven = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => isProvenFlow(f.confidence)).length, 0); const c = map.coverage; - console.error( + log( `patchstack: ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s), ` + `${proven} proven input→sink flow(s) [${map.framework}].`, ); - console.error( + log( // All three buckets, explicitly: "6/66 parsed" reads as "91% unanalysed" when the other 60 files // simply contain no server entry point (most of a project is client code). Only `skipped` is a // failure to analyse. @@ -55,7 +78,7 @@ export async function runMap(flags: Flags): Promise<number> { // declining to attribute `res.json()` to a package is a correct answer rather than a miss. const denominator = dependency + ambiguous; const quality = denominator > 0 ? Math.round((100 * dependency) / denominator) : 100; - console.error( + log( `patchstack: ${invoked.length} dependency API call(s) resolved across ${new Set(invoked.map((i) => i.package)).size} package(s) ` + `from ${c.callsTotal ?? 0} call site(s) — ${quality}% of dependency-candidate receivers resolved ` + `(${c.callsLocal ?? 0} local, ${ambiguous} ambiguous). Positive evidence only: absence here never ` + @@ -67,7 +90,7 @@ export async function runMap(flags: Flags): Promise<number> { // The unmodelled count is the honest headline: it is how much of the dependency surface this map // cannot speak to at all, and a reader who only sees flows would never learn it. const unmodelled = imported.filter((d) => d.recognizedSinkKinds.length === 0).length; - console.error( + log( `patchstack: ${imported.length} package(s) imported — ${unmodelled} with no recognized sink family, ` + `so a vulnerability in those cannot be judged reachable or unreachable from this map.`, ); @@ -76,22 +99,21 @@ export async function runMap(flags: Flags): Promise<number> { const out = getStringFlag(flags, 'out'); if (out) { atomicWriteFileSync(path.resolve(out), json, { encoding: 'utf8' }); - console.error(`patchstack: wrote ${out}`); + log(`patchstack: wrote ${out}`); } else if (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. + // Explicit standalone upload, or the documented upload within the setup workflow. if (flags.get('upload') === true) { // A map the API would refuse whole is not sent: the upload would fail anyway, after the work of sending it. const problems = ingestProblems(map); if (problems.length > 0) { - console.error(`patchstack: did not upload the attack surface — it cannot be fitted to the size Patchstack accepts (${problems.join('; ')}).`); + log(`patchstack: did not upload the attack surface — it cannot be fitted to the size Patchstack accepts (${problems.join('; ')}).`); - return 0; + return { code: 0, endpoints: map.endpoints.length, error: problems.join('; ') }; } // A map with no recognized entry points is still evidence, and withholding it was the difference // between "we could not judge this" and "we never looked". It carries the import inventory, the @@ -100,52 +122,52 @@ export async function runMap(flags: Flags): Promise<number> { // endpoint. The receiving end has always accepted it: `endpoints` is validated as `present`, with a // note that a project with no server entry points is legitimate. if (map.endpoints.length === 0) { - console.error( + log( 'patchstack: no server entry points were recognized — uploading the import inventory and ' + 'coverage notes anyway, so a vulnerability can still be judged imported or not. Nothing here ' + 'can decide whether a request reaches it.', ); } // Same resolution order as every other network path: CLI flags, then env, then `.patchstackrc.json`. - const config = await resolveConfig({ + const config = options.config ?? await resolveConfig({ cwd, cliSiteUuid: getStringFlag(flags, 'site-uuid'), cliEndpoint: getStringFlag(flags, 'endpoint'), }); - // Bind the upload to the bundle only when this command is running before the bundler. The identifier - // is derived from THIS map's policy content, written into the file the guard imports, and sent in the same request. - // A manual map remains useful evidence but cannot claim that its stamp will reach a runtime artifact. + // Setup prepares the next startup; a prebuild upload prepares the next bundle. Both write the + // identity of THIS map into the imported rules file. A standalone manual map remains unbound. let buildId: string | null = null; - if (isPreBundleBuildHook()) { + if (options.setup || isPreBundleBuildHook()) { const candidate = inputMapBuildId(map); const stamp = applyBuildStamp(cwd, candidate); if (stamp.kind === 'stamped' || stamp.kind === 'unchanged') { buildId = candidate; - console.error(`patchstack: bound this map to ${stamp.file} (${candidate.slice(0, 12)}).`); + log(`patchstack: bound this map to ${stamp.file} (${candidate.slice(0, 12)}).`); } else { const reason = stamp.kind === 'skipped' ? stamp.reason : 'the rules file did not retain the map identity'; - console.error( + log( `patchstack: could not bind this map to the runtime guard — ${reason}. ` + 'Rules generated from these coordinates will detect only, not block.', ); } } else { - console.error( + log( 'patchstack: no runtime binding recorded — run `map --upload` in a prebuild hook before the bundler, ' + 'so rules generated from these coordinates can be tied to the runtime guard. Until then they detect only, not block.', ); } const outcome = await postInputMap(config, map, buildId); if (outcome.result === 'stored') { - console.error(`patchstack: uploaded the attack surface (revision ${outcome.revision}).`); + log(`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.`); + log(`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}`); + log(`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}`); + log(`patchstack: could not upload the attack surface — ${outcome.message}`); } + return { code: 0, endpoints: map.endpoints.length, buildId, upload: outcome }; } - return 0; + return { code: 0, endpoints: map.endpoints.length }; } diff --git a/src/protect/rules/source.d.ts b/src/protect/rules/source.d.ts new file mode 100644 index 00000000..357e6d6c --- /dev/null +++ b/src/protect/rules/source.d.ts @@ -0,0 +1,10 @@ +import type { RuleStore } from './store.js'; + +export function resolveRules( + options: Record<string, unknown>, + store: RuleStore, + context?: { timeoutMs?: number; pulseAuth?: string | null }, +): Promise<{ + firewall: Array<Record<string, unknown>>; + source: { ok: boolean; origin: 'api' | 'cache' | 'bundled' | 'empty'; reason?: string }; +}>; diff --git a/src/protect/rules/store.d.ts b/src/protect/rules/store.d.ts new file mode 100644 index 00000000..05c1556c --- /dev/null +++ b/src/protect/rules/store.d.ts @@ -0,0 +1,5 @@ +export interface RuleStore { + read(): Promise<Record<string, unknown> | null>; + write(envelope: Record<string, unknown>): Promise<void>; +} +export function makeStore(options?: Record<string, unknown>): RuleStore; diff --git a/src/setup-sync.ts b/src/setup-sync.ts new file mode 100644 index 00000000..4d0b4707 --- /dev/null +++ b/src/setup-sync.ts @@ -0,0 +1,72 @@ +import { join } from 'node:path'; +import { buildRulesUrl, DEFAULT_ENDPOINT } from './client.js'; +import { ensureIgnored } from './config.js'; +import { assertConnectableEndpoint, isCanonicalUuid } from './endpoint-policy.js'; +import { runMapDetailed, type MapResult } from './map-command.js'; +import { resolveRules } from './protect/rules/source.js'; +import { makeStore } from './protect/rules/store.js'; +import type { Config } from './types.js'; + +export interface SetupSyncResult { + map: MapResult; + rules: { + ok: boolean; + count: number; + origin: string; + error?: string; + }; + notices: string[]; + warnings: string[]; +} + +/** The explicit setup workflow: source edits first, mapping second, authenticated rule pull last. */ +export async function syncSetupProtection(cwd: string, config: Config): Promise<SetupSyncResult> { + const notices: string[] = []; + const warnings: string[] = []; + let map: MapResult; + try { + map = await runMapDetailed(new Map<string, string | true>([['dir', cwd], ['upload', true]]), { + config, setup: true, log: line => notices.push(line), + }); + } catch { + map = { code: 1, error: 'Could not analyse or bind the map; check the project files and permissions.' }; + } + const unavailable = (error: string): SetupSyncResult => ({ + map, rules: { ok: false, count: 0, origin: 'empty', error }, notices, warnings, + }); + if (!isCanonicalUuid(config.siteUuid)) return unavailable('No site UUID is configured.'); + if (!config.pulseAuth) return unavailable('Set PATCHSTACK_API_KEY or run patchstack-connect login to fetch live rules.'); + + try { + const rulesUrl = buildRulesUrl(config.endpoint, config.siteUuid); + assertConnectableEndpoint(config, rulesUrl); + const base = rulesUrl.slice(0, rulesUrl.lastIndexOf('/rules/')); + const defaultRules = buildRulesUrl(DEFAULT_ENDPOINT, config.siteUuid); + if (rulesUrl !== defaultRules && process.env.PATCHSTACK_PULSE_RULES_URL !== base) { + warnings.push('A custom rules service was used for setup. Set PATCHSTACK_PULSE_RULES_URL to the same Pulse base in the server environment for runtime updates.'); + } + const options = { + siteUuid: config.siteUuid, + // Match the ordinary guard's default cache identity; explicit endpoints remain isolated. + pulseRulesUrl: base, + cacheDir: join(cwd, '.patchstack'), + buildId: map.buildId ?? undefined, + onError: () => notices.push('Some rules could not be refreshed or are detect-only; verify delivery after starting the app.'), + }; + const ignored = await ensureIgnored(cwd, '.patchstack/', 'Patchstack local runtime cache'); + if (!ignored.ignored) warnings.push('The local rule cache is not git-ignored. Keep .patchstack/ out of version control.'); + // Use the runtime's validator, source-scoped cache and build-verdict handling without creating a + // running guard: setup must not install global hooks, start refresh timers, or execute the app. + const store = makeStore({ ...options, pulseRulesUrl: rulesUrl === defaultRules ? undefined : base }); + const bundle = await resolveRules(options, store, { + pulseAuth: config.pulseAuth, timeoutMs: Math.min(config.timeoutMs, 10_000), + }); + return { + map, notices, warnings, + rules: { ok: bundle.source.ok, origin: bundle.source.origin, count: bundle.firewall.length, + ...(bundle.source.ok ? {} : { error: bundle.source.reason ?? 'Live rules could not be fetched.' }) }, + }; + } catch { + return unavailable('Live rules could not be fetched. Check the endpoint, authentication and project permissions.'); + } +} diff --git a/src/setup.ts b/src/setup.ts index a9585182..7ab3db78 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -8,6 +8,7 @@ import type { ProtectResult, VerifyReport } from './protect/install/types.js'; import { writeProjectFileSync } from './safe-file.js'; const SCAN_COMMAND = 'patchstack-connect scan'; +const MAP_COMMAND = 'patchstack-connect map --upload'; const MARK_BUILD_COMMAND = 'patchstack-connect mark-build'; interface PackageJson { @@ -65,6 +66,12 @@ function prependHook(existing: string | undefined, command: string): string { return [command, ...remaining].join(' && '); } +/** Map after source-generating prebuild commands, including when an older map step came first. */ +function finishWithMap(existing: string): string { + if (/(?:^|&&|;)\s*patchstack-connect map --upload\s*$/.test(existing)) return existing; + return `${existing} && ${MAP_COMMAND}`; +} + /** * Wire a scan after dependency installs and around the project's build without * invoking a shell. Bun skips npm-style pre/post build hooks, so Bun projects get @@ -93,6 +100,9 @@ export function wireBuildScripts( scripts.postinstall = postinstall; } else if (packageManager === 'bun') { let nextBuild = prependHook(build, SCAN_COMMAND); + if (!/^\s*patchstack-connect scan\s*&&\s*patchstack-connect map --upload(?:\s*(?:&&|;)|\s*$)/.test(nextBuild)) { + nextBuild = nextBuild.replace(SCAN_COMMAND, `${SCAN_COMMAND} && ${MAP_COMMAND}`); + } if (!nextBuild.includes(MARK_BUILD_COMMAND)) { nextBuild = `${nextBuild} && ${MARK_BUILD_COMMAND}`; } @@ -108,7 +118,7 @@ export function wireBuildScripts( } else { // The scan clears a previous map identity. It has to precede any existing prebuild command because // that command may create and upload the new map which the bundled guard should retain. - const prebuild = prependHook(scripts.prebuild, SCAN_COMMAND); + const prebuild = finishWithMap(prependHook(scripts.prebuild, SCAN_COMMAND)); const postbuild = appendHook(scripts.postbuild, MARK_BUILD_COMMAND); if ( prebuild === scripts.prebuild && @@ -144,11 +154,11 @@ export function wireBuildScripts( ? { changed: true, strategy: 'build-chain', - detail: 'added a dependency-install scan and chained scan/mark-build around the build.', + detail: 'added a dependency-install scan and chained scan/map-upload/mark-build around the build.', } : { changed: true, strategy: 'lifecycle-hooks', - detail: 'added scans to postinstall/prebuild and mark-build to postbuild.', + detail: 'added scans to postinstall/prebuild, map upload before bundling, and mark-build to postbuild.', }; } diff --git a/tests/setup-sync.test.ts b/tests/setup-sync.test.ts new file mode 100644 index 00000000..8fa090a6 --- /dev/null +++ b/tests/setup-sync.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { syncSetupProtection } from '../src/setup-sync.js'; +import { setupProtection } from '../src/setup.js'; +import { readBuildStamp } from '../src/build-id.js'; +import { findRulesFile } from '../src/build-stamp.js'; +import { clearPulseToken } from '../src/pulse-token.js'; +import { makeStore } from '../src/protect/rules/store.js'; +import type { Config } from '../src/types.js'; + +const uuid = '550e8400-e29b-41d4-a716-446655440000'; +const base = 'https://api.example.test/monitor/pulse'; +const bundle = (rules: unknown[] = []) => ({ firewall: rules, whitelists: [], whitelist_keys: {} }); +const rule = { id: 'synthetic-request', phase: 'request', action: 'block', rule_v2: [{ parameter: 'post.message', match: { type: 'contains', value: 'synthetic-attack' } }] }; +let cwd: string; +let config: Config; +let calls: Array<{ url: string; init: RequestInit }>; +let mapStatus: number; +let ruleStatus: number; +let delivered: unknown; +let confirm: boolean; + +beforeEach(() => { + clearPulseToken(); + cwd = mkdtempSync(join(tmpdir(), 'ps-setup-sync-')); + writeFileSync(join(cwd, 'package.json'), JSON.stringify({ type: 'module', dependencies: { express: '^4.21.2' } })); + writeFileSync(join(cwd, '.patchstackrc.json'), JSON.stringify({ siteUuid: uuid })); + writeFileSync(join(cwd, 'server.js'), "import express from 'express';\nconst app = express();\napp.use(express.json());\napp.post('/contact', (req,res) => res.json({message:req.body.message}));\napp.listen(3000);\n"); + expect(setupProtection(cwd).verification.wired).toBe(true); + config = { siteUuid: uuid, endpoint: `${base}/manifest`, endpointTrusted: true, pulseAuth: 'synthetic-secret-1', apiKey: 'synthetic-secret-1', timeoutMs: 1000, environment: 'local', widget: true } as Config; + calls = []; mapStatus = 200; ruleStatus = 200; delivered = bundle([rule]); confirm = true; + vi.stubGlobal('fetch', async (url: unknown, init: RequestInit = {}) => { + calls.push({url: String(url), init}); + if (String(url).endsWith('/token')) return Response.json({access_token:'synthetic-token', expires_in:3600}); + if (String(url).includes('/input-map/')) return Response.json({result:'stored', revision:1}, {status:mapStatus}); + if (String(url).includes('/rules/')) { + const id = new Headers(init.headers).get('X-Patchstack-Build'); + return Response.json(delivered, {status:ruleStatus, headers: id && confirm ? {'X-Patchstack-Build-Match':'match', 'X-Patchstack-Build-ID':id} : {}}); + } + throw new Error('unexpected network path'); + }); +}); +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); clearPulseToken(); rmSync(cwd, {recursive:true, force:true}); }); + +function cache() { return JSON.parse(readFileSync(join(cwd,'.patchstack/patchstack-rules.json'),'utf8')); } +function uploaded() { return JSON.parse(String(calls.find(c => c.url.includes('/input-map/'))!.init.body)); } + +describe('one-command setup synchronization', () => { + it('uploads after scaffolding, then fetches request and response rules using the same identity', async () => { + delivered = bundle([rule, {...rule, id:'synthetic-output', phase:'response', rule_v2:[{parameter:'response.body', match:{type:'contains',value:'synthetic-output'}}]}]); + const result = await syncSetupProtection(cwd, config); + expect(result.map.upload?.result).toBe('stored'); + expect(result.map.endpoints).toBeGreaterThan(0); + expect(result.rules).toMatchObject({ok:true, count:2, origin:'api'}); + const map = uploaded(); + expect(map.build_id).toMatch(/^[a-f0-9]{64}$/); + const location = findRulesFile(cwd); + expect(location.kind).toBe('one'); + expect(readBuildStamp(JSON.parse(readFileSync((location as {path:string}).path,'utf8')))).toBe(map.build_id); + const pull = calls.find(c => c.url.includes('/rules/'))!; + expect(new Headers(pull.init.headers).get('Authorization')).toBe('Bearer synthetic-token'); + expect(new Headers(pull.init.headers).get('X-Patchstack-Build')).toBe(map.build_id); + expect(calls.indexOf(pull)).toBeGreaterThan(calls.findIndex(c => c.url.includes('/input-map/'))); + expect(cache()).toMatchObject({buildId:map.build_id, matchedBuildId:map.build_id, source:`site:${uuid}@${base}`}); + expect(readFileSync(join(cwd,'.gitignore'),'utf8')).toContain('.patchstack/'); + expect(JSON.stringify(map)).not.toContain('synthetic-secret'); + expect(JSON.stringify(cache())).not.toContain('synthetic-secret'); + expect(calls.some(c => /detections|logs|build\//.test(c.url))).toBe(false); + }); + + it('does not treat an absent server build verdict as confirmation', async () => { + confirm = false; + const result = await syncSetupProtection(cwd, config); + expect(result.rules.ok).toBe(true); + expect(cache().matchedBuildId).toBeNull(); + }); + + it('is stable on repeat setup and rebinds changed input names', async () => { + const first = await syncSetupProtection(cwd, config); + expect((await syncSetupProtection(cwd, config)).map.buildId).toBe(first.map.buildId); + const source = readFileSync(join(cwd,'server.js'),'utf8'); + writeFileSync(join(cwd,'server.js'),source.replace('req.body.message','req.body.text')); + expect((await syncSetupProtection(cwd, config)).map.buildId).not.toBe(first.map.buildId); + }); + + it.each([401,403,422,500])('reports map rejection %s without skipping broad rule retrieval', async status => { + mapStatus = status; confirm = false; + const result = await syncSetupProtection(cwd, config); + expect(result.map.upload?.result).toBe('failed'); + expect(result.rules.ok).toBe(true); + expect(cache().matchedBuildId).toBeNull(); + }); + + it('reports an empty successful policy separately from failed delivery', async () => { + delivered = bundle(); + expect((await syncSetupProtection(cwd, config)).rules).toMatchObject({ok:true,count:0}); + ruleStatus = 401; + expect((await syncSetupProtection(cwd, config)).rules).toMatchObject({ok:false,count:0,origin:'cache'}); + }); + + it('rejects invalid updates atomically and preserves last-known-good', async () => { + await syncSetupProtection(cwd, config); + delivered = bundle([{id:'bad',rule_v2:[{parameter:'post.x',match:{type:'regex',value:'/(/'}}]}]); + expect((await syncSetupProtection(cwd, config)).rules).toMatchObject({ok:false,origin:'cache',count:1}); + expect(cache().bundle.firewall[0].id).toBe('synthetic-request'); + }); + + it('never authenticates rule fetches at an implicit environment endpoint', async () => { + vi.stubEnv('PATCHSTACK_PULSE_RULES_URL','https://another.example.test/monitor/pulse'); + expect((await syncSetupProtection(cwd, config)).rules.ok).toBe(true); + expect(calls.every(c => c.url.startsWith(base))).toBe(true); + }); + + it('default-endpoint cache is readable by the ordinary guard', async () => { + config.endpoint = 'https://api.patchstack.com/monitor/pulse/manifest'; + await syncSetupProtection(cwd, config); + const stored = await makeStore({siteUuid:uuid,cacheDir:join(cwd,'.patchstack')}).read(); + expect(stored?.source).toBe(`site:${uuid}@`); + }); + + it('refuses untrusted endpoints without any network requests', async () => { + config.endpointTrusted = false; + const result = await syncSetupProtection(cwd, config); + expect(result.rules.ok).toBe(false); + expect(calls).toEqual([]); + }); + + it('reports missing credentials and does not attempt a rules pull', async () => { + config.pulseAuth = null; + expect((await syncSetupProtection(cwd, config)).rules.error).toContain('PATCHSTACK_API_KEY'); + expect(calls.some(c => c.url.includes('/rules/'))).toBe(false); + }); + + it('preserves a symlinked cache target', async () => { + mkdirSync(join(cwd,'.patchstack')); + writeFileSync(join(cwd,'unrelated.json'),'unchanged'); + symlinkSync(join(cwd,'unrelated.json'),join(cwd,'.patchstack/patchstack-rules.json')); + await syncSetupProtection(cwd,config); + expect(readFileSync(join(cwd,'unrelated.json'),'utf8')).toBe('unchanged'); + }); + + it('uploads client-only import inventory without claiming runtime binding', async () => { + const location = findRulesFile(cwd) as {path:string}; + rmSync(location.path); + rmSync(join(cwd,'server.js')); + const result = await syncSetupProtection(cwd, config); + expect(result.map).toMatchObject({endpoints:0, buildId:null, upload:{result:'stored'}}); + expect(new Headers(calls.find(c => c.url.includes('/rules/'))!.init.headers).has('X-Patchstack-Build')).toBe(false); + }); +}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 70b816b3..4a9d0f1c 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -38,7 +38,7 @@ describe('wireBuildScripts', () => { expect(readPackage().scripts).toMatchObject({ build: 'vite build', postinstall: 'patchstack-connect scan', - prebuild: 'patchstack-connect scan && npm run lint', + prebuild: 'patchstack-connect scan && npm run lint && patchstack-connect map --upload', postbuild: 'echo complete && patchstack-connect mark-build', }); }); @@ -77,7 +77,7 @@ describe('wireBuildScripts', () => { expect(result).toMatchObject({ changed: true, strategy: 'build-chain' }); expect(readPackage().scripts.build).toBe( - 'patchstack-connect scan && vite build && patchstack-connect mark-build', + 'patchstack-connect scan && patchstack-connect map --upload && vite build && patchstack-connect mark-build', ); expect(readPackage().scripts.postinstall).toBe('patchstack-connect scan'); }); @@ -105,7 +105,7 @@ describe('wireBuildScripts', () => { expect(second.changed).toBe(false); expect(readPackage().scripts.postinstall).toBe('patchstack-connect scan'); - expect(readPackage().scripts.prebuild).toBe('patchstack-connect scan'); + expect(readPackage().scripts.prebuild).toBe('patchstack-connect scan && patchstack-connect map --upload'); expect(readPackage().scripts.postbuild).toBe('patchstack-connect mark-build'); }); @@ -135,6 +135,20 @@ describe('wireBuildScripts', () => { ); }); + it('maps after prebuild code generation even if an earlier map hook already exists', () => { + writePackage({scripts:{build:'vite build',prebuild:'patchstack-connect map --upload && node generate.js'}}); + wireBuildScripts(cwd,'npm'); + expect(readPackage().scripts.prebuild).toBe('patchstack-connect scan && patchstack-connect map --upload && node generate.js && patchstack-connect map --upload'); + expect(wireBuildScripts(cwd,'npm').changed).toBe(false); + }); + + it('puts a Bun map before bundling even when a user already maps after it', () => { + writePackage({scripts:{build:'vite build && patchstack-connect map --upload'}}); + wireBuildScripts(cwd,'bun'); + expect(readPackage().scripts.build).toBe('patchstack-connect scan && patchstack-connect map --upload && vite build && patchstack-connect map --upload && patchstack-connect mark-build'); + expect(wireBuildScripts(cwd,'bun').changed).toBe(false); + }); + it('creates a scripts object for the dependency-install scan', () => { writePackage({ name: 'no-scripts' }); mkdirSync(path.join(cwd, 'src')); From 3e21bac3b10986d28c0afacef74d57904d6f8454 Mon Sep 17 00:00:00 2001 From: Dave Jong <dave.jong@patchstack.com> Date: Fri, 2 Oct 2026 08:28:23 +0200 Subject: [PATCH 2/3] fix(setup): run build hooks across package managers --- .github/workflows/ci.yml | 5 +++ AGENT-INSTALL.md | 4 +- README.md | 4 +- src/build-hook.ts | 16 +++++--- src/guide.ts | 13 +++---- src/setup.ts | 49 ++++++++++++++++++++++--- tests/build-hook.test.ts | 29 ++++++++++++++- tests/build-stamp.test.ts | 6 ++- tests/guide.test.ts | 21 +++++++++-- tests/setup-build-execution.test.ts | 57 +++++++++++++++++++++++++++++ tests/setup.test.ts | 33 +++++++++++++++-- 11 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 tests/setup-build-execution.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16e4f49b..e4a9bcda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 4a79d527..699f2d7c 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -98,7 +98,7 @@ Every command at a glance — what it does, whether it reads your source, what i - **`scan` makes up to three source edits:** the Patchstack Connector's `<script>` tag, the production marker, and — during `prebuild` only — removal of a previous `_patchstack.build_id` from the existing guard rules file. None runs on `--dry-run`; all are idempotent. `"widget": false` disables the first two, while stale-stamp removal is independent because it prevents old coordinates being attributed to a new build. - The **widget tag** goes in the root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists — and only after a successful post, because it carries the site UUID. - The **production marker** goes in a root shell that is JSX rather than HTML (e.g. `src/routes/__root.tsx`, `app/layout.tsx`), inside a `{/* #region patchstack */}` block placed above the widget tag. It is written *before* the post: it carries no site UUID and needs no network, and build scripts commonly chain `patchstack-connect scan || true`, where waiting on the server would mean an offline build silently ships without the flag. The marker is guarded by the framework's own production expression (`import.meta.env.PROD`, or `process.env.NODE_ENV === 'production'`), so it is inert in dev and preview builds. Without it a server-rendered site has no built HTML for `mark-build` to stamp, and the widget treats the published site as build mode. `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` → `protect` → map upload → live-rule lookup**, then reports the outcome. Provisioning precedes guard installation. The map is stamped into the guard source for the NEXT startup/build; restart an already-running preview/server to load it. The rule lookup uses the runtime validator and source-scoped local cache without creating a running guard or installing global hooks. A successful empty policy is reported as zero assigned rules, not proof of protection. Failed uploads/pulls appear under Missing and can be retried by rerunning setup. It also wires install scans, prebuild scans + map uploads, and postbuild marking (direct build chain for Bun), preserving existing commands. It never starts, builds or deploys the app. Ambiguous/custom integration code still requires review rather than being overwritten. +- **`setup` runs `scan` → `protect` → map upload → live-rule lookup**, then reports the outcome. Provisioning precedes guard installation. The map is stamped into the guard source for the NEXT startup/build; restart an already-running preview/server to load it. The rule lookup uses the runtime validator and source-scoped local cache without creating a running guard or installing global hooks. A successful empty policy is reported as zero assigned rules, not proof of protection. Failed uploads/pulls appear under Missing and can be retried by rerunning setup. It also wires install scans, prebuild scans + map uploads, and postbuild marking (explicit build chains for Yarn, pnpm and Bun), preserving existing commands. It never starts, builds or deploys the app. Ambiguous/custom integration code still requires review rather than being overwritten. - 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`), **TanStack Start** (documented server Fetch entry without requiring Supabase), **Next.js** (scaffolds or composes middleware/proxy and adds request/response checks to supported App Router handlers), **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. - **Standalone `map` is local unless you pass `--upload`.** It skips dependencies, build output and hidden directories and does not follow external symlinks by default. It reports detected entry points, inputs, sinks, dependency calls and evidence tiers with coverage limitations. Static analysis is best-effort, not a completeness guarantee. `setup` runs this analysis after guard integration and uploads it automatically. @@ -302,7 +302,7 @@ Handle it in this order: If a lifecycle hook already exists, chain instead of replacing it, e.g. `"prebuild": "patchstack-connect scan && existing-command && patchstack-connect map --upload"`. The `postinstall` scan reports dependencies added during an iterative sandbox session and covers applications with no build command. - **Bun-managed projects:** `bun run` does not execute npm-style `pre`/`post` scripts, so wire the build script directly instead: `"build": "patchstack-connect scan && patchstack-connect map --upload && <existing build command> && patchstack-connect mark-build"`. + **Yarn, pnpm and Bun projects:** use an explicit build chain instead of assuming npm-style `pre`/`post` hooks run: `"build": "patchstack-connect scan && patchstack-connect map --upload && <existing build command> && patchstack-connect mark-build"`. Modern Yarn and Bun skip those hooks; pnpm behavior depends on version and configuration. `setup` uses this chain for all three managers, including Yarn Classic, and preserves existing custom hooks. **Checking a build yourself:** run it through the package manager (`npm run build`), never the framework's own CLI (`astro build`, `vite build`, `next build`). Calling the CLI directly skips the `prebuild`/`postbuild` hooks, so the build is not scanned, not marked and not reported, and it tells you nothing about what the deployed build will carry. diff --git a/README.md b/README.md index e280ea11..6d8c9b45 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ That's it. `setup`: 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. 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 and using direct build chaining for Bun. +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. @@ -427,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. diff --git a/src/build-hook.ts b/src/build-hook.ts index c8fbf652..96179ce3 100644 --- a/src/build-hook.ts +++ b/src/build-hook.ts @@ -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'; @@ -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; } } /** diff --git a/src/guide.ts b/src/guide.ts index f8cc4506..fdcfc56d 100644 --- a/src/guide.ts +++ b/src/guide.ts @@ -401,12 +401,12 @@ export async function collectGuideState(cwd: string): Promise<GuideState> { hasBuildScript: Boolean(pkg?.scripts?.build?.trim()), installScanWired: (pkg?.scripts?.postinstall ?? '').includes('patchstack-connect scan'), // The scan has to run first: a later prebuild command may upload and stamp the map that the bundle - // must retain. Bun skips npm-style pre/post scripts, so its build chain follows the same order. + // must retain. Non-npm managers use explicit chains independent of lifecycle settings. prebuildWired: - /^\s*patchstack-connect\s+scan(?:\s*(?:&&|;)|\s*$)/.test(pkg?.scripts?.prebuild ?? '') || + (packageManager === 'npm' && /^\s*patchstack-connect\s+scan(?:\s*(?:&&|;)|\s*$)/.test(pkg?.scripts?.prebuild ?? '')) || /^\s*patchstack-connect\s+scan(?:\s*(?:&&|;)|\s*$)/.test(pkg?.scripts?.build ?? ''), postbuildWired: - (pkg?.scripts?.postbuild ?? '').includes('patchstack-connect mark-build') || + (packageManager === 'npm' && (pkg?.scripts?.postbuild ?? '').includes('patchstack-connect mark-build')) || (pkg?.scripts?.build ?? '').includes('patchstack-connect mark-build'), widgetInstalled: widget.found, widgetTokenMatches: widget.uuidMatches, @@ -510,11 +510,10 @@ function buildScriptLines(state: GuideState): string[] { const lines: string[] = []; if (!state.installScanWired) lines.push('"postinstall": "patchstack-connect scan"'); if (state.hasBuildScript && !(state.prebuildWired && state.postbuildWired)) { - if (state.packageManager === 'bun') { - // bun run skips npm-style pre/post scripts, so the hooks chain inside the build script. - lines.push('"build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build"'); + if (state.packageManager !== 'npm') { + lines.push('"build": "patchstack-connect scan && patchstack-connect map --upload && <existing build command> && patchstack-connect mark-build"'); } else { - if (!state.prebuildWired) lines.push('"prebuild": "patchstack-connect scan"'); + if (!state.prebuildWired) lines.push('"prebuild": "patchstack-connect scan && patchstack-connect map --upload"'); if (!state.postbuildWired) lines.push('"postbuild": "patchstack-connect mark-build"'); } } diff --git a/src/setup.ts b/src/setup.ts index 7ab3db78..90e369e5 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -59,13 +59,50 @@ function prependHook(existing: string | undefined, command: string): string { if (new RegExp(`^\\s*${escaped}\\s*(?:;|\\|\\|)`).test(existing)) return existing; // A second scan after a map upload would clear the identity the upload just stamped. - const remaining = existing - .split(/\s*&&\s*/) + const remaining = splitAndChain(existing) .filter((part) => part.trim() !== command && part.trim().length > 0); return [command, ...remaining].join(' && '); } +/** Only standalone commands may be reordered; quoted text and shell groups stay intact. */ +function splitAndChain(source: string): string[] { + const parts: string[] = []; + let start = 0; + let quote = ''; + const groups: {close: string; quote: string}[] = []; + for (let i = 0; i < source.length; i++) { + const char = source[i]!; + if (char === '\\' && quote !== "'") { i++; continue; } + if (quote === "'" || quote === '`') { + if (char === quote) quote = quote === '`' ? groups.pop()!.quote : ''; + continue; + } + if (char === '`') { groups.push({close:'`',quote}); quote = '`'; continue; } + if (char === '$' && (source[i + 1] === '(' || source[i + 1] === '{')) { + groups.push({close:source[++i] === '(' ? ')' : '}',quote}); + quote = ''; + continue; + } + if (quote) { if (char === quote) quote = ''; continue; } + if (char === '"' || char === "'") { quote = char; continue; } + if (char === '#' && (i === 0 || /\s/.test(source[i - 1]!))) { + const newline = source.indexOf('\n', i); + if (newline === -1) break; + i = newline; + continue; + } + if (char === '(' || char === '{') groups.push({close:char === '(' ? ')' : '}',quote:''}); + if (char === groups.at(-1)?.close) quote = groups.pop()!.quote; + if (groups.length === 0 && char === '&' && source[i + 1] === '&') { + parts.push(source.slice(start, i).trim()); + start = ++i + 1; + } + } + parts.push(source.slice(start).trim()); + return parts; +} + /** Map after source-generating prebuild commands, including when an older map step came first. */ function finishWithMap(existing: string): string { if (/(?:^|&&|;)\s*patchstack-connect map --upload\s*$/.test(existing)) return existing; @@ -74,8 +111,8 @@ function finishWithMap(existing: string): string { /** * Wire a scan after dependency installs and around the project's build without - * invoking a shell. Bun skips npm-style pre/post build hooks, so Bun projects get - * a direct build chain; other package managers get lifecycle hooks. Existing + * invoking a shell. Only npm is assumed to run pre/post build hooks. Other managers + * get a direct build chain, independent of their version and lifecycle settings. Existing * commands are preserved and the operation is idempotent. */ export function wireBuildScripts( @@ -98,7 +135,7 @@ export function wireBuildScripts( }; } scripts.postinstall = postinstall; - } else if (packageManager === 'bun') { + } else if (packageManager !== 'npm') { let nextBuild = prependHook(build, SCAN_COMMAND); if (!/^\s*patchstack-connect scan\s*&&\s*patchstack-connect map --upload(?:\s*(?:&&|;)|\s*$)/.test(nextBuild)) { nextBuild = nextBuild.replace(SCAN_COMMAND, `${SCAN_COMMAND} && ${MAP_COMMAND}`); @@ -150,7 +187,7 @@ export function wireBuildScripts( }; } - return packageManager === 'bun' + return packageManager !== 'npm' ? { changed: true, strategy: 'build-chain', diff --git a/tests/build-hook.test.ts b/tests/build-hook.test.ts index 43a513cb..df9d03ad 100644 --- a/tests/build-hook.test.ts +++ b/tests/build-hook.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest'; -import { isInstallOrBuildHook, undeliveredReportLines } from '../src/build-hook.js'; +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { isInstallOrBuildHook, isPreBundleBuildHook, undeliveredReportLines } from '../src/build-hook.js'; import { PatchstackError, type Config } from '../src/types.js'; /** @@ -27,6 +30,28 @@ describe('isInstallOrBuildHook', () => { }); }); +describe('build-chain lifecycle metadata', () => { + const dirs: string[] = []; + afterEach(() => dirs.splice(0).forEach(dir => rmSync(dir,{recursive:true,force:true}))); + function manifest(contents: string) { + const cwd = mkdtempSync(join(tmpdir(),'ps-hook-metadata-')); + dirs.push(cwd); + const file = join(cwd,'package.json'); + writeFileSync(file,contents); + return file; + } + it.each([undefined, 'node unrelated-parent.cjs'])('reads the current manifest when script metadata is %s', script => { + const file = manifest(JSON.stringify({scripts:{build:'patchstack-connect scan && patchstack-connect map --upload && vite build'}})); + expect(isPreBundleBuildHook({npm_lifecycle_event:'build',npm_package_json:'unrelated-parent/package.json',npm_lifecycle_script:script},dirname(file))).toBe(true); + for (const event of ['dev','postinstall','postbuild']) { + expect(isPreBundleBuildHook({npm_lifecycle_event:event,npm_package_json:file},dirname(file))).toBe(false); + } + }); + it.each(['{','{}','{"scripts":{"build":true}}','{"scripts":{"build":"vite build && patchstack-connect scan"}}'])('refuses unproven build metadata: %s', contents => { + expect(isPreBundleBuildHook({npm_lifecycle_event:'build',npm_lifecycle_script:'patchstack-connect scan && vite build'},dirname(manifest(contents)))).toBe(false); + }); +}); + describe('undeliveredReportLines', () => { const config = (over: Partial<Config> = {}): Config => ({ siteUuid: '11111111-1111-4111-8111-111111111111', diff --git a/tests/build-stamp.test.ts b/tests/build-stamp.test.ts index 1264ab51..c7c36779 100644 --- a/tests/build-stamp.test.ts +++ b/tests/build-stamp.test.ts @@ -297,18 +297,20 @@ describe('writing the stamp', () => { describe('when a build may write to the project', () => { it('is a pre-bundle build lifecycle, and nothing else', () => { + const before = project({'package.json':JSON.stringify({scripts:{build:'patchstack-connect scan && vite build && patchstack-connect mark-build'}})}); + const after = project({'package.json':JSON.stringify({scripts:{build:'vite build && patchstack-connect scan'}})}); expect(isPreBundleBuildHook({ npm_lifecycle_event: 'prebuild' })).toBe(true); expect( isPreBundleBuildHook({ npm_lifecycle_event: 'build', npm_lifecycle_script: 'patchstack-connect scan && vite build && patchstack-connect mark-build', - }), + }, before), ).toBe(true); expect( isPreBundleBuildHook({ npm_lifecycle_event: 'build', npm_lifecycle_script: 'vite build && patchstack-connect scan', - }), + }, after), ).toBe(false); expect(isPreBundleBuildHook({ npm_lifecycle_event: 'build' })).toBe(false); // An install is not a build; `postbuild` is a build the bundler has already finished, so a value diff --git a/tests/guide.test.ts b/tests/guide.test.ts index fbe6ca3d..d52fbd7d 100644 --- a/tests/guide.test.ts +++ b/tests/guide.test.ts @@ -145,7 +145,20 @@ describe('guide', () => { const state = await collectGuideState(cwd); expect(state.prebuildWired).toBe(false); - expect(renderGuideChecklist(state, false, {}, { verbose: true })).toContain('"prebuild": "patchstack-connect scan"'); + expect(renderGuideChecklist(state, false, {}, { verbose: true })).toContain('"prebuild": "patchstack-connect scan && patchstack-connect map --upload"'); + }); + + it.each(['yarn', 'pnpm', 'bun'])('requires an explicit build chain for %s', async manager => { + const scripts = {build:'vite build',prebuild:'patchstack-connect scan && patchstack-connect map --upload',postbuild:'patchstack-connect mark-build'}; + writeJson('package.json', {packageManager:`${manager}@1.0.0`,scripts}); + const before = await collectGuideState(cwd); + expect(before.prebuildWired).toBe(false); + expect(before.postbuildWired).toBe(false); + scripts.build = 'patchstack-connect scan && patchstack-connect map --upload && vite build && patchstack-connect mark-build'; + writeJson('package.json', {packageManager:`${manager}@1.0.0`,scripts}); + const after = await collectGuideState(cwd); + expect(after.prebuildWired).toBe(true); + expect(after.postbuildWired).toBe(true); }); it('survives a project with no package.json', async () => { @@ -278,12 +291,12 @@ describe('guide', () => { const output = renderGuideChecklist(await collectGuideState(cwd), false, {}, { verbose: true }); expect(output).toContain( - '"build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build"', + '"build": "patchstack-connect scan && patchstack-connect map --upload && <existing build command> && patchstack-connect mark-build"', ); expect(output).not.toContain('"prebuild"'); }); - it('suggests prebuild/postbuild hooks on non-bun projects', async () => { + it('suggests prebuild/postbuild hooks on npm projects', async () => { writeJson('package.json', { name: 'npm-app', scripts: { build: 'vite build' } }); writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); @@ -292,7 +305,7 @@ describe('guide', () => { ); const output = renderGuideChecklist(await collectGuideState(cwd), false, {}, { verbose: true }); - expect(output).toContain('"prebuild": "patchstack-connect scan"'); + expect(output).toContain('"prebuild": "patchstack-connect scan && patchstack-connect map --upload"'); expect(output).toContain('"postbuild": "patchstack-connect mark-build"'); }); diff --git a/tests/setup-build-execution.test.ts b/tests/setup-build-execution.test.ts new file mode 100644 index 00000000..37280fda --- /dev/null +++ b/tests/setup-build-execution.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { wireBuildScripts } from '../src/setup.js'; +import { isPreBundleBuildHook } from '../src/build-hook.js'; +import type { PackageManager } from '../src/guide.js'; + +const manager = (process.env.PATCHSTACK_TEST_MANAGER ?? 'npm') as PackageManager; +if (!['npm','yarn','pnpm','bun'].includes(manager)) throw new Error('Unknown test package manager'); +const dirs: string[] = []; +afterEach(() => dirs.splice(0).forEach(dir => rmSync(dir, {recursive:true,force:true}))); + +describe('setup hooks under the real package manager', () => { + it('executes scan, map, build and mark in order on every build', () => { + const cwd = mkdtempSync(join(tmpdir(), 'ps-build-hooks-')); + dirs.push(cwd); + const cli = '#!/usr/bin/env node\n' + + 'require("node:fs").appendFileSync("events.jsonl", JSON.stringify(process.argv.slice(2)) + "\\n");\n' + + 'require("node:fs").appendFileSync("lifecycle.jsonl", JSON.stringify({npm_lifecycle_event:process.env.npm_lifecycle_event,npm_lifecycle_script:process.env.npm_lifecycle_script,npm_package_json:process.env.npm_package_json}) + "\\n");\n'; + mkdirSync(join(cwd,'fake-cli')); + writeFileSync(join(cwd,'fake-cli/package.json'), JSON.stringify({name:'synthetic-connect-cli',version:'1.0.0',bin:{'patchstack-connect':'cli.cjs'}})); + writeFileSync(join(cwd,'fake-cli/cli.cjs'),cli,{mode:0o755}); + writeFileSync(join(cwd, 'package.json'), JSON.stringify({name:'synthetic-build',private:true,dependencies:{'synthetic-connect-cli':'file:./fake-cli'},scripts:{build:'node build.cjs'}})); + if (manager === 'yarn') { + writeFileSync(join(cwd,'yarn.lock'),''); + writeFileSync(join(cwd,'.yarnrc.yml'),'nodeLinker: node-modules\n'); + const version = spawnSync(manager,['--version'],{encoding:'utf8'}).stdout?.trim() ?? ''; + if (!version.startsWith('1.')) { + const installed = spawnSync(manager,['install','--mode=skip-build'],{cwd,encoding:'utf8',env:{...process.env,YARN_ENABLE_NETWORK:'0'}}); + expect(installed.status).toBe(0); + } + } + const bin = join(cwd,'node_modules/.bin'); + mkdirSync(bin,{recursive:true}); + if (!existsSync(join(bin,'patchstack-connect'))) { + writeFileSync(join(bin,'patchstack-connect'),cli,{mode:0o755}); + writeFileSync(join(bin,'patchstack-connect.cmd'), '@node "%~dp0patchstack-connect" %*\r\n'); + } + writeFileSync(join(cwd,'build.cjs'), 'require("node:fs").appendFileSync("events.jsonl", "[\\"build\\"]\\n"); if(process.env.SYNTHETIC_BUILD_FAIL) process.exit(2);'); + wireBuildScripts(cwd,manager); + expect(wireBuildScripts(cwd,manager).changed).toBe(false); + const build = (fail = false) => spawnSync(manager,['run','build'],{ + cwd,encoding:'utf8',shell:process.platform === 'win32',env:{...process.env,SYNTHETIC_BUILD_FAIL:fail ? '1' : ''}, + }); + expect(build().status).toBe(0); + expect(build().status).toBe(0); + const events = () => readFileSync(join(cwd,'events.jsonl'),'utf8').trim().split('\n').map(line => JSON.parse(line)); + const sequence = [['scan'],['map','--upload'],['build'],['mark-build']]; + expect(events()).toEqual([...sequence,...sequence]); + const lifecycle = readFileSync(join(cwd,'lifecycle.jsonl'),'utf8').trim().split('\n').map(line => JSON.parse(line)); + expect(lifecycle.filter((_, index) => index % 3 !== 2).map(env => isPreBundleBuildHook(env,cwd))).toEqual([true,true,true,true]); + expect(build(true).status).not.toBe(0); + expect(events()).toEqual([...sequence,...sequence,...sequence.slice(0,3)]); + }, 30_000); +}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 4a9d0f1c..061bb215 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -70,10 +70,10 @@ describe('wireBuildScripts', () => { ); }); - it('chains directly around a Bun build', () => { + it.each(['bun', 'yarn', 'pnpm'] as const)('chains directly around a %s build', manager => { writePackage({ scripts: { build: 'vite build' } }); - const result = wireBuildScripts(cwd, 'bun'); + const result = wireBuildScripts(cwd, manager); expect(result).toMatchObject({ changed: true, strategy: 'build-chain' }); expect(readPackage().scripts.build).toBe( @@ -105,8 +105,33 @@ describe('wireBuildScripts', () => { expect(second.changed).toBe(false); expect(readPackage().scripts.postinstall).toBe('patchstack-connect scan'); - expect(readPackage().scripts.prebuild).toBe('patchstack-connect scan && patchstack-connect map --upload'); - expect(readPackage().scripts.postbuild).toBe('patchstack-connect mark-build'); + expect(readPackage().scripts.build).toBe('patchstack-connect scan && patchstack-connect map --upload && vite build && patchstack-connect mark-build'); + expect(readPackage().scripts.prebuild).toBeUndefined(); + expect(readPackage().scripts.postbuild).toBeUndefined(); + }); + + it.each(['yarn', 'pnpm'] as const)('upgrades a lifecycle-only %s setup without removing custom commands', manager => { + writePackage({scripts:{build:'vite build',prebuild:'patchstack-connect scan && node generate.js',postbuild:'node report.js && patchstack-connect mark-build'}}); + wireBuildScripts(cwd,manager); + expect(readPackage().scripts.build).toBe('patchstack-connect scan && patchstack-connect map --upload && vite build && patchstack-connect mark-build'); + expect(readPackage().scripts.prebuild).toBe('patchstack-connect scan && node generate.js'); + expect(readPackage().scripts.postbuild).toBe('node report.js && patchstack-connect mark-build'); + expect(wireBuildScripts(cwd,manager).changed).toBe(false); + }); + + it.each([ + 'node -e "console.log(\'a&&b\')"', + "node -e 'console.log(\"a&&b\")'", + '(node first.js && node second.js)', + 'node script.js a\\&\\&b', + 'node script.js "$(node -e "console.log(\'a&&b\')")"', + 'node script.js "${VALUE:-"a&&b"}"', + 'node script.js "`node -e "console.log(\'a&&b\')"`"', + ])('preserves shell arguments and grouped commands: %s', build => { + writePackage({scripts:{build}}); + wireBuildScripts(cwd,'yarn'); + expect(readPackage().scripts.build).toBe(`patchstack-connect scan && patchstack-connect map --upload && ${build} && patchstack-connect mark-build`); + expect(wireBuildScripts(cwd,'yarn').changed).toBe(false); }); it('does not duplicate a scan already first in a semicolon hook', () => { From 8f243beba847ed13172e5fc8cef9ae9c463ac2fb Mon Sep 17 00:00:00 2001 From: Dave Jong <dave.jong@patchstack.com> Date: Fri, 2 Oct 2026 08:32:50 +0200 Subject: [PATCH 3/3] test(setup): initialize the offline Yarn fixture on CI --- tests/setup-build-execution.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/setup-build-execution.test.ts b/tests/setup-build-execution.test.ts index 37280fda..5eb0ed75 100644 --- a/tests/setup-build-execution.test.ts +++ b/tests/setup-build-execution.test.ts @@ -28,7 +28,10 @@ describe('setup hooks under the real package manager', () => { writeFileSync(join(cwd,'.yarnrc.yml'),'nodeLinker: node-modules\n'); const version = spawnSync(manager,['--version'],{encoding:'utf8'}).stdout?.trim() ?? ''; if (!version.startsWith('1.')) { - const installed = spawnSync(manager,['install','--mode=skip-build'],{cwd,encoding:'utf8',env:{...process.env,YARN_ENABLE_NETWORK:'0'}}); + // This new local-only fixture needs its initial lockfile, including on CI. + const installed = spawnSync(manager,['install','--mode=skip-build'],{cwd,encoding:'utf8',env:{ + ...process.env,CI:'true',YARN_ENABLE_NETWORK:'0',YARN_ENABLE_IMMUTABLE_INSTALLS:'false', + }}); expect(installed.status).toBe(0); } }