Add environment sanitization

This commit is contained in:
JY Tan
2025-12-25 20:47:11 -08:00
parent 32d785c703
commit f86d9a2c82
17 changed files with 340 additions and 31 deletions

View File

@@ -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<br/>(JSON)"]
Manager
CmdCheck["Command<br/>Blocking"]
EnvSanitize["Env<br/>Sanitization"]
Sandbox["Platform Sandbox<br/>(macOS/Linux)"]
HTTP["HTTP Proxy<br/>(filtering)"]
SOCKS["SOCKS5 Proxy<br/>(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<br/>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<br/>(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

View File

@@ -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)

View File

@@ -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).

View File

@@ -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.

View File

@@ -1,4 +1,3 @@
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (

View File

@@ -1,6 +1,5 @@
//go:build linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (

View File

@@ -1,6 +1,5 @@
//go:build !linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import "time"

View File

@@ -1,6 +1,5 @@
//go:build linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (

View File

@@ -1,6 +1,5 @@
//go:build !linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
// LinuxFeatures describes available Linux sandboxing features.

View File

@@ -1,6 +1,5 @@
//go:build linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (

View File

@@ -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"

View File

@@ -1,6 +1,5 @@
//go:build linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (

View File

@@ -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.

View File

@@ -1,4 +1,3 @@
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (

View File

@@ -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"
}
}

View File

@@ -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))
}
}

View File

@@ -1,4 +1,3 @@
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (