Skip to content

Commit dda7c67

Browse files
Mossakaclaude
andauthored
fix: mount procfs in chroot for Java/dotnet runtime support (#556)
* fix: mount procfs in chroot for Java/dotnet runtime support The static bind mount of /proc/self always resolved to the parent shell's executable, causing .NET CLR to fail with "Cannot execute dotnet when renamed to bash" and preventing proper /proc/cpuinfo access needed by the JVM. Replace the static /proc/self bind mount with a fresh container-scoped procfs mounted at /host/proc via 'mount -t proc'. This provides dynamic /proc/self/exe resolution per-process while only exposing container processes (not host). Changes: - Mount procfs in entrypoint.sh before chroot (requires SYS_ADMIN capability) - Add SYS_ADMIN to container cap_add, dropped via capsh before user code - Add apparmor:unconfined in chroot mode (Docker's default blocks mount) - Remove mount/umount from seccomp blocklist (safe: SYS_ADMIN dropped) - Add DOTNET_ROOT passthrough for .NET runtime path resolution Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: harden procfs mount and keep umount blocked in seccomp - Mount procfs with nosuid,nodev,noexec options for defense-in-depth - Fail fast (exit 1) on mount failure instead of warning, since Java/.NET runtimes will fail with confusing errors without /proc/self/exe - Add umount and umount2 back to seccomp blocked list since only mount (not unmount) is needed for the procfs setup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e6b55ce commit dda7c67

4 files changed

Lines changed: 89 additions & 17 deletions

File tree

containers/agent/entrypoint.sh

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,10 @@ echo "[entrypoint] =================================="
133133
# Determine which capabilities to drop
134134
# - CAP_NET_ADMIN is always dropped (prevents iptables bypass)
135135
# - CAP_SYS_CHROOT is dropped when chroot mode is enabled (prevents user code from using chroot)
136+
# - CAP_SYS_ADMIN is dropped when chroot mode is enabled (was needed for mounting procfs)
136137
if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
137-
CAPS_TO_DROP="cap_net_admin,cap_sys_chroot"
138-
echo "[entrypoint] Chroot mode enabled - dropping CAP_NET_ADMIN and CAP_SYS_CHROOT"
138+
CAPS_TO_DROP="cap_net_admin,cap_sys_chroot,cap_sys_admin"
139+
echo "[entrypoint] Chroot mode enabled - dropping CAP_NET_ADMIN, CAP_SYS_CHROOT, and CAP_SYS_ADMIN"
139140
else
140141
CAPS_TO_DROP="cap_net_admin"
141142
echo "[entrypoint] Dropping CAP_NET_ADMIN capability"
@@ -150,6 +151,22 @@ echo ""
150151
if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
151152
echo "[entrypoint] Chroot mode: running command inside host filesystem (/host)"
152153

154+
# Mount a container-scoped procfs at /host/proc
155+
# This provides dynamic /proc/self/exe resolution (required by .NET CLR, JVM, and other
156+
# runtimes that read /proc/self/exe to find themselves). A static bind mount of /proc/self
157+
# always resolves to the parent shell's exe, causing runtime failures.
158+
# Security: This procfs is container-scoped (only shows container processes, not host).
159+
# SYS_ADMIN capability (required for mount) is dropped before user code runs.
160+
mkdir -p /host/proc
161+
if mount -t proc -o nosuid,nodev,noexec proc /host/proc; then
162+
echo "[entrypoint] Mounted procfs at /host/proc (nosuid,nodev,noexec)"
163+
else
164+
echo "[entrypoint][ERROR] Failed to mount procfs at /host/proc"
165+
echo "[entrypoint][ERROR] This is required for Java, .NET, and other runtimes that read /proc/self/exe"
166+
echo "[entrypoint][ERROR] Ensure the container has SYS_ADMIN capability (it will be dropped before user code runs)"
167+
exit 1
168+
fi
169+
153170
# Verify capsh is available on the host (required for privilege drop)
154171
if ! chroot /host which capsh >/dev/null 2>&1; then
155172
echo "[entrypoint][ERROR] capsh not found on host system"
@@ -262,6 +279,12 @@ AWFEOF
262279
# Java needs LD_LIBRARY_PATH to find libjli.so and other shared libs
263280
echo "export LD_LIBRARY_PATH=\"${AWF_JAVA_HOME}/lib:${AWF_JAVA_HOME}/lib/server:\$LD_LIBRARY_PATH\"" >> "/host${SCRIPT_FILE}"
264281
fi
282+
# Add DOTNET_ROOT to PATH if provided (for .NET on GitHub Actions)
283+
if [ -n "${AWF_DOTNET_ROOT}" ]; then
284+
echo "[entrypoint] Adding DOTNET_ROOT to PATH: ${AWF_DOTNET_ROOT}"
285+
echo "export PATH=\"${AWF_DOTNET_ROOT}:\$PATH\"" >> "/host${SCRIPT_FILE}"
286+
echo "export DOTNET_ROOT=\"${AWF_DOTNET_ROOT}\"" >> "/host${SCRIPT_FILE}"
287+
fi
265288
# Add GOROOT/bin to PATH if provided (required for Go on GitHub Actions with trimmed binaries)
266289
# This ensures the correct Go version is found even if AWF_HOST_PATH has wrong ordering
267290
if [ -n "${AWF_GOROOT}" ]; then

containers/agent/seccomp-profile.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,6 @@
2727
"acct",
2828
"swapon",
2929
"swapoff",
30-
"mount",
31-
"umount",
32-
"umount2",
3330
"pivot_root",
3431
"syslog",
3532
"add_key",
@@ -47,6 +44,15 @@
4744
],
4845
"action": "SCMP_ACT_ERRNO",
4946
"errnoRet": 1
47+
},
48+
{
49+
"names": [
50+
"umount",
51+
"umount2"
52+
],
53+
"action": "SCMP_ACT_ERRNO",
54+
"errnoRet": 1,
55+
"comment": "Block unmounting filesystems - mount is allowed for procfs but unmount is not needed"
5056
}
5157
]
5258
}

src/docker-manager.test.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -546,9 +546,11 @@ describe('docker-manager', () => {
546546
expect(volumes).toContain('/opt:/host/opt:ro');
547547

548548
// Should include special filesystems (read-only)
549-
// NOTE: Only /proc/self is mounted (not full /proc) to prevent exposure of other processes' env vars
549+
// NOTE: /proc is NOT bind-mounted. Instead, a container-scoped procfs is mounted
550+
// at /host/proc via 'mount -t proc' in entrypoint.sh (requires SYS_ADMIN, which
551+
// is dropped before user code). This provides dynamic /proc/self/exe resolution.
550552
expect(volumes).not.toContain('/proc:/host/proc:ro');
551-
expect(volumes).toContain('/proc/self:/host/proc/self:ro');
553+
expect(volumes).not.toContain('/proc/self:/host/proc/self:ro');
552554
expect(volumes).toContain('/sys:/host/sys:ro');
553555
expect(volumes).toContain('/dev:/host/dev:ro');
554556

@@ -592,7 +594,7 @@ describe('docker-manager', () => {
592594
expect(volumes).toContain(`${homeDir}:/host${homeDir}:rw`);
593595
});
594596

595-
it('should add SYS_CHROOT capability when enableChroot is true', () => {
597+
it('should add SYS_CHROOT and SYS_ADMIN capabilities when enableChroot is true', () => {
596598
const configWithChroot = {
597599
...mockConfig,
598600
enableChroot: true
@@ -602,14 +604,35 @@ describe('docker-manager', () => {
602604

603605
expect(agent.cap_add).toContain('NET_ADMIN');
604606
expect(agent.cap_add).toContain('SYS_CHROOT');
607+
// SYS_ADMIN is needed to mount procfs at /host/proc for dynamic /proc/self/exe
608+
expect(agent.cap_add).toContain('SYS_ADMIN');
605609
});
606610

607-
it('should not add SYS_CHROOT capability when enableChroot is false', () => {
611+
it('should not add SYS_CHROOT or SYS_ADMIN capability when enableChroot is false', () => {
608612
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
609613
const agent = result.services.agent;
610614

611615
expect(agent.cap_add).toContain('NET_ADMIN');
612616
expect(agent.cap_add).not.toContain('SYS_CHROOT');
617+
expect(agent.cap_add).not.toContain('SYS_ADMIN');
618+
});
619+
620+
it('should add apparmor:unconfined security_opt when enableChroot is true', () => {
621+
const configWithChroot = {
622+
...mockConfig,
623+
enableChroot: true
624+
};
625+
const result = generateDockerCompose(configWithChroot, mockNetworkConfig);
626+
const agent = result.services.agent;
627+
628+
expect(agent.security_opt).toContain('apparmor:unconfined');
629+
});
630+
631+
it('should not add apparmor:unconfined security_opt when enableChroot is false', () => {
632+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
633+
const agent = result.services.agent;
634+
635+
expect(agent.security_opt).not.toContain('apparmor:unconfined');
613636
});
614637

615638
it('should set AWF_CHROOT_ENABLED environment variable when enableChroot is true', () => {
@@ -624,15 +647,17 @@ describe('docker-manager', () => {
624647
expect(environment.AWF_CHROOT_ENABLED).toBe('true');
625648
});
626649

627-
it('should pass GOROOT, CARGO_HOME, JAVA_HOME, BUN_INSTALL to container when enableChroot is true and env vars are set', () => {
650+
it('should pass GOROOT, CARGO_HOME, JAVA_HOME, DOTNET_ROOT, BUN_INSTALL to container when enableChroot is true and env vars are set', () => {
628651
const originalGoroot = process.env.GOROOT;
629652
const originalCargoHome = process.env.CARGO_HOME;
630653
const originalJavaHome = process.env.JAVA_HOME;
654+
const originalDotnetRoot = process.env.DOTNET_ROOT;
631655
const originalBunInstall = process.env.BUN_INSTALL;
632656

633657
process.env.GOROOT = '/usr/local/go';
634658
process.env.CARGO_HOME = '/home/user/.cargo';
635659
process.env.JAVA_HOME = '/usr/lib/jvm/java-17';
660+
process.env.DOTNET_ROOT = '/usr/lib/dotnet';
636661
process.env.BUN_INSTALL = '/home/user/.bun';
637662

638663
try {
@@ -647,6 +672,7 @@ describe('docker-manager', () => {
647672
expect(environment.AWF_GOROOT).toBe('/usr/local/go');
648673
expect(environment.AWF_CARGO_HOME).toBe('/home/user/.cargo');
649674
expect(environment.AWF_JAVA_HOME).toBe('/usr/lib/jvm/java-17');
675+
expect(environment.AWF_DOTNET_ROOT).toBe('/usr/lib/dotnet');
650676
expect(environment.AWF_BUN_INSTALL).toBe('/home/user/.bun');
651677
} finally {
652678
// Restore original values
@@ -665,6 +691,11 @@ describe('docker-manager', () => {
665691
} else {
666692
delete process.env.JAVA_HOME;
667693
}
694+
if (originalDotnetRoot !== undefined) {
695+
process.env.DOTNET_ROOT = originalDotnetRoot;
696+
} else {
697+
delete process.env.DOTNET_ROOT;
698+
}
668699
if (originalBunInstall !== undefined) {
669700
process.env.BUN_INSTALL = originalBunInstall;
670701
} else {

src/docker-manager.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ export function generateDockerCompose(
363363
if (process.env.JAVA_HOME) {
364364
environment.AWF_JAVA_HOME = process.env.JAVA_HOME;
365365
}
366+
// .NET: Pass DOTNET_ROOT so entrypoint can add it to PATH and set DOTNET_ROOT
367+
if (process.env.DOTNET_ROOT) {
368+
environment.AWF_DOTNET_ROOT = process.env.DOTNET_ROOT;
369+
}
366370
// Bun: Pass BUN_INSTALL so entrypoint can add $BUN_INSTALL/bin to PATH
367371
// Bun crashes with core dump when installed inside chroot (restricted /proc access),
368372
// so it must be pre-installed on the host via setup-bun action
@@ -457,10 +461,13 @@ export function generateDockerCompose(
457461
agentVolumes.push('/opt:/host/opt:ro');
458462

459463
// Special filesystem mounts for chroot (needed for devices and runtime introspection)
460-
// NOTE: Only /proc/self is mounted (not full /proc) to prevent exposure of other
461-
// processes' environment variables while still allowing binaries like Go to find themselves
464+
// NOTE: /proc is NOT bind-mounted here. Instead, a fresh container-scoped procfs is
465+
// mounted at /host/proc in entrypoint.sh via 'mount -t proc'. This provides:
466+
// - Dynamic /proc/self/exe (required by .NET CLR and other runtimes)
467+
// - /proc/cpuinfo, /proc/meminfo (required by JVM, .NET GC)
468+
// - Container-scoped only (does not expose host process info)
469+
// The mount requires SYS_ADMIN capability, which is dropped before user code runs.
462470
agentVolumes.push(
463-
'/proc/self:/host/proc/self:ro', // Process self-info only (needed by Go to find GOROOT)
464471
'/sys:/host/sys:ro', // Read-only sysfs
465472
'/dev:/host/dev:ro', // Read-only device nodes (needed by some runtimes)
466473
);
@@ -568,10 +575,11 @@ export function generateDockerCompose(
568575
},
569576
// NET_ADMIN is required for iptables setup in entrypoint.sh.
570577
// SYS_CHROOT is added when --enable-chroot is specified for chroot operations.
571-
// Security: Both capabilities are dropped before running user commands
572-
// via 'capsh --drop=cap_net_admin,cap_sys_chroot' in containers/agent/entrypoint.sh.
573-
// This prevents malicious code from modifying iptables rules or using chroot.
574-
cap_add: config.enableChroot ? ['NET_ADMIN', 'SYS_CHROOT'] : ['NET_ADMIN'],
578+
// SYS_ADMIN is added in chroot mode to mount procfs at /host/proc (required for
579+
// dynamic /proc/self/exe resolution needed by .NET CLR and other runtimes).
580+
// Security: All capabilities are dropped before running user commands
581+
// via 'capsh --drop=cap_net_admin,cap_sys_chroot,cap_sys_admin' in entrypoint.sh.
582+
cap_add: config.enableChroot ? ['NET_ADMIN', 'SYS_CHROOT', 'SYS_ADMIN'] : ['NET_ADMIN'],
575583
// Drop capabilities to reduce attack surface (security hardening)
576584
cap_drop: [
577585
'NET_RAW', // Prevents raw socket creation (iptables bypass attempts)
@@ -581,9 +589,13 @@ export function generateDockerCompose(
581589
'MKNOD', // Prevents device node creation
582590
],
583591
// Apply seccomp profile and no-new-privileges to restrict dangerous syscalls and prevent privilege escalation
592+
// In chroot mode, AppArmor is set to unconfined to allow mounting procfs at /host/proc
593+
// (Docker's default AppArmor profile blocks mount). This is safe because SYS_ADMIN is
594+
// dropped via capsh before user code runs, so user code cannot mount anything.
584595
security_opt: [
585596
'no-new-privileges:true',
586597
`seccomp=${config.workDir}/seccomp-profile.json`,
598+
...(config.enableChroot ? ['apparmor:unconfined'] : []),
587599
],
588600
// Resource limits to prevent DoS attacks (conservative defaults)
589601
mem_limit: '4g', // 4GB memory limit

0 commit comments

Comments
 (0)