Skip to content

Commit e7223f7

Browse files
committed
feat(apps): scope process.env to a from-scratch allowlist during local execution
Milestone 8 (Secret Store parity). Without this, a customer's backend function reads the dev server's own real process.env with no restriction at all — leaking the developer's entire shell (AWS credentials, other API keys, the dev server's own DD_API_KEY/DD_APP_KEY) into arbitrary, possibly third-party code. Production has no equivalent gap: each execution gets a fresh Deno subprocess with --allow-env scoped to exactly the resolved credential names; in-process local execution has no process boundary to rely on, so this closes the gap at the module level instead, mirroring network-guard.ts's monkey-patch approach and reusing the same shared generation-counter protection against the same abandoned-execution race class. Also blocks reads of /proc/self/environ (and /proc/<pid>/environ) while a scoped-env window is active: swapping the JS-level process.env object alone isn't a real boundary on Linux, since that kernel-backed file exposes the process's real startup environment unaffected by reassigning process.env. Custom Credentials resolution (e.g. STRIPE_API_KEY) remains an open, undecided question — buildScopedEnv's customCredentials parameter is a forward-looking extension point, always {} for now.
1 parent 4fd59b3 commit e7223f7

4 files changed

Lines changed: 612 additions & 18 deletions

File tree

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import fs from 'fs';
6+
import os from 'os';
7+
import path from 'path';
8+
9+
import { buildScopedEnv, forceResetEnv, runWithScopedEnv } from './env-guard';
10+
11+
/**
12+
* Hard backstop, independent of whatever a given test's own assertions do:
13+
* `process.env` is a real, process-wide singleton, not per-test-file
14+
* sandboxed state — a test that leaves it swapped (e.g. a bug in one of
15+
* these tests that skips its own restore) would otherwise leak into every
16+
* test that runs afterward in the same Jest worker, including completely
17+
* unrelated test files elsewhere in the suite.
18+
*/
19+
afterEach(() => {
20+
forceResetEnv();
21+
});
22+
23+
describe('env-guard', () => {
24+
describe('buildScopedEnv', () => {
25+
const originalEnv = process.env;
26+
27+
afterEach(() => {
28+
process.env = originalEnv;
29+
});
30+
31+
test('Should include only the safe allowlisted keys from the real environment, dropping everything else', () => {
32+
process.env = {
33+
PATH: '/usr/bin',
34+
HOME: '/home/dev',
35+
NODE_ENV: 'development',
36+
TMPDIR: '/tmp',
37+
AWS_SECRET_ACCESS_KEY: 'super-secret-aws-key',
38+
DD_API_KEY: 'the-dev-servers-own-api-key',
39+
SOME_RANDOM_SHELL_VAR: 'whatever',
40+
};
41+
42+
const scoped = buildScopedEnv({});
43+
44+
expect(scoped).toEqual({
45+
PATH: '/usr/bin',
46+
HOME: '/home/dev',
47+
NODE_ENV: 'development',
48+
TMPDIR: '/tmp',
49+
});
50+
});
51+
52+
test('Should merge in the provided Custom Credentials under their own names', () => {
53+
process.env = { PATH: '/usr/bin' };
54+
55+
const scoped = buildScopedEnv({ STRIPE_API_KEY: 'sk_test_123' });
56+
57+
expect(scoped).toEqual({ PATH: '/usr/bin', STRIPE_API_KEY: 'sk_test_123' });
58+
});
59+
60+
test('Should omit an allowlisted key entirely when unset in the real environment, rather than including it as undefined', () => {
61+
process.env = { PATH: '/usr/bin' };
62+
63+
const scoped = buildScopedEnv({});
64+
65+
expect('HOME' in scoped).toBe(false);
66+
expect('NODE_ENV' in scoped).toBe(false);
67+
expect('TMPDIR' in scoped).toBe(false);
68+
});
69+
});
70+
71+
describe('runWithScopedEnv', () => {
72+
test('Should expose only the scoped env to fn, not the real process.env', async () => {
73+
const scoped = { PATH: '/usr/bin', STRIPE_API_KEY: 'sk_test_123' };
74+
75+
const seenKeys = await runWithScopedEnv(scoped, async () => Object.keys(process.env));
76+
77+
expect(seenKeys.sort()).toEqual(['PATH', 'STRIPE_API_KEY']);
78+
});
79+
80+
test("Should never expose the real DD_API_KEY/DATADOG_API_KEY (the dev server's own credential) to fn", async () => {
81+
const originalEnv = process.env;
82+
process.env = { ...originalEnv, DD_API_KEY: 'the-dev-servers-own-api-key' };
83+
84+
try {
85+
const seenApiKey = await runWithScopedEnv(
86+
{ PATH: '/usr/bin' },
87+
async () => process.env.DD_API_KEY,
88+
);
89+
expect(seenApiKey).toBeUndefined();
90+
} finally {
91+
process.env = originalEnv;
92+
}
93+
});
94+
95+
test('Should restore the real process.env after fn resolves', async () => {
96+
const realEnv = process.env;
97+
await runWithScopedEnv({ PATH: '/usr/bin' }, async () => undefined);
98+
expect(process.env).toBe(realEnv);
99+
});
100+
101+
test('Should restore the real process.env even when fn throws', async () => {
102+
const realEnv = process.env;
103+
await expect(
104+
runWithScopedEnv({ PATH: '/usr/bin' }, async () => {
105+
throw new Error('customer function boom');
106+
}),
107+
).rejects.toThrow('customer function boom');
108+
expect(process.env).toBe(realEnv);
109+
});
110+
111+
// Mirrors network-guard.ts's own abandon-not-cancel protection:
112+
// local-execution.ts's executions are abandoned rather than
113+
// cancelled on timeout, so an abandoned runWithScopedEnv call can
114+
// settle after a newer execution has started its own scoped-env
115+
// window. That late settlement must not restore the real env out
116+
// from under the newer, still-active window.
117+
test("Should not let an abandoned runWithScopedEnv call's late restore corrupt a newer, currently-active scoped window", async () => {
118+
const realEnv = process.env;
119+
120+
let resolveAbandoned: (() => void) | undefined;
121+
const abandoned = runWithScopedEnv(
122+
{ PATH: '/abandoned' },
123+
() =>
124+
new Promise<void>((resolve) => {
125+
resolveAbandoned = resolve;
126+
}),
127+
);
128+
129+
// Simulates the timeout handler abandoning this execution —
130+
// the real env is restored and the guard's generation moves on,
131+
// exactly like local-execution.ts's timer callback.
132+
forceResetEnv();
133+
expect(process.env).toBe(realEnv);
134+
135+
// A second, newer execution starts its own scoped-env window.
136+
let resolveCurrent: (() => void) | undefined;
137+
const current = runWithScopedEnv(
138+
{ PATH: '/current' },
139+
() =>
140+
new Promise<void>((resolve) => {
141+
resolveCurrent = resolve;
142+
}),
143+
);
144+
expect(process.env.PATH).toBe('/current');
145+
146+
// The abandoned execution's fn() finally settles, well after
147+
// being abandoned — its own finally block must not restore the
148+
// real env out from under the still-running newer window.
149+
resolveAbandoned?.();
150+
await abandoned;
151+
expect(process.env.PATH).toBe('/current');
152+
153+
resolveCurrent?.();
154+
await current;
155+
expect(process.env).toBe(realEnv);
156+
});
157+
158+
// Mirrors network-guard.ts's own savedX-consumed-not-just-restored
159+
// invariant: without clearing savedEnv after consuming it, a later
160+
// forceResetEnv() call made while the guard is idle would reinstall
161+
// whatever the *previous* cycle saved, clobbering a real env change
162+
// made in between.
163+
test('Should not let a later, idle forceResetEnv() reinstall a stale snapshot over a real env change made since', async () => {
164+
const realEnv = process.env;
165+
166+
await runWithScopedEnv({ PATH: '/scoped' }, async () => undefined);
167+
expect(process.env).toBe(realEnv);
168+
169+
// A real, legitimate change to process.env after the guard's
170+
// own window already closed — nothing to do with this guard.
171+
process.env = { ...realEnv, SOME_NEW_VAR: 'set-after-guard-closed' };
172+
const updatedRealEnv = process.env;
173+
174+
// Guard is idle (nothing currently scoped) — must be a true
175+
// no-op, not a reinstall of the snapshot from the call above.
176+
forceResetEnv();
177+
178+
expect(process.env).toBe(updatedRealEnv);
179+
process.env = realEnv;
180+
});
181+
});
182+
183+
// Regression coverage for the /proc/.../environ backing-store bypass:
184+
// swapping process.env alone doesn't stop customer code from reading the
185+
// kernel-backed environ file directly on Linux to recover the dev
186+
// server's real, unscoped environment.
187+
describe('environ-file guard', () => {
188+
test('Should block fs.readFileSync("/proc/self/environ") during an active scoped-env window', async () => {
189+
await runWithScopedEnv({ PATH: '/scoped' }, async () => {
190+
expect(() => fs.readFileSync('/proc/self/environ')).toThrow(
191+
/not allowed in backend functions/,
192+
);
193+
});
194+
});
195+
196+
test(`Should block fs.readFileSync("/proc/${process.pid}/environ") during an active scoped-env window`, async () => {
197+
await runWithScopedEnv({ PATH: '/scoped' }, async () => {
198+
expect(() => fs.readFileSync(`/proc/${process.pid}/environ`)).toThrow(
199+
/not allowed in backend functions/,
200+
);
201+
});
202+
});
203+
204+
test('Should block fs.promises.readFile("/proc/self/environ") during an active scoped-env window', async () => {
205+
await runWithScopedEnv({ PATH: '/scoped' }, async () => {
206+
await expect(fs.promises.readFile('/proc/self/environ')).rejects.toThrow(
207+
/not allowed in backend functions/,
208+
);
209+
});
210+
});
211+
212+
test('Should block the callback-style fs.readFile("/proc/self/environ") during an active scoped-env window', async () => {
213+
await runWithScopedEnv({ PATH: '/scoped' }, async () => {
214+
expect(() => fs.readFile('/proc/self/environ', () => {})).toThrow(
215+
/not allowed in backend functions/,
216+
);
217+
});
218+
});
219+
220+
test('Should not block reading /proc/self/environ once the scoped-env window has closed', async () => {
221+
await runWithScopedEnv({ PATH: '/scoped' }, async () => undefined);
222+
223+
// Off macOS/CI, /proc doesn't exist at all — the assertion here
224+
// is just that our own guard doesn't fire once idle; a real
225+
// ENOENT for a nonexistent path is an unrelated, expected error.
226+
expect(() => fs.readFileSync('/proc/self/environ')).not.toThrow(
227+
/not allowed in backend functions/,
228+
);
229+
});
230+
231+
test('Should not block reading an unrelated real file during an active scoped-env window', async () => {
232+
const tmpFile = path.join(os.tmpdir(), `env-guard-test-${process.pid}.txt`);
233+
fs.writeFileSync(tmpFile, 'not a secret');
234+
235+
try {
236+
await runWithScopedEnv({ PATH: '/scoped' }, async () => {
237+
expect(fs.readFileSync(tmpFile, 'utf8')).toBe('not a secret');
238+
await expect(fs.promises.readFile(tmpFile, 'utf8')).resolves.toBe(
239+
'not a secret',
240+
);
241+
});
242+
} finally {
243+
fs.rmSync(tmpFile);
244+
}
245+
});
246+
});
247+
});

0 commit comments

Comments
 (0)