Skip to content

[APPS-2792] Add process.env scoping for local execution (Secret Store parity) - #504

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 9 commits into
masterfrom
tiffany.trinh/apps-2792-secret-store-parity
Sep 11, 2026
Merged

gh-worker-dd-mergequeue-cf854d[bot] merged 9 commits into
masterfrom
tiffany.trinh/apps-2792-secret-store-parity

Conversation

@tyffical

@tyffical tyffical commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Tracked in APPS-2792 (Milestone: Secret Store parity for local execution).
  • Local execution runs a customer's backend function in-process, so without scoping it inherits the dev server's full, real process.env — every secret the dev server has access to, not just what that function's declared connections should see.
  • env-guard.ts closes this with a scoped env for the duration of runBlocked, plus the adjacent read paths that reach the same real environment (/proc/.../environ, process.report.excludeEnv) and a Vite build-time leak (VITE_*/.env inlining into the built bundle) — see Architecture for the mechanism and the Changes table for the full file list.
  • The process.env Proxy's set trap forwarded a mismatched receiver argument to Reflect.set, which for an existing key falls back to a partial-descriptor defineProperty call Node's native process.env rejects — 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, a FileHandle unwrap gap, deferred excludeEnv writes, callback-contract violations) are fixed together since they're load-bearing for this guard's own correctness, not deferred hardening.
  • The guard's own realpathSync/readlinkSync/getStore accesses, and the redacted-report write path, were themselves bypassable from inside a scope (a backend function could monkey-patch fs.realpathSync or reassign process.env to 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.
  • The shared state both this file and network-guard.ts stash on fs/net for cross-bundled-copy convergence used to expose the real environment and the raw AsyncLocalStorage instance directly — any code with require('fs')/require('net'), including a backend function's own third-party dependencies, could read realEnv or 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 disarm process.report.excludeEnv redaction for a different, concurrently-running execution's still-active scope. runWithScopedEnv/loadCustomerModuleEntry now hand back a scoped handle (mirroring runBlocked'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/readLines on an already-open FileHandle trusted its own fd property 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 native fd getter and pinning fd as a plain value for the read's full duration — see Changes.

Architecture

runBlocked(fn)
  │
  ├─▶ network-guard.ts: blockedContext.run(true, fn)   (existing)
  │
  └─▶ env-guard.ts: runWithScopedEnv(scopedEnv, fn)      (new)
        │
        ├─ installs a Proxy over process.env for this continuation only
        │    (AsyncLocalStorage-scoped, like network-guard.ts's blockedContext);
        │    set trap defaults its receiver to target, since forwarding the
        │    caller's mismatched receiver broke assignment to an existing key
        │
        ├─ wraps fs.readFileSync/readFile/openSync/open/createReadStream/
        │    ReadStream/copyFile*/cp* to block reads that resolve to
        │    /proc/.../environ (path regex matches any accessible pid;
        │    fs.promises.readFile unwraps a FileHandle argument to its fd);
        │    callback-style fs.readFile/open/copyFile/cp report a guard
        │    failure through their own callback, not a synchronous throw
        │
        └─ wraps process.report.excludeEnv's get/set to block reassignment
             from inside the scope; a write from outside an active scope is
             deferred until that scope closes instead of applied-then-clobbered
  • env-guard.ts and network-guard.ts share one getOrCreateShared() helper (shared-module-singleton.ts) for the Symbol.for-keyed singleton pattern both need to survive being evaluated more than once (bundled copies, Jest's per-test-file isolation).
  • env-guard.ts and network-guard.ts also share a "call through when unblocked, signal failure when blocked" wrapper in guarded-wrapper.ts: makeGuardWrapper() (generalized to take an explicit shouldBlock(...args) predicate) and makeGuardCallbackWrapper() (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
What changed File
New guard scoping process.env to an allowlist for the duration of runBlocked packages/plugins/apps/src/vite/env-guard.ts
Blocks reads of /proc/.../environ via every fs entry point that can reach it, including a FileHandle argument packages/plugins/apps/src/vite/env-guard.ts
Guards process.report.excludeEnv against reassignment from inside a scope packages/plugins/apps/src/vite/env-guard.ts
Fixed the process.env Proxy's set trap forwarding a mismatched receiver, 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>/environ regex generalized to match any accessible pid, not just self/thread-self/the dev server's own packages/plugins/apps/src/vite/env-guard.ts
fs.promises.readFile's guard predicate now unwraps a FileHandle argument to its underlying fd packages/plugins/apps/src/vite/env-guard.ts
process.report.excludeEnv writes from outside an active scope are now deferred until that scope closes, and validated immediately via Node's native setter packages/plugins/apps/src/vite/env-guard.ts
Callback-style fs.readFile/open/copyFile/cp now report a guard failure through their callback instead of throwing synchronously packages/plugins/apps/src/vite/env-guard.ts
Added a callback-style guard-wrapper variant for the above packages/plugins/apps/src/vite/guarded-wrapper.ts
New tests for the env-guard scoping and /proc/.../environ blocking packages/plugins/apps/src/vite/env-guard.test.ts
Extracted shared Symbol.for-keyed singleton helper packages/plugins/apps/src/vite/shared-module-singleton.ts
Extracted shared argument-dependent/independent guard-wrapper helper packages/plugins/apps/src/vite/guarded-wrapper.ts
network-guard.ts now uses the shared makeGuardWrapper instead of its own copy packages/plugins/apps/src/vite/network-guard.ts
Action-catalog/backend-runtime adapter registrations now resolve inside the same env/network scope as the customer function packages/plugins/apps/src/vite/local-execution.ts
Two lazy-import-and-memoize blocks consolidated into one lazyImportOnce() helper packages/plugins/apps/src/vite/local-execution.ts
Widened the $ credential-leak regression test to scan the whole object, not just Source packages/plugins/apps/src/vite/local-execution.test.ts
Disables .env loading and VITE_* env-prefix inlining for backend function builds — shared with the production bundling path, see Blast Radius packages/plugins/apps/src/vite/build-config.ts
New test covering the .env/VITE_* inlining fix packages/plugins/apps/src/vite/build-config.test.ts
New shared test helper for capturing/restoring a fake process.env across a describe block, with a defensive copy so a test mutating process.env by property can't corrupt the shared baseline for later tests packages/tests/src/_jest/helpers/env.ts
Wired forceResetEnv() into the timeout/abandon path — a genuinely hung backend function previously left process.report.excludeEnv armed forever, since its scope's own finally never ran packages/plugins/apps/src/vite/local-execution.ts
Object.defineProperty(process.env, key, { configurable: false }) now throws a clear, guard-specific error instead of a native Proxy invariant TypeError packages/plugins/apps/src/vite/env-guard.ts
process.report.writeReport(fileName)'s non-regular-sink check now also runs immediately before the real write, narrowing a symlink-swap TOCTOU window packages/plugins/apps/src/vite/env-guard.ts
realpathSync/readlinkSync/AsyncLocalStorage.prototype.getStore are captured once at module load, before any customer code runs, so a scope can't disarm its own guard by monkey-patching these packages/plugins/apps/src/vite/env-guard.ts
process.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 stack packages/plugins/apps/src/vite/env-guard.ts
process.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 disk packages/plugins/apps/src/vite/env-guard.ts
New tests for native-reference freezing, nested self-assignment restore, and the writeReport direct-write path; fixed 3 pre-existing tests whose restore logic relied on the old (buggy) self-assignment no-op packages/plugins/apps/src/vite/env-guard.test.ts
Shared state on fs now exposes only functions (getCurrentEnv, isInsideScope, armScope/disarmScope, ...) instead of raw realEnv/AsyncLocalStorage/counter fields; scope-count mutation gated by a per-scope Symbol() capability token packages/plugins/apps/src/vite/env-guard.ts
Shared state on net (blockedContext/allowedContext) now exposes isActive()/run() instead of the raw AsyncLocalStorage instance packages/plugins/apps/src/vite/network-guard.ts
New tests proving the shared registry exposes only functions and that a forged capability token can't disarm an active scope packages/plugins/apps/src/vite/env-guard.test.ts, packages/plugins/apps/src/vite/network-guard.test.ts
runWithScopedEnv/loadCustomerModuleEntry accept an optional onScopeStarted handle so a caller abandons only its own scope token instead of forceResetEnv()'s process-wide clear packages/plugins/apps/src/vite/env-guard.ts, packages/plugins/apps/src/vite/local-execution.ts
getReport()/writeReport() redaction now gates on the calling continuation's own scope (isInsideScope()) instead of the shared, resettable scope counter packages/plugins/apps/src/vite/env-guard.ts
New tests proving a scope handle discharges only its own token and that redaction survives an unrelated scope's abandonment packages/plugins/apps/src/vite/env-guard.test.ts
Tightened several oversized comments and removed self-referential/provenance phrasing flagged in review packages/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.ts
Cross-checks a FileHandle's own fd property against the prototype's native getter before every guarded read/readFile/readv/createReadStream/readableWebStream/readLines call, blocking any shadow that diverges in either direction packages/plugins/apps/src/vite/env-guard.ts
Pins fd as a plain value for read/readFile/readv's full async duration, closing a stateful-accessor-getter bypass a one-time divergence check alone can't catch packages/plugins/apps/src/vite/env-guard.ts
New tests for both FileHandle .fd shadow directions and the stateful-getter TOCTOU packages/plugins/apps/src/vite/env-guard.test.ts

QA Instructions

yarn typecheck:all
# Expected: no errors ✅ VERIFIED

yarn build:all
yarn test:unit
# Expected: 94 suites, 2426 tests (2425 passed, 1 pre-existing skip) ✅ VERIFIED

yarn cli integrity
# Expected: clean pass, no unexpected diffs ✅ VERIFIED

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.env Proxy set-trap fix, run as two separate processes (a single combined script that installs both proxies in one process interacts across the two Object.defineProperty calls and no longer discriminates reliably):

cat > /tmp/settrap_prefix.mjs << 'SCRIPT'
const proxy = new Proxy(process.env, {
    set: (t, p, v, r) => Reflect.set(t, p, v, r), // pre-fix: forwards the mismatched receiver
});
Object.defineProperty(process, 'env', { configurable: true, enumerable: true, get: () => proxy });
try {
    process.env.PATH = 'new-value-' + Date.now();
    console.log('PRE-FIX -> PATH assignment OK:', process.env.PATH);
} catch (e) {
    console.log('PRE-FIX -> THREW:', e.message);
}
SCRIPT
cat > /tmp/settrap_postfix.mjs << 'SCRIPT'
const proxy = new Proxy(process.env, {
    set: (t, p, v) => Reflect.set(t, p, v), // post-fix: receiver defaults to target
});
Object.defineProperty(process, 'env', { configurable: true, enumerable: true, get: () => proxy });
try {
    process.env.PATH = 'new-value-' + Date.now();
    console.log('POST-FIX -> PATH assignment OK:', process.env.PATH);
} catch (e) {
    console.log('POST-FIX -> THREW:', e.message);
}
SCRIPT
node /tmp/settrap_prefix.mjs
node /tmp/settrap_postfix.mjs
PRE-FIX -> THREW: 'process.env' only accepts a configurable, writable, and enumerable data descriptor
POST-FIX -> PATH assignment OK: new-value-1788932097109 ✅ VERIFIED
Manual QA — a standalone script exercising `env-guard.ts`'s real exports directly
// packages/plugins/apps/src/vite/manual-qa-repro.ts — run via `npx tsx`
import fs from 'fs';

let passed = 0;
let failed = 0;

function report(description: string, ok: boolean, detail?: string): void {
    if (ok) {
        passed += 1;
        console.log(`PASS: ${description}`);
    } else {
        failed += 1;
        console.log(`FAIL: ${description}${detail ? ` -- ${detail}` : ''}`);
    }
}

async function expectThrows(description: string, run: () => unknown): Promise<void> {
    try {
        await run();
        report(description, false, 'did not throw/reject');
    } catch (err) {
        report(description, err instanceof Error, `threw non-Error: ${String(err)}`);
    }
}

async function main(): Promise<void> {
    // Mocks process.platform/fs.readlinkSync BEFORE importing env-guard.ts, so its module-load-time
    // native-reference capture (nativeReadlinkSync = fs.readlinkSync) actually observes them — the
    // same reason the Jest suite mocks via jest.isolateModules() + require() called after the spy.
    const realPlatform = process.platform;
    const realReadlinkSync = fs.readlinkSync;
    Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
    // @ts-expect-error -- narrowing fs.readlinkSync's overloaded signature isn't worth it for a throwaway repro
    fs.readlinkSync = (linkPath: string) =>
        linkPath === '/proc/self/fd/99' ? '/proc/self/environ' : realReadlinkSync(linkPath);
    const { buildScopedEnv, forceResetEnv, runWithScopedEnv } = await import('./env-guard');
    Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
    fs.readlinkSync = realReadlinkSync;

    // 1. A non-allowlisted key is undefined inside a scope, not the dev server's real value.
    process.env.AWS_SECRET_KEY = 'dev-server-real-secret';
    const seenValue = await runWithScopedEnv(buildScopedEnv({}), async () => process.env.AWS_SECRET_KEY);
    report(
        'process.env.AWS_SECRET_KEY (not in SAFE_ENV_KEYS) is undefined inside a scope',
        seenValue === undefined,
        `saw ${JSON.stringify(seenValue)}`,
    );
    delete process.env.AWS_SECRET_KEY;

    // 2. /proc/self/environ reads are blocked, via a plain path and via fs.promises.open (FileHandle).
    await runWithScopedEnv(buildScopedEnv({}), async () => {
        await expectThrows('fs.createReadStream("/proc/self/environ") throws inside a scope', () =>
            fs.createReadStream('/proc/self/environ'),
        );
        await expectThrows(
            'fs.promises.open("/proc/self/environ") (FileHandle) rejects inside a scope',
            () => fs.promises.open('/proc/self/environ', 'r'),
        );
    });

    // 3. fd-based bypass: unrelatedPath with { fd } resolving to /proc/.../environ, re-mocked here
    // (still Linux-only) so it's active for this call specifically, not left on globally.
    Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
    // @ts-expect-error -- narrowing fs.readlinkSync's overloaded signature isn't worth it for a throwaway repro
    fs.readlinkSync = (linkPath: string) =>
        linkPath === '/proc/self/fd/99' ? '/proc/self/environ' : realReadlinkSync(linkPath);
    try {
        await runWithScopedEnv(buildScopedEnv({}), async () => {
            await expectThrows(
                'fs.createReadStream(unrelatedPath, { fd }) throws when fd resolves to /proc/self/environ',
                () => fs.createReadStream('/some/unrelated/path', { fd: 99 }),
            );
        });
    } finally {
        Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
        fs.readlinkSync = realReadlinkSync;
    }

    // 4. process.report.excludeEnv = false from inside a scope throws.
    await runWithScopedEnv(buildScopedEnv({}), async () => {
        await expectThrows('process.report.excludeEnv = false throws inside a scope', () => {
            process.report.excludeEnv = false;
        });
    });

    forceResetEnv();

    console.log(`\n${passed} passed, ${failed} failed`);
    if (failed > 0) {
        process.exitCode = 1;
    }
}

main();
npx tsx packages/plugins/apps/src/vite/manual-qa-repro.ts
# PASS: process.env.AWS_SECRET_KEY (not in SAFE_ENV_KEYS) is undefined inside a scope
# PASS: fs.createReadStream("/proc/self/environ") throws inside a scope
# PASS: fs.promises.open("/proc/self/environ") (FileHandle) rejects inside a scope
# PASS: fs.createReadStream(unrelatedPath, { fd }) throws when fd resolves to /proc/self/environ
# PASS: process.report.excludeEnv = false throws inside a scope
#
# 5 passed, 0 failed ✅ VERIFIED

The 5th outcome (a built backend function bundle no longer inlines a build-machine VITE_* value or .env file content) is covered exactly by a dedicated automated test, added in this PR:

yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/build-config.test.ts
# ✓ bundles a backend function that imports a real Node builtin module with a working import, not a browser-external stub
# ✓ Should not inline a VITE_-prefixed real process.env value into the built backend function
#
# Test Suites: 1 passed, 1 total
# Tests:       2 passed, 2 total ✅ VERIFIED

Manual QA — a zombie execution (a backend function whose fn() never settles) no longer leaves process.report.excludeEnv armed forever:

yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/local-execution.test.ts -t "Should restore process.report.excludeEnv"
# ✓ Should restore process.report.excludeEnv to its pre-scope value after a zombie execution is abandoned, not leave it armed forever
#
# Test Suites: 1 passed, 1 total
# Tests:       1 passed, 1 total ✅ VERIFIED

Manual QA — the three hardening fixes from review, each covered by a dedicated automated test:

yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/env-guard.test.ts
# ✓ Should keep blocking a forged path even when a backend function replaces fs.realpathSync/readlinkSync from inside its own scope
# ✓ Should restore the correct intermediate value when process.env is captured, swapped, and restored twice, nested
# ✓ Should never read the report file back off disk for an explicit filename, proving the redacted content is written directly rather than read-back-and-rewritten
#
# Test Suites: 1 passed, 1 total
# Tests:       68 passed, 68 total ✅ VERIFIED

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:

yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/env-guard.test.ts -t "fs-keyed shared registry"
# ✓ Should expose only functions on the fs-keyed shared registry, never a raw realEnv/AsyncLocalStorage/counter field
# ✓ Should return the scoped view, not the real environment, from the registry's own accessor when called from inside an active scope
# ✓ Should not let a forged token disarm an active scope's excludeEnv protection via the registry's own disarmScope
#
# Tests:       3 passed, 3 total ✅ VERIFIED

yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/network-guard.test.ts -t "disable"
# ✓ Should not let a `.disable()` call reached via the fs-keyed registry entry disarm network blocking for a later runBlocked call
#
# Tests:       1 passed, 1 total ✅ VERIFIED

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:

yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/env-guard.test.ts -t "abandon"
# ✓ Should keep redacting a still-active scope's own getReport() call after an unrelated scope's abandonment clears the shared counter
# ✓ Should let onScopeStarted's handle abandon only its own scope, leaving a concurrently active scope's excludeEnv protection armed
#
# Tests:       3 passed, 3 total ✅ VERIFIED

Manual QA — the FileHandle .fd shadow fix. The automated tests are Linux-only (/proc/self/environ doesn'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, transpiled env-guard.ts module on the repo's pinned Node version:

yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/env-guard.test.ts -t "FileHandle.prototype.read guard"
# ✓ Should block handle.read() when the handle is already open against /proc/self/environ
# ✓ Should not block handle.read() for an unrelated real file during an active scoped-env window
# ✓ Should block handle.read() using the handle's real fd, even when an own property shadows it with a harmless value
# ✓ Should block handle.read() when the handle's own fd is shadowed to a different, dangerous fd, even though its real target is harmless
# ✓ Should read only the fd the check validated, not a stateful getter's later, different answer, even though the getter is never consulted again once pinned
#
# Tests:       5 passed, 5 total (on Linux; no-op on macOS) ✅ VERIFIED
Docker verification against the real, transpiled module on real Linux (all four shadow/TOCTOU scenarios)
node_modules/.bin/esbuild packages/plugins/apps/src/vite/env-guard.ts --bundle --platform=node --format=cjs \
  --outfile=/tmp/env-guard.bundle.cjs --external:fs --external:node:async_hooks --external:node:module --external:path --external:url

cat > /tmp/real_module_full_check.js << 'SCRIPT'
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { runWithScopedEnv, forceResetEnv } = require('/repro/env-guard.bundle.cjs');

async function withTmpFile(content, fn) {
    const tmpFile = path.join(os.tmpdir(), `env-guard-full-check-${process.pid}-${Math.random()}.txt`);
    fs.writeFileSync(tmpFile, content);
    try {
        return await fn(tmpFile);
    } finally {
        fs.rmSync(tmpFile, { force: true });
    }
}

async function main() {
    // 1. Original threat: dangerous real handle, harmless static shadow -> must block.
    {
        const handle = await fs.promises.open('/proc/self/environ', 'r');
        const realFd = handle.fd;
        try {
            Object.defineProperty(handle, 'fd', { value: 999999, configurable: true });
            await runWithScopedEnv({ PATH: '/scoped' }, async () => {
                await assert.rejects(() => handle.read(Buffer.alloc(10), 0, 10, 0), /not allowed in backend functions/);
            });
        } finally {
            Object.defineProperty(handle, 'fd', { value: realFd, configurable: true });
            await handle.close();
        }
    }
    console.log('PASS 1: harmless static shadow over dangerous real handle -> blocked');

    // 2. Inverse direction: benign real handle, static shadow pointing at dangerous fd -> must block.
    await withTmpFile('not a secret', async (tmpFile) => {
        const dangerousHandle = await fs.promises.open('/proc/self/environ', 'r');
        const dangerousFd = dangerousHandle.fd;
        const handle = await fs.promises.open(tmpFile, 'r');
        try {
            Object.defineProperty(handle, 'fd', { value: dangerousFd, configurable: true });
            await runWithScopedEnv({ PATH: '/scoped' }, async () => {
                await assert.rejects(() => handle.read(Buffer.alloc(10), 0, 10, 0), /not allowed in backend functions/);
            });
        } finally {
            delete handle.fd;
            await handle.close();
            await dangerousHandle.close();
        }
    });
    console.log('PASS 2: static dangerous shadow over benign real handle -> blocked');

    // 3. Stateful getter (the fix under test): true fd on read #1, dangerous fd afterward -> must
    // read the pinned, validated (benign) content, never leak, getter invoked exactly once.
    await withTmpFile('not a secret', async (tmpFile) => {
        const dangerousHandle = await fs.promises.open('/proc/self/environ', 'r');
        const dangerousFd = dangerousHandle.fd;
        const handle = await fs.promises.open(tmpFile, 'r');
        const benignFd = handle.fd;
        let callCount = 0;
        try {
            Object.defineProperty(handle, 'fd', {
                configurable: true,
                get() {
                    callCount += 1;
                    return callCount === 1 ? benignFd : dangerousFd;
                },
            });
            const result = await runWithScopedEnv({ PATH: '/scoped' }, async () => handle.readFile('utf8'));
            assert.strictEqual(result, 'not a secret');
            assert.strictEqual(callCount, 1);
        } finally {
            delete handle.fd;
            await handle.close();
            await dangerousHandle.close();
        }
    });
    console.log('PASS 3: stateful getter cannot steer the real read -> safe content, getter called once');

    // 4. Sanity: untampered handle reads normally, unaffected by any of this.
    await withTmpFile('hello world', async (tmpFile) => {
        const handle = await fs.promises.open(tmpFile, 'r');
        try {
            const result = await runWithScopedEnv({ PATH: '/scoped' }, async () => handle.readFile('utf8'));
            assert.strictEqual(result, 'hello world');
        } finally {
            await handle.close();
        }
    });
    console.log('PASS 4: untampered handle still reads normally inside a scope');

    forceResetEnv();
    console.log('ALL PASS');
}

main().catch((err) => {
    console.error('FAIL:', err);
    process.exit(1);
});
SCRIPT

docker run --rm -v /tmp:/repro node:20.19.4-bullseye node /repro/real_module_full_check.js
# PASS 1: harmless static shadow over dangerous real handle -> blocked
# PASS 2: static dangerous shadow over benign real handle -> blocked
# PASS 3: stateful getter cannot steer the real read -> safe content, getter called once
# PASS 4: untampered handle still reads normally inside a scope
# ALL PASS ✅ VERIFIED

Blast Radius

  • Scoped to local execution's runBlocked continuation for the process.env/process.report.excludeEnv//proc/.../environ guarding — no change to production runtime execution (which already runs in its own Deno subprocess).
  • envFile:false/envPrefix:[] in getBaseBackendBuildConfig are shared with the production backend-bundle build path (build-backend-functions.ts), so uploaded production bundles also stop inlining build-machine VITE_* values and .env files 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.
  • Risk: low. This is JS-level defense-in-depth for the dev server's local-execution path, not a hard security boundary.

Out of Scope / Follow-ups

2 items deferred
Item Status Next step
The shared registries on fs/net are 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. Deferred — accepted as JS-level defense-in-depth, matching this guard's existing threat model Tracked as a follow-up under APPS-2792 if the team decides process isolation for local execution is worth pursuing
createReadStream/readableWebStream/readLines construct and return synchronously, but the actual reads happen lazily as the caller consumes the returned stream/iterator — well after the guarded wrapper (and any fd pin) has already returned. A stateful shadow getter on the handle can still steer those lazy reads, unlike read/readFile/readv, which withPinnedFd covers for their full async duration. Deferred — closing this needs pinning fd for the stream/iterator's whole lifetime, not just construction, a materially bigger design than this PR's scope Needs a sign-off decision on whether to pursue before a follow-up PR

Documentation

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from bbf9238 to 32ba868 Compare September 4, 2026 19:08
@datadog-prod-us1-6

datadog-prod-us1-6 Bot commented Sep 4, 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: c694dcf | Docs | View more details | Give us feedback!

@tyffical
tyffical requested a balanced review from Copilot September 4, 2026 19:33
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 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-09T05:40:33.173257Z 2376475 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[bot]

This comment was marked as resolved.

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 32ba868 to a0bd2df Compare September 4, 2026 19:58
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 3a6e6dc to dc63b88 Compare September 4, 2026 21:02
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from dc63b88 to 841961c Compare September 4, 2026 22:02
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 841961c to 964fe0d Compare September 4, 2026 22:10
@tyffical
tyffical requested a balanced review from Copilot September 5, 2026 03:56
chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 44bd4fc to 196ba96 Compare September 8, 2026 19:26
@tyffical
tyffical requested a balanced review from Copilot September 9, 2026 01:21

This comment was marked as resolved.

@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: 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".

Comment thread packages/plugins/apps/src/vite/env-guard.ts
Comment on lines +627 to +629
applyExcludeEnvValue = (newValue) => {
excludeEnvValue = newValue;
};

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

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

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch 4 times, most recently from 1b3182c to fcedb11 Compare September 9, 2026 04:03
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch 2 times, most recently from 7ea5b29 to 61176af Compare September 9, 2026 19:09
@tyffical
tyffical marked this pull request as ready for review September 9, 2026 20:53
@tyffical
tyffical requested review from a team as code owners September 9, 2026 20:53
@tyffical
tyffical requested review from oliverli and removed request for a team September 9, 2026 20:53

@oliverli oliverli left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

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 Outdated
Comment thread packages/plugins/apps/src/vite/env-guard.ts
Comment thread packages/plugins/apps/src/vite/env-guard.ts
Comment thread packages/plugins/apps/src/vite/env-guard.ts Outdated
tyffical and others added 6 commits September 10, 2026 22:03
…(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.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 0069c29 to ac4110e Compare September 11, 2026 02:04
…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.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 8b9245a to 5bf6365 Compare September 11, 2026 07:10
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.
@tyffical
tyffical requested a review from oliverli September 11, 2026 08:32
@tyffical

tyffical commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

/merge

🤖 Posted by Claude Code

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Sep 11, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-09-11 14:32:46 UTC ℹ️ Start processing command `/merge

Posted by Claude Code`


2026-09-11 14:32:51 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in master is approximately 2m (p90).


2026-09-11 14:34:25 UTC ℹ️ MergeQueue: This merge request was merged

@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit 645fb5c into master Sep 11, 2026
7 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the tiffany.trinh/apps-2792-secret-store-parity branch September 11, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants