Skip to content

Conversation

@Mossaka
Copy link
Collaborator

@Mossaka Mossaka commented Feb 6, 2026

Summary

  • Fix .NET CLR failure in chroot mode ("Cannot execute dotnet when renamed to bash") caused by static /proc/self bind mount always resolving to parent shell's exe
  • Fix JVM needing /proc/cpuinfo and /proc/meminfo for CPU feature detection and heap sizing
  • Mount container-scoped procfs at /host/proc via mount -t proc instead of static bind mount, providing dynamic /proc/self/exe resolution
  • Add DOTNET_ROOT env var passthrough for .NET runtime path resolution in chroot

Root Cause

The previous approach bind-mounted /proc/self from the host to /host/proc/self. This creates a static mount — every process reading /proc/self/exe inside the chroot sees /bin/bash (the parent shell) instead of their own executable. The .NET CLR reads /proc/self/exe to verify its binary name and refuses to run when it sees "bash".

Security Model

The fix adds SYS_ADMIN capability to the container (needed for mount -t proc) with multiple defense layers:

  1. Container-scoped procfs — only shows container processes, not host
  2. capsh --drop=cap_sys_admin — capability dropped before user code runs
  3. Non-root user — user code runs as unprivileged user via capsh --user
  4. no-new-privileges:true — prevents privilege escalation via execve
  5. Custom seccomp profile — blocks dangerous syscalls
  6. apparmor:unconfined added only in chroot mode (required because Docker's default AppArmor blocks mount)

Verified: mount attempts fail after privilege drop (mount exit=32).

Local Verification

Before fix:

java: error while loading shared libraries: libjli.so: cannot open shared object file
dotnet: A fatal error was encountered. Cannot execute dotnet when renamed to bash.

After fix:

java --version → openjdk 17.0.17 ✓
dotnet --version → 6.0.136 ✓
mount attempt → "must be superuser to use mount" (exit=32) ✓

Test plan

  • Local: Java --version works in chroot mode
  • Local: dotnet --version works in chroot mode
  • Local: mount blocked after capsh privilege drop
  • Local: Non-chroot mode regression test passes
  • Unit tests pass (737/737)
  • Lint passes (0 errors)
  • CI: Chroot integration tests
  • CI: Smoke tests

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings February 6, 2026 21:04
@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

📰 VERDICT: Smoke Copilot has concluded. All systems operational. This is a developing story. 🎤

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Chroot tests passed! Smoke Chroot - All security and functionality tests succeeded.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

🎬 THE ENDSmoke Claude MISSION: ACCOMPLISHED! The hero saves the day! ✨

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

✅ Coverage Check Passed

Overall Coverage

Metric Base PR Delta
Lines 82.03% 82.05% 📈 +0.02%
Statements 82.07% 82.09% 📈 +0.02%
Functions 81.95% 81.95% ➡️ +0.00%
Branches 75.44% 75.54% 📈 +0.10%
📁 Per-file Coverage Changes (1 files)
File Lines (Before → After) Statements (Before → After)
src/docker-manager.ts 82.6% → 82.7% (+0.09%) 82.0% → 82.0% (+0.09%)

Coverage comparison generated by scripts/ci/compare-coverage.ts

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Deno Build Test Results

Project Tests Status
oak 1/1 ✅ PASS
std 1/1 ✅ PASS

Overall: ✅ PASS

All Deno tests completed successfully.

AI generated by Build Test Deno

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

C++ Build Test Results

Project CMake Build Status
fmt PASS
json PASS

Overall: PASS

All C++ projects built successfully.

AI generated by Build Test C++

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Node.js Build Test Results

Project Install Tests Status
clsx PASS PASS
execa PASS PASS
p-limit PASS PASS

Overall: PASS

All Node.js projects successfully installed and passed their test suites.

AI generated by Build Test Node.js

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Bun Build Test Results

Project Install Tests Status
elysia 1/1 PASS
hono 1/1 PASS

Overall: PASS

All Bun projects installed and tested successfully.

AI generated by Build Test Bun

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Smoke Test: Claude Engine - PASS

Status: PASS

AI generated by Smoke Claude

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Security Review: ⚠️ Critical Issue Found

I've reviewed PR #556 for security implications. The PR adds SYS_ADMIN capability and disables AppArmor in chroot mode to support Java/dotnet runtimes. While the defense-in-depth approach is well-documented, there is one critical security weakness:


🔴 CRITICAL: Race Condition Between Mount and Capability Drop

File: containers/agent/entrypoint.sh
Lines: 154-165 (mount), 359 (capsh capability drop)

Issue:

The mount operation happens at line 161, but the capability drop via capsh --drop=cap_sys_admin doesn't occur until line 359 (inside the chroot exec). This creates a ~200 line execution window where:

  1. /host/proc is mounted (line 161)
  2. User script /host${SCRIPT_FILE} is written (lines 259-319)
  3. chroot /host executes (line 356)
  4. Only then capsh --drop removes SYS_ADMIN (line 359)

Attack Vector:

If the user's command file ($SCRIPT_FILE) can be influenced by an attacker before the chroot (e.g., race condition in workspace, malicious GitHub Actions artifact), the script could contain:

mount --bind /host/evil /host/proc/sys/kernel
# Now has access to host kernel tunables before capsh drops SYS_ADMIN

The capability is still active when the script runs initially.

Proof:

Looking at line 356-360:

exec chroot /host /bin/bash -c "
  cd '${CHROOT_WORKDIR}' 2>/dev/null || cd /
  trap '${CLEANUP_CMD}' EXIT
  exec capsh --drop=${CAPS_TO_DROP} --user=${HOST_USER} -- -c 'exec ${SCRIPT_FILE}'
"

The intermediate /bin/bash process has SYS_ADMIN until capsh runs. The exec ${SCRIPT_FILE} happens after capsh, but the bash process itself could be exploited.


⚠️ MEDIUM: AppArmor Disabled in Chroot Mode

File: src/docker-manager.ts
Lines: 592-599

Issue:

security_opt: [
  'no-new-privileges:true',
  `seccomp=${config.workDir}/seccomp-profile.json`,
  ...(config.enableChroot ? ['apparmor:unconfined'] : []),
],

AppArmor is completely disabled in chroot mode (line 599) to allow the mount syscall. While the PR description mentions "SYS_ADMIN is dropped via capsh before user code runs", the AppArmor layer is permanently removed for the entire container lifetime.

Impact: This removes an entire defense layer (AppArmor LSM) that would normally restrict:

  • File access patterns
  • Network operations
  • Capability usage
  • Signal delivery

Even after capsh drops SYS_ADMIN, AppArmor would provide additional constraints. With apparmor:unconfined, only seccomp remains.


ℹ️ INFO: Seccomp Allows Mount Syscalls

File: containers/agent/seccomp-profile.json
Lines: 30-32 (removed)

The PR removes mount, umount, umount2 from the seccomp blocklist. This is intentional to allow the entrypoint mount, but it means:

  • User code can attempt mount operations (will fail due to capability drop)
  • Seccomp no longer provides defense-in-depth against mount attempts
  • Relies entirely on capability drop for mount prevention

Note: This is acceptable IF the capability drop timing issue is fixed.


Recommended Mitigations

Fix 1: Drop SYS_ADMIN Immediately After Mount

Move the capability drop to happen right after the mount, before any user-influenced operations:

# Mount procfs (requires SYS_ADMIN)
mkdir -p /host/proc
if mount -t proc proc /host/proc; then
  echo "[entrypoint] Mounted procfs at /host/proc"
fi

# DROP SYS_ADMIN IMMEDIATELY - before writing user script
capsh --drop=cap_sys_admin --print > /dev/null 2>&1 || echo "[entrypoint][WARN] SYS_ADMIN drop failed"

# Now write user script and continue...

Fix 2: Use More Restrictive AppArmor Profile

Instead of apparmor:unconfined, create a custom AppArmor profile that:

  • Allows mount only for /host/proc (not arbitrary paths)
  • Restricts mount to entrypoint script (not user commands)
  • Maintains other AppArmor protections

Fix 3: Add Mount Verification

Verify the mount is container-scoped and hasn't been tampered with:

mount -t proc proc /host/proc
# Verify it's actually procfs and shows container PID namespace
if [ "$(stat -f -c %T /host/proc)" = "proc" ] && [ -d "/host/proc/self" ]; then
  echo "[entrypoint] Procfs verified"
else
  echo "[entrypoint][ERROR] Procfs mount verification failed"
  exit 1
fi

Assessment

Overall Risk: MEDIUM-HIGH

The capability drop timing issue is the primary concern. While the container is already privileged during entrypoint initialization, the extended window between mount and capability drop increases attack surface.

Recommended Action:

  • Implement Fix 1 (immediate capability drop) before merging
  • Consider Fix 2 (custom AppArmor profile) for defense-in-depth
  • Fix 3 is optional but recommended for additional verification

The Java/dotnet runtime support is valuable, but the security model should drop SYS_ADMIN as soon as the mount completes, not hundreds of lines later.

AI generated by Security Guard

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR improves chroot-mode runtime compatibility by providing a container-scoped /proc inside the chroot (fixing dynamic /proc/self/exe and JVM/.NET runtime introspection needs) and by passing through DOTNET_ROOT for correct .NET discovery.

Changes:

  • Replace the old /proc/self bind-mount approach with a procfs mount performed in entrypoint.sh (targeting /host/proc).
  • Add DOTNET_ROOT passthrough (AWF_DOTNET_ROOT) and export it into the chroot execution environment.
  • Adjust container security configuration for chroot mode (capabilities, seccomp, AppArmor) and update unit tests accordingly.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/docker-manager.ts Adds DOTNET_ROOT passthrough; removes /proc/self bind mount; adds SYS_ADMIN and apparmor:unconfined in chroot mode.
src/docker-manager.test.ts Updates expectations for proc mounts/capabilities and adds coverage for AppArmor + DOTNET_ROOT passthrough.
containers/agent/seccomp-profile.json Allows mount/umount* syscalls (previously blocked) to enable procfs mounting.
containers/agent/entrypoint.sh Mounts procfs at /host/proc in chroot mode; drops CAP_SYS_ADMIN; exports DOTNET_ROOT/PATH into the chroot script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 20 to 32
"names": [
"kexec_load",
"kexec_file_load",
"reboot",
"init_module",
"finit_module",
"delete_module",
"acct",
"swapon",
"swapoff",
"mount",
"umount",
"umount2",
"pivot_root",
"syslog",
"add_key",
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.

This change unblocks mount, umount, and umount2 for all container processes. The procfs mount requires mount, but umount/umount2 don’t appear to be needed by entrypoint.sh; consider keeping umount/umount2 blocked to minimize syscall surface while still allowing the procfs mount.

Copilot uses AI. Check for mistakes.
Comment on lines 591 to +598
// 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'] : []),
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.
Comment on lines 161 to 164
if mount -t proc proc /host/proc; then
echo "[entrypoint] Mounted procfs at /host/proc"
else
echo "[entrypoint][WARN] Failed to mount procfs at /host/proc - some runtimes may not work"
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.

The procfs mount is attempted without any hardening options and failure only logs a warning then continues. Since chroot-mode runtimes depend on this, consider (1) mounting with restrictive options (e.g., nosuid,nodev,noexec and hidepid=2 to reduce process info exposure for the unprivileged user) and (2) failing fast with an actionable error when the mount fails so runtime errors don’t show up later as confusing JVM/.NET failures.

Suggested change
if mount -t proc proc /host/proc; then
echo "[entrypoint] Mounted procfs at /host/proc"
else
echo "[entrypoint][WARN] Failed to mount procfs at /host/proc - some runtimes may not work"
# Hardened procfs mount: prefer hidepid=2, fall back to nosuid,nodev,noexec if hidepid unsupported
if mount -t proc -o nosuid,nodev,noexec,hidepid=2 proc /host/proc 2>/dev/null; then
echo "[entrypoint] Mounted hardened procfs at /host/proc (nosuid,nodev,noexec,hidepid=2)"
elif mount -t proc -o nosuid,nodev,noexec proc /host/proc 2>/dev/null; then
echo "[entrypoint][WARN] Mounted procfs at /host/proc without hidepid=2 (kernel may not support hidepid)"
echo "[entrypoint][WARN] Proc entries remain visible to awfuser within the container namespace"
else
echo "[entrypoint][ERROR] Failed to mount procfs at /host/proc"
echo "[entrypoint][ERROR] Chroot mode requires a working /host/proc for runtimes that use /proc/self/exe"
echo "[entrypoint][ERROR] Ensure the container has SYS_ADMIN capability and the kernel supports procfs"
exit 1

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Smoke Test Results (Copilot)

Last 2 Merged PRs:

Tests:

  • ✅ GitHub MCP (list PRs)
  • ✅ Playwright (GitHub page title verified)
  • ✅ File writing (smoke-test-copilot-21765845049.txt)
  • ✅ Bash tool (file read-back)

Status: PASS

cc @Mossaka

AI generated by Smoke Copilot

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Go Build Test Results

Project Download Tests Status
color 1/1 PASS
env 1/1 PASS
uuid 1/1 PASS

Overall: PASS

All Go projects successfully downloaded dependencies and passed tests.

AI generated by Build Test Go

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

🔴 Build Test: Rust - FAILED

Error: Rust toolchain not installed

The test environment does not have cargo or rustc installed. Unable to build and test Rust projects.

Required: Add Rust toolchain setup to the workflow before running this test:

- uses: actions-rust-lang/setup-rust-toolchain@v1
Project Build Tests Status
fd N/A ERROR: Cargo not found
zoxide N/A ERROR: Cargo not found

Overall: FAIL - Missing Rust toolchain

AI generated by Build Test Rust

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Java Build Test Results

Project Compile Tests Status
gson N/A FAIL
caffeine N/A FAIL

Overall: FAIL

Error Details

Both projects failed to compile due to network connectivity issues. Maven was unable to download required plugins from Maven Central repository (repo.maven.apache.org).

Error: Could not transfer artifact org.apache.maven.plugins:maven-resources-plugin:pom:3.3.1 from/to central (https://repo.maven.apache.org/maven2): Unsupported or unrecognized SSL message

Root Cause: The domain repo.maven.apache.org is not in the firewall's allowed domains list. Maven Central must be whitelisted for Java builds to succeed.

Required Action: Add repo.maven.apache.org to the --allow-domains configuration in the workflow that runs this test.

AI generated by Build Test Java

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Chroot Runtime Version Test Results

Runtime Host Version Chroot Version Match?
Python 3.12.12 3.12.3 ❌ NO
Node.js v24.13.0 v20.20.0 ❌ NO
Go go1.22.12 go1.22.12 ✅ YES

Overall Status: ❌ Failed (2/3 tests failed)

The chroot mode is not accessing the same runtime versions as the host. Python and Node.js versions differ between host and chroot environments.

AI generated by Smoke Chroot

- 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>
@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

🎬 THE ENDSmoke Claude MISSION: ACCOMPLISHED! The hero saves the day! ✨

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Chroot tests passed! Smoke Chroot - All security and functionality tests succeeded.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

📰 VERDICT: Smoke Copilot has concluded. All systems operational. This is a developing story. 🎤

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Build Test: Java - FAILED ❌

Both projects failed to compile due to network access restrictions.

Project Compile Tests Status
gson N/A FAIL
caffeine N/A FAIL

Overall: FAIL

Error Details

Both projects failed with the same error when Maven attempted to download dependencies:

Could not transfer artifact org.apache.maven.plugins:maven-resources-plugin:pom:3.3.1 
from/to central (https://repo.maven.apache.org/maven2): Unsupported or unrecognized SSL message

Root Cause: The domain repo.maven.apache.org is not whitelisted in the firewall configuration. Maven cannot download required plugins and dependencies.

Required Action: Add repo.maven.apache.org to the allowed domains list for Java build tests.

AI generated by Build Test Java

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Node.js Build Test Results

Project Install Tests Status
clsx PASS PASS
execa PASS PASS
p-limit PASS PASS

Overall: PASS

All Node.js projects installed successfully and passed their test suites.

AI generated by Build Test Node.js

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Smoke Test Results (Copilot)

Last 2 Merged PRs:

Test Results:

  • ✅ Playwright: GitHub page title verified
  • ✅ File Writing: Test file created successfully
  • ✅ Bash Tool: File read verified

Overall: PASS

cc @Mossaka (author)

AI generated by Smoke Copilot

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Bun Build Test Results

Project Install Tests Status
elysia 1/1 PASS
hono 1/1 PASS

Overall: PASS

All Bun projects installed and tested successfully.

AI generated by Build Test Bun

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

C++ Build Test Results

Project CMake Build Status
fmt PASS
json PASS

Overall: PASS

All C++ projects built successfully.

AI generated by Build Test C++

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Rust Build Test Results ✅

Project Build Tests Status
fd 1/1 PASS
zoxide 1/1 PASS

Overall: PASS

All Rust projects built and tested successfully.

AI generated by Build Test Rust

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Deno Build Test Results

Project Tests Status
oak 1/1 ✅ PASS
std 1/1 ✅ PASS

Overall: ✅ PASS

All Deno tests completed successfully.

AI generated by Build Test Deno

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Go Build Test Results

Project Download Tests Status
color 1/1 PASS
env 1/1 PASS
uuid 1/1 PASS

Overall: PASS

All Go projects successfully downloaded dependencies and passed tests.

AI generated by Build Test Go

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Claude Smoke Test Results

Recent PRs:

Test Results:

  • ✅ GitHub MCP: Fetched 2 merged PRs
  • ❌ Playwright: Navigation timeout (60s)
  • ✅ File Writing: Created test file successfully
  • ✅ Bash Tool: File verification passed

Status: FAIL (Playwright timeout)

AI generated by Smoke Claude

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Chroot Version Comparison Test Results

Runtime Host Version Chroot Version Match?
Python Python 3.12.12 Python 3.12.3 ❌ NO
Node.js v24.13.0 v20.20.0 ❌ NO
Go go1.22.12 go1.22.12 ✅ YES

Overall Result: ❌ Tests did not pass (not all versions matched)

The chroot environment successfully accessed host binaries but version mismatches were detected for Python and Node.js. This indicates the chroot is using different runtime versions than what's available on the host.

AI generated by Smoke Chroot

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

Security Review - PR #556

I've conducted a thorough security review of this PR's changes to support Java/.NET runtimes in chroot mode. Here are my findings:


⚠️ Security Concern: AppArmor Unconfined Mode

Issue Location

File: src/docker-manager.ts:598

security_opt: [
  'no-new-privileges:true',
  `seccomp=${config.workDir}/seccomp-profile.json`,
  ...(config.enableChroot ? ['apparmor:unconfined'] : []),  // ⚠️ NEW
],
```

### Concern
This PR adds `apparmor:unconfined` in chroot mode, which **removes an entire layer of defense-in-depth**. According to Docker security documentation, Docker's default AppArmor profile blocks various dangerous operations including:

- Writing to `/proc` even with SYS_ADMIN capability
- Mounting filesystems from inside the container (even with SYS_ADMIN)
- Other MAC (Mandatory Access Control) restrictions

By setting `apparmor:unconfined`, the container loses these protections and relies entirely on:
1. ✅ Capability dropping (CAP_SYS_ADMIN dropped via capsh)
2. ✅ Seccomp profile (custom restrictions)
3. ✅ no-new-privileges flag
4. ❌ ~~AppArmor (disabled)~~

### Risk Assessment
**Severity**: Medium-High

While the PR maintains other security layers (capability dropping, seccomp, privilege dropping to non-root user), removing AppArmor creates potential attack vectors:

1. **Reduced defense-in-depth**: If a vulnerability is found in the capability dropping mechanism or seccomp profile, there's no AppArmor safety net
2. **Broader attack surface**: Operations blocked by docker-default AppArmor profile are now only protected by capabilities/seccomp
3. **Future regression risk**: If capability dropping logic is accidentally broken in a future commit, AppArmor won't catch it

### Mitigation Recommendations

**Option 1: Custom AppArmor Profile (Recommended)**
Create a custom AppArmor profile that allows only the specific mount operation needed:
```
profile awf-chroot-profile flags=(attach_disconnected,mediate_deleted) {
  # Include base Docker profile protections
  #include (abstractions/base)
  
  # Allow mounting procfs at /host/proc specifically
  mount fstype=proc -> /host/proc/,
  
  # Deny other mounts
  deny mount,
  
  # Include other necessary rules...
}

Then use: apparmor=awf-chroot-profile instead of apparmor:unconfined

Option 2: Time-Based Capability Management
Drop SYS_ADMIN immediately after the mount instead of waiting until capsh:

# Mount procfs
mount -t proc -o nosuid,nodev,noexec proc /host/proc

# Drop SYS_ADMIN immediately using capsh before chroot
capsh --drop=cap_sys_admin -- -c "
  exec chroot /host /bin/bash -c '
    # ... rest of the script
  '
"

This would allow keeping the default AppArmor profile.

Option 3: Document and Accept Risk
If the convenience/compatibility trade-off is acceptable, add comprehensive documentation about:

  • Why AppArmor is disabled in chroot mode
  • What compensating controls are in place
  • Security testing that validates the remaining defenses

Secure Changes Verified

1. Mount Timing is Secure

  • mount -t proc happens at entrypoint.sh:161 while running as root with SYS_ADMIN
  • ✅ Capability drop happens at entrypoint.sh:362 via capsh --drop=cap_sys_admin
  • ✅ User code executes AFTER capability drop
  • ✅ No time window for user code to mount additional filesystems

2. Umount is Properly Blocked

File: containers/agent/seccomp-profile.json:48-56

{
  "names": ["umount", "umount2"],
  "action": "SCMP_ACT_ERRNO",
  "errnoRet": 1,
  "comment": "Block unmounting filesystems - mount is allowed for procfs but unmount is not needed"
}
  • mount syscall removed from seccomp blocklist (needed for entrypoint.sh mount)
  • umount/umount2 syscalls remain blocked by seccomp
  • ✅ User code cannot unmount the procfs mount

3. Procfs is Container-Scoped

  • mount -t proc proc /host/proc creates a new procfs mount (not bind mount)
  • ✅ Container-scoped procfs only shows container processes, not host processes
  • ✅ No exposure of host process information (env vars, cmdlines, etc.)
  • ✅ Mount flags include nosuid,nodev,noexec (hardening)

4. Capability Drop is Comprehensive

File: containers/agent/entrypoint.sh:136-139

if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
  CAPS_TO_DROP="cap_net_admin,cap_sys_chroot,cap_sys_admin"
  # ...
  • ✅ CAP_SYS_ADMIN dropped before user code execution
  • ✅ CAP_NET_ADMIN dropped (prevents iptables bypass)
  • ✅ CAP_SYS_CHROOT dropped (prevents nested chroot)
  • ✅ Uses capsh --drop which removes from bounding set (cannot be regained)

5. Non-Root Execution Verified

  • ✅ User code runs as awfuser (non-root) via capsh --user
  • ✅ Even without capabilities, mount() syscall requires root or CAP_SYS_ADMIN
  • ✅ Non-root user cannot call mount() even if seccomp allows it

📊 Summary

Security Layer Status Notes
Capability Dropping ✅ Secure SYS_ADMIN dropped before user code
Seccomp Profile ✅ Secure Umount blocked, mount allowed only for entrypoint
No New Privileges ✅ Secure Prevents privilege escalation via execve
Non-root Execution ✅ Secure User code runs as awfuser
AppArmor ⚠️ Weakened Disabled in chroot mode (apparmor:unconfined)
Procfs Isolation ✅ Secure Container-scoped with hardened mount options

🎯 Recommendation

The PR maintains functional security BUT weakens defense-in-depth by removing AppArmor. I recommend implementing Option 1 (Custom AppArmor Profile) before merging to maintain all security layers while enabling the required functionality.

If AppArmor remains disabled, ensure this is:

  1. ✅ Documented in security audit logs
  2. ✅ Tested with security regression tests
  3. ✅ Reviewed by security team

The other changes (procfs mounting, capability management, seccomp profile updates) are implemented securely and follow security best practices.


References

  • [AppArmor security profiles for Docker | Docker Docs]((docs.docker.com/redacted)
  • [Container security fundamentals part 5: AppArmor and SELinux | Datadog Security Labs]((securitylabs.datadoghq.com/redacted)

AI generated by Security Guard

@Mossaka Mossaka merged commit dda7c67 into main Feb 6, 2026
81 checks passed
@Mossaka Mossaka deleted the fix/chroot-java-dotnet-proc branch February 6, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant