diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..1a8387a7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +# Update pull requests for this package, and nothing else. +# +# This file selects which manifests Dependabot opens UPDATE pull requests for. It does not select which +# manifests produce security ALERTS — those follow the dependency graph — so keeping an intentionally +# vulnerable package out of the graph is what keeps the alerts meaningful. `examples/protect/` does that +# by installing its demo target on demand instead of declaring it. +# +# Root only: this package has no runtime dependencies, so everything Dependabot can usefully maintain is a +# devDependency at the root. +version: 2 + +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + # Grouped: a week of separate devDependency bumps is a week of separate CI runs and separate reviews + # for changes that are only meaningful together. + groups: + dev-dependencies: + patterns: + - "*" + update-types: + - minor + - patch + + # The workflows are part of the release path. A pinned action going stale is a supply-chain surface of + # its own, and nothing else in this repository watches it. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30992b6b..ec98a36d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,13 @@ jobs: - name: Bundle an edge guard and attack it run: npm run test:bundled + # The demos are the artifact shown to somebody to establish the product does what it claims, so a + # demo that cannot start is a claim with nothing behind it. Each must exit zero, print no failed + # step, reach its own verdict line, AND print the proof it exists to print. Installing the + # on-demand demo target is part of the run. + - name: Every demo runs and proves what it claims + run: npm run test:demos + # The generated `.cmd` launcher and path handling are Windows-only code paths in npm's shim, not ours, # and they are exactly what breaks a bin that works everywhere else. One smoke test rather than the whole # matrix: the question is whether the launcher runs and resolves, not whether four managers agree. diff --git a/.gitignore b/.gitignore index a5f4cfa9..28a9de11 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,8 @@ test-build/.work/ # template typecheck scratch dir .template-typecheck/ + +# The demo installs its knowingly-vulnerable target on demand (`npm run setup`) rather than declaring it. +# A lockfile committed from that directory would put the vulnerable package into the repository's +# dependency graph, where its advisories cannot be told apart from advisories about the shipped package. +examples/protect/package-lock.json diff --git a/examples/protect/README.md b/examples/protect/README.md index 1acaae46..7e0b1a40 100644 --- a/examples/protect/README.md +++ b/examples/protect/README.md @@ -4,13 +4,21 @@ Shows the full **Verified Vulnerability Shielding** loop against a **real, unmod vulnerable dependency** — no mocks of the vulnerability itself. ```bash +# From the repository root: the demos load the built runtime, which is what an application loads. +npm install +npm run build + cd examples/protect -npm install # pulls the real vulnerable lodash@4.17.11 (CVE-2019-10744) +npm run setup # installs lodash@4.17.11 (CVE-2019-10744), the vulnerable target npm run demo ``` Expected: all six steps ✓. +The vulnerable target is installed by `npm run setup` rather than declared as a dependency of this +example, so that a knowingly vulnerable package stays out of the repository's dependency graph. The +version lives in `demo-target.mjs`; the demos refuse to run against any other. + ## What it demonstrates | Step | | @@ -53,11 +61,15 @@ is what the observed→enforced auto-promote flow builds on. Guarded in CI by ## 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: +comprehensive demo rule set and a gallery runner. It needs no vulnerable dependency — so once the +repository is built, `npm run setup` is not required for this one: ```bash -node gallery.mjs # or: npm run gallery +# From the repository root, if you have not built yet: +npm install && npm run build + +cd examples/protect +npm run gallery # or: node gallery.mjs ``` It loads [`demo-rules.json`](./demo-rules.json) and shows, one row per rule, that the exploit is diff --git a/examples/protect/demo-pulse-chain.mjs b/examples/protect/demo-pulse-chain.mjs index a0523ce6..18fc36e8 100644 --- a/examples/protect/demo-pulse-chain.mjs +++ b/examples/protect/demo-pulse-chain.mjs @@ -7,12 +7,14 @@ // refresh, no redeploy). The exploit is a REAL unmodified vulnerable dependency // (lodash@4.17.11, CVE-2019-10744). Public CVE + demo rule only; no tokens/secrets. // -// cd examples/protect && npm install && node demo-pulse-chain.mjs +// npm install && npm run build (repo root), then cd examples/protect && npm run setup && node demo-pulse-chain.mjs import { createServer } from 'node:http'; -import _ from 'lodash'; -import { createProtection } from '../../src/protect/runtime.js'; +import { DEMO_TARGET, loadRuntime, requireDemoTarget } from './demo-target.mjs'; -const LODASH = _.VERSION; // 4.17.11 (vulnerable; fixed in 4.17.12) +const { createProtection } = await loadRuntime(); + +const _ = await requireDemoTarget(); +const LODASH = DEMO_TARGET.version; const SITE_UUID = '00000000-demo-4pul-se00-000000000001'; let ok = true; diff --git a/examples/protect/demo-target.mjs b/examples/protect/demo-target.mjs new file mode 100644 index 00000000..02a1da09 --- /dev/null +++ b/examples/protect/demo-target.mjs @@ -0,0 +1,75 @@ +// The vulnerable dependency the demos exploit, in one place. +// +// Deliberately not a declared dependency of this example: a knowingly vulnerable package named in a +// committed manifest enters the repository's dependency graph, where its advisories are +// indistinguishable from advisories about the package this repository actually ships. It is installed on +// demand instead (`npm run setup`). +// +// This module is the single place the version is written down, and `tests/demo-target.test.ts` pins it. +// The version matters to what the demos prove: against a patched version the exploit fails on its own, +// and the guard would be credited for a block that never happened. +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +export const DEMO_TARGET = Object.freeze({ + package: 'lodash', + version: '4.17.11', + cve: 'CVE-2019-10744', + fixedIn: '4.17.12', + /** What the demos say when the package is absent, so the instruction is identical everywhere. */ + installHint: 'cd examples/protect && npm run setup', +}); + +/** + * Load the vulnerable dependency, or exit with the instruction to install it. + * + * Exits rather than throwing: a stack trace about a missing module tells a reader nothing about what the + * demo needs, and the demos are the first thing anybody runs. + */ +export async function requireDemoTarget() { + try { + const mod = await import(DEMO_TARGET.package); + const loaded = mod.default ?? mod; + + if (loaded?.VERSION !== DEMO_TARGET.version) { + console.error( + `\n This demo exploits ${DEMO_TARGET.package}@${DEMO_TARGET.version} (${DEMO_TARGET.cve}).\n` + + ` Installed: ${loaded?.VERSION ?? 'unknown'} — a different version does not carry the flaw,\n` + + ` so the demo would report a block that proves nothing.\n\n Fix: ${DEMO_TARGET.installHint}\n`, + ); + process.exit(2); + } + + return loaded; + } catch { + console.error( + `\n This demo needs ${DEMO_TARGET.package}@${DEMO_TARGET.version} (${DEMO_TARGET.cve}), which is\n` + + ` installed on demand rather than declared as a dependency of this example.\n\n Run: ${DEMO_TARGET.installHint}\n`, + ); + process.exit(2); + } +} + +/** + * Load the built runtime, or exit with the command that builds it. + * + * The demos load `dist/protect.js` because that is the artifact an application loads. `dist/` is not + * tracked, so a clean checkout has to build first — and a static import of a missing module fails before + * any code in the demo can explain that. + */ +export async function loadRuntime() { + const runtime = new URL('../../dist/protect.js', import.meta.url); + + // Presence is checked separately from loading, so the two failures stay distinguishable: an absent + // build needs an instruction, while a build that exists and fails to load has a real cause worth + // seeing. Catching both and printing the same advice hides the second behind the first. + if (!existsSync(fileURLToPath(runtime))) { + console.error( + '\n This demo loads the built runtime from dist/, which is not tracked.\n\n' + + ' Run, from the repository root: npm install && npm run build\n', + ); + process.exit(2); + } + + return import(runtime); +} diff --git a/examples/protect/demo.mjs b/examples/protect/demo.mjs index 02e2a322..3cddccfa 100644 --- a/examples/protect/demo.mjs +++ b/examples/protect/demo.mjs @@ -5,13 +5,15 @@ // 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 +// npm install && npm run build (repo root), then cd examples/protect && npm run setup && node demo.mjs import { readFileSync } from 'node:fs'; -import _ from 'lodash'; -import { createProtection } from '../../src/protect/runtime.js'; +import { DEMO_TARGET, loadRuntime, requireDemoTarget } from './demo-target.mjs'; + +const { createProtection } = await loadRuntime(); 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) +const _ = await requireDemoTarget(); +const LODASH = DEMO_TARGET.version; let ok = true; const line = (pass, msg) => { ok = pass && ok; console.log(` ${pass ? '✓' : '✗'} ${msg}`); }; diff --git a/examples/protect/gallery.mjs b/examples/protect/gallery.mjs index 34a47b55..f8f2b88c 100644 --- a/examples/protect/gallery.mjs +++ b/examples/protect/gallery.mjs @@ -6,8 +6,10 @@ // // cd examples/protect && node gallery.mjs import { readFileSync } from 'node:fs'; -import { createProtection } from '../../src/protect/runtime.js'; import { runDemoBundle } from './demo-runner.mjs'; +import { loadRuntime } from './demo-target.mjs'; + +const { createProtection } = await loadRuntime(); const bundle = JSON.parse(readFileSync(new URL('./demo-rules.json', import.meta.url), 'utf8')); const results = await runDemoBundle(bundle, createProtection); @@ -29,7 +31,14 @@ for (const r of results) { } const passed = results.filter((r) => r.pass).length; -const ok = passed === results.length; +// `passed === results.length` alone is satisfied by zero of zero, so an empty gallery would report +// completion. A gallery with nothing in it has demonstrated nothing. +const ok = results.length > 0 && 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'); + +if (results.length === 0) { + console.log('\n✗ the gallery ran no demonstrations\n'); +} else { + console.log(ok ? '\n✓ gallery complete\n' : '\n✗ some demonstrations failed\n'); +} process.exit(ok ? 0 : 1); diff --git a/examples/protect/package-lock.json b/examples/protect/package-lock.json deleted file mode 100644 index a659fbc6..00000000 --- a/examples/protect/package-lock.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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" - } - } -} diff --git a/examples/protect/package.json b/examples/protect/package.json index 12a1070c..9e544abd 100644 --- a/examples/protect/package.json +++ b/examples/protect/package.json @@ -3,10 +3,8 @@ "private": true, "type": "module", "description": "End-to-end demo for @patchstack/connect/protect (public CVE, demo rules only)", - "dependencies": { - "lodash": "4.17.11" - }, "scripts": { + "setup": "node setup.mjs", "demo": "node demo.mjs", "demo:pulse": "node demo-pulse-chain.mjs", "gallery": "node gallery.mjs" diff --git a/examples/protect/rules.demo.json b/examples/protect/rules.demo.json index ed0db08d..6edd0da5 100644 --- a/examples/protect/rules.demo.json +++ b/examples/protect/rules.demo.json @@ -22,7 +22,7 @@ "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": ".." } } + { "parameter": ["get.file", "post.file", "get.path", "post.path"], "mutations": ["urldecode"], "match": { "type": "contains", "value": ".." } } ] }, { @@ -33,8 +33,8 @@ { "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" } } + { "parameter": ["get.url", "post.url"], "mutations": ["urldecode"], "match": { "type": "contains", "value": "localhost" } }, + { "parameter": ["get.url", "post.url"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/(127\\.0\\.0\\.1|169\\.254\\.169\\.254|::1|metadata\\.google)/i" } } ] } ] diff --git a/examples/protect/setup.mjs b/examples/protect/setup.mjs new file mode 100644 index 00000000..ed3f273b --- /dev/null +++ b/examples/protect/setup.mjs @@ -0,0 +1,28 @@ +// Install the vulnerable dependency the demos exploit. +// +// `--no-save`, so it never lands back in `package.json` and never re-enters the repository's dependency +// graph. The exact version comes from `demo-target.mjs`, which is also what the test pins. +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { DEMO_TARGET } from './demo-target.mjs'; + +const spec = `${DEMO_TARGET.package}@${DEMO_TARGET.version}`; + +console.log(`Installing ${spec} — knowingly vulnerable (${DEMO_TARGET.cve}, fixed in ${DEMO_TARGET.fixedIn}).`); +console.log('This is the demo target. It is installed here and not declared as a dependency.\n'); + +// `fileURLToPath`, not `url.pathname`: the latter keeps percent-encoding, so any directory with a space +// in its name yields a path that does not exist — and the failure surfaces as npm itself being ENOENT. +const here = fileURLToPath(new URL('.', import.meta.url)); + +// Run npm through the Node binary already running this script when npm launched it (`npm_execpath` is +// npm's own entry point). Falling back to spawning `npm` from PATH keeps `node setup.mjs` working when +// invoked directly, and `shell: true` is what makes that resolve the `.cmd` shim on Windows. +const viaNpmCli = process.env.npm_execpath; +const args = ['install', '--no-save', '--no-audit', '--no-fund', spec]; + +if (viaNpmCli) { + execFileSync(process.execPath, [viaNpmCli, ...args], { stdio: 'inherit', cwd: here }); +} else { + execFileSync('npm', args, { stdio: 'inherit', cwd: here, shell: true }); +} diff --git a/package-lock.json b/package-lock.json index d68659d9..a71f6150 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,9 +24,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -41,9 +41,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -58,9 +58,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -75,9 +75,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -92,9 +92,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -109,9 +109,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -126,9 +126,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -143,9 +143,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -160,9 +160,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -177,9 +177,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -194,9 +194,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -211,9 +211,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -228,9 +228,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -245,9 +245,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -262,9 +262,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -279,9 +279,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -296,9 +296,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -313,9 +313,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -330,9 +330,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -347,9 +347,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -364,9 +364,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -381,9 +381,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -398,9 +398,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -415,9 +415,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -432,9 +432,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -449,9 +449,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1404,9 +1404,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1417,32 +1417,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/estree-walker": { @@ -1808,9 +1808,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index fd541c1b..d1c3b09d 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "test": "vitest run", "test:consumers": "node scripts/compat-matrix.mjs", "test:bundled": "node scripts/bundled-consumer.mjs", + "test:demos": "npm run build && node scripts/run-demos.mjs", "audit:side-effects": "npm run build && node scripts/side-effect-audit.mjs --selftest && node scripts/side-effect-audit.mjs", "test:manifest": "bun scripts/test-manifest.ts", "test:watch": "vitest", @@ -97,6 +98,9 @@ "vitest": "^3.0.0", "yaml": "^2.9.0" }, + "overrides": { + "esbuild": "^0.28.1" + }, "repository": { "type": "git", "url": "git+https://github.com/patchstack/connect.git" diff --git a/scripts/run-demos.mjs b/scripts/run-demos.mjs new file mode 100644 index 00000000..4601ba9a --- /dev/null +++ b/scripts/run-demos.mjs @@ -0,0 +1,83 @@ +// Every demo must run, and must prove what it claims. +// +// The demos are what establishes that the product does what it says, so two things have to hold: the +// process completes, and its output contains the specific claim it exists to make. They are separate +// assertions because a process can exit zero having printed failures, and it can print a banner naming a +// CVE while demonstrating nothing. +// +// Each demo is checked for four things independently: exit status, absence of a failed-step marker, its +// own verdict line, and its proof. The proof pattern must be one an empty or inert run cannot satisfy. +// +// `npm run test:demos`. Requires the built runtime in `dist/` (what an application loads) and the +// on-demand demo target, which this installs. +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const exampleDir = path.join(root, 'examples', 'protect'); + +if (!existsSync(path.join(root, 'dist', 'protect.js'))) { + console.error(' dist/protect.js is missing — run `npm run build` first.'); + process.exit(2); +} + +// The vulnerable target is installed on demand rather than declared, so the harness installs it the same +// way a reader would. +console.log(' installing the demo target…'); +execFileSync(process.execPath, [path.join(exampleDir, 'setup.mjs')], { cwd: exampleDir, stdio: 'pipe' }); + +/** + * Each demo, and what its output must show. + * + * `success` is the demo's own verdict line, which it prints only after every step passed. `proof` is the + * claim the demo exists to make. Both are required, and separately: a demo can exit zero having printed + * failures, and it can print a banner mentioning the CVE while proving nothing. + */ +const DEMOS = [ + { + file: 'demo.mjs', + verdict: /ALL PASS/, + proof: /PROOF:.*is blocked here, right now/, + }, + { + file: 'demo-pulse-chain.mjs', + verdict: /ALL PASS/, + proof: /PROOF:.*shielded via a rule delivered by Pulse/, + }, + { + file: 'gallery.mjs', + verdict: /gallery complete/, + // A count, and it must be non-zero. "0/0 demonstrations passed" satisfies every other assertion. + proof: /\b([1-9]\d*)\/\1 demonstrations passed across ([1-9]\d*) phases/, + }, +]; + +let failed = 0; + +for (const { file, verdict, proof } of DEMOS) { + const run = spawnSync(process.execPath, [file], { cwd: exampleDir, encoding: 'utf8' }); + const output = `${run.stdout}${run.stderr}`; + + const checks = { + exit: run.status === 0, + // A failed step marker anywhere, whatever the exit code and whatever the summary line claims. + 'no-failed-step': !output.includes('✗'), + verdict: verdict.test(output), + proof: proof.test(output), + }; + + const ok = Object.values(checks).every(Boolean); + if (!ok) failed++; + + const detail = Object.entries(checks) + .filter(([, passed]) => !passed) + .map(([name]) => name) + .join(', '); + console.log(` ${ok ? 'ok ' : 'FAIL'} ${file.padEnd(22)} exit=${run.status}${ok ? '' : ` failed: ${detail}`}`); + if (!ok) console.log(output.split('\n').slice(-12).map((l) => ` ${l}`).join('\n')); +} + +console.log(failed === 0 ? '\n every demo runs and proves what it claims.' : `\n ${failed} demo(s) failed.`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/demo-rules.test.ts b/tests/demo-rules.test.ts new file mode 100644 index 00000000..76d8b0dd --- /dev/null +++ b/tests/demo-rules.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { validateBundle } from '../src/protect/rules/validate.js'; + +/** + * The demo rule bundles: every rule is enforceable, and every rule discriminates. + * + * Two independent properties, and a rule can satisfy one while failing the other. A bundle the contract + * refuses is dropped at load, so the demo runs with fewer rules than it appears to and still reports + * success — the engine says so in its output, which nothing reads. And a rule that loads may still match + * nothing, or match everything. + * + * The demos themselves only exercise the lodash rule, so without this the other rules in these bundles + * have no coverage at all. + */ +const root = new URL('../', import.meta.url); +const bundlePath = (name: string) => fileURLToPath(new URL(`examples/protect/${name}`, root)); +const load = (name: string) => JSON.parse(readFileSync(bundlePath(name), 'utf8')); + +const BUNDLES = ['rules.demo.json', 'demo-rules.json'].filter((n) => existsSync(bundlePath(n))); + +describe('demo rule bundles pass the contract that gates delivered rules', () => { + it('has bundles to check', () => { + expect(BUNDLES.length).toBeGreaterThan(0); + }); + + it.each(BUNDLES)('%s: the contract rejects nothing', (name) => { + // `validateBundle` is the same gate a delivered bundle goes through, so this is the real answer to + // "would this rule be enforced" rather than a re-implementation of the rules for the parameter names. + const { bundle, rejected } = validateBundle(load(name)); + const declared = (load(name).firewall ?? []).length; + + expect(rejected.map((r) => `${r.id}: ${r.reason}`)).toEqual([]); + // And the surviving count equals the declared count, so a rule silently dropped for any other reason + // is caught too. + expect(bundle.firewall).toHaveLength(declared); + }); +}); + +/** + * One exploit and one benign control per rule. + * + * The benign half is what makes each case evidence: a rule that blocks its exploit and everything else + * has not been shown to discriminate, and a demo bundle is exactly where an over-broad rule looks fine. + */ +type Case = { rule: string; exploit: Request; benign: Request }; + +const ORIGIN = 'https://demo.test'; +const post = (path: string, body: unknown) => + new Request(`${ORIGIN}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + +const CASES: Case[] = [ + { + rule: 'demo-CVE-2019-10744 (prototype pollution)', + exploit: post('/settings', { constructor: { prototype: { polluted: true } } }), + benign: post('/settings', { theme: 'dark', locale: 'en-GB' }), + }, + { + rule: 'demo-path-traversal', + exploit: new Request(`${ORIGIN}/download?file=..%2F..%2Fetc%2Fpasswd`), + benign: new Request(`${ORIGIN}/download?file=quarterly-report.pdf`), + }, + { + rule: 'demo-ssrf-url-param', + exploit: new Request(`${ORIGIN}/fetch?url=http%3A%2F%2F169.254.169.254%2Flatest%2Fmeta-data%2F`), + benign: new Request(`${ORIGIN}/fetch?url=https%3A%2F%2Fexample.com%2Flogo.png`), + }, +]; + +describe('every rule in the demo bundle discriminates', () => { + // Against the built engine, since that is what the demos load. `dist/` is gitignored, so a plain + // checkout has nothing to run; CI builds before it tests. + const enginePath = fileURLToPath(new URL('dist/protect.js', root)); + const built = existsSync(enginePath); + + it('has an engine to test against, or is honest that it did not run', () => { + expect(BUNDLES).toContain('rules.demo.json'); + }); + + describe.skipIf(!built)('with the built engine', () => { + it.each(CASES)('$rule blocks its exploit and allows its control', async ({ rule, exploit, benign }) => { + const { createProtection } = await import(/* @vite-ignore */ enginePath); + const protection: any = await createProtection({ rules: load('rules.demo.json'), mode: 'block' }); + const guard = protection.fetchGuard(); + + const blockedExploit = await guard(exploit); + const blockedBenign = await guard(benign); + + expect({ + exploit: blockedExploit?.status ?? 'allowed', + benign: blockedBenign?.status ?? 'allowed', + }, rule).toEqual({ exploit: 403, benign: 'allowed' }); + }); + }); +}); diff --git a/tests/demo-target.test.ts b/tests/demo-target.test.ts new file mode 100644 index 00000000..ce3287c3 --- /dev/null +++ b/tests/demo-target.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { DEMO_TARGET } from '../examples/protect/demo-target.mjs'; + +/** + * The demo's vulnerable target: pinned, and outside the dependency graph. + * + * The demos show a real CVE being exploited and then shielded. Against a version that carries no flaw the + * exploit fails on its own, both demos still print their proof, and the guard is credited for a block + * that never happened — so the version is part of what they prove, not an installation detail. + * + * It is installed on demand rather than declared, because a knowingly vulnerable package named in a + * committed manifest enters this repository's dependency graph, where its advisories cannot be told apart + * from advisories about the package that actually ships. + */ +const exampleDir = new URL('../examples/protect/', import.meta.url); +const read = (name: string) => readFileSync(fileURLToPath(new URL(name, exampleDir)), 'utf8'); + +/** Every manifest section that contributes to the dependency graph. */ +const GRAPH_SECTIONS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + // Both spellings: npm accepts either, and each is an array of names rather than a name-keyed object. + 'bundledDependencies', + 'bundleDependencies', +] as const; + +/** + * The sections of `manifest` that declare the demo target. + * + * Shared between the manifests so neither can be checked against a shorter list than the other. + */ +function declaresTarget(manifest: Record): string[] { + return GRAPH_SECTIONS.filter((section) => { + const value = manifest[section]; + const names = Array.isArray(value) ? value : Object.keys((value ?? {}) as object); + + return names.includes(DEMO_TARGET.package); + }); +} + +describe('the demo target', () => { + it('is the version that actually carries the flaw', () => { + // Pinned literally: changing it changes what the demos prove, which should require editing a test + // that says so. + expect({ pkg: DEMO_TARGET.package, version: DEMO_TARGET.version, cve: DEMO_TARGET.cve }).toEqual({ + pkg: 'lodash', + version: '4.17.11', + cve: 'CVE-2019-10744', + }); + }); + + it('is older than the version that fixes it', () => { + // States the relationship rather than restating the numbers: a target at or past `fixedIn` cannot be + // exploited, so the demo would prove nothing. + const asParts = (v: string) => v.split('.').map(Number); + const [tMaj, tMin, tPatch] = asParts(DEMO_TARGET.version); + const [fMaj, fMin, fPatch] = asParts(DEMO_TARGET.fixedIn); + + expect(tMaj * 1e6 + tMin * 1e3 + tPatch).toBeLessThan(fMaj * 1e6 + fMin * 1e3 + fPatch); + }); + + it('is absent from every section of the example manifest that reaches the dependency graph', () => { + // Naming the target rather than forbidding dependencies outright: the invariant is that this one + // package stays out of the graph, not that the example may never depend on anything. Optional and + // peer sections are included because both are resolved. + expect(declaresTarget(JSON.parse(read('package.json')))).toEqual([]); + }); + + it('is absent from every dependency-bearing section of the root manifest too', () => { + // The demo target must not arrive through the package itself either — the root has no runtime + // dependencies, and a devDependency on it would put it in the graph just the same. + const root = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')); + + expect(declaresTarget(root)).toEqual([]); + }); + + it('is installed by a setup step that names the same version', () => { + // The setup script must read the constant rather than name a version of its own, or the two can + // disagree. Asserted on the script's text so a second hard-coded spec cannot appear. + const setup = read('setup.mjs'); + + expect(setup).toContain('DEMO_TARGET'); + expect(setup).toContain('--no-save'); + expect(setup).not.toMatch(/lodash@\d/); + }); + + it('is loaded through the guard that refuses a wrong version', () => { + // A direct import runs against whatever happens to be installed. Both demos load the target through + // the helper, which refuses any version but the pinned one. + for (const name of ['demo.mjs', 'demo-pulse-chain.mjs']) { + const source = read(name); + + expect(source, `${name} must not import the target directly`).not.toMatch(/^import .* from 'lodash'/m); + expect(source, `${name} must load it through the helper`).toContain('requireDemoTarget'); + } + }); +});