diff --git a/packages/sandbox-server/src/__tests__/executor.test.ts b/packages/sandbox-server/src/__tests__/executor.test.ts index 48d49f3..44346d5 100644 --- a/packages/sandbox-server/src/__tests__/executor.test.ts +++ b/packages/sandbox-server/src/__tests__/executor.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { Executor, type ExecutionResult, type ExecutorConfig, type TestExecutionResult } from '../server/executor.js'; describe('Executor', () => { @@ -436,14 +436,14 @@ describe('Executor', () => { expect(result.output).toBe('object'); }); - it('can read environment variables', async () => { + it('cannot read host environment variables', async () => { const result = await executor.execute(` - // PATH should always be defined + // PATH should not be accessible in the sandbox console.log(typeof process.env.PATH); `); expect(result.success).toBe(true); - expect(result.output).toBe('string'); + expect(result.output).toBe('undefined'); }); }); @@ -663,6 +663,303 @@ describe('Executor', () => { }); }); + // ============================================================================ + // Environment Variable Leak Prevention Tests + // ============================================================================ + + describe('Environment Variable Leak Prevention', () => { + // ---- Layer 1: Sandbox process.env restriction ---- + + describe('Sandbox process.env restriction', () => { + it('exposes empty process.env to sandbox code', async () => { + const result = await executor.execute(` + const keys = Object.keys(process.env); + console.log(keys.length); + `); + expect(result.success).toBe(true); + expect(result.output).toBe('0'); + }); + + it('prevents sandbox code from adding to process.env', async () => { + const result = await executor.execute(` + try { + process.env.INJECTED = 'malicious'; + } catch(e) { + // strict mode or frozen — either way is fine + } + console.log(process.env.INJECTED === undefined ? 'blocked' : 'leaked'); + `); + expect(result.success).toBe(true); + expect(result.output).toBe('blocked'); + }); + + it('returns undefined for all env var reads', async () => { + const result = await executor.execute(` + const vars = [ + process.env.PATH, + process.env.HOME, + process.env.K8S_SERVICE_ACCOUNT_TOKEN, + process.env.SECRET_KEY, + process.env.NODE_ENV + ]; + console.log(vars.every(v => v === undefined)); + `); + expect(result.success).toBe(true); + expect(result.output).toBe('true'); + }); + + it('prevents env dump via JSON.stringify', async () => { + const result = await executor.execute(` + const dump = JSON.stringify(process.env); + console.log(dump); + `); + expect(result.success).toBe(true); + expect(result.output).toBe('{}'); + }); + + it('prevents env enumeration via Object methods', async () => { + const result = await executor.execute(` + console.log(Object.keys(process.env).length); + console.log(Object.values(process.env).length); + console.log(Object.entries(process.env).length); + `); + expect(result.success).toBe(true); + expect(result.output).toBe('0\n0\n0'); + }); + + it('prevents env enumeration via for...in', async () => { + const result = await executor.execute(` + const keys: string[] = []; + for (const key in process.env) { + keys.push(key); + } + console.log(keys.length); + `); + expect(result.success).toBe(true); + expect(result.output).toBe('0'); + }); + + it('allowed modules load successfully with restricted env', async () => { + const result = await executor.execute(` + const ss = require('simple-statistics'); + console.log(typeof ss.mean); + `); + expect(result.success).toBe(true); + expect(result.output).toBe('function'); + }); + + it('process.stdout.write still works with restricted env', async () => { + const result = await executor.execute(` + process.stdout.write('hello via stdout'); + `); + expect(result.success).toBe(true); + expect(result.output).toBe('hello via stdout'); + }); + }); + + // ---- Layer 2: Output leak filter (defense-in-depth) ---- + + describe('Output leak filter (defense-in-depth)', () => { + const TEST_SECRET = 'xK9mP2vL8qR4wN7j'; + let originalTestSecret: string | undefined; + + beforeEach(() => { + originalTestSecret = process.env.TEST_SECRET_KEY; + process.env.TEST_SECRET_KEY = TEST_SECRET; + // Recreate executor so it rebuilds sensitiveEnvValues + executor = new Executor(); + }); + + afterEach(() => { + if (originalTestSecret === undefined) { + delete process.env.TEST_SECRET_KEY; + } else { + process.env.TEST_SECRET_KEY = originalTestSecret; + } + }); + + it('blocks output containing a sensitive env value', async () => { + const result = await executor.execute(` + console.log("${TEST_SECRET}"); + `); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.output).toBe(''); + }); + + it('blocks output with sensitive value embedded in larger text', async () => { + const result = await executor.execute(` + console.log("The token is: ${TEST_SECRET} and more text"); + `); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.output).toBe(''); + }); + + it('blocks sensitive values leaked through error messages', async () => { + const result = await executor.execute(` + throw new Error("${TEST_SECRET}"); + `); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.error).not.toContain(TEST_SECRET); + expect(result.output).toBe(''); + }); + + it('blocks sensitive values via process.stdout.write', async () => { + const result = await executor.execute(` + process.stdout.write("${TEST_SECRET}"); + `); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.output).toBe(''); + }); + + it('blocks sensitive values via process.stderr.write', async () => { + const result = await executor.execute(` + process.stderr.write("${TEST_SECRET}"); + `); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.output).toBe(''); + }); + + it('blocks sensitive values via console.error', async () => { + const result = await executor.execute(` + console.error("${TEST_SECRET}"); + `); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.output).toBe(''); + }); + + it('blocks sensitive values via console.warn', async () => { + const result = await executor.execute(` + console.warn("${TEST_SECRET}"); + `); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.output).toBe(''); + }); + + it('does not block short env var values (less than 8 chars)', async () => { + process.env.TEST_SHORT_VAR = 'abc'; + const shortExecutor = new Executor(); + const result = await shortExecutor.execute('console.log("abc")'); + delete process.env.TEST_SHORT_VAR; + + expect(result.success).toBe(true); + expect(result.output).toBe('abc'); + }); + + it('does not block values from non-sensitive env vars', async () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development_extended_value'; + const envExecutor = new Executor(); + const result = await envExecutor.execute('console.log("development_extended_value")'); + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + + expect(result.success).toBe(true); + expect(result.output).toBe('development_extended_value'); + }); + + it('allows normal output that does not contain sensitive values', async () => { + const result = await executor.execute(` + console.log("hello world"); + console.log("line 2"); + console.log("line 3"); + `); + expect(result.success).toBe(true); + expect(result.output).toBe('hello world\nline 2\nline 3'); + }); + + it('suppresses all output after leak is detected', async () => { + const result = await executor.execute(` + console.log("line 1 safe"); + console.log("line 2 safe"); + console.log("${TEST_SECRET}"); + console.log("line 4 after leak"); + console.log("line 5 after leak"); + `); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.output).toBe(''); + }); + + it('blocks leaked values in streaming mode', async () => { + const chunks: string[] = []; + const result = await executor.executeStreaming({ + code: `console.log("${TEST_SECRET}");`, + onOutput: (output) => { chunks.push(output); }, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.output).toBe(''); + // The leaked chunk should NOT have been sent + expect(chunks.some(c => c.includes(TEST_SECRET))).toBe(false); + }); + + it('sends safe chunks before leak but stops after detection in streaming mode', async () => { + const chunks: string[] = []; + const result = await executor.executeStreaming({ + code: ` + console.log("safe line 1"); + console.log("safe line 2"); + console.log("${TEST_SECRET}"); + console.log("should not appear"); + `, + onOutput: (output) => { chunks.push(output); }, + }); + expect(result.success).toBe(false); + // Safe lines may have been streamed before detection + expect(chunks.some(c => c.includes(TEST_SECRET))).toBe(false); + expect(chunks.some(c => c.includes('should not appear'))).toBe(false); + }); + + it('blocks leaked values in test execution mode', async () => { + const result = await executor.executeTest({ + tests: ` + test('leaks secret', () => { + console.log("${TEST_SECRET}"); + assert.ok(true); + }); + `, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + }); + + it('blocks output matching any sensitive env var value', async () => { + process.env.TEST_ANOTHER_SECRET = 'aB3cD4eF5gH6iJ7k'; + const multiExecutor = new Executor(); + const result = await multiExecutor.execute('console.log("aB3cD4eF5gH6iJ7k")'); + delete process.env.TEST_ANOTHER_SECRET; + + expect(result.success).toBe(false); + expect(result.error).toContain('sensitive environment variable'); + expect(result.output).toBe(''); + }); + + it('does not block values from env vars with non-sensitive prefixes', async () => { + const original = process.env.npm_config_registry; + process.env.npm_config_registry = 'https://registry.npmjs.org'; + const npmExecutor = new Executor(); + const result = await npmExecutor.execute('console.log("https://registry.npmjs.org")'); + if (original === undefined) { + delete process.env.npm_config_registry; + } else { + process.env.npm_config_registry = original; + } + + expect(result.success).toBe(true); + }); + }); + }); + // ============================================================================ // Analytics Libraries Tests // ============================================================================ diff --git a/packages/sandbox-server/src/server/executor.ts b/packages/sandbox-server/src/server/executor.ts index fa8e449..e0421ef 100644 --- a/packages/sandbox-server/src/server/executor.ts +++ b/packages/sandbox-server/src/server/executor.ts @@ -9,6 +9,24 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; const MAX_OUTPUT_LINES = 5000; const MAX_OUTPUT_CHARS = 500000; // 500KB +// Environment variable leak detection (defense-in-depth) +const ENV_LEAK_ERROR = 'Execution blocked: output contains sensitive environment variable values'; +const MIN_SENSITIVE_VALUE_LENGTH = 8; + +const NON_SENSITIVE_ENV_NAMES = new Set([ + 'PATH', 'HOME', 'SHELL', 'LANG', 'LANGUAGE', 'TERM', 'USER', 'LOGNAME', + 'HOSTNAME', 'PWD', 'OLDPWD', 'TMPDIR', 'TMP', 'TEMP', 'EDITOR', 'VISUAL', + 'PAGER', 'COLORTERM', 'TERM_PROGRAM', 'DISPLAY', 'SHLVL', 'MANPATH', + 'NODE_ENV', 'NODE_PATH', 'NODE_OPTIONS', 'NODE_VERSION', + 'CI', 'GITHUB_ACTIONS', 'GITHUB_REPOSITORY', 'GITHUB_REF', 'GITHUB_SHA', + 'GITHUB_WORKSPACE', 'GITHUB_EVENT_NAME', 'GITHUB_RUN_ID', 'GITHUB_RUN_NUMBER', + 'SANDBOX_ALLOWED_MODULES', 'SANDBOX_MODULES_BASE_PATH', 'SANDBOX_TRANSPORT_MODE', + 'SANDBOX_USE_TCP', 'SANDBOX_TCP_HOST', 'SANDBOX_TCP_PORT', 'SANDBOX_SOCKET_PATH', + 'SANDBOX_CONFIG_PATH', 'PRODISCO_CONFIG_PATH', 'PROMETHEUS_URL', +]); + +const NON_SENSITIVE_ENV_PREFIXES = ['npm_', 'NVM_', 'XDG_', 'LC_', 'LESS', 'LS_']; + /** * Tracks output size and truncation state during execution. */ @@ -17,6 +35,7 @@ interface OutputTracker { charCount: number; truncated: boolean; truncatedAt?: { lines: number; chars: number }; + envLeakDetected: boolean; } export interface ExecutionResult { @@ -417,6 +436,7 @@ export class Executor { private prometheusUrl?: string; private moduleCache = new Map(); private preloadPromise: Promise | null = null; + private sensitiveEnvValues: Set; constructor(config: ExecutorConfig = {}) { this.allowedModules = config.allowedModules ? new Set(config.allowedModules) : parseAllowedModulesFromEnv(); @@ -429,6 +449,8 @@ export class Executor { this.requireFromBase = createRequire(path.join(this.basePath, 'index.js')); this.prometheusUrl = config.prometheusUrl || process.env.PROMETHEUS_URL; + this.sensitiveEnvValues = this.buildSensitiveValues(); + // Start preloading in the background so async APIs can return quickly. // Any errors are swallowed; a later require() will surface failures as needed. this.preloadPromise = this.preloadAllowedModules().catch(() => undefined); @@ -441,6 +463,39 @@ export class Executor { return 'unknown'; } + /** + * Build the set of sensitive env var values to check against output. + * Filters out short values and known non-sensitive variable names. + */ + private buildSensitiveValues(): Set { + const values = new Set(); + for (const [key, value] of Object.entries(process.env)) { + if (value === undefined || value.length < MIN_SENSITIVE_VALUE_LENGTH) { + continue; + } + if (NON_SENSITIVE_ENV_NAMES.has(key)) { + continue; + } + if (NON_SENSITIVE_ENV_PREFIXES.some(prefix => key.startsWith(prefix))) { + continue; + } + values.add(value); + } + return values; + } + + /** + * Check if text contains any sensitive env var value. + */ + private containsSensitiveEnvValue(text: string): boolean { + for (const value of this.sensitiveEnvValues) { + if (text.includes(value)) { + return true; + } + } + return false; + } + private async ensureAllowedModulesPreloaded(): Promise { if (!this.preloadPromise) { this.preloadPromise = this.preloadAllowedModules(); @@ -521,6 +576,15 @@ export class Executor { const makeLine = (args: unknown[]) => args.map(String).join(' '); const addLine = (line: string, isError: boolean) => { + // Check for env var leak (defense-in-depth) + if (tracker.envLeakDetected) { + return; + } + if (this.containsSensitiveEnvValue(line)) { + tracker.envLeakDetected = true; + return; + } + // Check if already truncated if (tracker.truncated) { return; @@ -579,7 +643,7 @@ export class Executor { const sandbox: Record = { console: consoleObj, require: (m: string) => this.safeRequire(m), - process: { env: process.env, stdout: stdoutStub, stderr: stderrStub }, + process: { env: Object.freeze({}), stdout: stdoutStub, stderr: stderrStub }, setTimeout, setInterval, clearTimeout, @@ -611,8 +675,19 @@ export class Executor { lines: [], charCount: 0, truncated: false, + envLeakDetected: false, }; + const envLeakResult = (): ExecutionResult => ({ + success: false, + output: '', + error: ENV_LEAK_ERROR, + executionTimeMs: Date.now() - startTime, + outputLineCount: 0, + outputCharCount: 0, + truncated: false, + }); + // Clamp timeout const timeout = Math.min(Math.max(timeoutMs, 1000), 120000); @@ -670,6 +745,11 @@ export class Executor { await Promise.race([resultPromise, timeoutPromise]); + // Check for env var leak in output + if (tracker.envLeakDetected) { + return envLeakResult(); + } + return { success: true, output: tracker.lines.join('\n'), @@ -681,10 +761,16 @@ export class Executor { }; } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + // Check for env var leak in output or error message + if (tracker.envLeakDetected || this.containsSensitiveEnvValue(errorMsg)) { + return envLeakResult(); + } + return { success: false, output: tracker.lines.join('\n'), - error: error instanceof Error ? error.message : String(error), + error: errorMsg, executionTimeMs: Date.now() - startTime, outputLineCount: tracker.lines.length, outputCharCount: tracker.charCount, @@ -705,8 +791,21 @@ export class Executor { lines: [], charCount: 0, truncated: false, + envLeakDetected: false, }; + const envLeakStreamingResult = (): StreamingExecutionResult => ({ + success: false, + output: '', + error: ENV_LEAK_ERROR, + executionTimeMs: Date.now() - startTime, + cancelled: false, + timedOut: false, + outputLineCount: 0, + outputCharCount: 0, + truncated: false, + }); + // Check if already aborted if (signal?.aborted) { return { @@ -787,6 +886,9 @@ export class Executor { ]); if (result === 'abort') { + if (tracker.envLeakDetected) { + return envLeakStreamingResult(); + } return { success: false, output: tracker.lines.join('\n'), @@ -802,6 +904,9 @@ export class Executor { } if (result === 'timeout') { + if (tracker.envLeakDetected) { + return envLeakStreamingResult(); + } return { success: false, output: tracker.lines.join('\n'), @@ -818,10 +923,14 @@ export class Executor { if (typeof result === 'object' && result !== null && 'error' in result) { const error = (result as { error: unknown }).error; + const errorMsg = error instanceof Error ? error.message : String(error); + if (tracker.envLeakDetected || this.containsSensitiveEnvValue(errorMsg)) { + return envLeakStreamingResult(); + } return { success: false, output: tracker.lines.join('\n'), - error: error instanceof Error ? error.message : String(error), + error: errorMsg, executionTimeMs: Date.now() - startTime, cancelled: false, timedOut: false, @@ -832,6 +941,11 @@ export class Executor { }; } + // Check for env var leak in output + if (tracker.envLeakDetected) { + return envLeakStreamingResult(); + } + return { success: true, output: tracker.lines.join('\n'), @@ -845,10 +959,14 @@ export class Executor { }; } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (tracker.envLeakDetected || this.containsSensitiveEnvValue(errorMsg)) { + return envLeakStreamingResult(); + } return { success: false, output: tracker.lines.join('\n'), - error: error instanceof Error ? error.message : String(error), + error: errorMsg, executionTimeMs: Date.now() - startTime, cancelled: false, timedOut: false,