Skip to content

Commit 062b833

Browse files
ClaudelpcoxMossakaclaude
authored
fix(docker): hide credentials at direct home mount in chroot mode (#700)
* Initial plan * fix(security): hide credentials at both mount paths in chroot mode Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * docs: update selective mounting docs with dual-mount protection Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * docs: add comprehensive security fix summary Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * docs: address PR feedback on comments and documentation Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * refactor: address review feedback on chroot credential hiding - Reuse effectiveHome instead of redundant getRealUserHome() call in chroot /host path credential hiding (effectiveHome === getRealUserHome() in chroot mode per line 433) - Strengthen Test 9 assertion from not.toContain checks to exact empty string check (toBe('')) for consistency with Test 8 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove enableChroot option from tests (always on after PR #699) Chroot mode is now always enabled, so the enableChroot option was removed from AwfOptions. Update Tests 8-9 to drop the obsolete flag. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> Co-authored-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 304476f commit 062b833

3 files changed

Lines changed: 149 additions & 41 deletions

File tree

docs/selective-mounting.md

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ const chrootVolumes = [
7676
'/dev:/host/dev:ro', // Device nodes
7777
'/tmp:/host/tmp:rw', // Temporary files
7878
`${HOME}:/host${HOME}:rw`, // User home at /host path
79+
`${HOME}:${HOME}:rw`, // User home at direct path (for container env)
7980

8081
// Minimal /etc (no /etc/shadow)
8182
'/etc/ssl:/host/etc/ssl:ro',
@@ -89,28 +90,50 @@ const chrootVolumes = [
8990
**What gets hidden:**
9091

9192
```typescript
92-
// Same credentials, but at /host paths
93+
// IMPORTANT: Home directory is mounted at TWO locations in chroot mode
94+
// Credentials MUST be hidden at BOTH paths to prevent bypass attacks
95+
96+
// 1. Direct home mount (for container environment)
97+
const directHomeCredentials = [
98+
'/dev/null:${HOME}/.docker/config.json:ro',
99+
'/dev/null:${HOME}/.npmrc:ro',
100+
'/dev/null:${HOME}/.cargo/credentials:ro',
101+
'/dev/null:${HOME}/.composer/auth.json:ro',
102+
'/dev/null:${HOME}/.config/gh/hosts.yml:ro',
103+
'/dev/null:${HOME}/.ssh/id_rsa:ro',
104+
'/dev/null:${HOME}/.ssh/id_ed25519:ro',
105+
'/dev/null:${HOME}/.ssh/id_ecdsa:ro',
106+
'/dev/null:${HOME}/.ssh/id_dsa:ro',
107+
'/dev/null:${HOME}/.aws/credentials:ro',
108+
'/dev/null:${HOME}/.aws/config:ro',
109+
'/dev/null:${HOME}/.kube/config:ro',
110+
'/dev/null:${HOME}/.azure/credentials:ro',
111+
'/dev/null:${HOME}/.config/gcloud/credentials.db:ro',
112+
];
113+
114+
// 2. Chroot /host mount (for chroot operations)
93115
const chrootHiddenCredentials = [
94-
'/dev/null:/host/home/runner/.docker/config.json:ro',
95-
'/dev/null:/host/home/runner/.npmrc:ro',
96-
'/dev/null:/host/home/runner/.cargo/credentials:ro',
97-
'/dev/null:/host/home/runner/.composer/auth.json:ro',
98-
'/dev/null:/host/home/runner/.config/gh/hosts.yml:ro',
99-
'/dev/null:/host/home/runner/.ssh/id_rsa:ro',
100-
'/dev/null:/host/home/runner/.ssh/id_ed25519:ro',
101-
'/dev/null:/host/home/runner/.ssh/id_ecdsa:ro',
102-
'/dev/null:/host/home/runner/.ssh/id_dsa:ro',
103-
'/dev/null:/host/home/runner/.aws/credentials:ro',
104-
'/dev/null:/host/home/runner/.aws/config:ro',
105-
'/dev/null:/host/home/runner/.kube/config:ro',
106-
'/dev/null:/host/home/runner/.azure/credentials:ro',
107-
'/dev/null:/host/home/runner/.config/gcloud/credentials.db:ro',
116+
'/dev/null:/host${HOME}/.docker/config.json:ro',
117+
'/dev/null:/host${HOME}/.npmrc:ro',
118+
'/dev/null:/host${HOME}/.cargo/credentials:ro',
119+
'/dev/null:/host${HOME}/.composer/auth.json:ro',
120+
'/dev/null:/host${HOME}/.config/gh/hosts.yml:ro',
121+
'/dev/null:/host${HOME}/.ssh/id_rsa:ro',
122+
'/dev/null:/host${HOME}/.ssh/id_ed25519:ro',
123+
'/dev/null:/host${HOME}/.ssh/id_ecdsa:ro',
124+
'/dev/null:/host${HOME}/.ssh/id_dsa:ro',
125+
'/dev/null:/host${HOME}/.aws/credentials:ro',
126+
'/dev/null:/host${HOME}/.aws/config:ro',
127+
'/dev/null:/host${HOME}/.kube/config:ro',
128+
'/dev/null:/host${HOME}/.azure/credentials:ro',
129+
'/dev/null:/host${HOME}/.config/gcloud/credentials.db:ro',
108130
];
109131
```
110132

111133
**Additional security:**
112134
- Docker socket hidden: `/dev/null:/host/var/run/docker.sock:ro`
113135
- Prevents `docker run` firewall bypass
136+
- **Dual-mount protection**: Credentials hidden at both `$HOME` and `/host$HOME` paths
114137

115138
## Usage Examples
116139

src/docker-manager.ts

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -630,37 +630,73 @@ export function generateDockerCompose(
630630

631631
// Add blanket mount for full filesystem access
632632
agentVolumes.unshift('/:/host:rw');
633+
} else {
634+
// Default: Selective mounting for security against credential exfiltration
635+
// This provides protection against prompt injection attacks
636+
logger.debug('Using selective mounting for security (credential files hidden)');
637+
638+
// SECURITY: Hide credential files by mounting /dev/null over them
639+
// This prevents prompt-injected commands from reading sensitive tokens
640+
// even if the attacker knows the file paths
641+
//
642+
// The home directory is mounted at both $HOME and /host$HOME.
643+
// We must hide credentials at BOTH paths to prevent bypass attacks.
644+
const credentialFiles = [
645+
`${effectiveHome}/.docker/config.json`, // Docker Hub tokens
646+
`${effectiveHome}/.npmrc`, // NPM registry tokens
647+
`${effectiveHome}/.cargo/credentials`, // Rust crates.io tokens
648+
`${effectiveHome}/.composer/auth.json`, // PHP Composer tokens
649+
`${effectiveHome}/.config/gh/hosts.yml`, // GitHub CLI OAuth tokens
650+
// SSH private keys (CRITICAL - server access, git operations)
651+
`${effectiveHome}/.ssh/id_rsa`,
652+
`${effectiveHome}/.ssh/id_ed25519`,
653+
`${effectiveHome}/.ssh/id_ecdsa`,
654+
`${effectiveHome}/.ssh/id_dsa`,
655+
// Cloud provider credentials (CRITICAL - infrastructure access)
656+
`${effectiveHome}/.aws/credentials`,
657+
`${effectiveHome}/.aws/config`,
658+
`${effectiveHome}/.kube/config`,
659+
`${effectiveHome}/.azure/credentials`,
660+
`${effectiveHome}/.config/gcloud/credentials.db`,
661+
];
662+
663+
credentialFiles.forEach(credFile => {
664+
agentVolumes.push(`/dev/null:${credFile}:ro`);
665+
});
666+
667+
logger.debug(`Hidden ${credentialFiles.length} credential file(s) via /dev/null mounts`);
633668
}
634669

635-
// Hide credentials at /host paths
670+
// Also hide credentials at /host paths (chroot mounts home at /host$HOME too)
636671
if (!config.allowFullFilesystemAccess) {
637-
logger.debug('Chroot mode: Hiding credential files at /host paths');
672+
logger.debug('Hiding credential files at /host paths');
638673

639-
const userHome = getRealUserHome();
674+
// Note: In chroot mode, effectiveHome === getRealUserHome() (see line 433),
675+
// so we reuse effectiveHome here instead of calling getRealUserHome() again.
640676
const chrootCredentialFiles = [
641-
`/dev/null:/host${userHome}/.docker/config.json:ro`,
642-
`/dev/null:/host${userHome}/.npmrc:ro`,
643-
`/dev/null:/host${userHome}/.cargo/credentials:ro`,
644-
`/dev/null:/host${userHome}/.composer/auth.json:ro`,
645-
`/dev/null:/host${userHome}/.config/gh/hosts.yml:ro`,
677+
`/dev/null:/host${effectiveHome}/.docker/config.json:ro`,
678+
`/dev/null:/host${effectiveHome}/.npmrc:ro`,
679+
`/dev/null:/host${effectiveHome}/.cargo/credentials:ro`,
680+
`/dev/null:/host${effectiveHome}/.composer/auth.json:ro`,
681+
`/dev/null:/host${effectiveHome}/.config/gh/hosts.yml:ro`,
646682
// SSH private keys (CRITICAL - server access, git operations)
647-
`/dev/null:/host${userHome}/.ssh/id_rsa:ro`,
648-
`/dev/null:/host${userHome}/.ssh/id_ed25519:ro`,
649-
`/dev/null:/host${userHome}/.ssh/id_ecdsa:ro`,
650-
`/dev/null:/host${userHome}/.ssh/id_dsa:ro`,
683+
`/dev/null:/host${effectiveHome}/.ssh/id_rsa:ro`,
684+
`/dev/null:/host${effectiveHome}/.ssh/id_ed25519:ro`,
685+
`/dev/null:/host${effectiveHome}/.ssh/id_ecdsa:ro`,
686+
`/dev/null:/host${effectiveHome}/.ssh/id_dsa:ro`,
651687
// Cloud provider credentials (CRITICAL - infrastructure access)
652-
`/dev/null:/host${userHome}/.aws/credentials:ro`,
653-
`/dev/null:/host${userHome}/.aws/config:ro`,
654-
`/dev/null:/host${userHome}/.kube/config:ro`,
655-
`/dev/null:/host${userHome}/.azure/credentials:ro`,
656-
`/dev/null:/host${userHome}/.config/gcloud/credentials.db:ro`,
688+
`/dev/null:/host${effectiveHome}/.aws/credentials:ro`,
689+
`/dev/null:/host${effectiveHome}/.aws/config:ro`,
690+
`/dev/null:/host${effectiveHome}/.kube/config:ro`,
691+
`/dev/null:/host${effectiveHome}/.azure/credentials:ro`,
692+
`/dev/null:/host${effectiveHome}/.config/gcloud/credentials.db:ro`,
657693
];
658694

659695
chrootCredentialFiles.forEach(mount => {
660696
agentVolumes.push(mount);
661697
});
662698

663-
logger.debug(`Hidden ${chrootCredentialFiles.length} credential file(s) in chroot mode`);
699+
logger.debug(`Hidden ${chrootCredentialFiles.length} credential file(s) at /host paths`);
664700
}
665701

666702
// Agent service configuration

tests/integration/credential-hiding.test.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,59 @@ describe('Credential Hiding Security', () => {
176176
// Check debug logs for chroot credential hiding messages
177177
expect(result.stderr).toMatch(/Chroot mode.*[Hh]iding credential|Hidden.*credential.*chroot/i);
178178
}, 120000);
179+
180+
test('Test 8: Chroot mode ALSO hides credentials at direct home path (bypass prevention)', async () => {
181+
const homeDir = os.homedir();
182+
183+
// SECURITY FIX TEST: Previously, credentials were only hidden at /host paths in chroot mode,
184+
// but the home directory was ALSO mounted directly at $HOME. An attacker could bypass
185+
// protection by reading from the direct mount instead of /host.
186+
//
187+
// This test specifically verifies that credentials are hidden at the direct home mount
188+
// (the bypass path). The /host chroot path is covered by
189+
// "Test 6: Chroot mode hides credentials at /host paths".
190+
191+
const result = await runner.runWithSudo(
192+
`cat ${homeDir}/.docker/config.json 2>&1 | grep -v "^\\[" | head -1`,
193+
{
194+
allowDomains: ['github.com'],
195+
logLevel: 'debug',
196+
timeout: 60000,
197+
// Chroot is always enabled (no flag needed)
198+
}
199+
);
200+
201+
// Command should succeed (file is "readable" but empty)
202+
expect(result).toSucceed();
203+
// Output should be empty (no credential data leaked via direct home mount)
204+
const output = result.stdout.trim();
205+
expect(output).toBe('');
206+
}, 120000);
207+
208+
test('Test 9: Chroot mode hides GitHub CLI tokens at direct home path', async () => {
209+
const homeDir = os.homedir();
210+
211+
// Verify another critical credential file is hidden at the direct home mount
212+
// (the bypass path). The /host chroot path is covered by Test 6.
213+
const result = await runner.runWithSudo(
214+
`cat ${homeDir}/.config/gh/hosts.yml 2>&1 | grep -v "^\\[" | head -1`,
215+
{
216+
allowDomains: ['github.com'],
217+
logLevel: 'debug',
218+
timeout: 60000,
219+
// Chroot is always enabled (no flag needed)
220+
}
221+
);
222+
223+
expect(result).toSucceed();
224+
// Output should be empty (no credential data leaked via direct home mount)
225+
const output = result.stdout.trim();
226+
expect(output).toBe('');
227+
}, 120000);
179228
});
180229

181230
describe('Full Filesystem Access Flag (--allow-full-filesystem-access)', () => {
182-
test('Test 8: Full filesystem access shows security warnings', async () => {
231+
test('Test 10: Full filesystem access shows security warnings', async () => {
183232
const result = await runner.runWithSudo(
184233
'echo "test"',
185234
{
@@ -197,7 +246,7 @@ describe('Credential Hiding Security', () => {
197246
expect(result.stderr).toMatch(/entire host filesystem.*mounted|Full filesystem access/i);
198247
}, 120000);
199248

200-
test('Test 9: With full access, Docker config is NOT hidden', async () => {
249+
test('Test 11: With full access, Docker config is NOT hidden', async () => {
201250
const homeDir = os.homedir();
202251
const dockerConfig = `${homeDir}/.docker/config.json`;
203252

@@ -227,7 +276,7 @@ describe('Credential Hiding Security', () => {
227276
});
228277

229278
describe('Security Verification', () => {
230-
test('Test 10: Simulated exfiltration attack gets empty data', async () => {
279+
test('Test 12: Simulated exfiltration attack gets empty data', async () => {
231280
const homeDir = os.homedir();
232281

233282
// Simulate prompt injection attack: read credential file and encode it
@@ -249,7 +298,7 @@ describe('Credential Hiding Security', () => {
249298
expect(output).toBe('');
250299
}, 120000);
251300

252-
test('Test 11: Multiple encoding attempts still get empty data', async () => {
301+
test('Test 13: Multiple encoding attempts still get empty data', async () => {
253302
const homeDir = os.homedir();
254303

255304
// Simulate sophisticated attack: multiple encoding layers
@@ -270,7 +319,7 @@ describe('Credential Hiding Security', () => {
270319
expect(output).toBe('');
271320
}, 120000);
272321

273-
test('Test 12: grep for tokens in hidden files finds nothing', async () => {
322+
test('Test 14: grep for tokens in hidden files finds nothing', async () => {
274323
const homeDir = os.homedir();
275324

276325
// Try to grep for common credential patterns
@@ -294,7 +343,7 @@ describe('Credential Hiding Security', () => {
294343
});
295344

296345
describe('MCP Logs Directory Hiding', () => {
297-
test('Test 13: /tmp/gh-aw/mcp-logs/ is hidden in normal mode', async () => {
346+
test('Test 15: /tmp/gh-aw/mcp-logs/ is hidden in normal mode', async () => {
298347
// Try to access the mcp-logs directory
299348
const result = await runner.runWithSudo(
300349
'ls -la /tmp/gh-aw/mcp-logs/ 2>&1 | grep -v "^\\[" | head -1',
@@ -314,7 +363,7 @@ describe('Credential Hiding Security', () => {
314363
expect(allOutput).toMatch(/total|Not a directory|cannot access/i);
315364
}, 120000);
316365

317-
test('Test 14: /tmp/gh-aw/mcp-logs/ is hidden in chroot mode', async () => {
366+
test('Test 16: /tmp/gh-aw/mcp-logs/ is hidden in chroot mode', async () => {
318367
// Try to access the mcp-logs directory at /host path
319368
const result = await runner.runWithSudo(
320369
'ls -la /host/tmp/gh-aw/mcp-logs/ 2>&1 | grep -v "^\\[" | head -1',
@@ -330,7 +379,7 @@ describe('Credential Hiding Security', () => {
330379
expect(allOutput).toMatch(/total|Not a directory|cannot access/i);
331380
}, 120000);
332381

333-
test('Test 15: MCP logs files cannot be read in normal mode', async () => {
382+
test('Test 17: MCP logs files cannot be read in normal mode', async () => {
334383
// Try to read a typical MCP log file path
335384
const result = await runner.runWithSudo(
336385
'cat /tmp/gh-aw/mcp-logs/safeoutputs/log.txt 2>&1 | grep -v "^\\[" | head -1',

0 commit comments

Comments
 (0)