From c9b76a015b9787da8e831503f61d1beae8647615 Mon Sep 17 00:00:00 2001 From: Renato Hysa Date: Tue, 14 Jul 2026 12:42:55 +0200 Subject: [PATCH 1/2] feat(protect): per-site Pulse rules client (fetch rules by site UUID) Adds PulseRuleClient (GET /rules/, fail-open + last-known-good cache) as a third rule source in createProtection; precedence siteUuid -> token -> bundled. The protect installer bakes the site UUID from .patchstackrc.json into the scaffolded guard so the runtime fetches per-site rules with no user config. Bundled rules.json stays as the offline fallback. --- src/protect/engine/pulse-client.js | 61 +++++++++++++ src/protect/install.ts | 30 ++++++- src/protect/protect.d.ts | 4 + src/protect/runtime.js | 29 ++++++- src/protect/templates/guard.ts | 12 ++- tests/protect/dave-example-rule.test.ts | 108 ++++++++++++++++++++++++ tests/protect/protect-install.test.ts | 36 ++++++++ tests/protect/pulse-client.test.ts | 46 ++++++++++ tests/protect/runtime-pulse.test.ts | 37 ++++++++ 9 files changed, 356 insertions(+), 7 deletions(-) create mode 100644 src/protect/engine/pulse-client.js create mode 100644 tests/protect/dave-example-rule.test.ts create mode 100644 tests/protect/protect-install.test.ts create mode 100644 tests/protect/pulse-client.test.ts create mode 100644 tests/protect/runtime-pulse.test.ts diff --git a/src/protect/engine/pulse-client.js b/src/protect/engine/pulse-client.js new file mode 100644 index 0000000..21e90a2 --- /dev/null +++ b/src/protect/engine/pulse-client.js @@ -0,0 +1,61 @@ +const DEFAULT_BASE_URL = 'https://api.patchstack.com/monitor/pulse'; +const DEFAULT_CACHE_TTL = 300_000; + +// Per-site rules client for Pulse (npm/JS) apps. Public endpoint — the site UUID is the only +// credential, passed in the path. Fail-open: any error returns success:false + empty rules so +// createProtection falls back to the disk cache or the bundled rules. +export class PulseRuleClient { + #siteUuid; + #baseUrl; + #cacheTtl; + #cache = null; + #cacheTime = null; + + constructor({ siteUuid, baseUrl, cacheTtl } = {}) { + this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID; + this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL ?? DEFAULT_BASE_URL; + this.#cacheTtl = cacheTtl ?? DEFAULT_CACHE_TTL; + if (!this.#siteUuid) { + throw new Error('Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.'); + } + } + + async getRules() { + const now = Date.now(); + if (this.#cache && this.#cacheTime && now - this.#cacheTime < this.#cacheTtl) { + return this.#cache; + } + const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`; + try { + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} }; + } + const data = await response.json(); + const result = { + success: true, + firewall: Array.isArray(data.firewall) ? data.firewall : [], + whitelists: Array.isArray(data.whitelists) ? data.whitelists : [], + whitelist_keys: data.whitelist_keys ?? {}, + }; + this.#cache = result; + this.#cacheTime = now; + return result; + } catch (err) { + return { + success: false, + error: err.name === 'TimeoutError' ? 'Request timed out' : err.message, + firewall: [], whitelists: [], whitelist_keys: {}, + }; + } + } + + clearCache() { + this.#cache = null; + this.#cacheTime = null; + } +} diff --git a/src/protect/install.ts b/src/protect/install.ts index 6b4755b..c8d352c 100644 --- a/src/protect/install.ts +++ b/src/protect/install.ts @@ -12,8 +12,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -// Guard templates ship next to the built CLI (dist/protect/templates). -const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), 'protect', 'templates'); +// Guard templates ship next to the built CLI (dist/protect/templates). When this module runs +// unbundled (e.g. under test, imported straight from src/), templates sit alongside it instead +// (src/protect/templates) — try that layout first, then fall back to the bundled one. +const HERE = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES = existsSync(join(HERE, 'templates')) ? join(HERE, 'templates') : join(HERE, 'protect', 'templates'); const APP = process.cwd(); const PS_DIR = join(APP, 'src/integrations/patchstack'); @@ -85,6 +88,28 @@ function scaffold(cwd: string): void { log('scaffolded guard.ts + rules.json'); } +// Bake the site UUID from .patchstackrc.json (written by `patchstack-connect scan`) into the +// scaffolded guard, so the deployed Worker calls the live Pulse rules API with zero user config. +// Left as the inert placeholder (guard falls back to PATCHSTACK_SITE_UUID env / bundled rules) +// when the app hasn't been scanned yet or the file can't be read. +function bakeSiteUuid(cwd: string): void { + const rc = join(cwd, '.patchstackrc.json'); + if (!existsSync(rc)) return log('no .patchstackrc.json — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback'); + let uuid: string | undefined; + try { + uuid = JSON.parse(read(rc)).siteUuid; + } catch { + return log('.patchstackrc.json unreadable — skipping site-UUID bake'); + } + if (!uuid) return; + const p = join(cwd, 'src/integrations/patchstack/guard.ts'); + if (!existsSync(p)) return; + const s = read(p); + if (!s.includes('__PATCHSTACK_SITE_UUID__')) return log('guard.ts site UUID already baked'); + writeFileSync(p, s.replace('__PATCHSTACK_SITE_UUID__', uuid)); + log('baked site UUID into guard.ts — live rules from the Patchstack API'); +} + function patchClient(cwd: string): void { const p = join(cwd, 'src/integrations/supabase/client.ts'); let s = read(p); @@ -156,6 +181,7 @@ export function runProtect(cwd: string): void { return; } scaffold(cwd); + bakeSiteUuid(cwd); patchClient(cwd); patchStart(cwd); log('done — guard wired and always-on (blocks by default). Set PATCHSTACK_MODE=dry-run for log-only.'); diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 85df960..d62421c 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -31,6 +31,10 @@ export interface CreateProtectionOptions { /** Patchstack WAF token — pull live per-site rules from the API. */ token?: string; baseUrl?: string; + /** Pulse site UUID — pull live per-site rules from the Pulse rules API (cached). */ + siteUuid?: string; + /** Override the Pulse rules API base URL. */ + pulseRulesUrl?: string; /** Directory for the last-known-good rule cache. */ cacheDir?: string; /** Override the default response-phase (secret-leak) rule set. */ diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 588edd4..5f2d500 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -11,6 +11,7 @@ // every runtime an AI builder deploys to. // Vendored node-waf engine (this package is self-contained — no @patchstack/node-waf dep). import { RuleEngine, PatchstackRuleClient } from './engine/index.js'; +import { PulseRuleClient } from './engine/pulse-client.js'; import { fromFetchRequest } from './engine/fetch.js'; import { fromNodeRequest } from './engine/node.js'; import { installEgressGuard } from './egress.js'; @@ -21,6 +22,9 @@ import { join } from 'node:path'; // Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase). export { createSupabaseGuard, GUARD_PATH } from './supabase-guard.js'; +// Per-site live rule client (Pulse). Re-exported for callers/tests that want to use it directly. +export { PulseRuleClient }; + // Server-function guard. Modern Lovable apps mutate data through TanStack server functions // (browser → server fn → server-side Supabase client), which bypass the browser-side tunnel the // Supabase guard relies on. This inspects the decoded server-fn call args against the SAME policy @@ -324,8 +328,25 @@ function leakResponse() { // --- rule source -------------------------------------------------------- async function resolveRules(options) { - if (options.rules) { - return normalizeBundle(options.rules); + if (options.siteUuid) { + const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl }); + const res = await client.getRules(); + if (res.success) { + const bundle = normalizeBundle(res); + cacheWrite(options.cacheDir, bundle); + return bundle; + } + const cached = cacheRead(options.cacheDir); + if (cached) { + options.onError?.(new Error(`pulse rule fetch failed (${res.error}); using cached bundle`)); + return normalizeBundle(cached); + } + if (options.rules) { + options.onError?.(new Error(`pulse rule fetch failed (${res.error}); using bundled fallback`)); + return normalizeBundle(options.rules); + } + options.onError?.(new Error(`pulse rule fetch failed (${res.error}); no cache — running with no rules`)); + return emptyBundle(); } if (options.token) { @@ -345,6 +366,10 @@ async function resolveRules(options) { return emptyBundle(); } + if (options.rules) { + return normalizeBundle(options.rules); + } + return emptyBundle(); } diff --git a/src/protect/templates/guard.ts b/src/protect/templates/guard.ts index 263e691..a92ea66 100644 --- a/src/protect/templates/guard.ts +++ b/src/protect/templates/guard.ts @@ -18,6 +18,9 @@ import { } from "@patchstack/connect/protect"; import fallbackRules from "./rules.json"; +// Baked by `patchstack-connect protect` from .patchstackrc.json (empty if the app isn't scanned yet). +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; + export { GUARD_PATH }; // One shared protection policy for both guards (rules load once). @@ -27,10 +30,13 @@ async function getProtection() { // Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only. 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; _protection = await createProtection( - token - ? { token, mode, cacheDir: ".patchstack" } // live per-site rules from the Patchstack API (cached) - : { rules: fallbackRules as never, mode }, // demo fallback until a token is set + siteUuid + ? { siteUuid, rules: fallbackRules as never, mode, cacheDir: ".patchstack" } // live per-site rules; bundled = offline fallback + : token + ? { token, mode, cacheDir: ".patchstack" } + : { rules: fallbackRules as never, mode }, ); } return _protection; diff --git a/tests/protect/dave-example-rule.test.ts b/tests/protect/dave-example-rule.test.ts new file mode 100644 index 0000000..869b5d2 --- /dev/null +++ b/tests/protect/dave-example-rule.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +// Patchstack's real rule_v2 bundle: block if `mytestparameter` is PRESENT (isset) +// OR if `hithere` CONTAINS `allgood`. Two plain (non-inclusive) conditions -> +// OR-combined by #evaluateRule (engine.js:310-314): ANY match -> block. +const bundle = { + firewall: [ + { + id: 'rm-npm-test-0001', + title: "Dave's example test rule", + rule_v2: [ + { + parameter: 'get.mytestparameter', + match: { + type: 'isset', + }, + }, + { + parameter: 'get.hithere', + match: { + type: 'contains', + value: 'allgood', + }, + }, + ], + }, + ], + whitelists: [], + whitelist_keys: {}, +}; + +// GET request carrying a query string — params reach the engine as get. +// via fromFetchRequest -> url.searchParams (src/protect/engine/fetch.js:22-29). +function getReq(qs: string) { + return new Request(`https://app.example.com/api/thing?${qs}`, { method: 'GET' }); +} + +describe("Dave's example rule — inline bundle via fetchGuard", () => { + it('BLOCKED: ?mytestparameter=1 (isset match) -> 403', async () => { + const protection = await createProtection({ rules: bundle, mode: 'block' }); + const guard = protection.fetchGuard(); // (request) => Promise + + const res = await guard(getReq('mytestparameter=1')); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('BLOCKED: ?hithere=xxallgoodxx (contains match) -> 403', async () => { + const protection = await createProtection({ rules: bundle, mode: 'block' }); + const guard = protection.fetchGuard(); + + const res = await guard(getReq('hithere=xxallgoodxx')); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('ALLOWED: ?hithere=nope and NO mytestparameter -> null (proves engine is not blocking everything)', async () => { + const protection = await createProtection({ rules: bundle, mode: 'block' }); + const guard = protection.fetchGuard(); + + const res = await guard(getReq('hithere=nope')); + expect(res).toBeNull(); + }); + + it('OR semantics: each condition blocks on its own, independent of the other', async () => { + const protection = await createProtection({ rules: bundle, mode: 'block' }); + const guard = protection.fetchGuard(); + + // Only mytestparameter present (hithere absent) -> isset fires alone. + const onlyIsset = await guard(getReq('mytestparameter=anything')); + expect(onlyIsset).not.toBeNull(); + expect(onlyIsset!.status).toBe(403); + + // Only hithere=...allgood... present (mytestparameter absent) -> contains fires alone. + const onlyContains = await guard(getReq('hithere=allgood')); + expect(onlyContains).not.toBeNull(); + expect(onlyContains!.status).toBe(403); + }); +}); + +describe("Dave's example rule — full production fetch path (PulseRuleClient)", () => { + it('BLOCKED via live Pulse fetch: ?mytestparameter=1 -> 403', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify(bundle), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + try { + const protection = await createProtection({ + siteUuid: 'test-site', + pulseRulesUrl: 'https://x.test/monitor/pulse', + mode: 'block', + }); + const guard = protection.fetchGuard(); + + const res = await guard(getReq('mytestparameter=1')); + expect(fetchMock).toHaveBeenCalled(); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + } finally { + vi.restoreAllMocks(); + } + }); +}); diff --git a/tests/protect/protect-install.test.ts b/tests/protect/protect-install.test.ts new file mode 100644 index 0000000..4f5c558 --- /dev/null +++ b/tests/protect/protect-install.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runProtect } from '../../src/protect/install.js'; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'ps-install-')); + mkdirSync(join(dir, 'src/integrations/supabase'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { '@tanstack/react-start': '^1' } })); + writeFileSync(join(dir, 'src/start.ts'), + 'import { createStart, createMiddleware } from "@tanstack/react-start";\n' + + 'export const startInstance = createStart(() => ({ functionMiddleware: [], requestMiddleware: [] }));\n'); + writeFileSync(join(dir, 'src/integrations/supabase/client.ts'), + "const headers = new Headers();\n headers.set('apikey', supabaseKey);\n"); + writeFileSync(join(dir, '.patchstackrc.json'), JSON.stringify({ siteUuid: 'uuid-abc-123' })); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe('runProtect bakes the site UUID', () => { + it('replaces the placeholder in guard.ts with the .patchstackrc.json uuid', () => { + runProtect(dir); + const guard = readFileSync(join(dir, 'src/integrations/patchstack/guard.ts'), 'utf8'); + expect(guard).toContain('uuid-abc-123'); + expect(guard).not.toContain('__PATCHSTACK_SITE_UUID__'); + }); + + it('leaves the placeholder inert (empty) when there is no .patchstackrc.json', () => { + rmSync(join(dir, '.patchstackrc.json')); + runProtect(dir); + const guard = readFileSync(join(dir, 'src/integrations/patchstack/guard.ts'), 'utf8'); + // unbaked placeholder must not crash the guard: it is treated as "no uuid" + expect(guard).toContain('__PATCHSTACK_SITE_UUID__'); + }); +}); diff --git a/tests/protect/pulse-client.test.ts b/tests/protect/pulse-client.test.ts new file mode 100644 index 0000000..8dcc5b7 --- /dev/null +++ b/tests/protect/pulse-client.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PulseRuleClient } from '../../src/protect/engine/pulse-client.js'; + +const RULES = { firewall: [{ id: 'rm-npm-0001', rule_v2: [{ parameter: 'post.title', match: { type: 'inline_xss' } }] }], whitelists: [], whitelist_keys: {} }; + +afterEach(() => vi.restoreAllMocks()); + +describe('PulseRuleClient', () => { + it('GETs /rules/ and returns the firewall on 200', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(RULES), { status: 200, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', fetchMock); + const client = new PulseRuleClient({ siteUuid: 'abc-123', baseUrl: 'https://x.test/monitor/pulse' }); + const res = await client.getRules(); + expect(res.success).toBe(true); + expect(res.firewall[0].id).toBe('rm-npm-0001'); + expect(fetchMock.mock.calls[0][0]).toBe('https://x.test/monitor/pulse/rules/abc-123'); + expect(fetchMock.mock.calls[0][1].method).toBe('GET'); + }); + + it('fails open (success:false, empty rules) on a non-200', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 500 }))); + const res = await new PulseRuleClient({ siteUuid: 'x' }).getRules(); + expect(res.success).toBe(false); + expect(res.firewall).toEqual([]); + }); + + it('fails open on a thrown fetch error', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); })); + const res = await new PulseRuleClient({ siteUuid: 'x' }).getRules(); + expect(res.success).toBe(false); + expect(res.firewall).toEqual([]); + }); + + it('caches within the TTL (one fetch for two calls)', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(RULES), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + const client = new PulseRuleClient({ siteUuid: 'x', cacheTtl: 10_000 }); + await client.getRules(); + await client.getRules(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('requires a siteUuid', () => { + expect(() => new PulseRuleClient({})).toThrow(); + }); +}); diff --git a/tests/protect/runtime-pulse.test.ts b/tests/protect/runtime-pulse.test.ts new file mode 100644 index 0000000..33094be --- /dev/null +++ b/tests/protect/runtime-pulse.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createProtection, createServerFnGuard } from '../../src/protect/runtime.js'; + +// A single-rule bundle mirroring the Pulse rules endpoint's { firewall, whitelists, whitelist_keys }. +const rules = { + firewall: [ + { + id: 'rm-npm-0001', + title: 'Block stored XSS via vulnerable markdown renderer (marked)', + rule_v2: [{ parameter: 'post.title', mutations: ['urldecode'], match: { type: 'inline_xss' } }], + }, + ], + whitelists: [], + whitelist_keys: {}, +}; + +describe('createProtection with a siteUuid (live Pulse rules)', () => { + it('uses rules fetched by site UUID; blocks the exploit', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {} }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const protection = await createProtection({ siteUuid: 'site-1', pulseRulesUrl: 'https://x.test/monitor/pulse', mode: 'block' }); + const guard = createServerFnGuard({ protection }); + expect((await guard({ title: '' }))?.rule).toBe('rm-npm-0001'); + expect(await guard({ title: 'buy milk' })).toBeNull(); + vi.restoreAllMocks(); + }); + + it('falls back to the bundled rules when the site-UUID fetch fails', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('down'); })); + const protection = await createProtection({ siteUuid: 'site-1', rules, mode: 'block' }); + const guard = createServerFnGuard({ protection }); + expect((await guard({ title: '' }))?.rule).toBe('rm-npm-0001'); + vi.restoreAllMocks(); + }); +}); From 2ad4988dd7649d2a542e2fa628de8cad58a5e5bb Mon Sep 17 00:00:00 2001 From: Renato Hysa Date: Tue, 14 Jul 2026 15:48:32 +0200 Subject: [PATCH 2/2] test(protect): cover disk cache, source precedence, whitelist + cache expiry; guard bakeSiteUuid on UUID format - bakeSiteUuid now validates UUID format before baking, falling through to the inert placeholder for missing/malformed values (avoids junk in the TS literal) - runtime-pulse: last-known-good disk cache, siteUuid>token precedence, whitelist suppression over the Pulse path - pulse-client: cache expiry past TTL + clearCache() refetch - protect-install: realistic UUID fixture + malformed-value inert case --- src/protect/install.ts | 6 +++- tests/protect/protect-install.test.ts | 14 ++++++-- tests/protect/pulse-client.test.ts | 21 +++++++++++ tests/protect/runtime-pulse.test.ts | 52 ++++++++++++++++++++++++--- 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/protect/install.ts b/src/protect/install.ts index c8d352c..6f34b0c 100644 --- a/src/protect/install.ts +++ b/src/protect/install.ts @@ -101,7 +101,11 @@ function bakeSiteUuid(cwd: string): void { } catch { return log('.patchstackrc.json unreadable — skipping site-UUID bake'); } - if (!uuid) return; + // Guard on UUID format so a malformed value falls through to the inert placeholder rather + // than baking junk into a TS string literal (broken build / replace-token hazards). + if (!uuid || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) { + return log('.patchstackrc.json siteUuid missing or malformed — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback'); + } const p = join(cwd, 'src/integrations/patchstack/guard.ts'); if (!existsSync(p)) return; const s = read(p); diff --git a/tests/protect/protect-install.test.ts b/tests/protect/protect-install.test.ts index 4f5c558..cbe9679 100644 --- a/tests/protect/protect-install.test.ts +++ b/tests/protect/protect-install.test.ts @@ -14,7 +14,8 @@ beforeEach(() => { 'export const startInstance = createStart(() => ({ functionMiddleware: [], requestMiddleware: [] }));\n'); writeFileSync(join(dir, 'src/integrations/supabase/client.ts'), "const headers = new Headers();\n headers.set('apikey', supabaseKey);\n"); - writeFileSync(join(dir, '.patchstackrc.json'), JSON.stringify({ siteUuid: 'uuid-abc-123' })); + // A real site UUID, matching what `patchstack-connect scan` actually writes. + writeFileSync(join(dir, '.patchstackrc.json'), JSON.stringify({ siteUuid: '3f1a9c2e-1b4d-4c8a-9e2f-7a6b5c4d3e2f' })); }); afterEach(() => rmSync(dir, { recursive: true, force: true })); @@ -22,7 +23,7 @@ describe('runProtect bakes the site UUID', () => { it('replaces the placeholder in guard.ts with the .patchstackrc.json uuid', () => { runProtect(dir); const guard = readFileSync(join(dir, 'src/integrations/patchstack/guard.ts'), 'utf8'); - expect(guard).toContain('uuid-abc-123'); + expect(guard).toContain('3f1a9c2e-1b4d-4c8a-9e2f-7a6b5c4d3e2f'); expect(guard).not.toContain('__PATCHSTACK_SITE_UUID__'); }); @@ -33,4 +34,13 @@ describe('runProtect bakes the site UUID', () => { // unbaked placeholder must not crash the guard: it is treated as "no uuid" expect(guard).toContain('__PATCHSTACK_SITE_UUID__'); }); + + it('leaves the placeholder inert when the siteUuid is malformed', () => { + // A non-UUID value must not be baked into the TS literal (broken build / replace-token hazard). + writeFileSync(join(dir, '.patchstackrc.json'), JSON.stringify({ siteUuid: 'not-a-uuid"; drop()' })); + runProtect(dir); + const guard = readFileSync(join(dir, 'src/integrations/patchstack/guard.ts'), 'utf8'); + expect(guard).toContain('__PATCHSTACK_SITE_UUID__'); + expect(guard).not.toContain('drop()'); + }); }); diff --git a/tests/protect/pulse-client.test.ts b/tests/protect/pulse-client.test.ts index 8dcc5b7..9d57f1b 100644 --- a/tests/protect/pulse-client.test.ts +++ b/tests/protect/pulse-client.test.ts @@ -40,6 +40,27 @@ describe('PulseRuleClient', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('refetches once the cache TTL has elapsed', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(0); + const fetchMock = vi.fn(async () => new Response(JSON.stringify(RULES), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + const client = new PulseRuleClient({ siteUuid: 'x', cacheTtl: 10_000 }); + await client.getRules(); + nowSpy.mockReturnValue(10_001); // past the TTL + await client.getRules(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('clearCache() forces the next call to refetch', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(RULES), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + const client = new PulseRuleClient({ siteUuid: 'x', cacheTtl: 10_000 }); + await client.getRules(); + client.clearCache(); + await client.getRules(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('requires a siteUuid', () => { expect(() => new PulseRuleClient({})).toThrow(); }); diff --git a/tests/protect/runtime-pulse.test.ts b/tests/protect/runtime-pulse.test.ts index 33094be..721ec9d 100644 --- a/tests/protect/runtime-pulse.test.ts +++ b/tests/protect/runtime-pulse.test.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { createProtection, createServerFnGuard } from '../../src/protect/runtime.js'; @@ -14,12 +17,12 @@ const rules = { whitelist_keys: {}, }; +const okFetch = () => + vi.fn(async () => new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {} }), { status: 200 })); + describe('createProtection with a siteUuid (live Pulse rules)', () => { it('uses rules fetched by site UUID; blocks the exploit', async () => { - const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {} }), { status: 200 }), - ); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', okFetch()); const protection = await createProtection({ siteUuid: 'site-1', pulseRulesUrl: 'https://x.test/monitor/pulse', mode: 'block' }); const guard = createServerFnGuard({ protection }); expect((await guard({ title: '' }))?.rule).toBe('rm-npm-0001'); @@ -34,4 +37,45 @@ describe('createProtection with a siteUuid (live Pulse rules)', () => { expect((await guard({ title: '' }))?.rule).toBe('rm-npm-0001'); vi.restoreAllMocks(); }); + + it('prefers the siteUuid (Pulse) source over a WAF token', async () => { + const fetchMock = okFetch(); + vi.stubGlobal('fetch', fetchMock); + await createProtection({ siteUuid: 'site-1', token: 'waf-token', pulseRulesUrl: 'https://x.test/monitor/pulse', mode: 'block' }); + // siteUuid wins: only the Pulse URL is hit; the token/get-rules path is never taken. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('https://x.test/monitor/pulse/rules/site-1'); + vi.restoreAllMocks(); + }); + + it('writes a last-known-good cache and serves it when a later fetch fails', async () => { + const cacheDir = mkdtempSync(join(tmpdir(), 'ps-cache-')); + try { + vi.stubGlobal('fetch', okFetch()); + const p1 = await createProtection({ siteUuid: 'site-1', pulseRulesUrl: 'https://x.test/monitor/pulse', cacheDir, mode: 'block' }); + expect((await createServerFnGuard({ protection: p1 })({ title: '' }))?.rule).toBe('rm-npm-0001'); + expect(existsSync(join(cacheDir, 'patchstack-rules.json'))).toBe(true); + + // Fetch now fails and there is no bundled fallback — the disk cache must still block. + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('down'); })); + const p2 = await createProtection({ siteUuid: 'site-1', pulseRulesUrl: 'https://x.test/monitor/pulse', cacheDir, mode: 'block' }); + expect((await createServerFnGuard({ protection: p2 })({ title: '' }))?.rule).toBe('rm-npm-0001'); + } finally { + rmSync(cacheDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + } + }); + + it('applies a whitelist delivered over the siteUuid path to suppress a matching rule', async () => { + const bundle = { + firewall: rules.firewall, + whitelists: [{ rule_id: 'rm-npm-0001', rule_v2: [{ parameter: 'post.bypass', match: { type: 'equals', value: 'yes' } }] }], + whitelist_keys: {}, + }; + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(bundle), { status: 200 }))); + const guard = createServerFnGuard({ protection: await createProtection({ siteUuid: 'site-1', pulseRulesUrl: 'https://x.test/monitor/pulse', mode: 'block' }) }); + expect((await guard({ title: '' }))?.rule).toBe('rm-npm-0001'); // blocked + expect(await guard({ title: '', bypass: 'yes' })).toBeNull(); // whitelisted + vi.restoreAllMocks(); + }); });