Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions containers/agent/seccomp-profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@
"acct",
"swapon",
"swapoff",
"mount",
"umount",
"umount2",
"pivot_root",
"syslog",
"add_key",
Expand All @@ -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"
}
]
}
41 changes: 36 additions & 5 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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
Expand All @@ -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', () => {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
26 changes: 19 additions & 7 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
);
Expand Down Expand Up @@ -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)
Expand All @@ -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'] : []),
Comment on lines 591 to +598
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In chroot mode this sets apparmor:unconfined, which disables AppArmor confinement for the entire container (including the privileged pre-drop phase). Since the goal is only to allow mount -t proc, consider using a dedicated/limited AppArmor profile (checked into the repo) that permits the procfs mount but keeps other AppArmor restrictions, rather than fully unconfined.

Suggested change
// 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'] : []),
// Apply seccomp profile and no-new-privileges to restrict dangerous syscalls and prevent privilege escalation.
// In chroot mode, use a dedicated AppArmor profile that permits mounting procfs at /host/proc
// while keeping other AppArmor restrictions in place (Docker's default profile blocks mount).
security_opt: [
'no-new-privileges:true',
`seccomp=${config.workDir}/seccomp-profile.json`,
...(config.enableChroot ? ['apparmor:awf-procfs-mount'] : []),

Copilot uses AI. Check for mistakes.
],
// Resource limits to prevent DoS attacks (conservative defaults)
mem_limit: '4g', // 4GB memory limit
Expand Down
Loading