diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 77a25f10..01e9211d 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -146,6 +146,54 @@ echo "[entrypoint] Switching to awfuser (UID: $(id -u awfuser), GID: $(id -g awf echo "[entrypoint] Executing command: $@" echo "" +# Default list of sensitive token environment variable names. +# Must stay aligned with DEFAULT_SENSITIVE_TOKENS in one-shot-token.c +# and SENSITIVE_ENV_NAMES in docker-manager.ts. +DEFAULT_SENSITIVE_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,GITHUB_PERSONAL_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" + +# scrub_sensitive_tokens CACHE_FILE_PATH +# +# Writes sensitive token values to a cache file (mode 0600, owned by awfuser), +# then unsets them from the environment. The LD_PRELOAD one-shot-token library +# reads this file on load to populate its in-memory cache so that getenv() +# still returns the values while /proc/self/environ is clean. +# +# The cache file is NOT deleted by the library (it must survive the full exec +# chain: capsh → gosu → user command). Cleanup is handled by the EXIT trap. +scrub_sensitive_tokens() { + local cache_file="$1" + # Use umask to create the file with restricted permissions atomically + (umask 077 && : > "${cache_file}") + # Use AWF_ONE_SHOT_TOKENS if set, otherwise use defaults + local sensitive_tokens + if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then + sensitive_tokens="${AWF_ONE_SHOT_TOKENS}" + else + sensitive_tokens="${DEFAULT_SENSITIVE_TOKENS}" + fi + local token_names + IFS=',' read -ra token_names <<< "${sensitive_tokens}" + local token_name token_value + for token_name in "${token_names[@]}"; do + token_name=$(echo "$token_name" | xargs) # trim whitespace + if [ -n "$token_name" ]; then + token_value=$(printenv "$token_name" 2>/dev/null || true) + if [ -n "$token_value" ]; then + printf '%s=%s\n' "$token_name" "$token_value" >> "${cache_file}" + echo "[entrypoint] Token ${token_name} written to cache file and will be scrubbed from environ" + fi + fi + done + chown "$(id -u awfuser):$(id -g awfuser)" "${cache_file}" 2>/dev/null || true + # Unset sensitive vars so they don't appear in /proc/self/environ of exec'd process + for token_name in "${token_names[@]}"; do + token_name=$(echo "$token_name" | xargs) + if [ -n "$token_name" ]; then + unset "$token_name" 2>/dev/null || true + fi + done +} + # If chroot mode is enabled, run user command INSIDE the chroot /host # This provides transparent host binary access - user command sees host filesystem as / if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then @@ -389,9 +437,23 @@ AWFEOF LD_PRELOAD_CMD="export LD_PRELOAD=${ONE_SHOT_TOKEN_LIB};" fi + # Scrub sensitive tokens from the environment before exec to prevent + # /proc/self/environ exposure. Write values to a cache file so the + # LD_PRELOAD library can still serve them via getenv(). + SCRUB_CMD="" + if [ -n "${ONE_SHOT_TOKEN_LIB}" ]; then + TOKEN_CACHE_FILE="/tmp/.awf-token-cache-$$" + # In chroot mode, the file lives under /host but the chroot sees it at TOKEN_CACHE_FILE + scrub_sensitive_tokens "/host${TOKEN_CACHE_FILE}" + SCRUB_CMD="export AWF_TOKEN_CACHE_FILE=${TOKEN_CACHE_FILE};" + # Also add cache file cleanup to the exit trap (chroot perspective, no /host prefix) + CLEANUP_CMD="${CLEANUP_CMD}; rm -f ${TOKEN_CACHE_FILE} 2>/dev/null || true" + fi + exec chroot /host /bin/bash -c " cd '${CHROOT_WORKDIR}' 2>/dev/null || cd / trap '${CLEANUP_CMD}' EXIT + ${SCRUB_CMD} ${LD_PRELOAD_CMD} exec capsh --drop=${CAPS_TO_DROP} --user=${HOST_USER} -- -c 'exec ${SCRIPT_FILE}' " @@ -409,5 +471,13 @@ else # Enable one-shot token protection - tokens are cached in memory and # unset from the environment so /proc/self/environ is cleared export LD_PRELOAD=/usr/local/lib/one-shot-token.so + + # Scrub sensitive tokens from the environment before exec to prevent + # /proc/self/environ exposure. Write values to a cache file so the + # LD_PRELOAD library can still serve them via getenv(). + TOKEN_CACHE_FILE="/tmp/.awf-token-cache-$$" + scrub_sensitive_tokens "${TOKEN_CACHE_FILE}" + export AWF_TOKEN_CACHE_FILE="${TOKEN_CACHE_FILE}" + exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")" fi diff --git a/containers/agent/one-shot-token/README.md b/containers/agent/one-shot-token/README.md index db2d21c0..a749628b 100644 --- a/containers/agent/one-shot-token/README.md +++ b/containers/agent/one-shot-token/README.md @@ -2,7 +2,11 @@ ## Overview -The one-shot token library is an `LD_PRELOAD` shared library that provides **cached access** to sensitive environment variables containing GitHub, OpenAI, Anthropic/Claude, and Codex API tokens. When a process reads a protected token via `getenv()`, the library caches the value in memory and immediately unsets the environment variable. Subsequent `getenv()` calls return the cached value, allowing the process to read tokens multiple times while `/proc/self/environ` is cleared. +The one-shot token library is an `LD_PRELOAD` shared library that provides **cached access** to sensitive environment variables containing GitHub, OpenAI, Anthropic/Claude, and Codex API tokens. On library load, a constructor eagerly caches all sensitive tokens and removes them from the process environment, ensuring `/proc/self/environ` never exposes secrets to user code. + +The library supports two token loading mechanisms: +1. **Token cache file** (preferred): `entrypoint.sh` writes token values to a temporary file (`AWF_TOKEN_CACHE_FILE`) and unsets the env vars before `exec`. The constructor reads the file and populates the cache. The file is NOT deleted by the library — it must survive the full exec chain (`capsh → gosu → user command`) since each `exec()` resets static data. Cleanup is handled by the EXIT trap in `entrypoint.sh`. +2. **Environment fallback**: If no cache file exists, tokens are read from the environment on library load, cached, and unset. This protects against exfiltration via `/proc/self/environ` inspection while allowing legitimate multi-read access patterns that programs like the Copilot CLI require. @@ -19,6 +23,7 @@ By default, the library protects these token variables: - `GITHUB_API_TOKEN` - `GITHUB_PAT` - `GH_ACCESS_TOKEN` +- `GITHUB_PERSONAL_ACCESS_TOKEN` **OpenAI:** - `OPENAI_API_KEY` @@ -48,9 +53,21 @@ LD_PRELOAD=/usr/local/lib/one-shot-token.so ./your-program - If `AWF_ONE_SHOT_TOKENS` is set but contains only whitespace or commas (e.g., `" "` or `",,,"`), the library falls back to the default token list to maintain protection - Use comma-separated token names (whitespace is automatically trimmed) - Maximum of 100 tokens can be protected -- The configuration is read once at library initialization (first `getenv()` call) +- The configuration is read once at library initialization (constructor on library load) - Uses `strtok_r()` internally, which is thread-safe and won't interfere with application code using `strtok()` +### Token Cache File + +The `AWF_TOKEN_CACHE_FILE` environment variable specifies a file containing pre-cached token values. This is set automatically by `entrypoint.sh` to eliminate the `/proc/self/environ` exposure window across `exec` chains. + +**Format:** One `NAME=VALUE` pair per line: +``` +GITHUB_TOKEN=ghp_abc123... +ANTHROPIC_API_KEY=sk-ant-... +``` + +The file is read by the library constructor and immediately deleted. This is an internal mechanism managed by AWF's entrypoint — users should not set this variable manually. + ## How It Works ### The LD_PRELOAD Mechanism diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index 3b8cda82..d4644e8c 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -1,13 +1,26 @@ /** * One-Shot Token LD_PRELOAD Library * - * Intercepts getenv() calls for sensitive token environment variables. - * On first access, caches the value in memory and unsets from environment. - * Subsequent calls return the cached value, so the process can read tokens - * multiple times while /proc/self/environ no longer exposes them. + * Protects sensitive token environment variables from exposure via + * /proc/self/environ and limits access via getenv(). + * + * When loaded, the library constructor reads cached token values from + * AWF_TOKEN_CACHE_FILE (written by entrypoint.sh) and populates an + * in-memory cache. The file is NOT deleted by the library because the + * constructor runs in every process in the exec chain (capsh → gosu → + * user command), and each exec() resets static data. The file is + * cleaned up by the EXIT trap in entrypoint.sh instead. + * + * The sensitive variables are never present in the process environment + * (entrypoint.sh unsets them before exec), so /proc/self/environ is clean. + * Subsequent getenv() calls return the cached values from memory. + * + * Fallback: If no cache file is found, tokens are read from the environment + * on library load, cached, and unset (original behavior). * * Configuration: * AWF_ONE_SHOT_TOKENS - Comma-separated list of token names to protect + * AWF_TOKEN_CACHE_FILE - Path to the token cache file (set by entrypoint.sh) * If not set, uses built-in defaults * * Compile: gcc -shared -fPIC -o one-shot-token.so one-shot-token.c -ldl @@ -20,6 +33,7 @@ #include #include #include +#include #include /* Default sensitive token environment variable names */ @@ -31,6 +45,7 @@ static const char *DEFAULT_SENSITIVE_TOKENS[] = { "GITHUB_API_TOKEN", "GITHUB_PAT", "GH_ACCESS_TOKEN", + "GITHUB_PERSONAL_ACCESS_TOKEN", /* OpenAI tokens */ "OPENAI_API_KEY", "OPENAI_KEY", @@ -75,6 +90,10 @@ static char *(*real_secure_getenv)(const char *name) = NULL; static pthread_once_t getenv_init_once = PTHREAD_ONCE_INIT; static pthread_once_t secure_getenv_init_once = PTHREAD_ONCE_INIT; +/* Forward declarations */ +static int get_token_index(const char *name); +static const char *format_token_value(const char *value); + /* Initialize the real getenv pointer (called exactly once via pthread_once) */ static void init_real_getenv_once(void) { real_getenv = dlsym(RTLD_NEXT, "getenv"); @@ -183,6 +202,114 @@ static void init_token_list(void) { tokens_initialized = 1; } + +/** + * Load cached token values from AWF_TOKEN_CACHE_FILE. + * + * The file format is one NAME=VALUE per line. The file is NOT deleted here + * because the LD_PRELOAD constructor runs in every process in the exec chain + * (capsh → gosu → user command), and each exec() creates a fresh process + * image with reset static data. Deleting here would cause subsequent processes + * to lose access to the cached tokens. The file is cleaned up by the EXIT + * trap in entrypoint.sh instead. The file is created with mode 0600 and + * owned by awfuser, so it is not readable by other users. + * + * Must be called with token_mutex held and after init_token_list(). + */ +static void load_token_cache_file(void) { + const char *cache_path = real_getenv("AWF_TOKEN_CACHE_FILE"); + if (cache_path == NULL || cache_path[0] == '\0') { + return; + } + + FILE *f = fopen(cache_path, "r"); + if (f == NULL) { + fprintf(stderr, "[one-shot-token] WARNING: Could not open token cache file: %s\n", cache_path); + return; + } + + char line[8192]; + int loaded = 0; + while (fgets(line, sizeof(line), f) != NULL) { + /* Strip trailing newline */ + size_t len = strlen(line); + if (len > 0 && line[len - 1] == '\n') { + line[len - 1] = '\0'; + len--; + } + + /* Find the '=' separator */ + char *eq = strchr(line, '='); + if (eq == NULL || eq == line) continue; + + *eq = '\0'; + const char *name = line; + const char *value = eq + 1; + + /* Find if this name matches a sensitive token (first-wins for duplicates) */ + int idx = get_token_index(name); + if (idx >= 0 && !token_accessed[idx]) { + token_cache[idx] = strdup(value); + if (token_cache[idx] != NULL) { + token_accessed[idx] = 1; + loaded++; + fprintf(stderr, "[one-shot-token] Loaded cached token %s from file\n", name); + } + } + } + + fclose(f); + + if (loaded > 0) { + fprintf(stderr, "[one-shot-token] Loaded %d token(s) from cache file\n", loaded); + } +} + +/** + * Library constructor - runs when the library is loaded (before main()). + * + * If AWF_TOKEN_CACHE_FILE is set (by entrypoint.sh), loads cached token + * values from the file. The sensitive variables are never present in + * /proc/self/environ because entrypoint.sh unsets them before exec. + * + * If no cache file exists, tokens remain in the environment and will be + * cached + unset on first getenv() call (original fallback behavior). + */ +__attribute__((constructor)) +static void one_shot_token_init(void) { + /* Initialize the real getenv pointer first */ + init_real_getenv_once(); + + pthread_mutex_lock(&token_mutex); + if (!tokens_initialized) { + init_token_list(); + } + + /* Load tokens from cache file if available (set by entrypoint.sh) */ + load_token_cache_file(); + + /* Eagerly cache any remaining sensitive tokens still in the environment + * (fallback for when no cache file was used) */ + for (int i = 0; i < num_tokens; i++) { + if (!token_accessed[i]) { + char *value = real_getenv(sensitive_tokens[i]); + if (value != NULL) { + token_cache[i] = strdup(value); + if (token_cache[i] != NULL) { + unsetenv(sensitive_tokens[i]); + fprintf(stderr, "[one-shot-token] Token %s eagerly cached and scrubbed from environ\n", + sensitive_tokens[i]); + } + token_accessed[i] = 1; + } + } + } + pthread_mutex_unlock(&token_mutex); + + fprintf(stderr, "[one-shot-token] Library initialized: %d token(s) protected, /proc/self/environ scrubbed\n", + num_tokens); +} + /* Ensure real_getenv is initialized (thread-safe) */ static void init_real_getenv(void) { pthread_once(&getenv_init_once, init_real_getenv_once); diff --git a/package-lock.json b/package-lock.json index 5d221e4c..17c2de59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3251,6 +3252,7 @@ "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3324,6 +3326,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -3804,6 +3807,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4130,6 +4134,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4411,6 +4416,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4633,6 +4639,7 @@ "integrity": "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5615,6 +5622,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7562,6 +7570,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7705,6 +7714,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index 02490c66..c536607d 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -35,6 +35,9 @@ describe('runMainWorkflow', () => { startContainers: jest.fn().mockImplementation(async () => { callOrder.push('startContainers'); }), + redactComposeSecrets: jest.fn().mockImplementation(() => { + callOrder.push('redactComposeSecrets'); + }), runAgentCommand: jest.fn().mockImplementation(async () => { callOrder.push('runAgentCommand'); return { exitCode: 0 }; @@ -55,6 +58,7 @@ describe('runMainWorkflow', () => { 'setupHostIptables', 'writeConfigs', 'startContainers', + 'redactComposeSecrets', 'runAgentCommand', 'performCleanup', ]); @@ -79,6 +83,9 @@ describe('runMainWorkflow', () => { startContainers: jest.fn().mockImplementation(async () => { callOrder.push('startContainers'); }), + redactComposeSecrets: jest.fn().mockImplementation(() => { + callOrder.push('redactComposeSecrets'); + }), runAgentCommand: jest.fn().mockImplementation(async () => { callOrder.push('runAgentCommand'); return { exitCode: 42 }; @@ -100,6 +107,7 @@ describe('runMainWorkflow', () => { 'setupHostIptables', 'writeConfigs', 'startContainers', + 'redactComposeSecrets', 'runAgentCommand', 'performCleanup', ]); diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 62ad1511..530a0bf0 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -5,6 +5,7 @@ export interface WorkflowDependencies { setupHostIptables: (squidIp: string, port: number, dnsServers: string[]) => Promise; writeConfigs: (config: WrapperConfig) => Promise; startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise; + redactComposeSecrets: (workDir: string) => void; runAgentCommand: ( workDir: string, allowedDomains: string[], @@ -52,12 +53,21 @@ export async function runMainWorkflow( // Step 2: Start containers await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir, config.skipPull); + + // Step 3: Redact secrets from docker-compose.yml after containers start + // This must happen AFTER docker compose up because Docker reads the file + // to configure container environment variables. Redacting before would + // prevent containers from receiving the tokens they need. + // The race window is mitigated by the file being written with mode 0600 + // (root-only readable), so the agent (running as awfuser) cannot read it. + dependencies.redactComposeSecrets(config.workDir); + onContainersStarted?.(); - // Step 3: Wait for agent to complete + // Step 4: Wait for agent to complete const result = await dependencies.runAgentCommand(config.workDir, config.allowedDomains, config.proxyLogsDir); - // Step 4: Cleanup (logs will be preserved automatically if they exist) + // Step 5: Cleanup (logs will be preserved automatically if they exist) await performCleanup(); if (result.exitCode === 0) { diff --git a/src/cli.ts b/src/cli.ts index e646fe2e..cbc7bbac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { runAgentCommand, stopContainers, cleanup, + redactComposeSecrets, } from './docker-manager'; import { ensureFirewallNetwork, @@ -1023,6 +1024,7 @@ program setupHostIptables, writeConfigs, startContainers, + redactComposeSecrets, runAgentCommand, }, { diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index e76db0a8..133922b8 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1,4 +1,4 @@ -import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE } from './docker-manager'; +import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE, redactComposeSecrets, SENSITIVE_ENV_NAMES } from './docker-manager'; import { WrapperConfig } from './types'; import * as fs from 'fs'; import * as path from 'path'; @@ -1920,4 +1920,100 @@ describe('docker-manager', () => { await expect(cleanup(nonExistentDir, false)).resolves.not.toThrow(); }); }); + + describe('redactComposeSecrets', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-redact-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should redact sensitive environment variable values in docker-compose.yml', () => { + const composePath = path.join(tmpDir, 'docker-compose.yml'); + const content = [ + 'services:', + ' agent:', + ' environment:', + ' - GITHUB_TOKEN=ghp_abc123secret456', + ' - ANTHROPIC_API_KEY=sk-ant-secret789', + ' - HOME=/root', + ' - PATH=/usr/bin', + ].join('\n'); + fs.writeFileSync(composePath, content); + + redactComposeSecrets(tmpDir); + + const result = fs.readFileSync(composePath, 'utf8'); + expect(result).toContain('GITHUB_TOKEN="***REDACTED***"'); + expect(result).toContain('ANTHROPIC_API_KEY="***REDACTED***"'); + expect(result).not.toContain('ghp_abc123secret456'); + expect(result).not.toContain('sk-ant-secret789'); + // Non-sensitive vars should remain + expect(result).toContain('HOME=/root'); + expect(result).toContain('PATH=/usr/bin'); + }); + + it('should handle YAML key-value format', () => { + const composePath = path.join(tmpDir, 'docker-compose.yml'); + const content = [ + 'services:', + ' agent:', + ' environment:', + ' GITHUB_TOKEN: ghp_abc123secret456', + ' GH_TOKEN: gho_another_secret', + ].join('\n'); + fs.writeFileSync(composePath, content); + + redactComposeSecrets(tmpDir); + + const result = fs.readFileSync(composePath, 'utf8'); + expect(result).toContain('GITHUB_TOKEN: "***REDACTED***"'); + expect(result).toContain('GH_TOKEN: "***REDACTED***"'); + expect(result).not.toContain('ghp_abc123secret456'); + expect(result).not.toContain('gho_another_secret'); + }); + + it('should not modify file when no sensitive vars present', () => { + const composePath = path.join(tmpDir, 'docker-compose.yml'); + const content = [ + 'services:', + ' agent:', + ' environment:', + ' - HOME=/root', + ' - PATH=/usr/bin', + ].join('\n'); + fs.writeFileSync(composePath, content); + + redactComposeSecrets(tmpDir); + + const result = fs.readFileSync(composePath, 'utf8'); + expect(result).toBe(content); + }); + + it('should handle missing docker-compose.yml gracefully', () => { + // Should not throw + expect(() => redactComposeSecrets(tmpDir)).not.toThrow(); + }); + + it('should redact all known sensitive env names', () => { + const composePath = path.join(tmpDir, 'docker-compose.yml'); + const lines = Array.from(SENSITIVE_ENV_NAMES).map( + name => ` - ${name}=secret_value_for_${name}` + ); + const content = ['services:', ' agent:', ' environment:', ...lines].join('\n'); + fs.writeFileSync(composePath, content); + + redactComposeSecrets(tmpDir); + + const result = fs.readFileSync(composePath, 'utf8'); + for (const name of SENSITIVE_ENV_NAMES) { + expect(result).toContain(`${name}="***REDACTED***"`); + expect(result).not.toContain(`secret_value_for_${name}`); + } + }); + }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 155a092b..375da100 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -819,8 +819,8 @@ export async function writeConfigs(config: WrapperConfig): Promise { // Write Docker Compose config const dockerCompose = generateDockerCompose(config, networkConfig, sslConfig); const dockerComposePath = path.join(config.workDir, 'docker-compose.yml'); - fs.writeFileSync(dockerComposePath, yaml.dump(dockerCompose)); - logger.debug(`Docker Compose config written to: ${dockerComposePath}`); + fs.writeFileSync(dockerComposePath, yaml.dump(dockerCompose), { mode: 0o600 }); + logger.debug(`Docker Compose config written to: ${dockerComposePath} (mode 0600)`); } /** @@ -890,6 +890,76 @@ async function checkSquidLogs(workDir: string, proxyLogsDir?: string): Promise<{ } } +/** + * Environment variable names that contain sensitive tokens/secrets. + * These are redacted from docker-compose.yml after containers start + * to prevent exposure via the /host mount. + * + * This list must stay aligned with: + * - DEFAULT_SENSITIVE_TOKENS in containers/agent/one-shot-token/one-shot-token.c + * - DEFAULT_SENSITIVE_TOKENS in containers/agent/entrypoint.sh + */ +export const SENSITIVE_ENV_NAMES = new Set([ + 'COPILOT_GITHUB_TOKEN', + 'GITHUB_TOKEN', + 'GH_TOKEN', + 'GITHUB_API_TOKEN', + 'GITHUB_PAT', + 'GH_ACCESS_TOKEN', + 'GITHUB_PERSONAL_ACCESS_TOKEN', + 'OPENAI_API_KEY', + 'OPENAI_KEY', + 'ANTHROPIC_API_KEY', + 'CLAUDE_API_KEY', + 'CODEX_API_KEY', +]); + +/** + * Redacts sensitive environment variable values in docker-compose.yml + * after containers have started. This prevents exposure of secrets via + * the /host mount point (e.g., reading the compose file from within the container). + * + * @param workDir - Working directory containing docker-compose.yml + */ +export function redactComposeSecrets(workDir: string): void { + const composePath = path.join(workDir, 'docker-compose.yml'); + + try { + // Read directly without existsSync to avoid TOCTOU race condition + let content: string; + try { + content = fs.readFileSync(composePath, 'utf8'); + } catch { + logger.debug('No docker-compose.yml found to redact'); + return; + } + let redactedCount = 0; + + for (const name of SENSITIVE_ENV_NAMES) { + // Match YAML environment entries like: + // - GITHUB_TOKEN=ghp_abc123 + // GITHUB_TOKEN: ghp_abc123 + // Replace the value with ***REDACTED*** + const envLinePattern = new RegExp( + `(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(=|:\\s*)([^\\n]+)`, + 'g' + ); + const newContent = content.replace(envLinePattern, `$1$2"***REDACTED***"`); + if (newContent !== content) { + redactedCount++; + content = newContent; + } + } + + if (redactedCount > 0) { + fs.writeFileSync(composePath, content, { mode: 0o600 }); + logger.info(`Redacted ${redactedCount} sensitive value(s) from docker-compose.yml`); + } + } catch (error) { + logger.warn('Could not redact docker-compose.yml secrets:', error); + } +} + /** * Starts Docker Compose services * @param workDir - Working directory containing Docker Compose config