Skip to content

protect: keep the runtime edge-safe (load Node fs lazily) - #117

Merged
patchstackdave merged 2 commits into
mainfrom
fix/edge-safe-runtime
Aug 13, 2026
Merged

protect: keep the runtime edge-safe (load Node fs lazily)#117
patchstackdave merged 2 commits into
mainfrom
fix/edge-safe-runtime

Conversation

@patchstackdave

Copy link
Copy Markdown
Contributor

What

The protect runtime is the module an edge guard imports — the Next edge-middleware template we scaffold, plus Cloudflare Workers / Deno / Bun / Supabase Functions. But runtime.js and rules/store.js imported node:fs / node:path statically, and the bundler rewrites those to bare fs / path in dist/protect.js — specifiers that don't resolve on an edge runtime at all. Net effect: a failed edge build or an unavailable guard on exactly the platforms we advertise.

Both filesystem uses are Node-only conveniences that were already fail-open, so they now load with a dynamic await import(…):

  • rules/store.js — the disk cache tier, behind a memoized loader. On a filesystem-less runtime it simply reports "no cache" and the memory / pluggable ruleCache tiers carry last-known-good.
  • runtime.js — the .patchstackrc.json api-key fallback (resolveApiKey is now async; createProtection already awaited its result).

After this, the runtime graph has zero static Node-builtin imports, and the built bundle is clean.

Tests

