diff --git a/examples/protect/README.md b/examples/protect/README.md new file mode 100644 index 0000000..2eb64a7 --- /dev/null +++ b/examples/protect/README.md @@ -0,0 +1,33 @@ +# @patchstack/connect/protect — end-to-end demo + +Shows the full **Verified Vulnerability Shielding** loop against a **real, unmodified +vulnerable dependency** — no mocks of the vulnerability itself. + +```bash +cd examples/protect +npm install # pulls the real vulnerable lodash@4.17.11 (CVE-2019-10744) +npm run demo +``` + +Expected: all six steps ✓. + +## What it demonstrates + +| Step | | +|---|---| +| 1 | The exploit works **unprotected** — `lodash.defaultsDeep` on a `{"constructor":{"prototype":…}}` body pollutes `Object.prototype`. | +| 2 | **dry-run**: the vPatch *detects + logs* the exploit but still serves it (the safe onramp). | +| 3 | **block**: the request is rejected (403) before the vulnerable sink runs — prototype stays clean. | +| 4 | A **benign** request to the same route is still served (no false positive). | +| 5 | A response that accidentally **leaks an AWS key** has it **redacted** (`[REDACTED]`) while the page is still served. | +| 6 | An **outbound SSRF** to cloud metadata (`169.254.169.254`) is blocked; an external call is allowed. | + +…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."* + +## Note on rules + +`rules.demo.json` holds **example rules for public CVEs only** — it is **not** the +Patchstack production corpus. In a real deployment the per-site, version-scoped rule set is +fetched from the Patchstack API (`createProtection({ token })`), cached to disk. The demo +uses a local bundle and **no token / secret**. diff --git a/examples/protect/demo.mjs b/examples/protect/demo.mjs new file mode 100644 index 0000000..02e2a32 --- /dev/null +++ b/examples/protect/demo.mjs @@ -0,0 +1,94 @@ +// End-to-end "Verified Vulnerability Shielding" demo for @patchstack/connect/protect. +// +// A REAL, unmodified vulnerable dependency (lodash@4.17.11, CVE-2019-10744) is exploited +// live through an app endpoint; then the vPatch is applied and the SAME exploit is replayed +// and blocked — with an auditable proof. Also demonstrates response secret-leak redaction +// and egress SSRF blocking. Public CVE + demo rules only; no tokens/secrets. +// +// cd examples/protect && npm install && node demo.mjs +import { readFileSync } from 'node:fs'; +import _ from 'lodash'; +import { createProtection } from '../../src/protect/runtime.js'; + +const rules = JSON.parse(readFileSync(new URL('./rules.demo.json', import.meta.url), 'utf8')); +const LODASH = _.VERSION; // 4.17.11 (vulnerable; fixed in 4.17.12) + +let ok = true; +const line = (pass, msg) => { ok = pass && ok; console.log(` ${pass ? '✓' : '✗'} ${msg}`); }; + +// The vulnerable app endpoint: "save settings" deep-merges the JSON body via lodash — the +// CVE-2019-10744 sink. +const appHandler = async (request) => { + const body = await request.json().catch(() => ({})); + _.defaultsDeep({}, body); // vulnerable 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 polluted = () => { + const hit = ({}).polluted === 'yes'; + delete Object.prototype.polluted; + return hit; +}; + +console.log(`\nTarget: lodash@${LODASH} (CVE-2019-10744, fixed in 4.17.12)\n`); + +// 1. UNPROTECTED — the exploit works. +await appHandler(exploit()); +line(polluted(), '1. unprotected: exploit pollutes Object.prototype (VULNERABLE)'); + +// 2. DRY-RUN — detected, logged, NOT enforced (the safe onramp). +{ + const detections = []; + const p = await createProtection({ rules, mode: 'dry-run', onDetect: (d) => detections.push(d) }); + await p.fetch(appHandler)(exploit()); + const detected = detections.some((d) => d.rule?.id === 'demo-CVE-2019-10744'); + line(detected && polluted(), '2. dry-run: detected + logged, but still served (not enforced)'); +} + +// 3. BLOCK — request rejected before the sink runs; no pollution. +{ + const p = await createProtection({ rules, mode: 'block' }); + const res = await p.fetch(appHandler)(exploit()); + line(res.status === 403 && !polluted(), '3. block: exploit → 403, sink never runs, prototype clean'); + const benign = await p.fetch(appHandler)(new Request('https://app.demo/api/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"theme":"dark"}' })); + line(benign.status === 200, '4. block: benign request still served (200, no false positive)'); +} + +// 5. RESPONSE leak — an endpoint accidentally returns a key; it's masked, page still served. +{ + const p = await createProtection({ mode: 'block' }); // default response rules + const leak = () => new Response(JSON.stringify({ ok: true, awsKey: 'AKIAIOSFODNN7EXAMPLE' }), { status: 200, headers: { 'content-type': 'application/json' } }); + const res = await p.fetch(leak)(new Request('https://app.demo/config')); + const body = await res.text(); + line(res.status === 200 && !body.includes('AKIAIOSFODNN7EXAMPLE') && body.includes('[REDACTED]'), '5. response: leaked AWS key redacted, page still served'); +} + +// 6. EGRESS SSRF — the app's outbound call to cloud metadata is blocked (stubbed fetch). +{ + const orig = globalThis.fetch; + globalThis.fetch = async (u) => ({ marker: 'stub', url: String(u) }); + const p = await createProtection({ egress: true, mode: 'block' }); + try { + let blocked = false; + try { await globalThis.fetch('http://169.254.169.254/latest/meta-data/'); } catch { blocked = true; } + const ext = await globalThis.fetch('https://api.github.com/'); + line(blocked && ext.marker === 'stub', '6. egress: outbound to 169.254.169.254 blocked, external allowed'); + } finally { + p.uninstallEgress?.(); + globalThis.fetch = orig; + } +} + +console.log( + `\n PROOF: CVE-2019-10744 in lodash@${LODASH} is blocked here, right now, by rule ` + + `demo-CVE-2019-10744 — until you upgrade to 4.17.12. No app redeploy required.\n`, +); +console.log(ok ? '✓ ALL PASS\n' : '✗ FAILED\n'); +process.exit(ok ? 0 : 1); diff --git a/examples/protect/package.json b/examples/protect/package.json new file mode 100644 index 0000000..76d92c5 --- /dev/null +++ b/examples/protect/package.json @@ -0,0 +1,12 @@ +{ + "name": "connect-protect-demo", + "private": true, + "type": "module", + "description": "End-to-end demo for @patchstack/connect/protect (public CVE, demo rules only)", + "dependencies": { + "lodash": "4.17.11" + }, + "scripts": { + "demo": "node demo.mjs" + } +} diff --git a/examples/protect/rules.demo.json b/examples/protect/rules.demo.json new file mode 100644 index 0000000..ed0db08 --- /dev/null +++ b/examples/protect/rules.demo.json @@ -0,0 +1,45 @@ +{ + "_comment": "DEMO / EXAMPLE rules for public CVEs only — NOT the Patchstack production corpus. In a real deployment the per-site, version-scoped rule set is fetched from the Patchstack API (createProtection({ token })). Hand-derived from public advisories for the example below.", + "firewall": [ + { + "id": "demo-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 } + ] + } + ] + }, + { + "id": "demo-path-traversal", + "title": "Path traversal in a file/path parameter", + "category": "lfi", + "rule_v2": [ + { "parameter": ["get.file", "post.file", "raw.file", "get.path", "post.path"], "mutations": ["urldecode"], "match": { "type": "contains", "value": ".." } } + ] + }, + { + "id": "demo-ssrf-url-param", + "title": "SSRF via a url parameter (request-side)", + "category": "ssrf", + "rule_v2": [ + { + "parameter": "rules", + "rules": [ + { "parameter": ["get.url", "post.url", "raw.url"], "mutations": ["urldecode"], "match": { "type": "contains", "value": "localhost" } }, + { "parameter": ["get.url", "post.url", "raw.url"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/(127\\.0\\.0\\.1|169\\.254\\.169\\.254|::1|metadata\\.google)/i" } } + ] + } + ] + } + ], + "whitelists": [], + "whitelist_keys": {} +}