From 17ad8a87a61d8ac2cadf7ad84266a0167ec65a61 Mon Sep 17 00:00:00 2001 From: Elliot Taylor Date: Wed, 15 Jul 2026 19:44:37 +0200 Subject: [PATCH 1/2] fix: support JavaScript Express guards --- src/protect/install/adapters/express.ts | 14 ++-- src/protect/install/find-app.ts | 5 +- src/protect/install/generic.ts | 14 ++-- src/protect/install/register.ts | 93 +++++++++++++++++++++++-- src/protect/templates/express-guard.cjs | 31 +++++++++ src/protect/templates/express-guard.js | 30 ++++++++ src/protect/templates/express-guard.ts | 32 +++++++++ tests/protect/adapters.test.ts | 82 +++++++++++++++++++++- 8 files changed, 282 insertions(+), 19 deletions(-) create mode 100644 src/protect/templates/express-guard.cjs create mode 100644 src/protect/templates/express-guard.js create mode 100644 src/protect/templates/express-guard.ts diff --git a/src/protect/install/adapters/express.ts b/src/protect/install/adapters/express.ts index c21c1ac..76dca5f 100644 --- a/src/protect/install/adapters/express.ts +++ b/src/protect/install/adapters/express.ts @@ -1,17 +1,23 @@ -// Adapter: Express (Node). Scaffolds the framework-agnostic guard and wires it as the first -// middleware — `app.use(patchstackMiddleware)` right after the `express()` app is created. +// Adapter: Express (Node). Scaffolds a guard that matches the entry file's module format, then +// registers parsed-body middleware after express.json() and before the application's routes. import { hasDependency } from '../util.js'; import { findAppInstance } from '../find-app.js'; import { wireRegister, verifyRegister, type RegisterSpec } from '../register.js'; import type { Adapter } from '../types.js'; +const jsonParserRe = (appVar: string) => new RegExp(`^\\s*${appVar}\\.use\\(\\s*express\\.json\\(`, 'm'); + const SPEC: RegisterSpec = { appRe: /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*express\(\)/, - guardTemplate: 'generic-guard.ts', + guardTemplate: 'express-guard.ts', + guardTemplateEsm: 'express-guard.js', + guardTemplateCjs: 'express-guard.cjs', importName: 'patchstackMiddleware', call: (v) => `${v}.use(patchstackMiddleware);`, + callAfter: jsonParserRe, + requireCallAfter: true, label: 'Express app', - manualHint: 'add `app.use(patchstackMiddleware)` right after you create your express() app', + manualHint: 'add `app.use(patchstackMiddleware)` after your JSON body parser and before the routes', }; export const expressAdapter: Adapter = { diff --git a/src/protect/install/find-app.ts b/src/protect/install/find-app.ts index ae0e8e1..cc754ba 100644 --- a/src/protect/install/find-app.ts +++ b/src/protect/install/find-app.ts @@ -44,8 +44,9 @@ export function findAppInstance(cwd: string, re: RegExp): { relPath: string; app } /** Relative ESM specifier from `fromRel` to `toRel` (both repo-relative, extension stripped). */ -export function importSpecifier(fromRel: string, toRel: string): string { - let spec = relative(dirname(fromRel), toRel).replace(/\\/g, '/').replace(/\.(?:ts|js)$/, ''); +export function importSpecifier(fromRel: string, toRel: string, preserveExtension = false): string { + let spec = relative(dirname(fromRel), toRel).replace(/\\/g, '/'); + if (!preserveExtension) spec = spec.replace(/\.(?:ts|js)$/, ''); if (!spec.startsWith('.')) spec = `./${spec}`; return spec; } diff --git a/src/protect/install/generic.ts b/src/protect/install/generic.ts index bdb3165..387ec6d 100644 --- a/src/protect/install/generic.ts +++ b/src/protect/install/generic.ts @@ -14,14 +14,20 @@ function genericDir(cwd: string): string { return existsSync(join(cwd, 'src')) ? 'src/patchstack' : 'patchstack'; } -export function scaffoldGeneric(cwd: string, opts: WireOptions, guardTemplate = 'generic-guard.ts'): { changed: string[]; dir: string } { +export function scaffoldGeneric( + cwd: string, + opts: WireOptions, + guardTemplate = 'generic-guard.ts', + guardFile = 'guard.ts', +): { changed: string[]; dir: string } { const templates = templatesDir(); const dir = genericDir(cwd); const dst = join(cwd, dir); mkdirSync(dst, { recursive: true }); - copyFileSync(join(templates, guardTemplate), join(dst, 'guard.ts')); - const changed = [`${dir}/guard.ts`]; - if (!opts.demo) bakeSiteUuid(cwd, `${dir}/guard.ts`); + copyFileSync(join(templates, guardTemplate), join(dst, guardFile)); + const guardRel = `${dir}/${guardFile}`; + const changed = [guardRel]; + if (!opts.demo) bakeSiteUuid(cwd, guardRel); const rulesDst = join(dst, 'rules.json'); if (opts.demo || !existsSync(rulesDst)) { copyFileSync(join(templates, opts.demo ? 'demo-rules.json' : 'rules.json'), rulesDst); diff --git a/src/protect/install/register.ts b/src/protect/install/register.ts index 9a9eb6b..ff87d31 100644 --- a/src/protect/install/register.ts +++ b/src/protect/install/register.ts @@ -13,22 +13,74 @@ import type { WireOptions, WireResult, VerifyResult } from './types.js'; export interface RegisterSpec { appRe: RegExp; // matches the app-instance line; MUST capture the app var in group 1 guardTemplate: string; // guard template scaffolded as src/patchstack/guard.ts + /** Optional JavaScript templates for apps that execute their source files directly. */ + guardTemplateEsm?: string; + guardTemplateCjs?: string; importName: string; // named export the entry imports from the guard call: (appVar: string) => string; // the registration statement, e.g. `${v}.use(patchstackMiddleware);` + /** Prefer a registration anchor after app creation, such as Express's JSON body parser. */ + callAfter?: (appVar: string) => RegExp; + /** Scaffold but leave the app untouched when the preferred anchor is absent. */ + requireCallAfter?: boolean; label: string; // human label for logs / verify checks, e.g. 'Express app' manualHint: string; // guidance when no app-instance site is found } const REGION = '// #region patchstack (managed by patchstack-connect protect — do not edit)'; +interface GuardTarget { + template: string; + file: string; + importLine: (name: string, specifier: string) => string; + preserveExtension: boolean; +} + +function guardTarget(cwd: string, entryRel: string, spec: RegisterSpec): GuardTarget { + if (/\.cjs$/.test(entryRel) || (/\.js$/.test(entryRel) && packageType(cwd) !== 'module')) { + if (spec.guardTemplateCjs) { + return { + template: spec.guardTemplateCjs, + file: 'guard.cjs', + importLine: (name, target) => `const { ${name} } = require("${target}");`, + preserveExtension: true, + }; + } + } else if (/\.(?:js|mjs)$/.test(entryRel) && spec.guardTemplateEsm) { + return { + template: spec.guardTemplateEsm, + file: entryRel.endsWith('.mjs') ? 'guard.mjs' : 'guard.js', + importLine: (name, target) => `import { ${name} } from "${target}";`, + preserveExtension: true, + }; + } + + return { + template: spec.guardTemplate, + file: 'guard.ts', + importLine: (name, target) => `import { ${name} } from "${target}";`, + preserveExtension: false, + }; +} + +function packageType(cwd: string): string | undefined { + try { + return JSON.parse(read(join(cwd, 'package.json'))).type; + } catch { + return undefined; + } +} + export function wireRegister(cwd: string, opts: WireOptions, spec: RegisterSpec): WireResult { - const { changed, dir } = scaffoldGeneric(cwd, opts, spec.guardTemplate); const entry = findAppInstance(cwd, spec.appRe); if (!entry) { + const { changed } = scaffoldGeneric(cwd, opts, spec.guardTemplate); log(`${spec.label} not located — scaffolded guard; ${spec.manualHint}`); return { ok: true, changed }; } + const target = guardTarget(cwd, entry.relPath, spec); + const { changed, dir } = scaffoldGeneric(cwd, opts, target.template, target.file); + const p = join(cwd, entry.relPath); const s = read(p); if (s.includes(spec.importName)) { @@ -36,14 +88,36 @@ export function wireRegister(cwd: string, opts: WireOptions, spec: RegisterSpec) return { ok: true, changed }; } - const importLine = `import { ${spec.importName} } from "${importSpecifier(entry.relPath, `${dir}/guard`)}";`; + const sourceLines = s.split('\n'); + const sourceAppIdx = sourceLines.findIndex((line) => spec.appRe.test(line)); + const requiredAnchor = spec.callAfter?.(entry.appVar); + if ( + spec.requireCallAfter && + requiredAnchor && + !sourceLines.some((line, index) => index > sourceAppIdx && requiredAnchor.test(line)) + ) { + log(`${entry.relPath} body-parser anchor not found — scaffolded guard; ${spec.manualHint}`); + return { ok: true, changed }; + } + + const importLine = target.importLine( + spec.importName, + importSpecifier(entry.relPath, `${dir}/${target.file}`, target.preserveExtension), + ); const lines = s.split('\n'); let lastImport = -1; - for (let i = 0; i < lines.length; i++) if (/^\s*import\b/.test(lines[i] ?? '')) lastImport = i; + for (let i = 0; i < lines.length; i++) { + if (/^\s*(?:import\b|(?:const|let|var)\s+.+?=\s*require\()/.test(lines[i] ?? '')) lastImport = i; + } lines.splice(lastImport + 1, 0, importLine); const appIdx = lines.findIndex((l) => spec.appRe.test(l)); if (appIdx !== -1) { - lines.splice(appIdx + 1, 0, REGION, spec.call(entry.appVar), '// #endregion patchstack'); + const preferred = spec.callAfter?.(entry.appVar); + const preferredIdx = preferred + ? lines.findIndex((line, index) => index > appIdx && preferred.test(line)) + : -1; + const callIdx = preferredIdx === -1 ? appIdx : preferredIdx; + lines.splice(callIdx + 1, 0, REGION, spec.call(entry.appVar), '// #endregion patchstack'); } writeFileSync(p, lines.join('\n')); changed.push(entry.relPath); @@ -53,9 +127,16 @@ export function wireRegister(cwd: string, opts: WireOptions, spec: RegisterSpec) export function verifyRegister(cwd: string, spec: RegisterSpec): VerifyResult { const dir = existsSync(join(cwd, 'src')) ? 'src/patchstack' : 'patchstack'; - const scaffolded = existsSync(join(cwd, dir, 'guard.ts')); const entry = findAppInstance(cwd, spec.appRe); - const wired = entry ? read(join(cwd, entry.relPath)).includes(spec.importName) : false; + const target = entry ? guardTarget(cwd, entry.relPath, spec) : null; + const scaffolded = target ? existsSync(join(cwd, dir, target.file)) : false; + const entrySource = entry ? read(join(cwd, entry.relPath)) : ''; + const entryLines = entrySource.split('\n'); + const anchor = entry && spec.callAfter ? spec.callAfter(entry.appVar) : null; + const anchorIndex = anchor ? entryLines.findIndex((line) => anchor.test(line)) : -1; + const callIndex = entry ? entryLines.findIndex((line) => line.includes(spec.call(entry.appVar))) : -1; + const ordered = anchor ? anchorIndex !== -1 && callIndex > anchorIndex : callIndex !== -1; + const wired = entrySource.includes(spec.importName) && ordered; return { wired: scaffolded && wired, checks: [ diff --git a/src/protect/templates/express-guard.cjs b/src/protect/templates/express-guard.cjs new file mode 100644 index 0000000..4ba68a6 --- /dev/null +++ b/src/protect/templates/express-guard.cjs @@ -0,0 +1,31 @@ +// Patchstack runtime guard for CommonJS Express apps. Managed by `patchstack-connect protect`. +const { createProtection } = require("@patchstack/connect/protect"); +const fallbackRules = require("./rules.json"); + +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; +let protection; + +async function getProtection() { + if (!protection) { + const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; + const token = process.env.PATCHSTACK_WAF_TOKEN; + const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; + const common = { mode, egress: true }; + protection = await createProtection( + siteUuid + ? { ...common, siteUuid, rules: fallbackRules, cacheDir: ".patchstack" } + : token + ? { ...common, token, cacheDir: ".patchstack" } + : { ...common, rules: fallbackRules }, + ); + } + return protection; +} + +function patchstackMiddleware(req, res, next) { + getProtection() + .then((active) => active.express()(req, res, next)) + .catch(() => next()); +} + +module.exports = { patchstackMiddleware }; diff --git a/src/protect/templates/express-guard.js b/src/protect/templates/express-guard.js new file mode 100644 index 0000000..abbce0d --- /dev/null +++ b/src/protect/templates/express-guard.js @@ -0,0 +1,30 @@ +// Patchstack runtime guard for ESM Express apps. Managed by `patchstack-connect protect`. +import { readFileSync } from "node:fs"; +import { createProtection } from "@patchstack/connect/protect"; + +const fallbackRules = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8")); +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; +let protection; + +async function getProtection() { + if (!protection) { + const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; + const token = process.env.PATCHSTACK_WAF_TOKEN; + const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; + const common = { mode, egress: true }; + protection = await createProtection( + siteUuid + ? { ...common, siteUuid, rules: fallbackRules, cacheDir: ".patchstack" } + : token + ? { ...common, token, cacheDir: ".patchstack" } + : { ...common, rules: fallbackRules }, + ); + } + return protection; +} + +export function patchstackMiddleware(req, res, next) { + getProtection() + .then((active) => active.express()(req, res, next)) + .catch(() => next()); +} diff --git a/src/protect/templates/express-guard.ts b/src/protect/templates/express-guard.ts new file mode 100644 index 0000000..5d42735 --- /dev/null +++ b/src/protect/templates/express-guard.ts @@ -0,0 +1,32 @@ +// Patchstack runtime guard for Express. Managed by `patchstack-connect protect`. +// Register after body parsing and before routes: app.use(patchstackMiddleware). +import { createProtection } from "@patchstack/connect/protect"; +import fallbackRules from "./rules.json"; + +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; +let _protection: Awaited> | undefined; + +async function getProtection() { + if (!_protection) { + const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; + const token = process.env.PATCHSTACK_WAF_TOKEN; + const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; + const common = { mode, egress: true } as const; + _protection = await createProtection( + siteUuid + ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } + : token + ? { ...common, token, cacheDir: ".patchstack" } + : { ...common, rules: fallbackRules as never }, + ); + } + return _protection; +} + +export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: unknown) => void) { + getProtection() + .then((protection) => + (protection.express() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, next), + ) + .catch(() => next()); +} diff --git a/tests/protect/adapters.test.ts b/tests/protect/adapters.test.ts index 6a3c7f8..445d527 100644 --- a/tests/protect/adapters.test.ts +++ b/tests/protect/adapters.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { runProtect, runVerify } from '../../src/protect/install/index.js'; @@ -13,11 +14,14 @@ describe('Express adapter', () => { const dir = tmp('ps-express-'); writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', dependencies: { express: '^4.19.0' } })); mkdirSync(path.join(dir, 'src'), { recursive: true }); - writeFileSync(path.join(dir, 'src/server.ts'), "import express from 'express';\nconst app = express();\napp.listen(3000);\n"); + writeFileSync( + path.join(dir, 'src/server.ts'), + "import express from 'express';\nconst app = express();\napp.use(express.json());\napp.listen(3000);\n", + ); return dir; } - it('scaffolds the guard and wires app.use(patchstackMiddleware) after the express() app', () => { + it('scaffolds the guard and wires app.use(patchstackMiddleware) after body parsing', () => { const dir = expressApp(); try { const res: any = runProtect(dir); @@ -27,7 +31,7 @@ describe('Express adapter', () => { const server = read(dir, 'src/server.ts'); expect(server).toContain('import { patchstackMiddleware } from "./patchstack/guard";'); expect(server).toContain('app.use(patchstackMiddleware);'); - expect(server.indexOf('const app = express()')).toBeLessThan(server.indexOf('app.use(patchstackMiddleware)')); + expect(server.indexOf('app.use(express.json())')).toBeLessThan(server.indexOf('app.use(patchstackMiddleware)')); expect(runVerify(dir).wired).toBe(true); } finally { rmSync(dir, { recursive: true, force: true }); @@ -44,6 +48,78 @@ describe('Express adapter', () => { rmSync(dir, { recursive: true, force: true }); } }); + + it('wires the plain ESM server.js shape generated by Bolt', () => { + const dir = tmp('ps-express-esm-'); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'minimal-todo-app', type: 'module', main: 'server.js', dependencies: { express: '^4.21.2' } }), + ); + writeFileSync( + path.join(dir, 'server.js'), + "import express from 'express';\nconst app = express();\napp.use(express.json());\napp.post('/api/tasks', (req, res) => res.status(201).json(req.body));\napp.listen(3000);\n", + ); + const uuid = '3f1a9c2e-1b4d-4c8a-9e2f-7a6b5c4d3e2f'; + writeFileSync(path.join(dir, '.patchstackrc.json'), JSON.stringify({ siteUuid: uuid })); + try { + runProtect(dir); + const server = read(dir, 'server.js'); + expect(server).toContain('import { patchstackMiddleware } from "./patchstack/guard.js";'); + expect(server.indexOf('app.use(express.json())')).toBeLessThan(server.indexOf('app.use(patchstackMiddleware)')); + expect(server.indexOf('app.use(patchstackMiddleware)')).toBeLessThan(server.indexOf("app.post('/api/tasks'")); + expect(existsSync(path.join(dir, 'patchstack/guard.js'))).toBe(true); + expect(existsSync(path.join(dir, 'patchstack/guard.ts'))).toBe(false); + expect(read(dir, 'patchstack/guard.js')).toContain('active.express()'); + expect(read(dir, 'patchstack/guard.js')).toContain(uuid); + expect(runVerify(dir).wired).toBe(true); + execFileSync(process.execPath, ['--check', path.join(dir, 'server.js')]); + execFileSync(process.execPath, ['--check', path.join(dir, 'patchstack/guard.js')]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('wires CommonJS server.js without adding ESM syntax', () => { + const dir = tmp('ps-express-cjs-'); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'express-cjs', main: 'server.js', dependencies: { express: '^4.21.2' } }), + ); + writeFileSync( + path.join(dir, 'server.js'), + "const express = require('express');\nconst app = express();\napp.use(express.json());\napp.post('/api/tasks', (req, res) => res.status(201).json(req.body));\n", + ); + try { + runProtect(dir); + const server = read(dir, 'server.js'); + expect(server).toContain('const { patchstackMiddleware } = require("./patchstack/guard.cjs");'); + expect(existsSync(path.join(dir, 'patchstack/guard.cjs'))).toBe(true); + expect(read(dir, 'patchstack/guard.cjs')).toContain('active.express()'); + expect(runVerify(dir).wired).toBe(true); + execFileSync(process.execPath, ['--check', path.join(dir, 'server.js')]); + execFileSync(process.execPath, ['--check', path.join(dir, 'patchstack/guard.cjs')]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('scaffolds but does not claim full wiring when no JSON body parser is present', () => { + const dir = tmp('ps-express-no-parser-'); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { express: '^4.21.2' } }), + ); + const original = "import express from 'express';\nconst app = express();\napp.post('/api/tasks', handler);\n"; + writeFileSync(path.join(dir, 'server.js'), original); + try { + runProtect(dir); + expect(read(dir, 'server.js')).toBe(original); + expect(existsSync(path.join(dir, 'patchstack/guard.js'))).toBe(true); + expect(runVerify(dir).wired).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe('Next.js adapter', () => { From 5acf761d312db41dc204b92596eb420580547d0d Mon Sep 17 00:00:00 2001 From: Elliot Date: Wed, 15 Jul 2026 20:59:52 +0200 Subject: [PATCH 2/2] feat: add guided production virtual patch demo (#88) * feat: add production virtual patch demo * feat: add guided virtual patch demo --- AGENT-INSTALL.md | 2 + README.md | 41 +++++- src/cli.ts | 135 +++++++++++++++++++- src/client.ts | 12 ++ src/demo.ts | 291 +++++++++++++++++++++++++++++++++++++++++++ tests/client.test.ts | 21 +++- tests/demo.test.ts | 224 +++++++++++++++++++++++++++++++++ 7 files changed, 717 insertions(+), 9 deletions(-) create mode 100644 src/demo.ts create mode 100644 tests/demo.test.ts diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index c48d595..0697c26 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -9,6 +9,8 @@ This versioned reference ships inside `@patchstack/connect` and documents each s - **`scan` makes one source edit, and only after a successful post:** it adds (or updates) the disclosure widget's `