Adds tests/protect/edge-safe.test.ts, which pins the invariant so this can't regress:

  1. no static Node-builtin import in any src/protect/** runtime source, and
  2. none in the built dist/protect.js (checked when dist exists — it's gitignored).

The Node-path behavior is unchanged and still covered: the disk-cache / last-known-good tests (rule-sync, runtime-pulse, runtime-guards) pass as-is. Full suite green (712), typecheck + build clean.

Follow-ups (not in this PR)

A real Next-Edge / Workers build smoke test is the natural next step — this PR removes the blocker and locks the import invariant, but doesn't yet execute the guard inside an edge build.

The protect runtime is what an EDGE guard imports — the Next edge-middleware
template, Cloudflare Workers, Deno, Bun, Supabase Functions. But runtime.js and
rules/store.js imported `node:fs`/`node:path` STATICALLY, and the bundler rewrites
those to bare `fs`/`path`, which don't resolve on an edge runtime at all: the built
dist/protect.js could fail the build or leave the guard unavailable on exactly the
platforms we advertise support for.

Both filesystem uses are Node-only conveniences and were already fail-open, so
load them with a dynamic `await import(…)` instead:
- rules/store.js: the disk cache tier (memoized loader; on a filesystem-less
  runtime it reports "no cache" and the memory / pluggable `ruleCache` tiers carry
  last-known-good).
- runtime.js: the `.patchstackrc.json` api-key fallback (resolveApiKey is now async;
  createProtection already awaited).

Adds tests/protect/edge-safe.test.ts to pin the invariant — no static Node-builtin
import anywhere in the runtime graph, and none in the built bundle. The Node disk
cache / last-known-good tests still pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderbuds

coderbuds Bot commented Aug 13, 2026

Copy link
Copy Markdown

Lazy-loaded fs imports ensure edge-safe runtime without static Node dependencies.

🎯 Quality: 100% Elite · 📦 Size: Medium

📈 This month: Your 53rd PR — above team average · Averaging Excellent

See how your team is trending →

The dist assertion could fail spuriously against a STALE bundle left by a build on
another branch (CI tests before building, so it skips there and never ran). Gate it
on dist being newer than the newest source file; the source-graph assertion is the
real invariant and always runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

@patchstackdave
patchstackdave merged commit 641a3fe into main Aug 13, 2026
5 checks passed
@patchstackdave
patchstackdave deleted the fix/edge-safe-runtime branch August 13, 2026 11:55
patchstackdave added a commit that referenced this pull request Aug 13, 2026
* map: trace imported helpers; protect: ship a real edge build (dist/protect.edge.js)

Two follow-ups, both driven by tests that prove the behaviour rather than assert a proxy for it.

1. IMPORTED-HELPER TRACING (map). AI-generated apps put data access in a sibling
   module, so a handler's real sink lives one file away and the endpoint looked
   sink-free — nothing to correlate a CVE against. The extractor now follows ONE
   cross-file hop into a relative import (resolving extensionless, /index and the
   TS-ESM `./db.js` -> db.ts convention), plus one same-file hop inside that module,
   with a shared parse cache. Bare package specifiers are deliberately NOT followed:
   a dependency's internals are not this app's attack surface. Coverage notes state
   the hop limit.

2. A REAL EDGE BUILD. #117 made the Node imports dynamic, which keeps the module
   loadable off Node — but bundlers FOLLOW dynamic imports, so an edge build still
   failed to resolve `node:fs`/`node:path`, and the shipped dist/protect.js could NOT
   be bundled for Next edge middleware / Workers / Deno. (tsup also strips the `node:`
   prefix, and bare `fs` resolves nowhere on Workers while `node:fs` does under
   nodejs_compat.) So the source-level "no static node import" check was necessary but
   not sufficient.
   Adds scripts/build-edge.mjs: an esbuild build (platform browser) that replaces every
   Node-only module — and refresh-manifest, which pulls in the lockfile scanner — with a
   stub that rejects on import. The runtime already treats a failed `import('node:fs')`
   as "no filesystem here" and falls back to the memory/pluggable rule cache, so
   behaviour is preserved; the disk cache and manifest re-post are simply unavailable,
   which is correct on edge. Wired into `npm run build` and selected automatically via
   package.json exports conditions (workerd / worker / edge-light / deno / browser),
   with Node still getting the full build.

tests/protect/edge-bundle.test.ts is the test that would have caught the original gap:
it bundles the shipped artifact the way an edge bundler does (nothing external), asserts
zero Node builtin references static OR dynamic, IMPORTS the bundle and verifies it still
blocks an exploit with cacheDir set, and pins the exports condition order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* map: recognize Deno / edge platform functions (Supabase Edge Functions, Base44)

Platform function runtimes have no router and no route file: one handler per module,
invoked by the function's NAME. Without a recognizer, a Supabase Edge Functions or
Base44 backend-functions project mapped to NOTHING — no entry points, so no
reachability signal and nothing to correlate a vulnerability against.

Adds a `Deno.serve(handler)` / `serve(handler)` entry recognizer (entryKind
`edge-function`), derives the deployed function name and route from the conventional
location (`supabase/functions/<name>/index.ts`, `functions/<name>.ts` → `/<name>`),
extends the textual pre-filter so those files are parsed at all, and detects the
project shape (`supabase-functions` / `deno-functions`) even when there is no
package.json — which is normal for a Deno project.

The existing request-read extraction already covers the idiomatic
`const { a, b } = await req.json()`, so these endpoints get real inputs, sinks and
PROVEN flows: the fixture's `hook -> fetch` (the classic SSRF shape) comes out as a
precise flow, which is exactly what a rule needs to pin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* map: prove flows with AST evidence; keep imported helpers in-project and locatable

Round-2 review fixes. Both are cases where the previous implementation could make a
claim it hadn't earned.

1. FALSE `precise` FLOWS (high). linkFlows concatenated all argument source text and
   then checked, independently, that (a) the input's leaf name appeared anywhere and
   (b) any tainted root appeared anywhere. So:
       const { title } = await req.json();
       db.from("items").insert({ title: "system", owner: req.user.id });
   was reported `title -> insert [precise]` — `title` matched a property KEY and `req`
   matched a different value. Since a consumer may PIN A RULE on `precise`, that is the
   exact false positive the designation exists to avoid.
   Replaced with AST evidence: collect leaves genuinely READ from a tainted source
   (`data.title`, `req.body.title`, `{ title }` shorthand, `fn(title)`, `x[\"title\"]`),
   explicitly excluding property keys, member names and binding names; a flow is
   `precise` only if the input's leaf is among them, else `heuristic`. Evidence is
   gathered from the enclosing statement so a fluent chain
   (`.update({…}).eq('id', data.id)`) counts as one operation.

2. IMPORTED HELPERS (medium).
   - The resolver did not enforce the project boundary the walker enforces, so
     `import '../../other-repo/db'` (or a symlink) could pull an unrelated codebase into
     this app's attack surface. It now rejects anything whose realpath leaves the project
     unless --follow-symlinks.
   - An imported sink kept the HELPER's line number while the endpoint reported the
     handler's file, so the coordinate pointed at the wrong file. Sinks reached through an
     import now carry their own `file`; flow linking also refuses to call such a sink
     `precise`, since its call site isn't visible in the handler.
   - `import { saveOrder as write } from './db'` looked up `write` in the target module and
     missed the helper. Bindings now track the exported name behind an alias.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants