From 3de8333f3d68d91ce7150157a8cd90e1a630f566 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Fri, 20 Feb 2026 01:37:06 +0000 Subject: [PATCH] fix: prevent agent from accessing host git credentials Block git credential files (.gitconfig, .git-credentials, .config/git/config, .config/git/credentials) via /dev/null mounts to prevent agents from extracting tokens for unauthorized API access. Additionally sanitize workspace .git/config at container startup to strip extraheader entries containing AUTHORIZATION tokens (set by actions/checkout), disable git credential helpers globally, and set GIT_TERMINAL_PROMPT=0. This addresses a security issue where agents could extract git auth tokens from the container environment and use them to make direct GitHub API calls (e.g., creating PRs via curl with stolen tokens). Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/agent/entrypoint.sh | 29 ++++ src/docker-manager.ts | 15 ++ tests/integration/credential-hiding.test.ts | 164 +++++++++++++++++++- 3 files changed, 204 insertions(+), 4 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 4f8170c8..b9bd5e7c 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -176,6 +176,35 @@ echo "[entrypoint] Hostname: $(hostname)" # Use runuser instead of su to avoid PAM session issues runuser -u awfuser -- git config --global --add safe.directory '*' 2>/dev/null || true +# SECURITY: Sanitize git credentials to prevent token extraction via prompt injection +# Attack vector: On GitHub Actions, actions/checkout sets an extraheader in .git/config +# containing an AUTHORIZATION token. Agents can extract this token and use it for +# unauthorized API calls (e.g., creating PRs via curl with stolen tokens). +# See: https://github.com/phpstan/phpstan/actions/runs/22106856182 +echo "[entrypoint] Sanitizing git credentials..." + +# 1. Disable credential helpers globally for awfuser (prevents credential store/cache lookups) +runuser -u awfuser -- git config --global credential.helper '' 2>/dev/null || true + +# 2. Strip credential-related settings from workspace .git/config +# AWF_WORKDIR contains the workspace path; check both /host prefix (for chroot) and direct path +for workspace_root in "/host${AWF_WORKDIR}" "${AWF_WORKDIR}"; do + GIT_CONFIG_FILE="${workspace_root}/.git/config" + if [ -f "$GIT_CONFIG_FILE" ]; then + # Remove ALL http.*.extraheader entries (these contain AUTHORIZATION: basic ) + git config -f "$GIT_CONFIG_FILE" --get-regexp 'http\..*\.extraheader' 2>/dev/null | while read -r key _rest; do + git config -f "$GIT_CONFIG_FILE" --unset-all "$key" 2>/dev/null || true + done + # Also remove plain http.extraheader (without URL scope) + git config -f "$GIT_CONFIG_FILE" --unset-all 'http.extraheader' 2>/dev/null || true + # Remove credential helper entries from local config + git config -f "$GIT_CONFIG_FILE" --unset-all 'credential.helper' 2>/dev/null || true + echo "[entrypoint] ✓ Sanitized git credentials in $GIT_CONFIG_FILE" + fi +done + +echo "[entrypoint] Git credential sanitization complete" + echo "[entrypoint] ==================================" # Determine which capabilities to drop diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 6f6e926c..2c17d1f1 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -351,6 +351,9 @@ export function generateDockerCompose( // Tools like Rich inject ANSI escape codes that break test assertions expecting plain text. // NO_COLOR is a standard convention (https://no-color.org/) supported by many libraries. NO_COLOR: '1', + // SECURITY: Prevent git from prompting for credentials interactively. + // This stops credential helpers from being invoked via terminal prompts. + GIT_TERMINAL_PROMPT: '0', // Configure one-shot-token library with sensitive tokens to protect // These tokens are cached on first access and unset from /proc/self/environ AWF_ONE_SHOT_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', @@ -757,6 +760,13 @@ export function generateDockerCompose( `${effectiveHome}/.cargo/credentials`, // Rust crates.io tokens `${effectiveHome}/.composer/auth.json`, // PHP Composer tokens `${effectiveHome}/.config/gh/hosts.yml`, // GitHub CLI OAuth tokens + // Git credentials (CRITICAL - repository access, API token extraction) + // Agents can extract tokens from git config and use them for unauthorized API calls + // (e.g., creating PRs via curl with stolen AUTHORIZATION headers) + `${effectiveHome}/.gitconfig`, // Git global config (may contain credential helpers or extraheader tokens) + `${effectiveHome}/.git-credentials`, // Git credential store (plaintext tokens: https://user:TOKEN@host) + `${effectiveHome}/.config/git/config`, // Git XDG config (may contain credential helpers or extraheader tokens) + `${effectiveHome}/.config/git/credentials`, // Git XDG credential store (plaintext tokens) // SSH private keys (CRITICAL - server access, git operations) `${effectiveHome}/.ssh/id_rsa`, `${effectiveHome}/.ssh/id_ed25519`, @@ -789,6 +799,11 @@ export function generateDockerCompose( `/dev/null:/host${effectiveHome}/.cargo/credentials:ro`, `/dev/null:/host${effectiveHome}/.composer/auth.json:ro`, `/dev/null:/host${effectiveHome}/.config/gh/hosts.yml:ro`, + // Git credentials (CRITICAL - repository access, API token extraction) + `/dev/null:/host${effectiveHome}/.gitconfig:ro`, + `/dev/null:/host${effectiveHome}/.git-credentials:ro`, + `/dev/null:/host${effectiveHome}/.config/git/config:ro`, + `/dev/null:/host${effectiveHome}/.config/git/credentials:ro`, // SSH private keys (CRITICAL - server access, git operations) `/dev/null:/host${effectiveHome}/.ssh/id_rsa:ro`, `/dev/null:/host${effectiveHome}/.ssh/id_ed25519:ro`, diff --git a/tests/integration/credential-hiding.test.ts b/tests/integration/credential-hiding.test.ts index 1aa3b6b5..5b050888 100644 --- a/tests/integration/credential-hiding.test.ts +++ b/tests/integration/credential-hiding.test.ts @@ -7,7 +7,7 @@ * Security Threat Model: * - AI agents can be manipulated through prompt injection attacks * - Attackers inject commands to read credential files using bash tools (cat, base64, curl) - * - Credentials at risk: Docker Hub, GitHub CLI, NPM, Cargo, Composer tokens + * - Credentials at risk: Docker Hub, GitHub CLI, NPM, Cargo, Composer tokens, Git credentials/extraheaders * * Security Mitigation: * - Selective mounting: Only mount directories needed for operation @@ -342,8 +342,164 @@ describe('Credential Hiding Security', () => { }, 120000); }); + describe('Git Credential Hiding', () => { + test('Test 15: Git XDG config is hidden (empty file)', async () => { + const homeDir = os.homedir(); + const gitConfig = `${homeDir}/.config/git/config`; + + const result = await runner.runWithSudo( + `cat ${gitConfig} 2>&1 | grep -v "^\\[" | head -1`, + { + allowDomains: ['github.com'], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + // Output should be empty (no credential data leaked) + const output = result.stdout.trim(); + expect(output).toBe(''); + }, 120000); + + test('Test 16: Git XDG credentials store is hidden (empty file)', async () => { + const homeDir = os.homedir(); + const gitCredentials = `${homeDir}/.config/git/credentials`; + + const result = await runner.runWithSudo( + `cat ${gitCredentials} 2>&1 | grep -v "^\\[" | head -1`, + { + allowDomains: ['github.com'], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + // Output should be empty (no plaintext tokens leaked) + const output = result.stdout.trim(); + expect(output).not.toContain('https://'); + expect(output).not.toContain('github.com'); + }, 120000); + + test('Test 17: .gitconfig is hidden (empty file)', async () => { + const homeDir = os.homedir(); + const gitconfig = `${homeDir}/.gitconfig`; + + const result = await runner.runWithSudo( + `cat ${gitconfig} 2>&1 | grep -v "^\\[" | head -1`, + { + allowDomains: ['github.com'], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + const output = result.stdout.trim(); + // Should not contain credential helpers or extraheader tokens + expect(output).not.toContain('credential'); + expect(output).not.toContain('extraheader'); + expect(output).not.toContain('AUTHORIZATION'); + }, 120000); + + test('Test 18: .git-credentials is hidden (empty file)', async () => { + const homeDir = os.homedir(); + const gitCredentials = `${homeDir}/.git-credentials`; + + const result = await runner.runWithSudo( + `cat ${gitCredentials} 2>&1 | grep -v "^\\[" | head -1`, + { + allowDomains: ['github.com'], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + const output = result.stdout.trim(); + // Should not contain plaintext tokens + expect(output).not.toContain('https://'); + expect(output).not.toContain('github.com'); + }, 120000); + + test('Test 19: Workspace .git/config has no extraheader tokens', async () => { + // Create a temporary git repo with a fake extraheader to test sanitization + const result = await runner.runWithSudo( + `sh -c 'cd /tmp && rm -rf awf-git-test && mkdir awf-git-test && cd awf-git-test && git init && git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic dGVzdDp0b2tlbg==" && cat .git/config' 2>&1 | grep -v "^\\["`, + { + allowDomains: ['github.com'], + logLevel: 'debug', + timeout: 60000, + } + ); + + // The entrypoint sanitizes AWF_WORKDIR, but this is a separate temp dir + // so it won't be sanitized. This test verifies the git config output format. + // The critical test is that the AWF_WORKDIR workspace is sanitized. + expect(result).toSucceed(); + }, 120000); + + test('Test 20: git credential fill returns empty for github.com', async () => { + // Verify that git credential helpers are disabled and cannot provide tokens + const result = await runner.runWithSudo( + `sh -c 'echo "protocol=https +host=github.com" | git credential fill 2>&1' | grep -v "^\\[" | head -5`, + { + allowDomains: ['github.com'], + logLevel: 'debug', + timeout: 60000, + } + ); + + // Should not contain any password/token + const output = result.stdout.trim(); + expect(output).not.toContain('password=ghp_'); + expect(output).not.toContain('password=gho_'); + expect(output).not.toContain('password=github_pat_'); + }, 120000); + + test('Test 21: Chroot mode hides git credentials at /host paths', async () => { + const homeDir = os.homedir(); + + // Try to read git config at /host path + const result = await runner.runWithSudo( + `cat /host${homeDir}/.config/git/credentials 2>&1 | grep -v "^\\[" | head -1`, + { + allowDomains: ['github.com'], + logLevel: 'debug', + timeout: 60000, + } + ); + + // May succeed with empty content or fail with "No such file" (both indicate hiding) + if (result.success) { + const output = result.stdout.trim(); + expect(output).not.toContain('https://'); + expect(output).not.toContain('github.com'); + } else { + expect(result.stderr).toMatch(/No such file|cannot access/i); + } + }, 120000); + + test('Test 22: Debug logs show git credential sanitization', async () => { + const result = await runner.runWithSudo( + 'echo "test"', + { + allowDomains: ['github.com'], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + // Check that entrypoint logs show git credential sanitization + expect(result.stderr).toMatch(/Sanitizing git credentials/i); + }, 120000); + }); + describe('MCP Logs Directory Hiding', () => { - test('Test 15: /tmp/gh-aw/mcp-logs/ is hidden in normal mode', async () => { + test('Test 23: /tmp/gh-aw/mcp-logs/ is hidden in normal mode', async () => { // Try to access the mcp-logs directory const result = await runner.runWithSudo( 'ls -la /tmp/gh-aw/mcp-logs/ 2>&1 | grep -v "^\\[" | head -1', @@ -363,7 +519,7 @@ describe('Credential Hiding Security', () => { expect(allOutput).toMatch(/total|Not a directory|cannot access/i); }, 120000); - test('Test 16: /tmp/gh-aw/mcp-logs/ is hidden in chroot mode', async () => { + test('Test 24: /tmp/gh-aw/mcp-logs/ is hidden in chroot mode', async () => { // Try to access the mcp-logs directory at /host path const result = await runner.runWithSudo( 'ls -la /host/tmp/gh-aw/mcp-logs/ 2>&1 | grep -v "^\\[" | head -1', @@ -379,7 +535,7 @@ describe('Credential Hiding Security', () => { expect(allOutput).toMatch(/total|Not a directory|cannot access/i); }, 120000); - test('Test 17: MCP logs files cannot be read in normal mode', async () => { + test('Test 25: MCP logs files cannot be read in normal mode', async () => { // Try to read a typical MCP log file path const result = await runner.runWithSudo( 'cat /tmp/gh-aw/mcp-logs/safeoutputs/log.txt 2>&1 | grep -v "^\\[" | head -1',