Skip to content

[APPS-2792] Add: scope process.env to an allowlist during local execution (Secret Store parity) - #493

Closed
tyffical wants to merge 4 commits into
tiffany.trinh/apps-2792-runtime-network-guardfrom
tiffany.trinh/apps-2792-secret-store-parity
Closed

[APPS-2792] Add: scope process.env to an allowlist during local execution (Secret Store parity)#493
tyffical wants to merge 4 commits into
tiffany.trinh/apps-2792-runtime-network-guardfrom
tiffany.trinh/apps-2792-secret-store-parity

Conversation

@tyffical

@tyffical tyffical commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Part of APPS-2792 — local Node execution for App Builder backend functions, closing the Secret Store parity gap described in the Kickoff doc. Stacked on the sandboxing runtime guard.
  • Backend functions run in-process inside the Vite dev server rather than a fresh Deno subprocess, so a function reading process.env directly would otherwise see the dev server's entire real environment — its own DD_API_KEY/DD_APP_KEY credentials plus the developer's unrelated shell secrets. Production has no equivalent gap: each execution gets a fresh Deno subprocess with --allow-env scoped to the resolved credential names.

Architecture

runScriptLocally()
   │
   ├─ runWithScopedEnv(buildScopedEnv({}), () =>
   │     runBlocked(() => fn(...args))      ← network-guard, unchanged
   │  )                                      ← env-guard, new
   │
   │  buildScopedEnv({}) returns only { PATH, HOME, NODE_ENV, TMPDIR }
   │  (whichever are set in the real env) — never a copy of process.env.
   │
   ▼
customer function body executes with the scoped env only
   │
   ▼ (success, throw, or timeout)
real process.env restored — via runWithScopedEnv's own finally, or
forceResetEnv() in the timeout handler if fn never settles

