From 7135bf371bda8a7054d0c8edd9192e9e0b103765 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 12 Aug 2026 15:04:20 +0200 Subject: [PATCH] examples/protect: demo the static-rule delivery + remote-promotion chain via Pulse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demo.mjs wires rules from a local file; this adds demo-pulse-chain.mjs, which proves the real delivery path: a rule is served by a (mock) Pulse endpoint over HTTP, fetched by the guard's own Pulse client by site UUID, enforced through the .fetch() HTTP guard, then promoted dry-run -> block *remotely* (Pulse flips the bundle's enforcement; the guard hot-swaps on refresh, no redeploy), and finally re-verified against the same lodash CVE-2019-10744 exploit. Uses a real, unmodified vulnerable lodash@4.17.11 and only a public CVE — no tokens/secrets. Adds tests/protect/pulse-chain.test.ts to guard the same chain in CI without a socket (stubbed fetch with ETag/If-None-Match -> 304): siteUuid fetch, dry-run pass-through with onDetect, manual refresh() promotion to block (403), benign 200, and 304 revalidation. Complements runtime-pulse.test.ts (server-fn guard + timer-driven hot-swap). Co-Authored-By: Claude Opus 4.8 --- examples/protect/README.md | 25 +++++ examples/protect/demo-pulse-chain.mjs | 130 ++++++++++++++++++++++++++ examples/protect/package.json | 1 + tests/protect/pulse-chain.test.ts | 103 ++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 examples/protect/demo-pulse-chain.mjs create mode 100644 tests/protect/pulse-chain.test.ts diff --git a/examples/protect/README.md b/examples/protect/README.md index 3253611..1acaae4 100644 --- a/examples/protect/README.md +++ b/examples/protect/README.md @@ -25,6 +25,31 @@ Expected: all six steps ✓. …and prints the proof line: *"CVE-2019-10744 in lodash@4.17.11 is blocked here, right now, by rule `demo-CVE-2019-10744` — until you upgrade to 4.17.12. No app redeploy required."* +## Delivery + promotion chain (rule served by Pulse) + +`demo.mjs` wires the rule from a local file. To show the **real delivery path the pilot uses** — +the rule served by Pulse over HTTP, fetched by the guard's own Pulse client, then **promoted from +dry-run to block *remotely*** (Pulse flips the bundle's `enforcement`; the guard hot-swaps on +refresh, no redeploy): + +```bash +npm run demo:pulse +``` + +It stands up a mock Pulse rules endpoint (local loopback) and walks the chain end to end: + +| Step | | +|---|---| +| 1 | The rule is **fetched from Pulse over HTTP** by site UUID (not a local file). | +| 2 | The guard adopts Pulse's `enforcement: dry-run` — the exploit is **detected + logged but served**. | +| 3 | Pulse flips the bundle to `enforcement: block` (new ETag); a **refresh hot-swaps** the guard — no redeploy. | +| 4 | The **same exploit** is now **blocked (403)**; the sink never runs; a benign request still returns 200. | +| 5 | A refresh with no change **revalidates as `304 Not Modified`** (conditional fetch, no body re-sent). | + +This is the static-rule delivery + remote-promotion chain the pilot ships on; the promotion seam +is what the observed→enforced auto-promote flow builds on. Guarded in CI by +[`tests/protect/pulse-chain.test.ts`](../../tests/protect/pulse-chain.test.ts). + ## Vulnerability gallery (demo-env showcase) For demonstrating **many** vulnerability classes at once (not one deep CVE proof), there's a diff --git a/examples/protect/demo-pulse-chain.mjs b/examples/protect/demo-pulse-chain.mjs new file mode 100644 index 0000000..a0523ce --- /dev/null +++ b/examples/protect/demo-pulse-chain.mjs @@ -0,0 +1,130 @@ +// End-to-end STATIC-RULE-THROUGH-PULSE chain demo for @patchstack/connect/protect. +// +// Unlike demo.mjs (which wires rules from a local file), this proves the real delivery +// path the pilot uses: a rule is served by Pulse over HTTP, fetched by the guard's own +// Pulse client (ETag/conditional-fetch), and enforced in-app — then PROMOTED from dry-run +// to block *remotely* (Pulse flips the bundle's `enforcement`; the guard hot-swaps on +// refresh, no redeploy). The exploit is a REAL unmodified vulnerable dependency +// (lodash@4.17.11, CVE-2019-10744). Public CVE + demo rule only; no tokens/secrets. +// +// cd examples/protect && npm install && node demo-pulse-chain.mjs +import { createServer } from 'node:http'; +import _ from 'lodash'; +import { createProtection } from '../../src/protect/runtime.js'; + +const LODASH = _.VERSION; // 4.17.11 (vulnerable; fixed in 4.17.12) +const SITE_UUID = '00000000-demo-4pul-se00-000000000001'; + +let ok = true; +const line = (pass, msg) => { ok = pass && ok; console.log(` ${pass ? '✓' : '✗'} ${msg}`); }; + +// ── The one static rule Pulse will serve (a Step-0 rule: lodash prototype pollution). ── +const lodashRule = { + id: 'PS-CVE-2019-10744', + title: 'Prototype pollution in lodash (defaultsDeep / merge / set)', + vulnerability_id: 'CVE-2019-10744', + category: 'prototype-pollution', + rule_v2: [ + { parameter: 'raw', mutations: ['urldecode'], match: { type: 'contains', value: '__proto__' } }, + { parameter: 'rules', rules: [ + { parameter: 'raw', mutations: ['urldecode'], match: { type: 'contains', value: 'constructor' }, inclusive: true }, + { parameter: 'raw', mutations: ['urldecode'], match: { type: 'contains', value: 'prototype' }, inclusive: true }, + ] }, + ], +}; + +// ── A mock Pulse rules endpoint (GET /rules/{uuid}) with ETag + remote enforcement flip. ── +const pulse = { + enforcement: 'dry-run', // what Pulse currently tells the site to do + etag: '"v1"', + hits: [], // audit of what the guard fetched +}; +const server = createServer((req, res) => { + const m = req.url.match(/^\/rules\/([^/?]+)/); + if (!m || decodeURIComponent(m[1]) !== SITE_UUID) { res.writeHead(404).end('{}'); return; } + // Conditional fetch: unchanged bundle → 304 (no body re-sent). + if (req.headers['if-none-match'] === pulse.etag) { + pulse.hits.push({ status: 304, etag: pulse.etag }); + res.writeHead(304, { ETag: pulse.etag }).end(); + return; + } + const body = JSON.stringify({ + success: true, + firewall: [lodashRule], + whitelists: [], + whitelist_keys: {}, + enforcement: pulse.enforcement, + }); + pulse.hits.push({ status: 200, etag: pulse.etag, enforcement: pulse.enforcement }); + res.writeHead(200, { 'Content-Type': 'application/json', ETag: pulse.etag }).end(body); +}); +const port = await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port))); +const PULSE_URL = `http://127.0.0.1:${port}`; + +// ── The vulnerable app: "save settings" deep-merges the JSON body via lodash (the sink). ── +const appHandler = async (request) => { + const body = await request.json().catch(() => ({})); + _.defaultsDeep({}, body); // CVE-2019-10744 sink + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }); +}; +const exploit = () => new Request('https://app.demo/api/settings', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: '{"constructor":{"prototype":{"polluted":"yes"}}}', +}); +const benign = () => new Request('https://app.demo/api/settings', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"theme":"dark"}', +}); +const polluted = () => { const hit = ({}).polluted === 'yes'; delete Object.prototype.polluted; return hit; }; + +console.log(`\nTarget: lodash@${LODASH} (CVE-2019-10744, fixed in 4.17.12)`); +console.log(`Pulse: mock rules endpoint at ${PULSE_URL}/rules/${SITE_UUID}\n`); + +const detections = []; +// The guard fetches its rules from Pulse by site UUID — the real pilot wiring. +const p = await createProtection({ + siteUuid: SITE_UUID, + pulseRulesUrl: PULSE_URL, + onDetect: (d) => detections.push(d), +}); + +try { + // 1. The rule arrived over HTTP (not a local file) and the guard is honoring Pulse's dry-run. + line(pulse.hits.length >= 1 && pulse.hits[0].status === 200, '1. rule fetched from Pulse over HTTP (siteUuid path)'); + line(p.mode === 'dry-run', '2. guard adopted Pulse enforcement = dry-run (safe onramp)'); + + // 3. DRY-RUN: the exploit is DETECTED + logged, but still served → app stays vulnerable. + await p.fetch(appHandler)(exploit()); + const detected = detections.some((d) => d.rule?.id === 'PS-CVE-2019-10744'); + line(detected && polluted(), '3. dry-run: exploit detected + logged, but served (still vulnerable)'); + + // 4. REMOTE PROMOTION: Pulse flips the bundle to block (new ETag). No app redeploy. + pulse.enforcement = 'block'; + pulse.etag = '"v2"'; + await p.refresh(); // one manual refresh tick (the poll loop / push endpoint drive the same tick) + line(p.mode === 'block', '4. remote promotion: Pulse flipped dry-run → block, guard hot-swapped'); + + // 5. BLOCK: replay the SAME exploit → rejected before the sink runs; prototype stays clean. + const blockedRes = await p.fetch(appHandler)(exploit()); + line(blockedRes.status === 403 && !polluted(), '5. block: SAME exploit → 403, sink never runs, prototype clean'); + + // 6. Benign request still served — no false positive. + const benignRes = await p.fetch(appHandler)(benign()); + line(benignRes.status === 200, '6. block: benign request still served (200, no false positive)'); + + // 7. ETag conditional fetch: an unchanged refresh revalidates as 304 (no body re-sent). + const before = pulse.hits.length; + await p.refresh(); + const last = pulse.hits[pulse.hits.length - 1]; + line(pulse.hits.length === before + 1 && last.status === 304, '7. refresh with no change → 304 Not Modified (conditional fetch)'); + + console.log( + `\n PROOF: CVE-2019-10744 in lodash@${LODASH} was shielded via a rule delivered by Pulse, ` + + `then\n promoted dry-run → block remotely and re-verified against the same exploit — ` + + `no app\n redeploy. This is the pilot's static-rule delivery + promotion chain, end to end.\n`, + ); + console.log(ok ? '✓ ALL PASS\n' : '✗ FAILED\n'); +} finally { + p.stopRefresh?.(); + await new Promise((r) => server.close(r)); +} +process.exit(ok ? 0 : 1); diff --git a/examples/protect/package.json b/examples/protect/package.json index ee165a7..12a1070 100644 --- a/examples/protect/package.json +++ b/examples/protect/package.json @@ -8,6 +8,7 @@ }, "scripts": { "demo": "node demo.mjs", + "demo:pulse": "node demo-pulse-chain.mjs", "gallery": "node gallery.mjs" } } diff --git a/tests/protect/pulse-chain.test.ts b/tests/protect/pulse-chain.test.ts new file mode 100644 index 0000000..e616081 --- /dev/null +++ b/tests/protect/pulse-chain.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +// The static-rule-through-Pulse chain the pilot ships and demo-pulse-chain.mjs showcases: +// a rule is fetched from Pulse by site UUID, enforced through the .fetch() HTTP guard, then +// PROMOTED dry-run -> block remotely via a manual refresh, with ETag conditional revalidation. +// (runtime-pulse.test.ts covers the server-fn guard + timer-driven hot-swap; this pins the +// HTTP guard path, the manual refresh() promotion, and the 304 revalidation.) + +const lodashRule = { + id: 'PS-CVE-2019-10744', + title: 'Prototype pollution in lodash', + category: 'prototype-pollution', + rule_v2: [ + { parameter: 'raw', mutations: ['urldecode'], match: { type: 'contains', value: '__proto__' } }, + { parameter: 'rules', rules: [ + { parameter: 'raw', mutations: ['urldecode'], match: { type: 'contains', value: 'constructor' }, inclusive: true }, + { parameter: 'raw', mutations: ['urldecode'], match: { type: 'contains', value: 'prototype' }, inclusive: true }, + ] }, + ], +}; + +// A mock Pulse endpoint with a mutable enforcement + ETag that honors If-None-Match -> 304, +// exactly like the mock server in demo-pulse-chain.mjs but with no real socket (CI-safe). +function mockPulse() { + const state = { enforcement: 'dry-run', etag: '"v1"' }; + const calls: Array<{ status: number; ifNoneMatch?: string }> = []; + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + const inm = (init?.headers as Record | undefined)?.['If-None-Match']; + if (inm === state.etag) { + calls.push({ status: 304, ifNoneMatch: inm }); + return new Response(null, { status: 304, headers: { ETag: state.etag } }); + } + calls.push({ status: 200, ifNoneMatch: inm }); + return new Response( + JSON.stringify({ firewall: [lodashRule], whitelists: [], whitelist_keys: {}, enforcement: state.enforcement }), + { status: 200, headers: { 'Content-Type': 'application/json', ETag: state.etag } }, + ); + }); + return { state, calls, fetchMock }; +} + +const exploit = () => new Request('https://app.demo/api/settings', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: '{"constructor":{"prototype":{"polluted":"yes"}}}', +}); +const benign = () => new Request('https://app.demo/api/settings', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"theme":"dark"}', +}); +const appHandler = async () => new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }); + +describe('static-rule-through-Pulse chain (HTTP guard + manual refresh promotion)', () => { + const prevMode = process.env.PATCHSTACK_MODE; + afterEach(() => { + if (prevMode === undefined) delete process.env.PATCHSTACK_MODE; + else process.env.PATCHSTACK_MODE = prevMode; + vi.restoreAllMocks(); + }); + + it('fetches from Pulse, honors dry-run, then a manual refresh promotes to block; ETag revalidates', async () => { + delete process.env.PATCHSTACK_MODE; + const { state, calls, fetchMock } = mockPulse(); + vi.stubGlobal('fetch', fetchMock); + const detections: Array<{ rule?: { id?: string } }> = []; + + const p = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + onDetect: (d: { rule?: { id?: string } }) => detections.push(d), + }); + + // 1. Rule arrived over HTTP; guard adopted Pulse's dry-run enforcement. + expect(calls[0].status).toBe(200); + expect(fetchMock.mock.calls[0][0]).toBe('https://x.test/monitor/pulse/rules/site-1'); + expect(p.mode).toBe('dry-run'); + + // 2. Dry-run: the exploit is detected + logged but still served (guard passes through). + const dryRes = await p.fetch(appHandler)(exploit()); + expect(dryRes.status).toBe(200); + expect(detections.some((d) => d.rule?.id === 'PS-CVE-2019-10744')).toBe(true); + + // 3. Remote promotion: Pulse flips enforcement -> block (new ETag); a manual refresh hot-swaps. + state.enforcement = 'block'; + state.etag = '"v2"'; + await p.refresh(); + expect(p.mode).toBe('block'); + + // 4. Block: the SAME exploit is now rejected (403); the sink never runs. + const blockedRes = await p.fetch(appHandler)(exploit()); + expect(blockedRes.status).toBe(403); + // 5. Benign traffic is unaffected. + expect((await p.fetch(appHandler)(benign())).status).toBe(200); + + // 6. A refresh with no change revalidates as 304 (conditional fetch, no body re-sent). + const before = calls.length; + await p.refresh(); + expect(calls.length).toBe(before + 1); + expect(calls[calls.length - 1]).toMatchObject({ status: 304, ifNoneMatch: '"v2"' }); + expect(p.mode).toBe('block'); // 304 keeps the last-known-good enforcement + + p.stopRefresh?.(); + }); +});