From 67224eb384d966c57bced5ae43548ab4707b5905 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 12:36:57 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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" + } + } +}