diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index ab44a1fb..4c3c4679 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -133,9 +133,10 @@ echo "[entrypoint] ==================================" # Determine which capabilities to drop # - CAP_NET_ADMIN is always dropped (prevents iptables bypass) # - CAP_SYS_CHROOT is dropped when chroot mode is enabled (prevents user code from using chroot) +# - CAP_SYS_ADMIN is dropped when chroot mode is enabled (was needed for mounting procfs) if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then - CAPS_TO_DROP="cap_net_admin,cap_sys_chroot" - echo "[entrypoint] Chroot mode enabled - dropping CAP_NET_ADMIN and CAP_SYS_CHROOT" + CAPS_TO_DROP="cap_net_admin,cap_sys_chroot,cap_sys_admin" + echo "[entrypoint] Chroot mode enabled - dropping CAP_NET_ADMIN, CAP_SYS_CHROOT, and CAP_SYS_ADMIN" else CAPS_TO_DROP="cap_net_admin" echo "[entrypoint] Dropping CAP_NET_ADMIN capability" @@ -150,6 +151,22 @@ echo "" if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then echo "[entrypoint] Chroot mode: running command inside host filesystem (/host)" + # Mount a container-scoped procfs at /host/proc + # This provides dynamic /proc/self/exe resolution (required by .NET CLR, JVM, and other + # runtimes that read /proc/self/exe to find themselves). A static bind mount of /proc/self + # always resolves to the parent shell's exe, causing runtime failures. + # Security: This procfs is container-scoped (only shows container processes, not host). + # SYS_ADMIN capability (required for mount) is dropped before user code runs. + mkdir -p /host/proc + if mount -t proc -o nosuid,nodev,noexec proc /host/proc; then + echo "[entrypoint] Mounted procfs at /host/proc (nosuid,nodev,noexec)" + else + echo "[entrypoint][ERROR] Failed to mount procfs at /host/proc" + echo "[entrypoint][ERROR] This is required for Java, .NET, and other runtimes that read /proc/self/exe" + echo "[entrypoint][ERROR] Ensure the container has SYS_ADMIN capability (it will be dropped before user code runs)" + exit 1 + fi + # Verify capsh is available on the host (required for privilege drop) if ! chroot /host which capsh >/dev/null 2>&1; then echo "[entrypoint][ERROR] capsh not found on host system" @@ -262,6 +279,12 @@ AWFEOF # Java needs LD_LIBRARY_PATH to find libjli.so and other shared libs echo "export LD_LIBRARY_PATH=\"${AWF_JAVA_HOME}/lib:${AWF_JAVA_HOME}/lib/server:\$LD_LIBRARY_PATH\"" >> "/host${SCRIPT_FILE}" fi + # Add DOTNET_ROOT to PATH if provided (for .NET on GitHub Actions) + if [ -n "${AWF_DOTNET_ROOT}" ]; then + echo "[entrypoint] Adding DOTNET_ROOT to PATH: ${AWF_DOTNET_ROOT}" + echo "export PATH=\"${AWF_DOTNET_ROOT}:\$PATH\"" >> "/host${SCRIPT_FILE}" + echo "export DOTNET_ROOT=\"${AWF_DOTNET_ROOT}\"" >> "/host${SCRIPT_FILE}" + fi # Add GOROOT/bin to PATH if provided (required for Go on GitHub Actions with trimmed binaries) # This ensures the correct Go version is found even if AWF_HOST_PATH has wrong ordering if [ -n "${AWF_GOROOT}" ]; then diff --git a/containers/agent/seccomp-profile.json b/containers/agent/seccomp-profile.json index 4c3cda60..0643e3e4 100644 --- a/containers/agent/seccomp-profile.json +++ b/containers/agent/seccomp-profile.json @@ -27,9 +27,6 @@ "acct", "swapon", "swapoff", - "mount", - "umount", - "umount2", "pivot_root", "syslog", "add_key", @@ -47,6 +44,15 @@ ], "action": "SCMP_ACT_ERRNO", "errnoRet": 1 + }, + { + "names": [ + "umount", + "umount2" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1, + "comment": "Block unmounting filesystems - mount is allowed for procfs but unmount is not needed" } ] } diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index c2976b73..bffec27f 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -546,9 +546,11 @@ describe('docker-manager', () => { expect(volumes).toContain('/opt:/host/opt:ro'); // Should include special filesystems (read-only) - // NOTE: Only /proc/self is mounted (not full /proc) to prevent exposure of other processes' env vars + // NOTE: /proc is NOT bind-mounted. Instead, a container-scoped procfs is mounted + // at /host/proc via 'mount -t proc' in entrypoint.sh (requires SYS_ADMIN, which + // is dropped before user code). This provides dynamic /proc/self/exe resolution. expect(volumes).not.toContain('/proc:/host/proc:ro'); - expect(volumes).toContain('/proc/self:/host/proc/self:ro'); + expect(volumes).not.toContain('/proc/self:/host/proc/self:ro'); expect(volumes).toContain('/sys:/host/sys:ro'); expect(volumes).toContain('/dev:/host/dev:ro'); @@ -592,7 +594,7 @@ describe('docker-manager', () => { expect(volumes).toContain(`${homeDir}:/host${homeDir}:rw`); }); - it('should add SYS_CHROOT capability when enableChroot is true', () => { + it('should add SYS_CHROOT and SYS_ADMIN capabilities when enableChroot is true', () => { const configWithChroot = { ...mockConfig, enableChroot: true @@ -602,14 +604,35 @@ describe('docker-manager', () => { expect(agent.cap_add).toContain('NET_ADMIN'); expect(agent.cap_add).toContain('SYS_CHROOT'); + // SYS_ADMIN is needed to mount procfs at /host/proc for dynamic /proc/self/exe + expect(agent.cap_add).toContain('SYS_ADMIN'); }); - it('should not add SYS_CHROOT capability when enableChroot is false', () => { + it('should not add SYS_CHROOT or SYS_ADMIN capability when enableChroot is false', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfig); const agent = result.services.agent; expect(agent.cap_add).toContain('NET_ADMIN'); expect(agent.cap_add).not.toContain('SYS_CHROOT'); + expect(agent.cap_add).not.toContain('SYS_ADMIN'); + }); + + it('should add apparmor:unconfined security_opt when enableChroot is true', () => { + const configWithChroot = { + ...mockConfig, + enableChroot: true + }; + const result = generateDockerCompose(configWithChroot, mockNetworkConfig); + const agent = result.services.agent; + + expect(agent.security_opt).toContain('apparmor:unconfined'); + }); + + it('should not add apparmor:unconfined security_opt when enableChroot is false', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const agent = result.services.agent; + + expect(agent.security_opt).not.toContain('apparmor:unconfined'); }); it('should set AWF_CHROOT_ENABLED environment variable when enableChroot is true', () => { @@ -624,15 +647,17 @@ describe('docker-manager', () => { expect(environment.AWF_CHROOT_ENABLED).toBe('true'); }); - it('should pass GOROOT, CARGO_HOME, JAVA_HOME, BUN_INSTALL to container when enableChroot is true and env vars are set', () => { + it('should pass GOROOT, CARGO_HOME, JAVA_HOME, DOTNET_ROOT, BUN_INSTALL to container when enableChroot is true and env vars are set', () => { const originalGoroot = process.env.GOROOT; const originalCargoHome = process.env.CARGO_HOME; const originalJavaHome = process.env.JAVA_HOME; + const originalDotnetRoot = process.env.DOTNET_ROOT; const originalBunInstall = process.env.BUN_INSTALL; process.env.GOROOT = '/usr/local/go'; process.env.CARGO_HOME = '/home/user/.cargo'; process.env.JAVA_HOME = '/usr/lib/jvm/java-17'; + process.env.DOTNET_ROOT = '/usr/lib/dotnet'; process.env.BUN_INSTALL = '/home/user/.bun'; try { @@ -647,6 +672,7 @@ describe('docker-manager', () => { expect(environment.AWF_GOROOT).toBe('/usr/local/go'); expect(environment.AWF_CARGO_HOME).toBe('/home/user/.cargo'); expect(environment.AWF_JAVA_HOME).toBe('/usr/lib/jvm/java-17'); + expect(environment.AWF_DOTNET_ROOT).toBe('/usr/lib/dotnet'); expect(environment.AWF_BUN_INSTALL).toBe('/home/user/.bun'); } finally { // Restore original values @@ -665,6 +691,11 @@ describe('docker-manager', () => { } else { delete process.env.JAVA_HOME; } + if (originalDotnetRoot !== undefined) { + process.env.DOTNET_ROOT = originalDotnetRoot; + } else { + delete process.env.DOTNET_ROOT; + } if (originalBunInstall !== undefined) { process.env.BUN_INSTALL = originalBunInstall; } else { diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 6869a606..e30923c5 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -363,6 +363,10 @@ export function generateDockerCompose( if (process.env.JAVA_HOME) { environment.AWF_JAVA_HOME = process.env.JAVA_HOME; } + // .NET: Pass DOTNET_ROOT so entrypoint can add it to PATH and set DOTNET_ROOT + if (process.env.DOTNET_ROOT) { + environment.AWF_DOTNET_ROOT = process.env.DOTNET_ROOT; + } // Bun: Pass BUN_INSTALL so entrypoint can add $BUN_INSTALL/bin to PATH // Bun crashes with core dump when installed inside chroot (restricted /proc access), // so it must be pre-installed on the host via setup-bun action @@ -457,10 +461,13 @@ export function generateDockerCompose( agentVolumes.push('/opt:/host/opt:ro'); // Special filesystem mounts for chroot (needed for devices and runtime introspection) - // NOTE: Only /proc/self is mounted (not full /proc) to prevent exposure of other - // processes' environment variables while still allowing binaries like Go to find themselves + // NOTE: /proc is NOT bind-mounted here. Instead, a fresh container-scoped procfs is + // mounted at /host/proc in entrypoint.sh via 'mount -t proc'. This provides: + // - Dynamic /proc/self/exe (required by .NET CLR and other runtimes) + // - /proc/cpuinfo, /proc/meminfo (required by JVM, .NET GC) + // - Container-scoped only (does not expose host process info) + // The mount requires SYS_ADMIN capability, which is dropped before user code runs. agentVolumes.push( - '/proc/self:/host/proc/self:ro', // Process self-info only (needed by Go to find GOROOT) '/sys:/host/sys:ro', // Read-only sysfs '/dev:/host/dev:ro', // Read-only device nodes (needed by some runtimes) ); @@ -568,10 +575,11 @@ export function generateDockerCompose( }, // NET_ADMIN is required for iptables setup in entrypoint.sh. // SYS_CHROOT is added when --enable-chroot is specified for chroot operations. - // Security: Both capabilities are dropped before running user commands - // via 'capsh --drop=cap_net_admin,cap_sys_chroot' in containers/agent/entrypoint.sh. - // This prevents malicious code from modifying iptables rules or using chroot. - cap_add: config.enableChroot ? ['NET_ADMIN', 'SYS_CHROOT'] : ['NET_ADMIN'], + // SYS_ADMIN is added in chroot mode to mount procfs at /host/proc (required for + // dynamic /proc/self/exe resolution needed by .NET CLR and other runtimes). + // Security: All capabilities are dropped before running user commands + // via 'capsh --drop=cap_net_admin,cap_sys_chroot,cap_sys_admin' in entrypoint.sh. + cap_add: config.enableChroot ? ['NET_ADMIN', 'SYS_CHROOT', 'SYS_ADMIN'] : ['NET_ADMIN'], // Drop capabilities to reduce attack surface (security hardening) cap_drop: [ 'NET_RAW', // Prevents raw socket creation (iptables bypass attempts) @@ -581,9 +589,13 @@ export function generateDockerCompose( 'MKNOD', // Prevents device node creation ], // Apply seccomp profile and no-new-privileges to restrict dangerous syscalls and prevent privilege escalation + // In chroot mode, AppArmor is set to unconfined to allow mounting procfs at /host/proc + // (Docker's default AppArmor profile blocks mount). This is safe because SYS_ADMIN is + // dropped via capsh before user code runs, so user code cannot mount anything. security_opt: [ 'no-new-privileges:true', `seccomp=${config.workDir}/seccomp-profile.json`, + ...(config.enableChroot ? ['apparmor:unconfined'] : []), ], // Resource limits to prevent DoS attacks (conservative defaults) mem_limit: '4g', // 4GB memory limit