From 90df4bc63d1fea79a3dda2fdf7aaf4286e404281 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:41:11 +0000 Subject: [PATCH 1/4] Initial plan From 2a4888ec459b951bad7b771ede1d77c18d897d65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:50:38 +0000 Subject: [PATCH 2/4] fix: restrict /proc/self/environ and docker-compose.yml secret exposure Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/entrypoint.sh | 36 ++++++++ src/docker-manager.test.ts | 145 ++++++++++++++++++++++++++++++++- src/docker-manager.ts | 66 +++++++++++++++ 3 files changed, 246 insertions(+), 1 deletion(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 775f832d7..9de1f4d7c 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -389,6 +389,24 @@ AWFEOF LD_PRELOAD_CMD="export LD_PRELOAD=${ONE_SHOT_TOKEN_LIB};" fi + # Scrub sensitive tokens from environment before exec to prevent + # /proc/self/environ from exposing them (bypasses LD_PRELOAD interception) + # Build the token list from AWF_ONE_SHOT_TOKENS or use defaults + SCRUB_TOKENS="" + if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then + SCRUB_TOKENS="${AWF_ONE_SHOT_TOKENS}" + else + SCRUB_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" + fi + IFS=',' read -ra TOKENS_TO_SCRUB <<< "$SCRUB_TOKENS" + for token_name in "${TOKENS_TO_SCRUB[@]}"; do + token_name=$(echo "$token_name" | tr -d ' ') + if [ -n "$token_name" ]; then + unset "$token_name" 2>/dev/null || true + fi + done + echo "[entrypoint] Scrubbed sensitive tokens from environment (/proc/self/environ protection)" + exec chroot /host /bin/bash -c " cd '${CHROOT_WORKDIR}' 2>/dev/null || cd / trap '${CLEANUP_CMD}' EXIT @@ -408,5 +426,23 @@ else # # Enable one-shot token protection to prevent tokens from being read multiple times export LD_PRELOAD=/usr/local/lib/one-shot-token.so + + # Scrub sensitive tokens from environment before exec to prevent + # /proc/self/environ from exposing them (bypasses LD_PRELOAD interception) + SCRUB_TOKENS="" + if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then + SCRUB_TOKENS="${AWF_ONE_SHOT_TOKENS}" + else + SCRUB_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" + fi + IFS=',' read -ra TOKENS_TO_SCRUB <<< "$SCRUB_TOKENS" + for token_name in "${TOKENS_TO_SCRUB[@]}"; do + token_name=$(echo "$token_name" | tr -d ' ') + if [ -n "$token_name" ]; then + unset "$token_name" 2>/dev/null || true + fi + done + echo "[entrypoint] Scrubbed sensitive tokens from environment (/proc/self/environ protection)" + exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")" fi diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 63aa74906..236feb7a5 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1,8 +1,9 @@ -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'; import * as os from 'os'; +import * as yaml from 'js-yaml'; // Create mock functions const mockExecaFn = jest.fn(); @@ -1812,4 +1813,146 @@ 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 variables from docker-compose.yml', () => { + const composeConfig = { + services: { + agent: { + environment: { + GITHUB_TOKEN: 'ghp_secret_token_12345', + HOME: '/home/user', + PATH: '/usr/bin', + }, + }, + }, + }; + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(composeConfig)); + + redactComposeSecrets(tmpDir); + + const result = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf-8')) as any; + expect(result.services.agent.environment.GITHUB_TOKEN).toBe('**REDACTED**'); + expect(result.services.agent.environment.HOME).toBe('/home/user'); + expect(result.services.agent.environment.PATH).toBe('/usr/bin'); + }); + + it('should redact multiple sensitive tokens', () => { + const composeConfig = { + services: { + agent: { + environment: { + GITHUB_TOKEN: 'ghp_token', + GH_TOKEN: 'gh_token', + COPILOT_GITHUB_TOKEN: 'copilot_token', + ANTHROPIC_API_KEY: 'sk-ant-key', + OPENAI_API_KEY: 'sk-openai-key', + GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_pat', + USER: 'testuser', + }, + }, + }, + }; + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(composeConfig)); + + redactComposeSecrets(tmpDir); + + const result = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf-8')) as any; + expect(result.services.agent.environment.GITHUB_TOKEN).toBe('**REDACTED**'); + expect(result.services.agent.environment.GH_TOKEN).toBe('**REDACTED**'); + expect(result.services.agent.environment.COPILOT_GITHUB_TOKEN).toBe('**REDACTED**'); + expect(result.services.agent.environment.ANTHROPIC_API_KEY).toBe('**REDACTED**'); + expect(result.services.agent.environment.OPENAI_API_KEY).toBe('**REDACTED**'); + expect(result.services.agent.environment.GITHUB_PERSONAL_ACCESS_TOKEN).toBe('**REDACTED**'); + expect(result.services.agent.environment.USER).toBe('testuser'); + }); + + it('should not modify file when no sensitive tokens are present', () => { + const composeConfig = { + services: { + agent: { + environment: { + HOME: '/home/user', + USER: 'testuser', + }, + }, + }, + }; + const content = yaml.dump(composeConfig); + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), content); + + redactComposeSecrets(tmpDir); + + const result = fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf-8'); + expect(result).toBe(content); + }); + + it('should handle missing docker-compose.yml gracefully', () => { + // Should not throw + expect(() => redactComposeSecrets(tmpDir)).not.toThrow(); + }); + + it('should handle empty environment section gracefully', () => { + const composeConfig = { + services: { + agent: { + environment: {}, + }, + }, + }; + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(composeConfig)); + + expect(() => redactComposeSecrets(tmpDir)).not.toThrow(); + }); + + it('should not redact empty or falsy token values', () => { + const composeConfig = { + services: { + agent: { + environment: { + GITHUB_TOKEN: '', + HOME: '/home/user', + }, + }, + }, + }; + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(composeConfig)); + + redactComposeSecrets(tmpDir); + + const result = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf-8')) as any; + // Empty values should not be redacted (no secret to protect) + expect(result.services.agent.environment.GITHUB_TOKEN).toBe(''); + }); + }); + + describe('SENSITIVE_ENV_NAMES', () => { + it('should include all default one-shot-token library tokens', () => { + expect(SENSITIVE_ENV_NAMES.has('COPILOT_GITHUB_TOKEN')).toBe(true); + expect(SENSITIVE_ENV_NAMES.has('GITHUB_TOKEN')).toBe(true); + expect(SENSITIVE_ENV_NAMES.has('GH_TOKEN')).toBe(true); + expect(SENSITIVE_ENV_NAMES.has('OPENAI_API_KEY')).toBe(true); + expect(SENSITIVE_ENV_NAMES.has('ANTHROPIC_API_KEY')).toBe(true); + }); + + it('should include GITHUB_PERSONAL_ACCESS_TOKEN', () => { + expect(SENSITIVE_ENV_NAMES.has('GITHUB_PERSONAL_ACCESS_TOKEN')).toBe(true); + }); + + it('should not include non-sensitive variables', () => { + expect(SENSITIVE_ENV_NAMES.has('HOME')).toBe(false); + expect(SENSITIVE_ENV_NAMES.has('PATH')).toBe(false); + expect(SENSITIVE_ENV_NAMES.has('USER')).toBe(false); + }); + }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 18481fe99..16e65f907 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -858,6 +858,67 @@ async function checkSquidLogs(workDir: string, proxyLogsDir?: string): Promise<{ } } +/** + * Sensitive environment variable names that may contain secrets. + * Matches the default list in the one-shot-token LD_PRELOAD library. + * @internal Exported for testing + */ +export const SENSITIVE_ENV_NAMES = new Set([ + 'COPILOT_GITHUB_TOKEN', + 'GITHUB_TOKEN', + 'GH_TOKEN', + 'GITHUB_API_TOKEN', + 'GITHUB_PAT', + 'GH_ACCESS_TOKEN', + 'OPENAI_API_KEY', + 'OPENAI_KEY', + 'ANTHROPIC_API_KEY', + 'CLAUDE_API_KEY', + 'CODEX_API_KEY', + 'GITHUB_PERSONAL_ACCESS_TOKEN', +]); + +/** + * Redacts sensitive environment variable values from the docker-compose.yml file. + * Called after containers start to prevent the agent from reading secrets via + * /host/tmp/awf-* mount. The file has already been consumed by docker compose up. + * @internal Exported for testing + */ +export function redactComposeSecrets(workDir: string): void { + const composePath = path.join(workDir, 'docker-compose.yml'); + try { + if (!fs.existsSync(composePath)) { + return; + } + const content = fs.readFileSync(composePath, 'utf-8'); + const config = yaml.load(content) as DockerComposeConfig; + if (!config?.services) { + return; + } + + let redacted = false; + for (const service of Object.values(config.services)) { + const env = (service as any).environment; + if (env && typeof env === 'object') { + for (const key of Object.keys(env)) { + if (SENSITIVE_ENV_NAMES.has(key) && env[key]) { + env[key] = '**REDACTED**'; + redacted = true; + } + } + } + } + + if (redacted) { + fs.writeFileSync(composePath, yaml.dump(config)); + logger.debug('Redacted sensitive environment variables from docker-compose.yml'); + } + } catch (error) { + // Non-fatal: redaction is a defense-in-depth measure + logger.debug('Could not redact docker-compose.yml secrets:', error); + } +} + /** * Starts Docker Compose services * @param workDir - Working directory containing Docker Compose config @@ -891,6 +952,11 @@ export async function startContainers(workDir: string, allowedDomains: string[], stdio: 'inherit', }); logger.success('Containers started successfully'); + + // Redact sensitive environment variables from docker-compose.yml + // The file has been consumed by docker compose up, but remains readable + // from inside the container via /host mount. Redact to prevent exposure. + redactComposeSecrets(workDir); } catch (error) { // Check if this is a healthcheck failure const errorMsg = error instanceof Error ? error.message : String(error); From 11f4ada9f1b1ba5b550ec9c0bf2f9f3b8b4c50cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:53:30 +0000 Subject: [PATCH 3/4] fix: deduplicate token list and align across shell/ts Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/entrypoint.sh | 9 +++++++-- containers/agent/one-shot-token/README.md | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 9de1f4d7c..742f1b347 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -142,6 +142,11 @@ else echo "[entrypoint] Dropping CAP_NET_ADMIN capability" fi +# Default sensitive token names to scrub from /proc/self/environ before exec. +# Matches the one-shot-token library defaults plus GITHUB_PERSONAL_ACCESS_TOKEN. +# Override via AWF_ONE_SHOT_TOKENS environment variable. +DEFAULT_SENSITIVE_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY,GITHUB_PERSONAL_ACCESS_TOKEN" + echo "[entrypoint] Switching to awfuser (UID: $(id -u awfuser), GID: $(id -g awfuser))" echo "[entrypoint] Executing command: $@" echo "" @@ -396,7 +401,7 @@ AWFEOF if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then SCRUB_TOKENS="${AWF_ONE_SHOT_TOKENS}" else - SCRUB_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" + SCRUB_TOKENS="${DEFAULT_SENSITIVE_TOKENS}" fi IFS=',' read -ra TOKENS_TO_SCRUB <<< "$SCRUB_TOKENS" for token_name in "${TOKENS_TO_SCRUB[@]}"; do @@ -433,7 +438,7 @@ else if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then SCRUB_TOKENS="${AWF_ONE_SHOT_TOKENS}" else - SCRUB_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" + SCRUB_TOKENS="${DEFAULT_SENSITIVE_TOKENS}" fi IFS=',' read -ra TOKENS_TO_SCRUB <<< "$SCRUB_TOKENS" for token_name in "${TOKENS_TO_SCRUB[@]}"; do diff --git a/containers/agent/one-shot-token/README.md b/containers/agent/one-shot-token/README.md index b00f09af7..6709d8a80 100644 --- a/containers/agent/one-shot-token/README.md +++ b/containers/agent/one-shot-token/README.md @@ -272,6 +272,7 @@ Note: The `AWF_ONE_SHOT_TOKENS` variable must be exported before running `awf` s - **Interception before first read**: If malicious code runs before the legitimate code reads the token, it gets the value - **Static linking**: Programs statically linked with libc bypass LD_PRELOAD - **Direct syscalls**: Code that reads `/proc/self/environ` directly (without getenv) bypasses this protection + - **Mitigated by AWF**: The entrypoint scrubs sensitive env vars from the process environment before `exec`, so `/proc/self/environ` of the user process will not contain them ### Defense in Depth From cf5fff4a6d08087f910528ecd3f7a8b341eb0d1e Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 10 Feb 2026 21:00:02 +0000 Subject: [PATCH 4/4] fix: scrub /proc/self/environ via LD_PRELOAD constructor - Remove shell-level `unset` of sensitive tokens from entrypoint.sh. The shell unset ran before exec, so tokens were gone from the environment before the one-shot-token library could cache them, breaking authentication (Copilot CLI could not find tokens). - Add __attribute__((constructor)) to one-shot-token.c that eagerly caches all sensitive token values and calls unsetenv() at library load time (before main()). This closes the /proc/self/environ exposure window without breaking getenv() access. - Write docker-compose.yml with mode 0600 to prevent the agent container (running as awfuser) from reading secrets via /host mount. This eliminates the TOCTOU race between container start and redaction. - Fix CodeQL TOCTOU alert in redactComposeSecrets by replacing existsSync+readFileSync with a single readFileSync in try/catch. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/agent/entrypoint.sh | 48 ++++--------------- .../agent/one-shot-token/one-shot-token.c | 36 ++++++++++++++ src/docker-manager.ts | 14 ++++-- 3 files changed, 55 insertions(+), 43 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 16e545237..4a1924584 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -142,11 +142,6 @@ else echo "[entrypoint] Dropping CAP_NET_ADMIN capability" fi -# Default sensitive token names to scrub from /proc/self/environ before exec. -# Matches the one-shot-token library defaults plus GITHUB_PERSONAL_ACCESS_TOKEN. -# Override via AWF_ONE_SHOT_TOKENS environment variable. -DEFAULT_SENSITIVE_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY,GITHUB_PERSONAL_ACCESS_TOKEN" - echo "[entrypoint] Switching to awfuser (UID: $(id -u awfuser), GID: $(id -g awfuser))" echo "[entrypoint] Executing command: $@" echo "" @@ -394,23 +389,11 @@ AWFEOF LD_PRELOAD_CMD="export LD_PRELOAD=${ONE_SHOT_TOKEN_LIB};" fi - # Scrub sensitive tokens from environment before exec to prevent - # /proc/self/environ from exposing them (bypasses LD_PRELOAD interception) - # Build the token list from AWF_ONE_SHOT_TOKENS or use defaults - SCRUB_TOKENS="" - if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then - SCRUB_TOKENS="${AWF_ONE_SHOT_TOKENS}" - else - SCRUB_TOKENS="${DEFAULT_SENSITIVE_TOKENS}" - fi - IFS=',' read -ra TOKENS_TO_SCRUB <<< "$SCRUB_TOKENS" - for token_name in "${TOKENS_TO_SCRUB[@]}"; do - token_name=$(echo "$token_name" | tr -d ' ') - if [ -n "$token_name" ]; then - unset "$token_name" 2>/dev/null || true - fi - done - echo "[entrypoint] Scrubbed sensitive tokens from environment (/proc/self/environ protection)" + # Note: /proc/self/environ scrubbing is handled by the one-shot-token library's + # constructor (__attribute__((constructor))). The library eagerly caches all + # sensitive token values and calls unsetenv() at load time, before main() runs. + # This ensures /proc/self/environ is clean from process start while still + # allowing the process to read tokens via getenv(). exec chroot /host /bin/bash -c " cd '${CHROOT_WORKDIR}' 2>/dev/null || cd / @@ -433,22 +416,11 @@ else # unset from the environment so /proc/self/environ is cleared export LD_PRELOAD=/usr/local/lib/one-shot-token.so - # Scrub sensitive tokens from environment before exec to prevent - # /proc/self/environ from exposing them (bypasses LD_PRELOAD interception) - SCRUB_TOKENS="" - if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then - SCRUB_TOKENS="${AWF_ONE_SHOT_TOKENS}" - else - SCRUB_TOKENS="${DEFAULT_SENSITIVE_TOKENS}" - fi - IFS=',' read -ra TOKENS_TO_SCRUB <<< "$SCRUB_TOKENS" - for token_name in "${TOKENS_TO_SCRUB[@]}"; do - token_name=$(echo "$token_name" | tr -d ' ') - if [ -n "$token_name" ]; then - unset "$token_name" 2>/dev/null || true - fi - done - echo "[entrypoint] Scrubbed sensitive tokens from environment (/proc/self/environ protection)" + # Note: /proc/self/environ scrubbing is handled by the one-shot-token library's + # constructor (__attribute__((constructor))). The library eagerly caches all + # sensitive token values and calls unsetenv() at load time, before main() runs. + # This ensures /proc/self/environ is clean from process start while still + # allowing the process to read tokens via getenv(). exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")" fi diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index 3b8cda82b..4dbf429c2 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -183,6 +183,42 @@ static void init_token_list(void) { tokens_initialized = 1; } +/** + * Library constructor - runs before main() when loaded via LD_PRELOAD. + * Eagerly caches all sensitive token values and unsets them from the + * environment. This ensures /proc/self/environ is clean from the very + * start of the process, closing the window between process start and + * first getenv() call. + */ +__attribute__((constructor)) +static void eagerly_scrub_tokens(void) { + /* Initialize real getenv first */ + pthread_once(&getenv_init_once, init_real_getenv_once); + + /* Initialize token list */ + pthread_mutex_lock(&token_mutex); + if (!tokens_initialized) { + init_token_list(); + } + + /* Cache and unset all tokens that have values */ + 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); + unsetenv(sensitive_tokens[i]); + fprintf(stderr, "[one-shot-token] Eagerly cached and cleared %s (value: %s)\n", + sensitive_tokens[i], format_token_value(token_cache[i])); + } + token_accessed[i] = 1; + } + } + pthread_mutex_unlock(&token_mutex); + + fprintf(stderr, "[one-shot-token] /proc/self/environ scrubbed at library load time\n"); +} + /* 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/src/docker-manager.ts b/src/docker-manager.ts index 16e65f907..936c2cf93 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -787,8 +787,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)`); } /** @@ -887,10 +887,14 @@ export const SENSITIVE_ENV_NAMES = new Set([ export function redactComposeSecrets(workDir: string): void { const composePath = path.join(workDir, 'docker-compose.yml'); try { - if (!fs.existsSync(composePath)) { + // Read directly without existsSync to avoid TOCTOU race condition + let content: string; + try { + content = fs.readFileSync(composePath, 'utf-8'); + } catch { + // File doesn't exist or can't be read - nothing to redact return; } - const content = fs.readFileSync(composePath, 'utf-8'); const config = yaml.load(content) as DockerComposeConfig; if (!config?.services) { return; @@ -910,7 +914,7 @@ export function redactComposeSecrets(workDir: string): void { } if (redacted) { - fs.writeFileSync(composePath, yaml.dump(config)); + fs.writeFileSync(composePath, yaml.dump(config), { mode: 0o600 }); logger.debug('Redacted sensitive environment variables from docker-compose.yml'); } } catch (error) {