From ce0077fb701f705827d0026a13c87969b427afab Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 31 Aug 2026 14:46:36 +0200 Subject: [PATCH] Keep the knowingly-vulnerable demo target out of the dependency graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ships. The demos exploit lodash@4.17.11 deliberately, so they install it on demand (`npm run setup`) rather than declaring it. `examples/protect/demo-target.mjs` is the single place the version is written down. The version is part of what the demos prove: against a patched version the exploit fails on its own, both demos still print their proof, and the guard is credited for a block that never happened. `tests/demo-target.test.ts` pins it and asserts the target is absent from every dependency-graph section of both the example and root manifests — optional, peer and both bundled spellings included, through one shared list so neither manifest is checked against a shorter one. `.github/dependabot.yml` scopes update pull requests to the root manifest and the workflows. It selects which manifests get update pull requests, not which produce alerts: alerts follow the dependency graph, so keeping the package out of the graph is what keeps them meaningful. The example's lockfile is gitignored for the same reason. The demos load the built runtime, which is what an application loads, and the instructions give the sequence: root install and build, then `npm run setup`, then the demo. The gallery needs the build but not the target. An absent build exits with the command that produces it; a build that exists and fails to load keeps its own error, since presence is checked before loading. `npm run test:demos` asserts, per demo and independently, that it exits zero, prints no failed step, reaches its own verdict line, and prints its proof — with a proof pattern an empty or inert run cannot satisfy. The gallery treats zero demonstrations as a failure. `tests/demo-rules.test.ts` puts both demo bundles through `validateBundle`, the gate a delivered bundle passes, and requires the surviving rule count to equal the declared count. Every rule in the demo bundle has an exploit that it blocks and a benign control that it allows, so each rule is shown to discriminate rather than merely to fire. Rule parameters name keyed sources only: `raw` takes no key, and a keyed `raw.` is not enforceable under the contract. Dev-dependency advisories are resolved: esbuild via an override to `^0.28.1`, since tsup pins `^0.27.0`, and nanoid via the available fix. `npm audit` reports zero. Co-Authored-By: Claude Opus 5 (1M context) --- .github/dependabot.yml | 34 ++++ .github/workflows/ci.yml | 7 + .gitignore | 5 + examples/protect/README.md | 20 ++- examples/protect/demo-pulse-chain.mjs | 10 +- examples/protect/demo-target.mjs | 75 +++++++++ examples/protect/demo.mjs | 10 +- examples/protect/gallery.mjs | 15 +- examples/protect/package-lock.json | 19 --- examples/protect/package.json | 4 +- examples/protect/rules.demo.json | 6 +- examples/protect/setup.mjs | 28 ++++ package-lock.json | 220 +++++++++++++------------- package.json | 4 + scripts/run-demos.mjs | 83 ++++++++++ tests/demo-rules.test.ts | 100 ++++++++++++ tests/demo-target.test.ts | 101 ++++++++++++ 17 files changed, 591 insertions(+), 150 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 examples/protect/demo-target.mjs delete mode 100644 examples/protect/package-lock.json create mode 100644 examples/protect/setup.mjs create mode 100644 scripts/run-demos.mjs create mode 100644 tests/demo-rules.test.ts create mode 100644 tests/demo-target.test.ts 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'); + } + }); +});