Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions examples/protect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions examples/protect/demo-pulse-chain.mjs
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions examples/protect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"scripts": {
"demo": "node demo.mjs",
"demo:pulse": "node demo-pulse-chain.mjs",
"gallery": "node gallery.mjs"
}
}
103 changes: 103 additions & 0 deletions tests/protect/pulse-chain.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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?.();
});
});
Loading