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',