[APPS-2792] Add process.env scoping for local execution (Secret Store parity) - #504
Conversation
bbf9238 to
32ba868
Compare
|
✅ All CI checks and tests passed. 🎉 All green!🧪 All tests passed 🔗 Commit SHA: c694dcf | Docs | View more details | Give us feedback! |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
32ba868 to
a0bd2df
Compare
3a6e6dc to
dc63b88
Compare
dc63b88 to
841961c
Compare
841961c to
964fe0d
Compare
44bd4fc to
196ba96
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1483269fc0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| applyExcludeEnvValue = (newValue) => { | ||
| excludeEnvValue = newValue; | ||
| }; |
There was a problem hiding this comment.
Block signal-generated reports on Node 20
On the repository's Node 20 target, this shadow value has no effect on native report generation, while only direct getReport() and writeReport() calls are wrapped. A scoped backend function can set process.report.reportOnSignal = true and a filename, call process.kill(process.pid, 'SIGUSR2'), then read the generated report; Node 20 writes the real environment into that file despite excludeEnv reading as true. Guard the signal-triggered path (or prevent scoped code from enabling and triggering it) on runtimes without native exclusion support.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This is the same accepted residual gap this file's own comment on excludeEnvGuardInstalled already documents: on Node <22.13 (including this repo's pinned Node 20.19.4 CI target), excludeEnv has no effect on native report generation, and the JS-level getReport()/writeReport() wraps only cover directly-called reports, not ones Node generates on its own via --report-on-signal/--report-on-fatalerror — there's no JS call to intercept for those. Not a new gap introduced by this PR; it's the same JS-level-defense-in-depth-not-a-hard-boundary limitation already called out for the pre-22.13 case elsewhere in this file. Leaving this thread open rather than resolving it, since it's a real (if pre-existing and already-documented) exposure worth an explicit maintainer decision — e.g. disabling signal/fatal-error report generation for scoped executions on old Node — rather than silent acceptance.
1b3182c to
fcedb11
Compare
7ea5b29 to
61176af
Compare
oliverli
left a comment
There was a problem hiding this comment.
Review pass over the env-guard work. Two blockers and four major findings inline — the two blockers are one-line bypasses of the module's core guarantee. Everything else (guarded-wrapper/network-guard refactor, Vite envPrefix/envFile fix, shared singleton) verified clean.
…(Secret Store parity) Local execution runs a customer's backend function in-process, so without this it inherits the dev server's own full, real process.env — every secret and credential the dev server process has access to, not just what that function's declared connections should see. env-guard.ts closes this by installing a guarded, scoped env for the duration of runBlocked, mirroring network-guard.ts's shape and AsyncLocalStorage-per-call-chain scoping. Also closes several other read paths that reach the same real environment outside the documented process.env property: the /proc/self/environ (and /proc/[pid]/environ) file, reachable via fs.createReadStream/open/openSync/ promises.open/copyFileSync/copyFile/cpSync/cp (in either string or Buffer/URL path form), via constructing fs.ReadStream directly, via a numeric file descriptor already open against the real environ file, and via createReadStream/ReadStream's own options.fd (or a FileHandle's .fd) — resolved exactly once and reused for both the check and the real call, since an accessor-backed options.fd could otherwise show the check a harmless value and hand the real, separate read a different one; and process.report.excludeEnv, guarded against a customer function reassigning it from inside its own scope the same way process.env itself is guarded. The guard wraps Node's own native excludeEnv get/set (on versions that have one) rather than replacing them with a plain JS variable — a native, non- JS-triggered report (--report-on-fatalerror/--report-on-signal) reads Node's own internal flag directly, so a disconnected shadow would read back whatever value was last written while having no effect on what those reports actually contain. The action-catalog/backend-runtime adapter registrations in local-execution.ts now resolve their npm packages inside the same env/network scope as the customer function itself, rather than before it — their own top-level code would otherwise see the real, unscoped environment and unblocked network on first load in the process. env-guard.ts and network-guard.ts now share one getOrCreateShared() helper for the Symbol.for-keyed singleton pattern both need to survive being evaluated more than once (bundled copies, Jest's per-test-file isolation), instead of each keeping its own copy. env-guard.test.ts and local-execution.test.ts share a new installFakeProcessEnv() test helper for the same reason, instead of each duplicating the same beforeAll/afterAll real-environment swap — captured as a value snapshot rather than a reference to process.env itself, since the latter can already be a guard-installed accessor by the time the snapshot is taken, and restoring through that same reference later is a no-op under the guard's own self-reassignment check, permanently stranding process.env at the fake baseline instead of restoring the real environment for every test file that runs afterward in the same Jest worker. local-execution.ts's two lazy-import-and-memoize blocks for network-guard.ts and env-guard.ts are now one shared lazyImportOnce() helper. The reassignment-rejection check shared by process.env's and process.report.excludeEnv's setters, and the Object.defineProperty shape excludeEnv's native-vs-shadow branches both used, are now each expressed once instead of twice. env-guard.test.ts and local-execution.test.ts shared the same collection-time-capture bug: a describe-body `const` captured process.env before cleanEnv()'s beforeAll had a chance to strip secrets, so afterEach then restored the real, unstripped environment for the rest of the block. Both now capture inside beforeAll instead.
…tion builds configFile: false only skips loading a vite.config.js — it doesn't disable Vite's separate .env-file/import.meta.env machinery. loadEnv() copies any VITE_-prefixed key straight out of the dev server's own real process.env (independently of envFile/envDir), and the define plugin statically inlines that value into the built backend function at build time, bypassing runWithScopedEnv's runtime scoping entirely, since that only wraps module execution, never the bundling step itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t just Source $ now spreads an opaque preview-API response onto it, and the existing test only ever checked .Source for token-shaped keys — so a credential-shaped field added anywhere else on $ would go uncaught. The scan now recurses over all of $ except Actions (a Proxy dispatch mechanism, not a data container) and checks for token/secret/key/ password/credential substrings instead of just "token".
…bug found in review The process.env Proxy's `set` trap forwarded the mismatched `receiver` argument straight to Reflect.set, which for an existing key falls back to a partial-descriptor defineProperty call that Node's native process.env rejects — breaking any code (dd-trace's require-hook included) that assigns to an existing key from outside a scope. Fixes that by defaulting the receiver to the target object instead. Also closes four more gaps found in review: - The environ-path regex only matched self/thread-self/the dev server's own pid, letting a readable parent /proc entry (or any other accessible pid) through — now matches any numeric pid. - fs.promises.readFile's guard predicate didn't unwrap a FileHandle argument to its underlying fd, so a handle opened against /proc/self/environ before the scope reached the real read unguarded. - process.report.excludeEnv could be disarmed by an unrelated caller writing from outside any scope while a different scope was still active — such writes are now deferred until that scope closes, instead of applied immediately and then clobbered by its cleanup. - The callback-style fs.readFile/open/copyFile/cp were wrapped with the same synchronous-throw guard as their Sync counterparts, violating their real error-first-callback contract. They now report a guard failure through the callback instead.
…a self-assignment no-op, and unredacted writeReport output Closes three review findings on the process.env scoping guard: - currentEnv() and the environ-path checks resolved through AsyncLocalStorage.prototype.getStore and fs.realpathSync/readlinkSync dynamically, so a backend function could replace any of them to defeat scope detection or the forged-path check. Native references are now captured and bound at module load, before any customer code runs. - process.env's self-assignment branch (`process.env = capturedProxy`) was a silent no-op, breaking the standard capture/swap/restore pattern. A history stack now pops the pre-swap value on self-assignment, correctly supporting arbitrarily nested save/restore. - writeReport() with an explicit filename let Node persist the real, unredacted report to disk before it was read back and rewritten. The redacted content is now built and written directly for that case, closing the window where the real environment briefly exists on disk.
…fs/net registries
Both env-guard.ts and network-guard.ts stashed their shared state as plain
mutable fields on a registry attached to fs/net for cross-bundled-copy
sharing — any code with require('fs')/require('net'), including a backend
function's own third-party dependencies, could read the real environment
directly or call .disable() on the raw AsyncLocalStorage instance to kill
scope/network-block detection process-wide.
Every registry entry now exposes only functions that re-check the real
async-continuation state before doing anything sensitive, so calling them
from inside an active scope yields the same result a legitimate caller
gets. Scope-count mutation is additionally gated by a per-scope Symbol()
capability token, closing the "decrement enough times to disarm redaction
early" bypass a raw counter would allow.
0069c29 to
ac4110e
Compare
…ming a concurrent one forceResetEnv() cleared every active env-scope token at once, so a timed-out execution's cleanup could disarm process.report.excludeEnv redaction for a different, still-running execution's own scope. runWithScopedEnv/loadCustomerModuleEntry now accept an onScopeStarted handle (mirroring runBlocked's own pattern) so a caller abandons only its own token. getReport()/writeReport() redaction now gates on the calling continuation's own scope instead of the shared counter. Also tightens several oversized comments and removes tone/provenance violations flagged during review.
handle.read()/readFile()/readv()/createReadStream()/readableWebStream()/ readLines() previously trusted a FileHandle's own fd unconditionally, so a customer-controlled own property shadowing the prototype's real fd getter could steer either the guard's check or Node's real read to a different target than the other saw. Capturing the native getter once and comparing it against a naive read catches any shadow at all; pinning fd as a plain value for the call's full async duration additionally closes a stateful accessor getter that could otherwise pass the check on one read and steer the real operation via a different value on a later one.
8b9245a to
5bf6365
Compare
extractFdNumber (fs.promises.readFile(handle) and createReadStream's options.fd) read a FileHandle's .fd property naively, letting a shadowed own property report a harmless fd while the real underlying descriptor pointed at /proc/.../environ — the same TOCTOU class read()/readFile()/readv() are already hardened against via readNativeFd.
|
/merge 🤖 Posted by Claude Code |
|
View all feedbacks in Devflow UI.
Posted by Claude Code`
The expected merge time in
|
Motivation
process.env— every secret the dev server has access to, not just what that function's declared connections should see.env-guard.tscloses this with a scoped env for the duration ofrunBlocked, plus the adjacent read paths that reach the same real environment (/proc/.../environ,process.report.excludeEnv) and a Vite build-time leak (VITE_*/.envinlining into the built bundle) — see Architecture for the mechanism and the Changes table for the full file list.process.envProxy'ssettrap forwarded a mismatchedreceiverargument toReflect.set, which for an existing key falls back to a partial-descriptordefinePropertycall Node's nativeprocess.envrejects — breaking any code (dd-trace's own require-hook included) that assigns to an existing key from outside a scope. This and four related bypass gaps found in review (environ-path pid matching, aFileHandleunwrap gap, deferredexcludeEnvwrites, callback-contract violations) are fixed together since they're load-bearing for this guard's own correctness, not deferred hardening.realpathSync/readlinkSync/getStoreaccesses, and the redacted-report write path, were themselves bypassable from inside a scope (a backend function could monkey-patchfs.realpathSyncor reassignprocess.envto itself to escape scoping, and a report write briefly existed unredacted on disk before being rewritten). Fixed via module-load-time native reference capture, a self-assignment restore stack, and a direct redacted write — see Changes.fs/netfor cross-bundled-copy convergence used to expose the real environment and the rawAsyncLocalStorageinstance directly — any code withrequire('fs')/require('net'), including a backend function's own third-party dependencies, could readrealEnvor call.disable()to kill scope/network-block detection process-wide. Both registries now expose only functions that re-check the real async-continuation state before doing anything sensitive, with scope-count mutation additionally gated by a per-scope capability token — see Changes.forceResetEnv()cleared every active scope token at once, so a timed-out execution's own cleanup could disarmprocess.report.excludeEnvredaction for a different, concurrently-running execution's still-active scope.runWithScopedEnv/loadCustomerModuleEntrynow hand back a scoped handle (mirroringrunBlocked's own pattern) so a caller only ever discharges its own token;getReport()/writeReport()redaction now gates on the calling continuation's own scope instead of the shared counter — see Changes.read/readFile/readv/createReadStream/readableWebStream/readLineson an already-openFileHandletrusted its ownfdproperty unconditionally, so a customer-controlled shadow of that property could steer the guard's check and Node's real read to different targets — in either direction, and even via a stateful accessor returning one value to the check and a different one on Node's own later reads of the same call. Fixed by cross-checking against the prototype's nativefdgetter and pinningfdas a plain value for the read's full duration — see Changes.Architecture
env-guard.tsandnetwork-guard.tsshare onegetOrCreateShared()helper (shared-module-singleton.ts) for theSymbol.for-keyed singleton pattern both need to survive being evaluated more than once (bundled copies, Jest's per-test-file isolation).env-guard.tsandnetwork-guard.tsalso share a "call through when unblocked, signal failure when blocked" wrapper in guarded-wrapper.ts:makeGuardWrapper()(generalized to take an explicitshouldBlock(...args)predicate) andmakeGuardCallbackWrapper()(the same shape for callback-style APIs).36 changes across env-guard.ts, env-guard.test.ts, guarded-wrapper.ts, network-guard.ts, network-guard.test.ts, shared-module-singleton.ts, local-execution.ts, local-execution.test.ts, build-config.ts, env.ts
process.envto an allowlist for the duration ofrunBlockedpackages/plugins/apps/src/vite/env-guard.ts/proc/.../environvia every fs entry point that can reach it, including a FileHandle argumentpackages/plugins/apps/src/vite/env-guard.tsprocess.report.excludeEnvagainst reassignment from inside a scopepackages/plugins/apps/src/vite/env-guard.tsprocess.envProxy'ssettrap forwarding a mismatchedreceiver, which broke assignment to an existing key from outside a scope (a real CI End-to-End failure, since dd-trace's own require-hook instrumentation hit it)packages/plugins/apps/src/vite/env-guard.ts/proc/<pid>/environregex generalized to match any accessible pid, not just self/thread-self/the dev server's ownpackages/plugins/apps/src/vite/env-guard.tsfs.promises.readFile's guard predicate now unwraps aFileHandleargument to its underlying fdpackages/plugins/apps/src/vite/env-guard.tsprocess.report.excludeEnvwrites from outside an active scope are now deferred until that scope closes, and validated immediately via Node's native setterpackages/plugins/apps/src/vite/env-guard.tsfs.readFile/open/copyFile/cpnow report a guard failure through their callback instead of throwing synchronouslypackages/plugins/apps/src/vite/env-guard.tspackages/plugins/apps/src/vite/guarded-wrapper.ts/proc/.../environblockingpackages/plugins/apps/src/vite/env-guard.test.tsSymbol.for-keyed singleton helperpackages/plugins/apps/src/vite/shared-module-singleton.tspackages/plugins/apps/src/vite/guarded-wrapper.tsnetwork-guard.tsnow uses the sharedmakeGuardWrapperinstead of its own copypackages/plugins/apps/src/vite/network-guard.tspackages/plugins/apps/src/vite/local-execution.tslazyImportOnce()helperpackages/plugins/apps/src/vite/local-execution.ts$credential-leak regression test to scan the whole object, not justSourcepackages/plugins/apps/src/vite/local-execution.test.ts.envloading andVITE_*env-prefix inlining for backend function builds — shared with the production bundling path, see Blast Radiuspackages/plugins/apps/src/vite/build-config.ts.env/VITE_*inlining fixpackages/plugins/apps/src/vite/build-config.test.tsprocess.envacross a describe block, with a defensive copy so a test mutatingprocess.envby property can't corrupt the shared baseline for later testspackages/tests/src/_jest/helpers/env.tsforceResetEnv()into the timeout/abandon path — a genuinely hung backend function previously leftprocess.report.excludeEnvarmed forever, since its scope's ownfinallynever ranpackages/plugins/apps/src/vite/local-execution.tsObject.defineProperty(process.env, key, { configurable: false })now throws a clear, guard-specific error instead of a native Proxy invariantTypeErrorpackages/plugins/apps/src/vite/env-guard.tsprocess.report.writeReport(fileName)'s non-regular-sink check now also runs immediately before the real write, narrowing a symlink-swap TOCTOU windowpackages/plugins/apps/src/vite/env-guard.tsrealpathSync/readlinkSync/AsyncLocalStorage.prototype.getStoreare captured once at module load, before any customer code runs, so a scope can't disarm its own guard by monkey-patching thesepackages/plugins/apps/src/vite/env-guard.tsprocess.env = process.env(a save/restore idiom some code uses to swap the whole object) now correctly pops the pre-scope value instead of silently no-op'ing, via a restore stackpackages/plugins/apps/src/vite/env-guard.tsprocess.report.writeReport(fileName)with an explicit filename now builds and writes the redacted report directly, instead of writing Node's own (unredacted) version and rewriting it after — closing the window where an unredacted copy briefly exists on diskpackages/plugins/apps/src/vite/env-guard.tspackages/plugins/apps/src/vite/env-guard.test.tsfsnow exposes only functions (getCurrentEnv,isInsideScope,armScope/disarmScope, ...) instead of rawrealEnv/AsyncLocalStorage/counter fields; scope-count mutation gated by a per-scopeSymbol()capability tokenpackages/plugins/apps/src/vite/env-guard.tsnet(blockedContext/allowedContext) now exposesisActive()/run()instead of the rawAsyncLocalStorageinstancepackages/plugins/apps/src/vite/network-guard.tspackages/plugins/apps/src/vite/env-guard.test.ts,packages/plugins/apps/src/vite/network-guard.test.tsrunWithScopedEnv/loadCustomerModuleEntryaccept an optionalonScopeStartedhandle so a caller abandons only its own scope token instead offorceResetEnv()'s process-wide clearpackages/plugins/apps/src/vite/env-guard.ts,packages/plugins/apps/src/vite/local-execution.tsgetReport()/writeReport()redaction now gates on the calling continuation's own scope (isInsideScope()) instead of the shared, resettable scope counterpackages/plugins/apps/src/vite/env-guard.tspackages/plugins/apps/src/vite/env-guard.test.tspackages/plugins/apps/src/vite/env-guard.ts,packages/plugins/apps/src/vite/guarded-wrapper.ts,packages/plugins/apps/src/vite/local-execution.ts,packages/plugins/apps/src/vite/network-guard.ts,packages/plugins/apps/src/vite/network-guard.test.ts,packages/plugins/apps/src/vite/local-execution.test.tsFileHandle's ownfdproperty against the prototype's native getter before every guardedread/readFile/readv/createReadStream/readableWebStream/readLinescall, blocking any shadow that diverges in either directionpackages/plugins/apps/src/vite/env-guard.tsfdas a plain value forread/readFile/readv's full async duration, closing a stateful-accessor-getter bypass a one-time divergence check alone can't catchpackages/plugins/apps/src/vite/env-guard.tsFileHandle.fdshadow directions and the stateful-getter TOCTOUpackages/plugins/apps/src/vite/env-guard.test.tsQA Instructions
Test URL: Unit tests run for this PR's tip (
5bf63652) — no browsable page exists for this build-plugin/CLI change, so this CI run is the closest equivalent.Manual QA — the
process.envProxyset-trap fix, run as two separate processes (a single combined script that installs both proxies in one process interacts across the twoObject.definePropertycalls and no longer discriminates reliably):Manual QA — a standalone script exercising `env-guard.ts`'s real exports directly
The 5th outcome (a built backend function bundle no longer inlines a build-machine
VITE_*value or.envfile content) is covered exactly by a dedicated automated test, added in this PR:Manual QA — a zombie execution (a backend function whose
fn()never settles) no longer leavesprocess.report.excludeEnvarmed forever:Manual QA — the three hardening fixes from review, each covered by a dedicated automated test:
Manual QA — the shared-registry hardening, reproducing the reviewer's own PoC (
require('fs')[Symbol.for(...)].realEnv.DD_API_KEY) and proving it now returns the scoped view, plus proving a forged capability token can't disarm an active scope:Manual QA — the abandon-doesn't-blast-radius fix, proving a scope handle discharges only its own token and that report redaction survives an unrelated scope's abandonment:
Manual QA — the
FileHandle.fdshadow fix. The automated tests are Linux-only (/proc/self/environdoesn't exist elsewhere), so they no-op on a macOS dev machine — real coverage comes from CI (Linux) plus a direct Docker-based verification against the actual, transpiledenv-guard.tsmodule on the repo's pinned Node version:Docker verification against the real, transpiled module on real Linux (all four shadow/TOCTOU scenarios)
Blast Radius
runBlockedcontinuation for theprocess.env/process.report.excludeEnv//proc/.../environguarding — no change to production runtime execution (which already runs in its own Deno subprocess).envFile:false/envPrefix:[]ingetBaseBackendBuildConfigare shared with the production backend-bundle build path (build-backend-functions.ts), so uploaded production bundles also stop inlining build-machineVITE_*values and.envfiles at build time, not just in local dev. This is intentional, strictly-more-secure hardening — a server-side backend function has no legitimate use for either being statically inlined into its uploaded bundle.Out of Scope / Follow-ups
2 items deferred
fs/netare still reachable by any code in the process (this PR closes what they expose, not that they're reachable at all). Real process isolation (matching production's Deno-subprocess model) is the structurally complete fix, but a materially larger architectural change than this PR's scope.createReadStream/readableWebStream/readLinesconstruct and return synchronously, but the actual reads happen lazily as the caller consumes the returned stream/iterator — well after the guarded wrapper (and anyfdpin) has already returned. A stateful shadow getter on the handle can still steer those lazy reads, unlikeread/readFile/readv, whichwithPinnedFdcovers for their full async duration.fdfor the stream/iterator's whole lifetime, not just construction, a materially bigger design than this PR's scopeDocumentation