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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 16 additions & 4 deletions examples/protect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions examples/protect/demo-pulse-chain.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
75 changes: 75 additions & 0 deletions examples/protect/demo-target.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 6 additions & 4 deletions examples/protect/demo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`); };
Expand Down
15 changes: 12 additions & 3 deletions examples/protect/gallery.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
19 changes: 0 additions & 19 deletions examples/protect/package-lock.json

This file was deleted.

4 changes: 1 addition & 3 deletions examples/protect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions examples/protect/rules.demo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": ".." } }
]
},
{
Expand All @@ -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" } }
]
}
]
Expand Down
28 changes: 28 additions & 0 deletions examples/protect/setup.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading