Skip to content

Commit a242f58

Browse files
Mossakaclaude
andcommitted
test(chroot): verify capsh execution chain after PR #715
Add integration tests verifying the capsh execution chain works correctly after PR #715 eliminated the nested bash layer for Java/.NET compatibility. Tests verify: - CAP_NET_ADMIN, CAP_SYS_CHROOT, CAP_SYS_ADMIN dropped from CapBnd bitmask - iptables, chroot, mount commands fail (capabilities enforced) - Commands run under bash shell (BASH_VERSION set) - /proc/self/exe resolves correctly for python3 (not /bin/bash) - Special characters and pipe chains work with direct-write approach Fixes #842 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 177e1f2 commit a242f58

File tree

1 file changed

+159
-0
lines changed

1 file changed

+159
-0
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* Chroot capsh Execution Chain Tests
3+
*
4+
* Verifies that the capsh execution chain works correctly after PR #715,
5+
* which eliminated the nested bash layer in chroot command execution.
6+
*
7+
* PR #715 changed the entrypoint.sh command-writing logic:
8+
* - Before: `printf '%q ' "$@"` created nested `/bin/bash -c cmd` in the script
9+
* - After: For standard Docker CMD pattern (`/bin/bash -c <cmd>`), writes `$3`
10+
* directly to the script file, eliminating the extra bash process layer
11+
*
12+
* These tests verify:
13+
* 1. Capabilities are properly dropped via capsh (CapBnd bitmask)
14+
* 2. The user command runs under bash (not another shell)
15+
* 3. The direct-write approach handles special characters correctly
16+
* 4. /proc/self/exe resolves correctly (not to /bin/bash for all processes)
17+
*
18+
* Fixes #842
19+
*
20+
* OPTIMIZATION: Tests are batched into a single AWF invocation where possible.
21+
*/
22+
23+
/// <reference path="../jest-custom-matchers.d.ts" />
24+
25+
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
26+
import { createRunner, AwfRunner } from '../fixtures/awf-runner';
27+
import { cleanup } from '../fixtures/cleanup';
28+
import { runBatch, BatchResults } from '../fixtures/batch-runner';
29+
30+
describe('Chroot capsh Execution Chain (PR #715 verification)', () => {
31+
let runner: AwfRunner;
32+
33+
beforeAll(async () => {
34+
await cleanup(false);
35+
runner = createRunner();
36+
});
37+
38+
afterAll(async () => {
39+
await cleanup(false);
40+
});
41+
42+
describe('Capability verification (batched)', () => {
43+
let batch: BatchResults;
44+
45+
beforeAll(async () => {
46+
batch = await runBatch(runner, [
47+
// Check the CapBnd (bounding set) from /proc/self/status
48+
// After capsh --drop, specific capability bits should be cleared
49+
{ name: 'cap_bnd', command: 'grep CapBnd /proc/self/status' },
50+
// Verify CAP_NET_ADMIN (bit 12) is dropped by attempting iptables
51+
{ name: 'iptables_blocked', command: 'iptables -L 2>&1; echo "exit=$?"' },
52+
// Verify CAP_SYS_CHROOT (bit 18) is dropped in chroot mode
53+
{ name: 'chroot_blocked', command: 'chroot / /bin/true 2>&1; echo "exit=$?"' },
54+
// Verify CAP_SYS_ADMIN (bit 21) is dropped - mount should fail
55+
{ name: 'mount_blocked', command: 'mount -t tmpfs tmpfs /tmp/test-mount 2>&1; echo "exit=$?"' },
56+
// Verify the shell is bash
57+
{ name: 'shell_check', command: 'echo "SHELL_NAME=$BASH_VERSION"' },
58+
// Verify /proc/self/exe does NOT point to bash for non-bash processes
59+
{ name: 'proc_exe_python', command: 'python3 -c "import os; print(os.readlink(\'/proc/self/exe\'))"' },
60+
// Verify commands with special characters work (direct-write approach)
61+
{ name: 'special_chars', command: 'echo "hello world" && echo \'single quotes\' && echo "dollar $HOME" && echo "backtick $(echo nested)"' },
62+
// Verify pipes work through the direct-write approach
63+
{ name: 'pipe_chain', command: 'echo "abc def ghi" | tr " " "\\n" | sort | head -1' },
64+
// Verify that the process tree doesn't have an extra bash layer
65+
// ps should show bash -> capsh -> bash -> command, NOT bash -> capsh -> bash -> bash -> command
66+
{ name: 'process_tree', command: 'ps -o comm= --ppid $PPID 2>/dev/null || ps -o comm= $PPID 2>/dev/null || echo "ps_unavailable"' },
67+
], {
68+
allowDomains: ['localhost'],
69+
logLevel: 'debug',
70+
timeout: 120000,
71+
});
72+
}, 180000);
73+
74+
test('should have CAP_NET_ADMIN dropped from bounding set', () => {
75+
const r = batch.get('cap_bnd');
76+
expect(r.exitCode).toBe(0);
77+
// CapBnd is a hex bitmask. CAP_NET_ADMIN is bit 12 (0x1000).
78+
// If dropped, bit 12 should be 0.
79+
const match = r.stdout.match(/CapBnd:\s*([0-9a-f]+)/i);
80+
expect(match).toBeTruthy();
81+
if (match) {
82+
const capBnd = BigInt('0x' + match[1]);
83+
const CAP_NET_ADMIN = BigInt(1) << BigInt(12);
84+
expect(capBnd & CAP_NET_ADMIN).toBe(BigInt(0));
85+
}
86+
});
87+
88+
test('should have CAP_SYS_CHROOT dropped from bounding set', () => {
89+
const r = batch.get('cap_bnd');
90+
expect(r.exitCode).toBe(0);
91+
const match = r.stdout.match(/CapBnd:\s*([0-9a-f]+)/i);
92+
expect(match).toBeTruthy();
93+
if (match) {
94+
const capBnd = BigInt('0x' + match[1]);
95+
const CAP_SYS_CHROOT = BigInt(1) << BigInt(18);
96+
expect(capBnd & CAP_SYS_CHROOT).toBe(BigInt(0));
97+
}
98+
});
99+
100+
test('should have CAP_SYS_ADMIN dropped from bounding set', () => {
101+
const r = batch.get('cap_bnd');
102+
expect(r.exitCode).toBe(0);
103+
const match = r.stdout.match(/CapBnd:\s*([0-9a-f]+)/i);
104+
expect(match).toBeTruthy();
105+
if (match) {
106+
const capBnd = BigInt('0x' + match[1]);
107+
const CAP_SYS_ADMIN = BigInt(1) << BigInt(21);
108+
expect(capBnd & CAP_SYS_ADMIN).toBe(BigInt(0));
109+
}
110+
});
111+
112+
test('should fail iptables command (CAP_NET_ADMIN dropped)', () => {
113+
const r = batch.get('iptables_blocked');
114+
expect(r.stdout).toMatch(/exit=[^0]/);
115+
});
116+
117+
test('should fail chroot command (CAP_SYS_CHROOT dropped)', () => {
118+
const r = batch.get('chroot_blocked');
119+
expect(r.stdout).toMatch(/exit=[^0]/);
120+
});
121+
122+
test('should fail mount command (CAP_SYS_ADMIN dropped)', () => {
123+
const r = batch.get('mount_blocked');
124+
expect(r.stdout).toMatch(/exit=[^0]/);
125+
});
126+
127+
test('should run commands under bash shell', () => {
128+
const r = batch.get('shell_check');
129+
expect(r.exitCode).toBe(0);
130+
// BASH_VERSION is set only when running under bash
131+
expect(r.stdout).toMatch(/SHELL_NAME=\d+\.\d+/);
132+
});
133+
134+
test('should resolve /proc/self/exe correctly for python3 (not bash)', () => {
135+
const r = batch.get('proc_exe_python');
136+
if (r.exitCode === 0) {
137+
// python3's /proc/self/exe should point to python, not bash
138+
expect(r.stdout).toMatch(/python/);
139+
expect(r.stdout).not.toMatch(/\/bin\/bash$/);
140+
}
141+
// Skip if python3 not available
142+
});
143+
144+
test('should handle special characters in direct-write commands', () => {
145+
const r = batch.get('special_chars');
146+
expect(r.exitCode).toBe(0);
147+
expect(r.stdout).toContain('hello world');
148+
expect(r.stdout).toContain('single quotes');
149+
expect(r.stdout).toContain('dollar');
150+
expect(r.stdout).toContain('backtick nested');
151+
});
152+
153+
test('should handle pipe chains in direct-write commands', () => {
154+
const r = batch.get('pipe_chain');
155+
expect(r.exitCode).toBe(0);
156+
expect(r.stdout).toContain('abc');
157+
});
158+
});
159+
});

0 commit comments

Comments
 (0)