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
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
},
"./protect": {
"types": "./dist/protect.d.ts",
"workerd": "./dist/protect.edge.js",
"worker": "./dist/protect.edge.js",
"edge-light": "./dist/protect.edge.js",
"deno": "./dist/protect.edge.js",
"browser": "./dist/protect.edge.js",
"import": "./dist/protect.js",
"require": "./dist/protect.cjs"
}
Expand All @@ -38,7 +43,7 @@
"LICENSE"
],
"scripts": {
"build": "tsup && node scripts/copy-protect-templates.mjs",
"build": "tsup && node scripts/build-edge.mjs && node scripts/copy-protect-templates.mjs",
"dev": "tsup --watch",
"test": "vitest run",
"test:manifest": "bun scripts/test-manifest.ts",
Expand Down
55 changes: 55 additions & 0 deletions scripts/build-edge.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Build the EDGE variant of the protect runtime: dist/protect.edge.js
//
// Why a separate artifact rather than one universal bundle: making the Node imports dynamic
// (`await import('node:fs')`) keeps the module *loadable* off Node, but bundlers FOLLOW dynamic
// imports, so an edge bundler (Next edge middleware, Cloudflare Workers, Deno, Supabase Functions)
// still tries to resolve `node:fs`/`node:path` and fails the build. The only way to be bundle-clean is
// for those modules to be absent from the graph entirely.
//
// So this build replaces every Node-only module with a stub that REJECTS on import. The runtime already
// treats a failed `await import('node:fs')` as "no filesystem on this runtime" and falls back to the
// memory / pluggable (`ruleCache`) tiers, so behaviour is preserved — the disk cache and the manifest
// re-post simply aren't available, which is correct on edge.
//
// We call esbuild directly instead of adding a tsup entry because tsup externalises Node builtins
// before a plugin can intercept them (and drops the `node:` prefix while doing so).
import * as esbuild from 'esbuild';

const NODE_ONLY = /^(node:)?(fs|fs\/promises|path|os|dns|net|crypto|http|https|child_process|worker_threads|module|url)$/;
// `refresh-manifest` pulls in the lockfile scanner (node:fs/promises) — Node-only by nature.
const NODE_ONLY_LOCAL = /refresh-manifest(\.js)?$/;

const stubNodeOnly = {
name: 'ps-stub-node-only',
setup(build) {
const toStub = () => ({ path: 'ps-edge-stub', namespace: 'ps-edge' });
build.onResolve({ filter: NODE_ONLY }, toStub);
build.onResolve({ filter: NODE_ONLY_LOCAL }, toStub);
build.onLoad({ filter: /.*/, namespace: 'ps-edge' }, () => ({
// Throwing on import is exactly what the runtime's try/catch fallbacks expect.
contents: 'throw new Error("[patchstack] this module is Node-only and unavailable on an edge runtime");',
loader: 'js',
}));
},
};

const result = await esbuild.build({
entryPoints: { 'protect.edge': 'src/protect/runtime.js' },
outdir: 'dist',
bundle: true,
format: 'esm',
platform: 'browser', // WinterCG: no Node globals assumed
target: 'es2022',
sourcemap: true,
// Keep the stub inline so the artifact is a single self-contained file (an edge bundler should not
// have to chase a chunk that only ever throws).
splitting: false,
plugins: [stubNodeOnly],
logLevel: 'warning',
});

if (result.errors.length) {
console.error('[patchstack] edge build failed');
process.exit(1);
}
console.log('built dist/protect.edge.js (edge-safe: no Node modules in the graph)');
330 changes: 286 additions & 44 deletions src/map/extract.ts

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src/map/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@ export interface Sink {
table?: string;
/** The operation at the sink (db: insert | select | …; fs/exec/http: the called function). */
op?: string;
/** 1-based line of the sink call in the endpoint's file — the auditable coordinate. */
/** 1-based line of the sink call, in `file` when present, otherwise in the endpoint's own file. */
line?: number;
/**
* Repo-relative file of the sink call, set ONLY when the sink was reached through an imported module
* — i.e. it does not live in the endpoint's file. Without this, `line` would point at the wrong file.
*/
file?: string;
}

export interface Endpoint {
Expand Down
65 changes: 65 additions & 0 deletions tests/map-edge-functions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildInputMap } from '../src/map/index.js';

// Platform function runtimes (Supabase Edge Functions, Base44 backend functions, Deno workers) have no
// route file and no framework router: one handler per module, invoked by the function's NAME. Without a
// recognizer these projects map to nothing at all.
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'ps-edgefn-'));
mkdirSync(join(dir, 'supabase', 'functions', 'charge'), { recursive: true });
mkdirSync(join(dir, 'functions'), { recursive: true });

