diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 77a25f10a..016c33330 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -155,8 +155,9 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then # 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. + # SECURITY: This creates a NEW procfs (mount -t proc), NOT a bind mount of host's /proc. + # Result: Container processes see only container PIDs, not host processes. + # The mount requires SYS_ADMIN capability (granted at container start, dropped before user code). 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)" diff --git a/containers/agent/one-shot-token/README.md b/containers/agent/one-shot-token/README.md index 56316e4eb..fe631a0af 100644 --- a/containers/agent/one-shot-token/README.md +++ b/containers/agent/one-shot-token/README.md @@ -274,6 +274,20 @@ Note: The `AWF_ONE_SHOT_TOKENS` variable must be exported before running `awf` s - **In-process getenv() calls**: Since values are cached, any code in the same process can still call `getenv()` and get the cached token - **Static linking**: Programs statically linked with libc bypass LD_PRELOAD - **Direct syscalls**: Code that reads `/proc/self/environ` directly (without getenv) bypasses this protection +- **Task-level /proc exposure**: `/proc/PID/task/TID/environ` may still expose tokens even after `unsetenv()`. The library checks and logs warnings about this exposure. + +### Environment Verification + +After calling `unsetenv()` to clear tokens, the library automatically verifies whether the token was successfully removed by directly checking the process's environment pointer. This works correctly in both regular and chroot modes. + +**Log messages:** +- `INFO: Token cleared from process environment` - Token successfully cleared (✓ secure) +- `WARNING: Token still exposed in process environment` - Token still visible (⚠ security concern) +- `INFO: Token cleared (environ is null)` - Environment pointer is null + +This verification runs automatically after `unsetenv()` on first access to each sensitive token and helps identify potential security issues with environment exposure. + +**Note on chroot mode:** The verification uses the process's `environ` pointer directly rather than reading from `/proc/self/environ`. This is necessary because in chroot mode, `/proc` may be bind-mounted from the host and show stale environment data. ### Defense in Depth diff --git a/containers/agent/one-shot-token/src/lib.rs b/containers/agent/one-shot-token/src/lib.rs index d52255ce8..0683a1cce 100644 --- a/containers/agent/one-shot-token/src/lib.rs +++ b/containers/agent/one-shot-token/src/lib.rs @@ -19,6 +19,12 @@ use std::ffi::{CStr, CString}; use std::ptr; use std::sync::Mutex; +// External declaration of the environ pointer +// This is a POSIX standard global that points to the process's environment +extern "C" { + static mut environ: *mut *mut c_char; +} + /// Maximum number of tokens we can track const MAX_TOKENS: usize = 100; @@ -196,6 +202,52 @@ fn format_token_value(value: &str) -> String { } } +/// Check if a token still exists in the process environment +/// +/// This function verifies whether unsetenv() successfully cleared the token +/// by directly checking the process's environ pointer. This works correctly +/// in both chroot and non-chroot modes (reading /proc/self/environ fails in +/// chroot because it shows the host's procfs, not the chrooted process's state). +fn check_task_environ_exposure(token_name: &str) { + // SAFETY: environ is a standard POSIX global that points to the process's environment. + // It's safe to read as long as we don't hold references across modifications. + // We're only reading it after unsetenv() has completed, so the pointer is stable. + unsafe { + let mut env_ptr = environ; + if env_ptr.is_null() { + eprintln!("[one-shot-token] INFO: Token {} cleared (environ is null)", token_name); + return; + } + + // Iterate through environment variables + let token_prefix = format!("{}=", token_name); + let token_prefix_bytes = token_prefix.as_bytes(); + + while !(*env_ptr).is_null() { + let env_cstr = CStr::from_ptr(*env_ptr); + let env_bytes = env_cstr.to_bytes(); + + // Check if this entry starts with our token name + if env_bytes.len() >= token_prefix_bytes.len() + && &env_bytes[..token_prefix_bytes.len()] == token_prefix_bytes { + eprintln!( + "[one-shot-token] WARNING: Token {} still exposed in process environment", + token_name + ); + return; + } + + env_ptr = env_ptr.add(1); + } + + // Token not found in environment - success! + eprintln!( + "[one-shot-token] INFO: Token {} cleared from process environment", + token_name + ); + } +} + /// Core implementation for cached token access /// /// # Safety @@ -268,9 +320,12 @@ unsafe fn handle_getenv_impl( // Cache the pointer so subsequent reads return the same value state.cache.insert(name_str.to_string(), cached); - // Unset the environment variable so /proc/self/environ is cleared + // Unset the environment variable so it's no longer accessible libc::unsetenv(name); + // Verify the token was cleared from the process environment + check_task_environ_exposure(name_str); + let suffix = if via_secure { " (via secure_getenv)" } else { "" }; eprintln!( "[one-shot-token] Token {} accessed and cached (value: {}){}", diff --git a/docs/chroot-mode.md b/docs/chroot-mode.md index 851943bf1..8bd70fec7 100644 --- a/docs/chroot-mode.md +++ b/docs/chroot-mode.md @@ -97,6 +97,8 @@ As of v0.13.13, chroot mode mounts a fresh container-scoped procfs at `/host/pro **Security implications:** - The mounted procfs only exposes container processes, not host processes +- **SECURITY GUARANTEE**: No process inside the container can read the host's /proc filesystem +- The procfs mount is type `proc` (new filesystem), NOT a bind mount of the host's /proc - Mount operation completes before user code runs (capability dropped) - procfs is mounted with security restrictions: `nosuid,nodev,noexec` - User code cannot unmount or remount (no `CAP_SYS_ADMIN`, umount blocked in seccomp) diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 0f290a3b1..426fec0e7 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -480,12 +480,13 @@ export function generateDockerCompose( agentVolumes.push('/opt:/host/opt:ro'); // Special filesystem mounts for chroot (needed for devices and runtime introspection) - // 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. + // SECURITY: /proc is NOT bind-mounted from host. Instead, a fresh container-scoped + // procfs is mounted at /host/proc in entrypoint.sh via 'mount -t proc'. This ensures: + // - Container processes can access /proc/self/exe (required by .NET CLR, JVM) + // - /proc/cpuinfo, /proc/meminfo available (required by JVM, .NET GC) + // - ISOLATION: No process inside container can read host's /proc filesystem + // - Container-scoped procfs only shows container processes, not host processes + // - Mount requires SYS_ADMIN capability, which is dropped before user code runs agentVolumes.push( '/sys:/host/sys:ro', // Read-only sysfs '/dev:/host/dev:ro', // Read-only device nodes (needed by some runtimes)