From f86d9a2c8243007c5e25e6eaf77c86aea1f5535d Mon Sep 17 00:00:00 2001 From: JY Tan Date: Thu, 25 Dec 2025 20:47:11 -0800 Subject: [PATCH] Add environment sanitization --- ARCHITECTURE.md | 63 +++++++--- cmd/fence/main.go | 13 +- docs/security-model.md | 9 ++ internal/sandbox/command.go | 5 +- internal/sandbox/dangerous.go | 1 - internal/sandbox/linux_ebpf.go | 1 - internal/sandbox/linux_ebpf_stub.go | 1 - internal/sandbox/linux_features.go | 1 - internal/sandbox/linux_features_stub.go | 1 - internal/sandbox/linux_landlock.go | 1 - internal/sandbox/linux_landlock_stub.go | 1 - internal/sandbox/linux_seccomp.go | 1 - internal/sandbox/linux_seccomp_stub.go | 1 - internal/sandbox/monitor.go | 1 - internal/sandbox/sanitize.go | 114 +++++++++++++++++ internal/sandbox/sanitize_test.go | 156 ++++++++++++++++++++++++ internal/sandbox/shell.go | 1 - 17 files changed, 340 insertions(+), 31 deletions(-) create mode 100644 internal/sandbox/sanitize.go create mode 100644 internal/sandbox/sanitize_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2c84c46..605370a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,23 +1,28 @@ # Architecture -Fence restricts network and filesystem access for arbitrary commands. It works by: +Fence restricts network, filesystem, and command access for arbitrary commands. It works by: -1. **Intercepting network traffic** via HTTP/SOCKS5 proxies that filter by domain -2. **Sandboxing processes** using OS-native mechanisms (macOS sandbox-exec, Linux bubblewrap) -3. **Bridging connections** to allow controlled inbound/outbound traffic in isolated namespaces +1. **Blocking commands** via configurable deny/allow lists before execution +2. **Intercepting network traffic** via HTTP/SOCKS5 proxies that filter by domain +3. **Sandboxing processes** using OS-native mechanisms (macOS sandbox-exec, Linux bubblewrap) +4. **Sanitizing environment** by stripping dangerous variables (LD_PRELOAD, DYLD_INSERT_LIBRARIES, etc.) ```mermaid flowchart TB subgraph Fence Config["Config
(JSON)"] Manager + CmdCheck["Command
Blocking"] + EnvSanitize["Env
Sanitization"] Sandbox["Platform Sandbox
(macOS/Linux)"] HTTP["HTTP Proxy
(filtering)"] SOCKS["SOCKS5 Proxy
(filtering)"] end Config --> Manager - Manager --> Sandbox + Manager --> CmdCheck + CmdCheck --> EnvSanitize + EnvSanitize --> Sandbox Manager --> HTTP Manager --> SOCKS ``` @@ -42,6 +47,8 @@ fence/ │ ├── linux_features.go # Kernel feature detection │ ├── linux_*_stub.go # Non-Linux build stubs │ ├── monitor.go # macOS log stream violation monitoring +│ ├── command.go # Command blocking/allow lists +│ ├── hardening.go # Environment sanitization │ ├── dangerous.go # Protected file/directory lists │ ├── shell.go # Shell quoting utilities │ └── utils.go # Path normalization @@ -59,12 +66,13 @@ Handles loading and validating sandbox configuration: type Config struct { Network NetworkConfig // Domain allow/deny lists Filesystem FilesystemConfig // Read/write restrictions + Command CommandConfig // Command deny/allow lists AllowPty bool // Allow pseudo-terminal allocation } ``` - Loads from `~/.fence.json` or custom path -- Falls back to restrictive defaults (block all network) +- Falls back to restrictive defaults (block all network, default command deny list) - Validates paths and normalizes them ### Platform (`internal/platform/`) @@ -108,8 +116,27 @@ Orchestrates the sandbox lifecycle: 1. Initializes HTTP and SOCKS proxies 2. Sets up platform-specific bridges (Linux) -3. Wraps commands with sandbox restrictions -4. Handles cleanup on exit +3. Checks command against deny/allow lists +4. Wraps commands with sandbox restrictions +5. Handles cleanup on exit + +#### Command Blocking (`command.go`) + +Blocks commands before they run based on configurable policies: + +- **Default deny list**: Dangerous system commands (`shutdown`, `reboot`, `mkfs`, `rm -rf`, etc.) +- **Custom deny/allow**: User-configured prefixes (e.g., `git push`, `npm publish`) +- **Chain detection**: Parses `&&`, `||`, `;`, `|` to catch blocked commands in pipelines +- **Nested shells**: Detects `bash -c "blocked_cmd"` patterns + +#### Environment Sanitization (`hardening.go`) + +Strips dangerous environment variables before command execution: + +- Linux: `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, etc. +- macOS: `DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, etc. + +This prevents library injection attacks where a sandboxed process writes a malicious `.so`/`.dylib` and uses `LD_PRELOAD`/`DYLD_INSERT_LIBRARIES` in a subsequent command. #### macOS Implementation (`macos.go`) @@ -229,15 +256,18 @@ flowchart TD D1 & D2 & D3 & D4 --> E["5. Manager.WrapCommand()"] - E --> E1["[macOS] Generate Seatbelt profile"] - E --> E2["[Linux] Generate bwrap command"] + E --> E0{"Check command
deny/allow lists"} + E0 -->|blocked| ERR["Return error"] + E0 -->|allowed| E1["[macOS] Generate Seatbelt profile"] + E0 -->|allowed| E2["[Linux] Generate bwrap command"] - E1 & E2 --> F["6. Execute wrapped command"] - F --> G["7. Manager.Cleanup()"] + E1 & E2 --> F["6. Sanitize env
(strip LD_*/DYLD_*)"] + F --> G["7. Execute wrapped command"] + G --> H["8. Manager.Cleanup()"] - G --> G1["Kill socat processes"] - G --> G2["Remove Unix sockets"] - G --> G3["Stop proxy servers"] + H --> H1["Kill socat processes"] + H --> H2["Remove Unix sockets"] + H --> H3["Stop proxy servers"] ``` ## Platform Comparison @@ -251,6 +281,7 @@ flowchart TD | Syscall filtering | Implicit (Seatbelt) | seccomp BPF | | Inbound connections | Profile rules (`network-bind`) | Reverse socat bridges | | Violation monitoring | log stream + proxy | eBPF + proxy | +| Env sanitization | Strips DYLD_* | Strips LD_* | | Requirements | Built-in | bwrap, socat | ### Linux Security Layers @@ -269,7 +300,7 @@ See [Linux Security Features](./docs/linux-security-features.md) for details. ## Violation Monitoring -The `-m` (monitor) flag enables real-time visibility into blocked operations. +The `-m` (monitor) flag enables real-time visibility into blocked operations. These only apply to filesystem and network operations, not blocked commands. ### Output Prefixes diff --git a/cmd/fence/main.go b/cmd/fence/main.go index 2dfe007..2b68a95 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -185,7 +185,15 @@ func runCommand(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "[fence] Sandboxed command: %s\n", sandboxedCommand) } + hardenedEnv := sandbox.GetHardenedEnv() + if debug { + if stripped := sandbox.GetStrippedEnvVars(os.Environ()); len(stripped) > 0 { + fmt.Fprintf(os.Stderr, "[fence] Stripped dangerous env vars: %v\n", stripped) + } + } + execCmd := exec.Command("sh", "-c", sandboxedCommand) //nolint:gosec // sandboxedCommand is constructed from user input - intentional + execCmd.Env = hardenedEnv execCmd.Stdin = os.Stdin execCmd.Stdout = os.Stdout execCmd.Stderr = os.Stderr @@ -318,8 +326,11 @@ parseCommand: fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Exec: %s %v\n", execPath, command[1:]) } + // Sanitize environment (strips LD_PRELOAD, etc.) + hardenedEnv := sandbox.FilterDangerousEnv(os.Environ()) + // Exec the command (replaces this process) - err = syscall.Exec(execPath, command, os.Environ()) //nolint:gosec + err = syscall.Exec(execPath, command, hardenedEnv) //nolint:gosec if err != nil { fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Exec failed: %v\n", err) os.Exit(1) diff --git a/docs/security-model.md b/docs/security-model.md index 7858a67..a74c3ec 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -43,6 +43,15 @@ Localhost is separate from "external domains": - **denyRead** can block reads from sensitive paths. - Fence includes an internal list of always-protected targets (e.g. shell configs, git hooks) to reduce common persistence vectors. +### Environment sanitization + +Fence strips dangerous environment variables before passing them to sandboxed commands: + +- `LD_*` (Linux): `LD_PRELOAD`, `LD_LIBRARY_PATH`, etc. +- `DYLD_*` (macOS): `DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, etc. + +This prevents a library injection attack where a sandboxed process writes a malicious `.so`/`.dylib` and then uses `LD_PRELOAD`/`DYLD_INSERT_LIBRARIES` in a subsequent command to load it. + ## Visibility / auditing - `-m/--monitor` helps you discover what a command *tries* to access (blocked only). diff --git a/internal/sandbox/command.go b/internal/sandbox/command.go index f077a0e..440e788 100644 --- a/internal/sandbox/command.go +++ b/internal/sandbox/command.go @@ -1,4 +1,3 @@ -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import ( @@ -18,9 +17,9 @@ type CommandBlockedError struct { func (e *CommandBlockedError) Error() string { if e.IsDefault { - return fmt.Sprintf("command blocked by default policy: %q matches %q", e.Command, e.BlockedPrefix) + return fmt.Sprintf("command blocked by default sandbox command policy: %q matches %q", e.Command, e.BlockedPrefix) } - return fmt.Sprintf("command blocked by policy: %q matches %q", e.Command, e.BlockedPrefix) + return fmt.Sprintf("command blocked by sandbox command policy: %q matches %q", e.Command, e.BlockedPrefix) } // CheckCommand checks if a command is allowed by the configuration. diff --git a/internal/sandbox/dangerous.go b/internal/sandbox/dangerous.go index 2a7d1a1..bd35bbc 100644 --- a/internal/sandbox/dangerous.go +++ b/internal/sandbox/dangerous.go @@ -1,4 +1,3 @@ -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import ( diff --git a/internal/sandbox/linux_ebpf.go b/internal/sandbox/linux_ebpf.go index 73ffff2..ce4ea4a 100644 --- a/internal/sandbox/linux_ebpf.go +++ b/internal/sandbox/linux_ebpf.go @@ -1,6 +1,5 @@ //go:build linux -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import ( diff --git a/internal/sandbox/linux_ebpf_stub.go b/internal/sandbox/linux_ebpf_stub.go index 9de847e..cd5aa8d 100644 --- a/internal/sandbox/linux_ebpf_stub.go +++ b/internal/sandbox/linux_ebpf_stub.go @@ -1,6 +1,5 @@ //go:build !linux -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import "time" diff --git a/internal/sandbox/linux_features.go b/internal/sandbox/linux_features.go index da3ed5e..290c476 100644 --- a/internal/sandbox/linux_features.go +++ b/internal/sandbox/linux_features.go @@ -1,6 +1,5 @@ //go:build linux -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import ( diff --git a/internal/sandbox/linux_features_stub.go b/internal/sandbox/linux_features_stub.go index 850b451..3db8224 100644 --- a/internal/sandbox/linux_features_stub.go +++ b/internal/sandbox/linux_features_stub.go @@ -1,6 +1,5 @@ //go:build !linux -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox // LinuxFeatures describes available Linux sandboxing features. diff --git a/internal/sandbox/linux_landlock.go b/internal/sandbox/linux_landlock.go index 8706bf0..4ab71ed 100644 --- a/internal/sandbox/linux_landlock.go +++ b/internal/sandbox/linux_landlock.go @@ -1,6 +1,5 @@ //go:build linux -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import ( diff --git a/internal/sandbox/linux_landlock_stub.go b/internal/sandbox/linux_landlock_stub.go index 38e64f4..57166d4 100644 --- a/internal/sandbox/linux_landlock_stub.go +++ b/internal/sandbox/linux_landlock_stub.go @@ -1,6 +1,5 @@ //go:build !linux -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import "github.com/Use-Tusk/fence/internal/config" diff --git a/internal/sandbox/linux_seccomp.go b/internal/sandbox/linux_seccomp.go index 665eb89..ce28016 100644 --- a/internal/sandbox/linux_seccomp.go +++ b/internal/sandbox/linux_seccomp.go @@ -1,6 +1,5 @@ //go:build linux -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import ( diff --git a/internal/sandbox/linux_seccomp_stub.go b/internal/sandbox/linux_seccomp_stub.go index 953c557..b878c36 100644 --- a/internal/sandbox/linux_seccomp_stub.go +++ b/internal/sandbox/linux_seccomp_stub.go @@ -1,6 +1,5 @@ //go:build !linux -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox // SeccompFilter is a stub for non-Linux platforms. diff --git a/internal/sandbox/monitor.go b/internal/sandbox/monitor.go index 2dcb4d9..6484d5d 100644 --- a/internal/sandbox/monitor.go +++ b/internal/sandbox/monitor.go @@ -1,4 +1,3 @@ -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import ( diff --git a/internal/sandbox/sanitize.go b/internal/sandbox/sanitize.go new file mode 100644 index 0000000..b314c38 --- /dev/null +++ b/internal/sandbox/sanitize.go @@ -0,0 +1,114 @@ +package sandbox + +import ( + "os" + "runtime" + "strings" +) + +// DangerousEnvPrefixes lists environment variable prefixes that can be used +// to subvert library loading and should be stripped from sandboxed processes. +// +// - LD_* (Linux): LD_PRELOAD, LD_LIBRARY_PATH can inject malicious shared libraries +// - DYLD_* (macOS): DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH can inject dylibs +var DangerousEnvPrefixes = []string{ + "LD_", // Linux dynamic linker + "DYLD_", // macOS dynamic linker +} + +// DangerousEnvVars lists specific environment variables that should be stripped. +var DangerousEnvVars = []string{ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "LD_DEBUG", + "LD_DEBUG_OUTPUT", + "LD_DYNAMIC_WEAK", + "LD_ORIGIN_PATH", + "LD_PROFILE", + "LD_PROFILE_OUTPUT", + "LD_SHOW_AUXV", + "LD_TRACE_LOADED_OBJECTS", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + "DYLD_FALLBACK_FRAMEWORK_PATH", + "DYLD_IMAGE_SUFFIX", + "DYLD_FORCE_FLAT_NAMESPACE", + "DYLD_PRINT_LIBRARIES", + "DYLD_PRINT_APIS", +} + +// GetHardenedEnv returns a copy of the current environment with dangerous +// variables removed. This prevents library injection attacks where a malicious +// agent writes a .so/.dylib and then uses LD_PRELOAD/DYLD_INSERT_LIBRARIES +// in a subsequent command. +func GetHardenedEnv() []string { + return FilterDangerousEnv(os.Environ()) +} + +// FilterDangerousEnv filters out dangerous environment variables from the given slice. +func FilterDangerousEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !isDangerousEnvVar(e) { + filtered = append(filtered, e) + } + } + return filtered +} + +// isDangerousEnvVar checks if an environment variable entry (KEY=VALUE) is dangerous. +func isDangerousEnvVar(entry string) bool { + // Split on first '=' to get the key + key := entry + if idx := strings.Index(entry, "="); idx != -1 { + key = entry[:idx] + } + + // Check against known dangerous prefixes + for _, prefix := range DangerousEnvPrefixes { + if strings.HasPrefix(key, prefix) { + return true + } + } + + // Check against specific dangerous vars + for _, dangerous := range DangerousEnvVars { + if key == dangerous { + return true + } + } + + return false +} + +// GetStrippedEnvVars returns a list of environment variable names that were +// stripped from the given environment. Useful for debug logging. +func GetStrippedEnvVars(env []string) []string { + var stripped []string + for _, e := range env { + if isDangerousEnvVar(e) { + // Extract just the key + if idx := strings.Index(e, "="); idx != -1 { + stripped = append(stripped, e[:idx]) + } else { + stripped = append(stripped, e) + } + } + } + return stripped +} + +// HardeningFeatures returns a description of environment sanitization applied on this platform. +func HardeningFeatures() string { + switch runtime.GOOS { + case "linux": + return "env-filter(LD_*)" + case "darwin": + return "env-filter(DYLD_*)" + default: + return "env-filter" + } +} diff --git a/internal/sandbox/sanitize_test.go b/internal/sandbox/sanitize_test.go new file mode 100644 index 0000000..3d27834 --- /dev/null +++ b/internal/sandbox/sanitize_test.go @@ -0,0 +1,156 @@ +package sandbox + +import ( + "testing" +) + +func TestIsDangerousEnvVar(t *testing.T) { + tests := []struct { + entry string + dangerous bool + }{ + // Linux LD_* variables + {"LD_PRELOAD=/tmp/evil.so", true}, + {"LD_LIBRARY_PATH=/tmp", true}, + {"LD_AUDIT=/tmp/audit.so", true}, + {"LD_DEBUG=all", true}, + + // macOS DYLD_* variables + {"DYLD_INSERT_LIBRARIES=/tmp/evil.dylib", true}, + {"DYLD_LIBRARY_PATH=/tmp", true}, + {"DYLD_FRAMEWORK_PATH=/tmp", true}, + {"DYLD_FORCE_FLAT_NAMESPACE=1", true}, + + // Safe variables + {"PATH=/usr/bin:/bin", false}, + {"HOME=/home/user", false}, + {"USER=user", false}, + {"SHELL=/bin/bash", false}, + {"HTTP_PROXY=http://localhost:8080", false}, + {"HTTPS_PROXY=http://localhost:8080", false}, + + // Edge cases - variables that start with similar prefixes but aren't dangerous + {"LDFLAGS=-L/usr/lib", false}, // Not LD_ prefix + {"DISPLAY=:0", false}, + + // Empty and malformed + {"LD_PRELOAD", true}, // No value but still dangerous + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.entry, func(t *testing.T) { + got := isDangerousEnvVar(tt.entry) + if got != tt.dangerous { + t.Errorf("isDangerousEnvVar(%q) = %v, want %v", tt.entry, got, tt.dangerous) + } + }) + } +} + +func TestFilterDangerousEnv(t *testing.T) { + env := []string{ + "PATH=/usr/bin:/bin", + "LD_PRELOAD=/tmp/evil.so", + "HOME=/home/user", + "DYLD_INSERT_LIBRARIES=/tmp/evil.dylib", + "HTTP_PROXY=http://localhost:8080", + "LD_LIBRARY_PATH=/tmp", + } + + filtered := FilterDangerousEnv(env) + + // Should have 3 safe vars + if len(filtered) != 3 { + t.Errorf("expected 3 safe vars, got %d: %v", len(filtered), filtered) + } + + // Verify the safe vars are present + expected := map[string]bool{ + "PATH=/usr/bin:/bin": true, + "HOME=/home/user": true, + "HTTP_PROXY=http://localhost:8080": true, + } + + for _, e := range filtered { + if !expected[e] { + t.Errorf("unexpected var in filtered env: %s", e) + } + } + + // Verify dangerous vars are gone + for _, e := range filtered { + if isDangerousEnvVar(e) { + t.Errorf("dangerous var not filtered: %s", e) + } + } +} + +func TestGetStrippedEnvVars(t *testing.T) { + env := []string{ + "PATH=/usr/bin", + "LD_PRELOAD=/tmp/evil.so", + "DYLD_INSERT_LIBRARIES=/tmp/evil.dylib", + "HOME=/home/user", + } + + stripped := GetStrippedEnvVars(env) + + if len(stripped) != 2 { + t.Errorf("expected 2 stripped vars, got %d: %v", len(stripped), stripped) + } + + // Should contain just the keys, not values + found := make(map[string]bool) + for _, s := range stripped { + found[s] = true + } + + if !found["LD_PRELOAD"] { + t.Error("expected LD_PRELOAD to be in stripped list") + } + if !found["DYLD_INSERT_LIBRARIES"] { + t.Error("expected DYLD_INSERT_LIBRARIES to be in stripped list") + } +} + +func TestFilterDangerousEnv_EmptyInput(t *testing.T) { + filtered := FilterDangerousEnv(nil) + if filtered == nil { + t.Error("expected non-nil slice for nil input") + } + if len(filtered) != 0 { + t.Errorf("expected empty slice, got %v", filtered) + } + + filtered = FilterDangerousEnv([]string{}) + if len(filtered) != 0 { + t.Errorf("expected empty slice, got %v", filtered) + } +} + +func TestFilterDangerousEnv_AllDangerous(t *testing.T) { + env := []string{ + "LD_PRELOAD=/tmp/evil.so", + "LD_LIBRARY_PATH=/tmp", + "DYLD_INSERT_LIBRARIES=/tmp/evil.dylib", + } + + filtered := FilterDangerousEnv(env) + if len(filtered) != 0 { + t.Errorf("expected all vars to be filtered, got %v", filtered) + } +} + +func TestFilterDangerousEnv_AllSafe(t *testing.T) { + env := []string{ + "PATH=/usr/bin", + "HOME=/home/user", + "USER=test", + } + + filtered := FilterDangerousEnv(env) + if len(filtered) != 3 { + t.Errorf("expected all 3 vars to pass through, got %d", len(filtered)) + } +} diff --git a/internal/sandbox/shell.go b/internal/sandbox/shell.go index 4360f17..154a7d9 100644 --- a/internal/sandbox/shell.go +++ b/internal/sandbox/shell.go @@ -1,4 +1,3 @@ -// Package sandbox provides sandboxing functionality for macOS and Linux. package sandbox import (