// Supabase Edge Function: Deno.serve + destructured request read + a db sink.
writeFileSync(join(dir, 'supabase', 'functions', 'charge', 'index.ts'), `
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const admin = createClient(Deno.env.get("URL"), Deno.env.get("KEY"));
Deno.serve(async (req) => {
const { orderId, amount } = await req.json();
await admin.from("charges").insert({ orderId, amount });
return new Response("ok");
});
`);

// Generic Deno function dir (Base44 shape): bare serve() import + an outbound call.
writeFileSync(join(dir, 'functions', 'notify.ts'), `
import { serve } from "https://deno.land/std/http/server.ts";
serve(async (req) => {
const { hook } = await req.json();
await fetch(hook, { method: "POST" });
return new Response("sent");
});
`);
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));

describe('platform function entry points', () => {
it('recognizes a Supabase Edge Function, its route, inputs and sink', async () => {
const { map } = await buildInputMap(dir);
expect(map!.framework).toBe('supabase-functions');
const charge = map!.endpoints.find((e) => e.name === 'charge');
expect(charge, 'Deno.serve handler should be an entry point').toBeDefined();
expect(charge!.entryKind).toBe('edge-function');
expect(charge!.route).toBe('/charge'); // how the platform invokes it
expect(charge!.inputs.map((i) => i.name).sort()).toEqual(['amount', 'orderId']);
expect(charge!.sinks).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: 'db', table: 'charges', op: 'insert' })]),
);
// The insert receives the request data → a proven flow, so a rule can pin the parameter.
expect(charge!.flows.some((f) => f.confidence === 'precise' && f.input === 'orderId')).toBe(true);
});

it('recognizes a bare serve() function and its outbound (SSRF-relevant) sink', async () => {
const { map } = await buildInputMap(dir);
const notify = map!.endpoints.find((e) => e.name === 'notify')!;
expect(notify.entryKind).toBe('edge-function');
expect(notify.route).toBe('/notify');
expect(notify.inputs.map((i) => i.name)).toEqual(['hook']);
expect(notify.sinks).toEqual(expect.arrayContaining([expect.objectContaining({ kind: 'http' })]));
// hook -> fetch is the classic SSRF shape; it must be a PROVEN flow, not a co-occurrence.
expect(notify.flows.some((f) => f.input === 'hook' && f.sink.kind === 'http' && f.confidence === 'precise')).toBe(true);
});
});
103 changes: 103 additions & 0 deletions tests/map-flow-precision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { buildInputMap } from '../src/map/index.js';

// `precise` is a claim a consumer may PIN A RULE ON, so it must be evidence-backed: the input has to be
// genuinely READ into the sink. A property key that merely shares the input's name, with an unrelated
// tainted value elsewhere in the same call, is NOT evidence.
let dir: string, outside: string;
beforeAll(() => {
outside = mkdtempSync(join(tmpdir(), 'ps-other-repo-'));
writeFileSync(join(outside, 'db.ts'), `
import { createClient } from "@supabase/supabase-js";
const c = createClient("u", "k");
export function shouldNotBeSeen(x) { return c.from("secrets").delete().eq("id", x); }
`);

dir = mkdtempSync(join(tmpdir(), 'ps-flow-'));
mkdirSync(join(dir, 'src', 'lib'), { recursive: true });
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { express: '4' } }));

// The counterexample: `title` appears only as a KEY; the tainted `req` appears in a DIFFERENT value.
writeFileSync(join(dir, 'src', 'keyonly.ts'), `
import { createClient } from "@supabase/supabase-js";
const db = createClient("u", "k");
export async function POST(req) {
const { title } = await req.json();
await db.from("items").insert({ title: "system", owner: req.user.id });
return new Response("ok");
}
`);