env-guard.ts mirrors network-guard.ts's generation-counter design: a monotonic currentGeneration, bumped by every runWithScopedEnv call and by forceResetEnv, 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
What changed File
New buildScopedEnv/runWithScopedEnv/forceResetEnv, scoping process.env to PATH/HOME/NODE_ENV/TMPDIR for the duration of a local execution. env-guard.ts
Unit coverage: allowlist filtering, Custom Credentials merge point, restore-on-throw, and the abandoned/newer-execution race (mirrors network-guard.test.ts's equivalent test). env-guard.test.ts
Wired runWithScopedEnv/buildScopedEnv around the customer function call in runScriptLocally, nested with the existing runBlocked network guard. Added forceResetEnv() alongside the existing forceReset() in the timeout handler. local-execution.ts
Integration tests through executeScriptLocally: DD_API_KEY and an AWS-like credential are never visible to the customer function; PATH/HOME/NODE_ENV/TMPDIR remain visible; real process.env is restored after both success and throw. local-execution.test.ts
Extended the /proc/.../environ guard to createReadStream/open/openSync/promises.open and normalized Buffer/URL path arguments — the original guard only covered readFile* and string paths. env-guard.ts
process.report.getReport()/writeReport() strip environmentVariables at 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. env-guard.ts, env-guard.test.ts

Two deliberate scope decisions, both documented in the RFC's Security section:

  1. The safe allowlist is intentionally narrow (PATH/HOME/NODE_ENV/TMPDIR only) — it excludes build-plugins' own OVERRIDE_VARIABLES (DD_API_KEY, DD_APP_KEY, DD_SITE, etc.), which are the dev server's own credentials and must never reach customer code.
  2. Custom Credentials resolution (e.g. a declared STRIPE_API_KEY) is an open, undecided question — buildScopedEnv is 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 install
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 ✅ VERIFIED
yarn build:all && yarn test:unit
# Expected: Test Suites: 92 passed / Tests: 2259 passed, 1 skipped ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, clean exit ✅ VERIFIED
npx eslint 'packages/plugins/apps/**/*.ts' --quiet
# Expected: no output, clean exit ✅ VERIFIED

Real manual QA against a scaffolded app, per the Testing and QA Guide:

# npm create @datadog/apps@latest -- qa-test-app --template vite-react -y, npm link @datadog/vite-plugin,
# add a backend function reading process.env.DD_API_KEY/AWS_SECRET_ACCESS_KEY/PATH, npm run dev
curl -X POST http://localhost:5180/__dd/executeAction -d '{"functionName":"...checkEnv","args":[]}'
# {"success":true,"result":{"data":{"apiKeyVisible":false,"awsKeyVisible":false,"path":"visible"}}} ✅ VERIFIED

Confirmed with the real dev server process holding DD_API_KEY/AWS_SECRET_ACCESS_KEY in its actual environment (via DD_API_KEY=... AWS_SECRET_ACCESS_KEY=... npm run dev) — neither leaked to the customer function, PATH stayed visible.

Blast Radius

  • No behavior change to npm run dev:verify's cloud path — this only affects the in-process local-execution branch.
  • Risk: low-medium. This is a real security boundary (the only thing standing between customer code and the dev server's own credentials), but it's purely additive, well-isolated to runScriptLocally, and follows an already-reviewed pattern (network-guard.ts).

Out of Scope / Follow-ups

2 items deferred
Item Status Next step
Custom Credentials real resolution (e.g. proxying through $.Actions's own channel) Open, undecided Explicit design decision needed before treating Secret Store parity as fully closed — see RFC's "Open tension, not yet resolved" row
process.env scoping is a global swap, not chain-scoped like network-guard.ts's AsyncLocalStorage-based blocking — a fire-and-forget setTimeout in customer code can observe the real, unscoped environment once the outer call's finally has already restored it Confirmed via code review, not yet fixed Needs a Proxy-based process.env design (values resolved per active AsyncLocalStorage scope, not a single swapped object) — bigger lift than network-guard.ts's equivalent fix, since arbitrary code reads process.env directly rather than through an interceptable function

Documentation

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +447 to +449
const result = await runWithScopedEnv(buildScopedEnv({}), () =>
runBlocked(() => fn(...args)),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +447 to +449
const result = await runWithScopedEnv(buildScopedEnv({}), () =>
runBlocked(() => fn(...args)),
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/toJSON running after this scope during serialization → not an open gap. assertJsonSerializable runs inside runWithScopedEnv's callback (local-execution.ts:701-706), so a malicious toJSON()/getter fires while the scoped env is still active, not after restoration.

No new action beyond the two already-tracked, already-accepted gaps above.

Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
Comment on lines +447 to +449
const result = await runWithScopedEnv(buildScopedEnv({}), () =>
runBlocked(() => fn(...args)),
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread packages/plugins/apps/src/vite/env-guard.ts Outdated
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 063d85e to 8edc949 Compare August 24, 2026 16:53
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 2311dbe to ffeab70 Compare August 24, 2026 16:56
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 8edc949 to c38e390 Compare August 24, 2026 18:47
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch 2 times, most recently from 444e9a0 to 23788af Compare August 24, 2026 19:13
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 2c6aa37 to a048f65 Compare August 24, 2026 19:24
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 23788af to 2c0613b Compare August 24, 2026 19:28
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from a048f65 to 5193ab7 Compare August 24, 2026 20:07
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 170fb1e to 2565a46 Compare August 24, 2026 20:12
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 5193ab7 to 8d94ddf Compare August 25, 2026 00:24
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 2565a46 to 9597d94 Compare August 25, 2026 00:29
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 8d94ddf to 51f9d73 Compare August 25, 2026 01:13
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 9597d94 to 5769358 Compare August 25, 2026 01:16
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 51f9d73 to ceeece1 Compare August 25, 2026 04:41
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 5769358 to d0e7887 Compare August 25, 2026 15:29
@tyffical
tyffical requested a balanced review from Copilot August 25, 2026 15:46

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 8d82135 to 737ac66 Compare August 25, 2026 17:23
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from d0e7887 to ac28c6b Compare August 25, 2026 18:44
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 6720f6b to 07cca79 Compare August 26, 2026 01:46
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 4be6a31 to 0662e77 Compare August 26, 2026 01:49
@datadog-prod-us1-4

datadog-prod-us1-4 Bot commented Aug 26, 2026

Copy link
Copy Markdown

Tests

All CI checks and tests passed.

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: d0371c5 | Docs | View more details | Give us feedback!

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch 2 times, most recently from ea84a47 to 4fd59b3 Compare August 26, 2026 02:35
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from fc6b930 to bce65fa Compare August 28, 2026 02:02
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from a778035 to c6d1396 Compare August 28, 2026 02:03
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from bce65fa to f91e35a Compare August 28, 2026 03:40
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from c6d1396 to 328656a Compare August 28, 2026 03:41
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from f91e35a to 61aa3c0 Compare August 28, 2026 04:14
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 328656a to e6ae693 Compare August 28, 2026 04:17
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 61aa3c0 to 7ac8d5d Compare August 28, 2026 04:20
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from e6ae693 to 90929ac Compare August 28, 2026 04:21
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 7ac8d5d to 1030511 Compare August 28, 2026 05:15
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 90929ac to 2e59129 Compare August 28, 2026 05:20
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch 2 times, most recently from 6e919dc to d5f7725 Compare August 28, 2026 05:44
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 2e59129 to fef4b68 Compare August 28, 2026 05:50
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from d5f7725 to cfae8d3 Compare August 28, 2026 06:15
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from fef4b68 to d469f40 Compare August 28, 2026 06:21
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from cfae8d3 to c6f535d Compare August 28, 2026 06:43
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from d469f40 to 34cee8b Compare August 28, 2026 06:46
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from c6f535d to df9fa8e Compare August 28, 2026 07:22
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 34cee8b to 8570d6c Compare August 28, 2026 07:25
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from df9fa8e to 5a8fe06 Compare August 28, 2026 08:09
@tyffical

tyffical commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T18:15:07.688927Z b511d0d Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/plugins/apps/src/vite/env-guard.ts
Comment thread packages/plugins/apps/src/vite/network-guard.ts Outdated
Comment thread packages/plugins/apps/src/vite/local-execution.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 36 changed files in this pull request and generated 5 comments.

Comment thread packages/plugins/apps/src/vite/env-guard.ts Outdated
Comment thread packages/plugins/apps/src/vite/env-guard.ts Outdated
Comment thread packages/plugins/apps/src/vite/env-guard.ts
Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
Comment thread packages/plugins/apps/src/vite/network-guard.ts
tyffical and others added 4 commits September 2, 2026 12:17
… 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.
Comment on lines +276 to +278
// @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 };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure where you got this information, but excludeEnv was introduced in v22.13.0 ref

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

3 participants