[APPS-2792] Add: scope process.env to an allowlist during local execution (Secret Store parity) - #493
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2311dbe9e4
ℹ️ 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".
| const result = await runWithScopedEnv(buildScopedEnv({}), () => | ||
| runBlocked(() => fn(...args)), | ||
| ); |
There was a problem hiding this comment.
Scope the environment while evaluating customer modules
When a backend module reads an environment variable at module scope, loadModule(...) executes that customer code before runWithScopedEnv is entered, so code such as const key = process.env.DD_API_KEY can retain the dev server credential and return or use it later from the guarded function. The environment must be scoped during customer-module evaluation as well, even if Vite's own transform work needs a separate unscoped phase.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Same underlying cause as the analogous, already-accepted network-guard gap in local-execution.ts (a customer module's own top-level code runs before any guard is entered, since loadModule needs real network/fs access for Vite's own transform pipeline — see that file's own doc comment). Confirmed real for env-guard too: a module-scope const key = process.env.DD_API_KEY would see the real value. Same fix shape as the network-guard case would need (scoping before loadModule, which previously broke the real dev-server integration test outright when tried for network-guard) — not attempting a different fix here without re-litigating that same tradeoff. Flagging alongside the post-timeout gap above for the #security review.
| // would otherwise leave process.env scoped for the rest of this | ||
| // process. | ||
| forceReset(); | ||
| forceResetEnv(); |
There was a problem hiding this comment.
Do not expose the real environment after a timeout
When a customer function continues after exceeding timeoutMs (for example, it awaits a timer and then resumes), the timeout wins the race but does not cancel that function; this reset consequently exposes the real process.env to the still-running customer continuation. Such a function can simply wait past the timeout and then read DD_API_KEY, AWS credentials, or any other shell secret, defeating the new isolation guarantee.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed real, and not something this PR can close on its own. It's the same root cause as the RFC's already-accepted 'abandoned, not cancelled' execution model (Decisions and Trade-Offs, and Milestone 7's own confirmed findings): a timed-out function isn't cancelled, just abandoned, so forceResetEnv() restoring the real env for the next execution necessarily also restores it underneath the still-running old one. Closing this fully needs either true per-execution process isolation or an AsyncLocalStorage-based proxy over process.env (replacing the whole-object-swap approach here with per-access-context resolution) — both bigger than this milestone's scope, and the latter has the same category of complexity the RFC already deferred for action-catalog/apps-backend's own registration scoping. Flagging this explicitly as a residual gap for the Secret Store parity #security review this milestone already requires, rather than leaving it implicit.
There was a problem hiding this comment.
Pull request overview
Friend, this PR scopes environment variables during local backend-function execution to reduce secret exposure.
Changes:
- Adds an environment allowlist guard and reset handling.
- Integrates the guard with local execution and timeouts.
- Adds unit and integration coverage.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
env-guard.ts |
Implements environment scoping and restoration. |
env-guard.test.ts |
Tests filtering, restoration, and generation handling. |
local-execution.ts |
Applies and resets the environment guard. |
local-execution.test.ts |
Tests local-execution environment behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const result = await runWithScopedEnv(buildScopedEnv({}), () => | ||
| runBlocked(() => fn(...args)), | ||
| ); |
There was a problem hiding this comment.
This consolidates three points already answered individually on other threads:
- Module-scope leak (customer init runs before this scope) → confirmed real, same root cause as the network-guard's analogous gap; see reply on the module-init-leak thread.
- Post-timeout continuation exposure → confirmed real, same "abandoned, not cancelled" execution model as the RFC's accepted trade-off; see reply on the post-timeout thread.
- Returned object's getter/
toJSONrunning after this scope during serialization → not an open gap.assertJsonSerializableruns insiderunWithScopedEnv's callback (local-execution.ts:701-706), so a malicioustoJSON()/getter fires while the scoped env is still active, not after restoration.
No new action beyond the two already-tracked, already-accepted gaps above.
| const result = await runWithScopedEnv(buildScopedEnv({}), () => | ||
| runBlocked(() => fn(...args)), | ||
| ); |
There was a problem hiding this comment.
Same root question as the analogous finding on PR #484's local-execution.ts (network-guard side) — traced through carefully and it doesn't currently reproduce, for the same reason: the abandoned check runs synchronously with no await before either guard is entered. See my reply on #484 for the regression test that proves it end-to-end through both runBlocked and runWithScopedEnv together (they're nested in the same call).
063d85e to
8edc949
Compare
2311dbe to
ffeab70
Compare
8edc949 to
c38e390
Compare
444e9a0 to
23788af
Compare
2c6aa37 to
a048f65
Compare
23788af to
2c0613b
Compare
a048f65 to
5193ab7
Compare
170fb1e to
2565a46
Compare
5193ab7 to
8d94ddf
Compare
2565a46 to
9597d94
Compare
8d94ddf to
51f9d73
Compare
9597d94 to
5769358
Compare
51f9d73 to
ceeece1
Compare
5769358 to
d0e7887
Compare
8d82135 to
737ac66
Compare
d0e7887 to
ac28c6b
Compare
6720f6b to
07cca79
Compare
4be6a31 to
0662e77
Compare
|
✅ All CI checks and tests passed. 🎉 All green!🧪 All tests passed 🔗 Commit SHA: d0371c5 | Docs | View more details | Give us feedback! |
ea84a47 to
4fd59b3
Compare
fc6b930 to
bce65fa
Compare
a778035 to
c6d1396
Compare
bce65fa to
f91e35a
Compare
c6d1396 to
328656a
Compare
f91e35a to
61aa3c0
Compare
328656a to
e6ae693
Compare
61aa3c0 to
7ac8d5d
Compare
e6ae693 to
90929ac
Compare
7ac8d5d to
1030511
Compare
90929ac to
2e59129
Compare
6e919dc to
d5f7725
Compare
2e59129 to
fef4b68
Compare
d5f7725 to
cfae8d3
Compare
fef4b68 to
d469f40
Compare
cfae8d3 to
c6f535d
Compare
d469f40 to
34cee8b
Compare
c6f535d to
df9fa8e
Compare
34cee8b to
8570d6c
Compare
df9fa8e to
5a8fe06
Compare
|
@codex review |
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b511d0d4dd
ℹ️ 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".
… mode Adds routeToCloudHandler, shared by /__dd/executeActionViaCloud and /__dd/executeAction's dev-verify-mode branch, so a fix to auth-checking or error handling can't drift between the two routes. Composes with the guardAuthenticated helper introduced upstream rather than duplicating its auth-gate-plus-catch logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fer/URL paths for /proc/.../environ readFile*'s guard only covered 3 of the fs entry points that map a path to file contents, and only checked string paths — createReadStream, open(Sync), fs.promises.open, or a Buffer/URL path (all valid PathLike/PathOrFileDescriptor types) all bypassed it and read the real environ file straight from the kernel.
…ck, sync ESM fs bindings process.report.getReport()/writeReport() read the OS-level environment table directly, completely bypassing the process.env swap this guard exists to enforce -- confirmed by reproduction (real shell env, including credentials, appeared in the report despite an active scoped-env window). Now toggles Node's own process.report.excludeEnv flag for the duration of a scoped window, which covers both JS-triggered calls and reports Node generates on its own (--report-on-fatalerror/--report-on-signal). Also: canonicalize the /proc/.../environ path check so /proc/self/../self/ environ and /proc/thread-self/environ can't bypass it via an unnormalized or unlisted path variant; call syncBuiltinESMExports() so ESM named imports of fs functions hit the same guard as the default-export object; consolidate the seven near-identical guarded-fs-function wrappers into two small generic helpers; and add the same missing forceReset()/forceResetEnv() calls to the absolute-execution-timeout handler that scheduleTimeout's own handler already has, so an abandoned execution's epoch/env state can't leak into a newer execution the way the per-call hang-detection path already guards against.
| // @types/node doesn't declare excludeEnv yet even though Node itself has supported it | ||
| // since v13.12 — matches the same cast env-guard.ts's own implementation uses. | ||
| const processReport = process.report as unknown as { excludeEnv?: boolean }; |
There was a problem hiding this comment.
Not sure where you got this information, but excludeEnv was introduced in v22.13.0 ref
There was a problem hiding this comment.
Confirmed, thanks — verified via nodejs/node#55697: excludeEnv landed in v22.13.0. This file's comment and env-guard.ts's own matching comment (which said v22.0.0, also wrong) are fixed locally. Neither had any behavioral impact since the code already treats it as unavailable below its actual threshold via the CI-pinned-Node-20.19.4 fallback path — this was a comment-accuracy fix only.
Motivation
process.envdirectly would otherwise see the dev server's entire real environment — its ownDD_API_KEY/DD_APP_KEYcredentials plus the developer's unrelated shell secrets. Production has no equivalent gap: each execution gets a fresh Deno subprocess with--allow-envscoped to the resolved credential names.Architecture
env-guard.tsmirrorsnetwork-guard.ts's generation-counter design: a monotoniccurrentGeneration, bumped by everyrunWithScopedEnvcall and byforceResetEnv, so a late restore from an abandoned (timed-out) execution can't clobber a newer execution's still-active scoped window.Changes
6 changes across 4 files
buildScopedEnv/runWithScopedEnv/forceResetEnv, scopingprocess.envtoPATH/HOME/NODE_ENV/TMPDIRfor the duration of a local execution.network-guard.test.ts's equivalent test).runWithScopedEnv/buildScopedEnvaround the customer function call inrunScriptLocally, nested with the existingrunBlockednetwork guard. AddedforceResetEnv()alongside the existingforceReset()in the timeout handler.executeScriptLocally:DD_API_KEYand an AWS-like credential are never visible to the customer function;PATH/HOME/NODE_ENV/TMPDIRremain visible; realprocess.envis restored after both success and throw./proc/.../environguard tocreateReadStream/open/openSync/promises.openand normalizedBuffer/URLpath arguments — the original guard only coveredreadFile*and string paths.process.report.getReport()/writeReport()stripenvironmentVariablesat the JS level during an active scoped-env window, on every Node version this repo supports —process.report.excludeEnv(the native flag) is only wired up to Node's report generator from v22.0.0 onward, so relying on it alone leaks the real environment through on CI's pinned Node 20.19.4.Two deliberate scope decisions, both documented in the RFC's Security section:
PATH/HOME/NODE_ENV/TMPDIRonly) — it excludes build-plugins' ownOVERRIDE_VARIABLES(DD_API_KEY,DD_APP_KEY,DD_SITE, etc.), which are the dev server's own credentials and must never reach customer code.STRIPE_API_KEY) is an open, undecided question —buildScopedEnvis always called with{}for now, matching production's "skip on unresolvable, never fail the task" behavior. The parameter is a forward-looking extension point only; no local-override mechanism exists yet.QA Instructions
yarn test:unit packages/plugins/apps/src/vite/env-guard.test.ts packages/plugins/apps/src/vite/local-execution.test.ts # Expected: Test Suites: 2 passed / Tests: 118 passed ✅ VERIFIEDyarn workspace @dd/apps-plugin run typecheck # Expected: no output, clean exit ✅ VERIFIEDReal manual QA against a scaffolded app, per the Testing and QA Guide:
Confirmed with the real dev server process holding
DD_API_KEY/AWS_SECRET_ACCESS_KEYin its actual environment (viaDD_API_KEY=... AWS_SECRET_ACCESS_KEY=... npm run dev) — neither leaked to the customer function,PATHstayed visible.Blast Radius
npm run dev:verify's cloud path — this only affects the in-process local-execution branch.runScriptLocally, and follows an already-reviewed pattern (network-guard.ts).Out of Scope / Follow-ups
2 items deferred
$.Actions's own channel)process.envscoping is a global swap, not chain-scoped likenetwork-guard.ts'sAsyncLocalStorage-based blocking — a fire-and-forgetsetTimeoutin customer code can observe the real, unscoped environment once the outer call'sfinallyhas already restored itProxy-basedprocess.envdesign (values resolved per activeAsyncLocalStoragescope, not a single swapped object) — bigger lift thannetwork-guard.ts's equivalent fix, since arbitrary code readsprocess.envdirectly rather than through an interceptable functionDocumentation