// A genuine read of the input into the sink.
writeFileSync(join(dir, 'src', 'real.ts'), `
import { createClient } from "@supabase/supabase-js";
const db = createClient("u", "k");
export async function PUT(req) {
const { title } = await req.json();
await db.from("items").insert({ title });
return new Response("ok");
}
`);

// Aliased import of a helper that owns the sink.
writeFileSync(join(dir, 'src', 'lib', 'db.ts'), `
import { createClient } from "@supabase/supabase-js";
const c = createClient("u", "k");
export function saveOrder(o) { return c.from("orders").insert(o); }
`);
writeFileSync(join(dir, 'src', 'alias.ts'), `
import { saveOrder as write } from "./lib/db";
export async function PATCH(req) {
const body = await req.json();
return write({ note: body.note });
}
`);

// An import that escapes the project directory.
writeFileSync(join(dir, 'src', 'escape.ts'), `
import { shouldNotBeSeen } from "${join(outside, 'db').replace(/\\/g, '/')}";
export async function DELETE(req) { return shouldNotBeSeen(req.query.id); }
`);
});
afterAll(() => { rmSync(dir, { recursive: true, force: true }); rmSync(outside, { recursive: true, force: true }); });

describe('flow precision', () => {
it('does NOT claim precise when the input name is only a property key', async () => {
const { map } = await buildInputMap(dir);
const ep = map!.endpoints.find((e) => e.file.endsWith('keyonly.ts'))!;
const titleFlows = ep.flows.filter((f) => f.input === 'title');
expect(titleFlows.length).toBeGreaterThan(0);
expect(titleFlows.every((f) => f.confidence === 'heuristic')).toBe(true);
});

it('does claim precise for a real read (shorthand property)', async () => {
const { map } = await buildInputMap(dir);
const ep = map!.endpoints.find((e) => e.file.endsWith('real.ts'))!;
expect(ep.flows.some((f) => f.input === 'title' && f.confidence === 'precise')).toBe(true);
});

it('resolves an ALIASED imported helper to its exported name', async () => {
const { map } = await buildInputMap(dir);
const ep = map!.endpoints.find((e) => e.file.endsWith('alias.ts'))!;
expect(ep.sinks).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: 'db', table: 'orders', op: 'insert' })]),
);
});

it('labels an imported sink with ITS OWN file, and never calls it precise', async () => {
const { map } = await buildInputMap(dir);
const ep = map!.endpoints.find((e) => e.file.endsWith('alias.ts'))!;
const imported = ep.sinks.find((s) => s.table === 'orders')!;
expect(imported.file).toBe(join('src', 'lib', 'db.ts'));
expect(ep.flows.filter((f) => f.sink.table === 'orders').every((f) => f.confidence === 'heuristic')).toBe(true);
});

it('refuses to follow an import outside the project directory', async () => {
const { map } = await buildInputMap(dir);
const ep = map!.endpoints.find((e) => e.file.endsWith('escape.ts'))!;
expect(ep.sinks.some((s) => s.table === 'secrets')).toBe(false);
});
});
64 changes: 64 additions & 0 deletions tests/map-imported.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildInputMap } from '../src/map/index.js';

// AI-generated apps put data access in a sibling module, so a handler's real sink is one file away.
// Following one cross-file hop is what keeps those endpoints from looking sink-free.
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'ps-imp-'));
mkdirSync(join(dir, 'src', 'lib'), { recursive: true });
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { next: '14' } }));

// The helper module: exported fn hits supabase; a second exported fn delegates to a local helper.
writeFileSync(join(dir, 'src', 'lib', 'db.ts'), `
import { createClient } from "@supabase/supabase-js";
const client = createClient(process.env.URL, process.env.KEY);
export function saveOrder(o) { return client.from("orders").insert(o); }
function reallyPurge(id) { return client.from("orders").delete().eq("id", id); }
export function purgeOrder(id) { return reallyPurge(id); }
`);

// Handler imports both — note the TS-ESM `.js` specifier for one of them.
writeFileSync(join(dir, 'src', 'route.ts'), `
import { saveOrder } from "./lib/db.js";
import { purgeOrder } from "./lib/db";
export async function POST(request) {
const body = await request.json();
return saveOrder({ note: body.note });
}
export async function DELETE(request) {
return purgeOrder(request.query.id);
}
`);
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));

describe('imported-helper tracing', () => {
it('attributes a sink reached through an imported module (incl. a .js specifier)', async () => {
const { map } = await buildInputMap(dir);
const post = map!.endpoints.find((e) => e.name === 'POST')!;
expect(post.sinks).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'db', package: '@supabase/supabase-js', table: 'orders', op: 'insert' }),
]),
);
});

it('follows one same-file hop inside the imported module', async () => {
const { map } = await buildInputMap(dir);
const del = map!.endpoints.find((e) => e.name === 'DELETE')!;
expect(del.sinks).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'db', package: '@supabase/supabase-js', table: 'orders', op: 'delete' }),
]),
);
});

it('states the hop limit in coverage notes', async () => {
const { map } = await buildInputMap(dir);
expect(map!.coverage.notes.join(' ')).toMatch(/ONE hop into an imported relative module/i);
});
});
71 changes: 71 additions & 0 deletions tests/protect/edge-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';

// A REAL edge build test. The source-level "no static node import" check (edge-safe.test.ts) is
// necessary but NOT sufficient: bundlers FOLLOW dynamic imports, so `await import('node:fs')` still
// fails to resolve in an edge build. Only bundling the shipped artifact the way Next edge middleware /
// Cloudflare Workers / Deno do proves it. This test does exactly that with esbuild
// (platform: 'browser', nothing external) and then RUNS the bundle to prove behaviour survives.

const root = fileURLToPath(new URL('../../', import.meta.url));
const EDGE = root + 'dist/protect.edge.js';

async function bundlesForEdge(entry: string): Promise<{ ok: boolean; errors: string[] }> {
const esbuild = await import('esbuild');
try {
await esbuild.build({ entryPoints: [entry], bundle: true, write: false, format: 'esm', platform: 'browser', logLevel: 'silent' });
return { ok: true, errors: [] };
} catch (e: any) {
return { ok: false, errors: (e.errors ?? []).map((x: any) => x.text) };
}
}

describe('edge bundle', () => {
beforeAll(() => {
if (!existsSync(EDGE)) {
// CI runs tests before the build; build just this artifact so the assertions are real.
execFileSync(process.execPath, ['scripts/build-edge.mjs'], { cwd: root, stdio: 'ignore' });
}
}, 120_000);

it('bundles for an edge runtime with no Node builtins available', async () => {
const { ok, errors } = await bundlesForEdge(EDGE);
expect(errors).toEqual([]);
expect(ok).toBe(true);
}, 60_000);

it('contains no Node builtin import at all (static or dynamic)', () => {
const src = readFileSync(EDGE, 'utf8');
const refs = src.match(/(?:^|[\s(])(?:import|require)\s*\(?\s*["'](?:node:)?(?:fs|fs\/promises|path|os|dns|net|crypto|child_process|worker_threads|module)["']/gm);
expect(refs ?? []).toEqual([]);
});

it('still enforces rules when imported (no filesystem, cacheDir ignored)', async () => {
const { createProtection } = await import(EDGE);
const rules = {
firewall: [{ id: 'edge-1', rule_v2: [{ parameter: 'raw', match: { type: 'contains', value: '__proto__' } }] }],
whitelists: [],
whitelist_keys: {},
};
// cacheDir is deliberately set: the disk tier must fail open on a filesystem-less runtime.
const p: any = await createProtection({ rules, mode: 'block', cacheDir: '/tmp/ignored-on-edge' });
const post = (body: string) =>
new Request('https://app.test/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body });
expect((await p.fetch(() => new Response('ok'))(post('{"__proto__":{"x":1}}'))).status).toBe(403);
expect((await p.fetch(() => new Response('ok'))(post('{"a":1}'))).status).toBe(200);
}, 60_000);

it('is selected by edge conditions in package.json exports', () => {
const pkg = JSON.parse(readFileSync(root + 'package.json', 'utf8'));
const protect = pkg.exports['./protect'];
for (const cond of ['workerd', 'worker', 'edge-light', 'deno', 'browser']) {
expect(protect[cond]).toBe('./dist/protect.edge.js');
}
expect(protect.import).toBe('./dist/protect.js'); // Node still gets the full build
// Condition order matters: an edge condition must be matched before the generic `import`.
const keys = Object.keys(protect);
expect(keys.indexOf('workerd')).toBeLessThan(keys.indexOf('import'));
});
});
Loading