Initial commit
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Binary (only at root, not cmd/fence or pkg/fence)
|
||||||
|
/fence
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
*.test
|
||||||
|
coverage.out
|
||||||
|
|
||||||
338
ARCHITECTURE.md
Normal file
338
ARCHITECTURE.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# Fence Architecture
|
||||||
|
|
||||||
|
Fence restricts network and filesystem 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
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Fence
|
||||||
|
Config["Config<br/>(JSON)"]
|
||||||
|
Manager
|
||||||
|
Sandbox["Platform Sandbox<br/>(macOS/Linux)"]
|
||||||
|
HTTP["HTTP Proxy<br/>(filtering)"]
|
||||||
|
SOCKS["SOCKS5 Proxy<br/>(filtering)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Config --> Manager
|
||||||
|
Manager --> Sandbox
|
||||||
|
Manager --> HTTP
|
||||||
|
Manager --> SOCKS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
fence/
|
||||||
|
├── cmd/fence/ # CLI entry point
|
||||||
|
│ └── main.go
|
||||||
|
├── internal/ # Private implementation
|
||||||
|
│ ├── config/ # Configuration loading/validation
|
||||||
|
│ ├── platform/ # OS detection
|
||||||
|
│ ├── proxy/ # HTTP and SOCKS5 filtering proxies
|
||||||
|
│ └── sandbox/ # Platform-specific sandboxing
|
||||||
|
│ ├── manager.go # Orchestrates sandbox lifecycle
|
||||||
|
│ ├── macos.go # macOS sandbox-exec profiles
|
||||||
|
│ ├── linux.go # Linux bubblewrap + socat bridges
|
||||||
|
│ ├── dangerous.go # Protected file/directory lists
|
||||||
|
│ └── utils.go # Path normalization, shell quoting
|
||||||
|
└── pkg/fence/ # Public Go API
|
||||||
|
└── fence.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
### Config (`internal/config/`)
|
||||||
|
|
||||||
|
Handles loading and validating sandbox configuration:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
Network NetworkConfig // Domain allow/deny lists
|
||||||
|
Filesystem FilesystemConfig // Read/write restrictions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Loads from `~/.fence.json` or custom path
|
||||||
|
- Falls back to restrictive defaults (block all network)
|
||||||
|
- Validates paths and normalizes them
|
||||||
|
|
||||||
|
### Platform (`internal/platform/`)
|
||||||
|
|
||||||
|
Simple OS detection:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Detect() Platform // Returns MacOS, Linux, Windows, or Unknown
|
||||||
|
func IsSupported() bool // True for MacOS and Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proxy (`internal/proxy/`)
|
||||||
|
|
||||||
|
Two proxy servers that filter traffic by domain:
|
||||||
|
|
||||||
|
#### HTTP Proxy (`http.go`)
|
||||||
|
|
||||||
|
- Handles HTTP and HTTPS (via CONNECT tunneling)
|
||||||
|
- Extracts domain from Host header or CONNECT request
|
||||||
|
- Returns 403 for blocked domains
|
||||||
|
- Listens on random available port
|
||||||
|
|
||||||
|
#### SOCKS5 Proxy (`socks.go`)
|
||||||
|
|
||||||
|
- Uses `github.com/things-go/go-socks5`
|
||||||
|
- Handles TCP connections (git, ssh, etc.)
|
||||||
|
- Same domain filtering logic as HTTP proxy
|
||||||
|
- Listens on random available port
|
||||||
|
|
||||||
|
**Domain Matching:**
|
||||||
|
|
||||||
|
- Exact match: `example.com`
|
||||||
|
- Wildcard prefix: `*.example.com` (matches `api.example.com`)
|
||||||
|
- Deny takes precedence over allow
|
||||||
|
|
||||||
|
### Sandbox (`internal/sandbox/`)
|
||||||
|
|
||||||
|
#### Manager (`manager.go`)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
#### macOS Implementation (`macos.go`)
|
||||||
|
|
||||||
|
Uses Apple's `sandbox-exec` with Seatbelt profiles:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph macOS Sandbox
|
||||||
|
CMD["User Command"]
|
||||||
|
SE["sandbox-exec -p profile"]
|
||||||
|
ENV["Environment Variables<br/>HTTP_PROXY, HTTPS_PROXY<br/>ALL_PROXY, GIT_SSH_COMMAND"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Profile Controls
|
||||||
|
NET["Network: deny except localhost"]
|
||||||
|
FS["Filesystem: read/write rules"]
|
||||||
|
PROC["Process: fork/exec permissions"]
|
||||||
|
end
|
||||||
|
|
||||||
|
CMD --> SE
|
||||||
|
SE --> ENV
|
||||||
|
SE -.-> NET
|
||||||
|
SE -.-> FS
|
||||||
|
SE -.-> PROC
|
||||||
|
```
|
||||||
|
|
||||||
|
Seatbelt profiles are generated dynamically based on config:
|
||||||
|
|
||||||
|
- `(deny default)` - deny all by default
|
||||||
|
- `(allow network-outbound (remote ip "localhost:*"))` - only allow proxy
|
||||||
|
- `(allow file-read* ...)` - selective file access
|
||||||
|
- `(allow process-fork)`, `(allow process-exec)` - allow running programs
|
||||||
|
|
||||||
|
#### Linux Implementation (`linux.go`)
|
||||||
|
|
||||||
|
Uses `bubblewrap` (bwrap) with network namespace isolation:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Host
|
||||||
|
HTTP["HTTP Proxy<br/>:random"]
|
||||||
|
SOCKS["SOCKS Proxy<br/>:random"]
|
||||||
|
HSOCAT["socat<br/>(HTTP bridge)"]
|
||||||
|
SSOCAT["socat<br/>(SOCKS bridge)"]
|
||||||
|
USOCK["Unix Sockets<br/>/tmp/fence-*.sock"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Sandbox ["Sandbox (bwrap --unshare-net)"]
|
||||||
|
CMD["User Command"]
|
||||||
|
ISOCAT["socat :3128"]
|
||||||
|
ISOCKS["socat :1080"]
|
||||||
|
ENV2["HTTP_PROXY=127.0.0.1:3128"]
|
||||||
|
end
|
||||||
|
|
||||||
|
HTTP <--> HSOCAT
|
||||||
|
SOCKS <--> SSOCAT
|
||||||
|
HSOCAT <--> USOCK
|
||||||
|
SSOCAT <--> USOCK
|
||||||
|
USOCK <-->|bind-mounted| ISOCAT
|
||||||
|
USOCK <-->|bind-mounted| ISOCKS
|
||||||
|
CMD --> ISOCAT
|
||||||
|
CMD --> ISOCKS
|
||||||
|
CMD -.-> ENV2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why socat bridges?**
|
||||||
|
|
||||||
|
With `--unshare-net`, the sandbox has its own isolated network namespace - it cannot reach the host's network at all. Unix sockets provide filesystem-based IPC that works across namespace boundaries:
|
||||||
|
|
||||||
|
1. Host creates Unix socket, connects to TCP proxy
|
||||||
|
2. Socket file is bind-mounted into sandbox
|
||||||
|
3. Sandbox's socat listens on localhost:3128, forwards to Unix socket
|
||||||
|
4. Traffic flows: `sandbox:3128 → Unix socket → host proxy → internet`
|
||||||
|
|
||||||
|
## Inbound Connections (Reverse Bridge)
|
||||||
|
|
||||||
|
For servers running inside the sandbox that need to accept connections:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
EXT["External Request"]
|
||||||
|
|
||||||
|
subgraph Host
|
||||||
|
HSOCAT["socat<br/>TCP-LISTEN:8888"]
|
||||||
|
USOCK["Unix Socket<br/>/tmp/fence-rev-8888-*.sock"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Sandbox
|
||||||
|
ISOCAT["socat<br/>UNIX-LISTEN"]
|
||||||
|
APP["App Server<br/>:8888"]
|
||||||
|
end
|
||||||
|
|
||||||
|
EXT --> HSOCAT
|
||||||
|
HSOCAT -->|UNIX-CONNECT| USOCK
|
||||||
|
USOCK <-->|shared via bind /| ISOCAT
|
||||||
|
ISOCAT --> APP
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. Host socat listens on TCP port (e.g., 8888)
|
||||||
|
2. Sandbox socat creates Unix socket, forwards to app
|
||||||
|
3. External request → Host:8888 → Unix socket → Sandbox socat → App:8888
|
||||||
|
|
||||||
|
## Execution Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["1. CLI parses arguments"] --> B["2. Load config from ~/.fence.json"]
|
||||||
|
B --> C["3. Create Manager"]
|
||||||
|
C --> D["4. Manager.Initialize()"]
|
||||||
|
|
||||||
|
D --> D1["Start HTTP proxy"]
|
||||||
|
D --> D2["Start SOCKS proxy"]
|
||||||
|
D --> D3["[Linux] Create socat bridges"]
|
||||||
|
D --> D4["[Linux] Create reverse bridges"]
|
||||||
|
|
||||||
|
D1 & D2 & D3 & D4 --> E["5. Manager.WrapCommand()"]
|
||||||
|
|
||||||
|
E --> E1["[macOS] Generate Seatbelt profile"]
|
||||||
|
E --> E2["[Linux] Generate bwrap command"]
|
||||||
|
|
||||||
|
E1 & E2 --> F["6. Execute wrapped command"]
|
||||||
|
F --> G["7. Manager.Cleanup()"]
|
||||||
|
|
||||||
|
G --> G1["Kill socat processes"]
|
||||||
|
G --> G2["Remove Unix sockets"]
|
||||||
|
G --> G3["Stop proxy servers"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Comparison
|
||||||
|
|
||||||
|
| Feature | macOS | Linux |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| Sandbox mechanism | sandbox-exec (Seatbelt) | bubblewrap (namespaces) |
|
||||||
|
| Network isolation | Syscall filtering | Network namespace |
|
||||||
|
| Proxy routing | Environment variables | socat bridges + env vars |
|
||||||
|
| Filesystem control | Profile rules | Bind mounts |
|
||||||
|
| Inbound connections | Profile rules (`network-bind`) | Reverse socat bridges |
|
||||||
|
| Requirements | Built-in | bwrap, socat |
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
### How Each Layer Works
|
||||||
|
|
||||||
|
#### Network Isolation
|
||||||
|
|
||||||
|
All outbound connections are routed through local HTTP/SOCKS5 proxies that filter by domain:
|
||||||
|
|
||||||
|
- Direct socket connections are blocked at the OS level (syscall filtering on macOS, network namespace on Linux)
|
||||||
|
- Only localhost connections to the proxy ports are allowed
|
||||||
|
- The proxy inspects the target domain and allows/denies based on config
|
||||||
|
- Environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`) route application traffic
|
||||||
|
|
||||||
|
#### Filesystem Restrictions
|
||||||
|
|
||||||
|
Access control follows a deny-by-default model for writes:
|
||||||
|
|
||||||
|
| Operation | Default | Config |
|
||||||
|
|-----------|---------|--------|
|
||||||
|
| Read | Allow all | `denyRead` blocks specific paths |
|
||||||
|
| Write | Deny all | `allowWrite` permits specific paths |
|
||||||
|
| Write exceptions | - | `denyWrite` overrides `allowWrite` |
|
||||||
|
|
||||||
|
#### Dangerous File Protection
|
||||||
|
|
||||||
|
Certain paths are always protected regardless of config to prevent common attack vectors:
|
||||||
|
|
||||||
|
- Shell configs: `.bashrc`, `.zshrc`, `.profile`, `.bash_profile`
|
||||||
|
- Git hooks: `.git/hooks/*` (can execute arbitrary code on git operations)
|
||||||
|
- Git config: `.gitconfig`, `.git/config` (can define aliases that run code)
|
||||||
|
- SSH config: `.ssh/config`, `.ssh/authorized_keys`
|
||||||
|
- Editor configs that can execute code: `.vimrc`, `.emacs`
|
||||||
|
|
||||||
|
#### Process Isolation
|
||||||
|
|
||||||
|
- Commands run in isolated namespaces (Linux) or with syscall restrictions (macOS)
|
||||||
|
- PID namespace isolation prevents seeing/signaling host processes (Linux)
|
||||||
|
- New session prevents terminal control attacks
|
||||||
|
|
||||||
|
### What Fence Blocks
|
||||||
|
|
||||||
|
| Attack Vector | How It's Blocked |
|
||||||
|
|---------------|------------------|
|
||||||
|
| Arbitrary network access | Proxy filtering + OS-level enforcement |
|
||||||
|
| Data exfiltration to unknown hosts | Only allowlisted domains reachable |
|
||||||
|
| Writing malicious git hooks | `.git/hooks` always protected |
|
||||||
|
| Modifying shell startup files | `.bashrc`, `.zshrc`, etc. always protected |
|
||||||
|
| Reading sensitive paths | Configurable via `denyRead` |
|
||||||
|
| Arbitrary filesystem writes | Only `allowWrite` paths permitted |
|
||||||
|
|
||||||
|
### What Fence Allows
|
||||||
|
|
||||||
|
- Network access to explicitly allowed domains
|
||||||
|
- Filesystem reads (except `denyRead` paths)
|
||||||
|
- Filesystem writes to `allowWrite` paths
|
||||||
|
- Process spawning within the sandbox
|
||||||
|
- Inbound connections on exposed ports (`-p` flag)
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
#### Domain-based filtering only
|
||||||
|
|
||||||
|
Fence doesn't inspect request content. A sandboxed process can:
|
||||||
|
|
||||||
|
- Download arbitrary code from allowed domains
|
||||||
|
- Exfiltrate data by encoding it in requests to allowed domains
|
||||||
|
- Use allowed domains as tunnels if they support it
|
||||||
|
|
||||||
|
You're trusting your allowlist - if you allow `*.github.com`, the sandbox can reach any GitHub-hosted content.
|
||||||
|
|
||||||
|
#### Root processes may escape restrictions
|
||||||
|
|
||||||
|
- *Linux*: The sandboxed process has root inside its namespace and could potentially manipulate mounts, cgroups, or exploit kernel vulnerabilities. Network namespace isolation (`--unshare-net`) is solid, but filesystem isolation via bind mounts is more permeable.
|
||||||
|
- *macOS*: `sandbox-exec` is robust at the kernel level, but root can modify system state before the sandbox starts or (on older macOS) load kernel extensions.
|
||||||
|
|
||||||
|
#### macOS sandbox-exec is deprecated
|
||||||
|
|
||||||
|
Apple deprecated `sandbox-exec` but it still works on current macOS (including Sequoia). There's no good alternative for CLI sandboxing:
|
||||||
|
|
||||||
|
- App Sandbox requires signed `.app` bundles
|
||||||
|
- Virtualization.framework is heavyweight
|
||||||
|
- We use `sandbox-exec` pragmatically until Apple removes it
|
||||||
|
|
||||||
|
#### Not for hostile code containment
|
||||||
|
|
||||||
|
Fence is defense-in-depth for running semi-trusted code (npm install, build scripts, CI jobs), not a security boundary against actively malicious software designed to escape sandboxes.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `github.com/spf13/cobra` - CLI framework
|
||||||
|
- `github.com/things-go/go-socks5` - SOCKS5 proxy implementation
|
||||||
|
- `bubblewrap` (Linux) - Unprivileged sandboxing
|
||||||
|
- `socat` (Linux) - Socket relay for namespace bridging
|
||||||
194
README.md
Normal file
194
README.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# fence
|
||||||
|
|
||||||
|
A Go implementation of process sandboxing with network and filesystem restrictions.
|
||||||
|
|
||||||
|
`fence` wraps arbitrary commands in a security sandbox, blocking network access by default and restricting filesystem operations based on configurable rules.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This is still a work in progress and may see significant changes.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Network Isolation**: All network access blocked by default
|
||||||
|
- **Domain Allowlisting**: Configure which domains are allowed
|
||||||
|
- **Filesystem Restrictions**: Control read/write access to paths
|
||||||
|
- **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap)
|
||||||
|
- **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control
|
||||||
|
- **Library + CLI**: Use as a Go package or command-line tool
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install github.com/Use-Tusk/fence/cmd/fence@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Use-Tusk/fence
|
||||||
|
cd fence
|
||||||
|
go build -o fence ./cmd/fence
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This will be blocked (no domains allowed by default)
|
||||||
|
fence curl https://example.com
|
||||||
|
|
||||||
|
# Run with shell expansion
|
||||||
|
fence -c "echo hello && ls"
|
||||||
|
|
||||||
|
# Enable debug logging
|
||||||
|
fence -d curl https://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create `~/.fence.json` to configure allowed domains and filesystem access:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"network": {
|
||||||
|
"allowedDomains": ["github.com", "*.npmjs.org", "registry.yarnpkg.com"],
|
||||||
|
"deniedDomains": ["evil.com"]
|
||||||
|
},
|
||||||
|
"filesystem": {
|
||||||
|
"denyRead": ["/etc/passwd"],
|
||||||
|
"allowWrite": [".", "/tmp"],
|
||||||
|
"denyWrite": [".git/hooks"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Configuration
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `allowedDomains` | List of allowed domains. Supports wildcards like `*.example.com` |
|
||||||
|
| `deniedDomains` | List of denied domains (checked before allowed) |
|
||||||
|
| `allowUnixSockets` | List of allowed Unix socket paths (macOS) |
|
||||||
|
| `allowAllUnixSockets` | Allow all Unix sockets |
|
||||||
|
| `allowLocalBinding` | Allow binding to local ports |
|
||||||
|
|
||||||
|
### Filesystem Configuration
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `denyRead` | Paths to deny reading (deny-only pattern) |
|
||||||
|
| `allowWrite` | Paths to allow writing |
|
||||||
|
| `denyWrite` | Paths to deny writing (takes precedence) |
|
||||||
|
| `allowGitConfig` | Allow writes to `.git/config` files |
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
```text
|
||||||
|
fence [flags] [command...]
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
-c string Run command string directly (like sh -c)
|
||||||
|
-d, --debug Enable debug logging
|
||||||
|
-s, --settings Path to settings file (default: ~/.fence.json)
|
||||||
|
-h, --help Help for fence
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Block all network (default behavior)
|
||||||
|
fence curl https://example.com
|
||||||
|
# Output: curl: (7) Couldn't connect to server
|
||||||
|
|
||||||
|
# Use a custom config
|
||||||
|
fence --settings ./my-config.json npm install
|
||||||
|
|
||||||
|
# Run a shell command
|
||||||
|
fence -c "git clone https://github.com/user/repo && cd repo && npm install"
|
||||||
|
|
||||||
|
# Debug mode shows proxy activity
|
||||||
|
fence -d wget https://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Library Usage
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/Use-Tusk/fence/pkg/fence"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Create config
|
||||||
|
cfg := &fence.Config{
|
||||||
|
Network: fence.NetworkConfig{
|
||||||
|
AllowedDomains: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
Filesystem: fence.FilesystemConfig{
|
||||||
|
AllowWrite: []string{"."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create manager
|
||||||
|
manager := fence.NewManager(cfg, false)
|
||||||
|
defer manager.Cleanup()
|
||||||
|
|
||||||
|
// Initialize (starts proxies)
|
||||||
|
if err := manager.Initialize(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap a command
|
||||||
|
wrapped, err := manager.WrapCommand("curl https://api.example.com/data")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Sandboxed command:", wrapped)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### macOS (sandbox-exec)
|
||||||
|
|
||||||
|
On macOS, fence uses Apple's `sandbox-exec` with a generated seatbelt profile that:
|
||||||
|
|
||||||
|
- Denies all operations by default
|
||||||
|
- Allows specific Mach services needed for basic operation
|
||||||
|
- Controls network access via localhost proxies
|
||||||
|
- Restricts filesystem read/write based on configuration
|
||||||
|
|
||||||
|
### Linux (bubblewrap)
|
||||||
|
|
||||||
|
On Linux, fence uses `bubblewrap` (bwrap) with:
|
||||||
|
|
||||||
|
- Network namespace isolation (`--unshare-net`)
|
||||||
|
- Filesystem bind mounts for access control
|
||||||
|
- PID namespace isolation
|
||||||
|
- Unix socket bridges for proxy communication
|
||||||
|
|
||||||
|
For detailed security model, limitations, and architecture, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
- macOS 10.12+ (uses `sandbox-exec`)
|
||||||
|
- No additional dependencies
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
- `bubblewrap` (bwrap)
|
||||||
|
- `socat` (for network bridging)
|
||||||
|
|
||||||
|
Install on Ubuntu/Debian:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install bubblewrap socat
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0
|
||||||
163
cmd/fence/main.go
Normal file
163
cmd/fence/main.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// Package main implements the fence CLI.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
|
"github.com/Use-Tusk/fence/internal/sandbox"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
debug bool
|
||||||
|
settingsPath string
|
||||||
|
cmdString string
|
||||||
|
exposePorts []string
|
||||||
|
exitCode int
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "fence [flags] -- [command...]",
|
||||||
|
Short: "Run commands in a sandbox with network and filesystem restrictions",
|
||||||
|
Long: `fence is a command-line tool that runs commands in a sandboxed environment
|
||||||
|
with network and filesystem restrictions.
|
||||||
|
|
||||||
|
By default, all network access is blocked. Configure allowed domains in
|
||||||
|
~/.fence.json or pass a settings file with --settings.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
fence curl https://example.com # Will be blocked (no domains allowed)
|
||||||
|
fence -- curl -s https://example.com # Use -- to separate fence flags from command
|
||||||
|
fence -c "echo hello && ls" # Run with shell expansion
|
||||||
|
fence --settings config.json npm install
|
||||||
|
fence -p 3000 -c "npm run dev" # Expose port 3000 for inbound connections
|
||||||
|
fence -p 3000 -p 8080 -c "npm start" # Expose multiple ports
|
||||||
|
|
||||||
|
Configuration file format (~/.fence.json):
|
||||||
|
{
|
||||||
|
"network": {
|
||||||
|
"allowedDomains": ["github.com", "*.npmjs.org"],
|
||||||
|
"deniedDomains": []
|
||||||
|
},
|
||||||
|
"filesystem": {
|
||||||
|
"denyRead": [],
|
||||||
|
"allowWrite": ["."],
|
||||||
|
"denyWrite": []
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
RunE: runCommand,
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
|
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: ~/.fence.json)")
|
||||||
|
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
|
||||||
|
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
|
||||||
|
|
||||||
|
rootCmd.Flags().SetInterspersed(true)
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
}
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCommand(cmd *cobra.Command, args []string) error {
|
||||||
|
var command string
|
||||||
|
if cmdString != "" {
|
||||||
|
command = cmdString
|
||||||
|
} else if len(args) > 0 {
|
||||||
|
command = strings.Join(args, " ")
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("no command specified. Use -c <command> or provide command arguments")
|
||||||
|
}
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence] Command: %s\n", command)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ports []int
|
||||||
|
for _, p := range exposePorts {
|
||||||
|
port, err := strconv.Atoi(p)
|
||||||
|
if err != nil || port < 1 || port > 65535 {
|
||||||
|
return fmt.Errorf("invalid port: %s", p)
|
||||||
|
}
|
||||||
|
ports = append(ports, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if debug && len(ports) > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence] Exposing ports: %v\n", ports)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := settingsPath
|
||||||
|
if configPath == "" {
|
||||||
|
configPath = config.DefaultConfigPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath)
|
||||||
|
}
|
||||||
|
cfg = config.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := sandbox.NewManager(cfg, debug)
|
||||||
|
manager.SetExposedPorts(ports)
|
||||||
|
defer manager.Cleanup()
|
||||||
|
|
||||||
|
if err := manager.Initialize(); err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize sandbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sandboxedCommand, err := manager.WrapCommand(command)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to wrap command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence] Sandboxed command: %s\n", sandboxedCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
execCmd := exec.Command("sh", "-c", sandboxedCommand)
|
||||||
|
execCmd.Stdin = os.Stdin
|
||||||
|
execCmd.Stdout = os.Stdout
|
||||||
|
execCmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sig := <-sigChan
|
||||||
|
if execCmd.Process != nil {
|
||||||
|
execCmd.Process.Signal(sig)
|
||||||
|
}
|
||||||
|
// Give child time to exit, then cleanup will happen via defer
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := execCmd.Run(); err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
// Set exit code but don't os.Exit() here - let deferred cleanup run
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("command failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
13
go.mod
Normal file
13
go.mod
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
module github.com/Use-Tusk/fence
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/cobra v1.8.1
|
||||||
|
github.com/things-go/go-socks5 v0.0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
||||||
21
go.sum
Normal file
21
go.sum
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/things-go/go-socks5 v0.0.5 h1:qvKaGcBkfDrUL33SchHN93srAmYGzb4CxSM2DPYufe8=
|
||||||
|
github.com/things-go/go-socks5 v0.0.5/go.mod h1:mtzInf8v5xmsBpHZVbIw2YQYhc4K0jRwzfsH64Uh0IQ=
|
||||||
|
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||||
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
173
internal/config/config.go
Normal file
173
internal/config/config.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// Package config defines the configuration types and loading for fence.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the main configuration for fence.
|
||||||
|
type Config struct {
|
||||||
|
Network NetworkConfig `json:"network"`
|
||||||
|
Filesystem FilesystemConfig `json:"filesystem"`
|
||||||
|
AllowPty bool `json:"allowPty,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkConfig defines network restrictions.
|
||||||
|
type NetworkConfig struct {
|
||||||
|
AllowedDomains []string `json:"allowedDomains"`
|
||||||
|
DeniedDomains []string `json:"deniedDomains"`
|
||||||
|
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
|
||||||
|
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
|
||||||
|
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
|
||||||
|
HTTPProxyPort int `json:"httpProxyPort,omitempty"`
|
||||||
|
SOCKSProxyPort int `json:"socksProxyPort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilesystemConfig defines filesystem restrictions.
|
||||||
|
type FilesystemConfig struct {
|
||||||
|
DenyRead []string `json:"denyRead"`
|
||||||
|
AllowWrite []string `json:"allowWrite"`
|
||||||
|
DenyWrite []string `json:"denyWrite"`
|
||||||
|
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns the default configuration with all network blocked.
|
||||||
|
func Default() *Config {
|
||||||
|
return &Config{
|
||||||
|
Network: NetworkConfig{
|
||||||
|
AllowedDomains: []string{},
|
||||||
|
DeniedDomains: []string{},
|
||||||
|
},
|
||||||
|
Filesystem: FilesystemConfig{
|
||||||
|
DenyRead: []string{},
|
||||||
|
AllowWrite: []string{},
|
||||||
|
DenyWrite: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfigPath returns the default config file path.
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ".fence.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".fence.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load loads configuration from a file path.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty file
|
||||||
|
if len(strings.TrimSpace(string(data))) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid JSON in config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the configuration.
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
for _, domain := range c.Network.AllowedDomains {
|
||||||
|
if err := validateDomainPattern(domain); err != nil {
|
||||||
|
return fmt.Errorf("invalid allowed domain %q: %w", domain, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, domain := range c.Network.DeniedDomains {
|
||||||
|
if err := validateDomainPattern(domain); err != nil {
|
||||||
|
return fmt.Errorf("invalid denied domain %q: %w", domain, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(c.Filesystem.DenyRead, "") {
|
||||||
|
return errors.New("filesystem.denyRead contains empty path")
|
||||||
|
}
|
||||||
|
if slices.Contains(c.Filesystem.AllowWrite, "") {
|
||||||
|
return errors.New("filesystem.allowWrite contains empty path")
|
||||||
|
}
|
||||||
|
if slices.Contains(c.Filesystem.DenyWrite, "") {
|
||||||
|
return errors.New("filesystem.denyWrite contains empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDomainPattern(pattern string) error {
|
||||||
|
if pattern == "localhost" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(pattern, "://") || strings.Contains(pattern, "/") || strings.Contains(pattern, ":") {
|
||||||
|
return errors.New("domain pattern cannot contain protocol, path, or port")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcard patterns
|
||||||
|
if strings.HasPrefix(pattern, "*.") {
|
||||||
|
domain := pattern[2:]
|
||||||
|
// Must have at least one more dot after the wildcard
|
||||||
|
if !strings.Contains(domain, ".") {
|
||||||
|
return errors.New("wildcard pattern too broad (e.g., *.com not allowed)")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
|
||||||
|
return errors.New("invalid domain format")
|
||||||
|
}
|
||||||
|
// Check each part has content
|
||||||
|
parts := strings.Split(domain, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return errors.New("wildcard pattern too broad")
|
||||||
|
}
|
||||||
|
if slices.Contains(parts, "") {
|
||||||
|
return errors.New("invalid domain format")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject other uses of wildcards
|
||||||
|
if strings.Contains(pattern, "*") {
|
||||||
|
return errors.New("only *.domain.com wildcard patterns are allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular domains must have at least one dot
|
||||||
|
if !strings.Contains(pattern, ".") || strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") {
|
||||||
|
return errors.New("invalid domain format")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchesDomain checks if a hostname matches a domain pattern.
|
||||||
|
func MatchesDomain(hostname, pattern string) bool {
|
||||||
|
hostname = strings.ToLower(hostname)
|
||||||
|
pattern = strings.ToLower(pattern)
|
||||||
|
|
||||||
|
// Wildcard pattern like *.example.com
|
||||||
|
if strings.HasPrefix(pattern, "*.") {
|
||||||
|
baseDomain := pattern[2:]
|
||||||
|
return strings.HasSuffix(hostname, "."+baseDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
return hostname == pattern
|
||||||
|
}
|
||||||
34
internal/platform/platform.go
Normal file
34
internal/platform/platform.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Package platform provides OS detection utilities.
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import "runtime"
|
||||||
|
|
||||||
|
// Type represents the detected platform.
|
||||||
|
type Type string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MacOS Type = "macos"
|
||||||
|
Linux Type = "linux"
|
||||||
|
Windows Type = "windows"
|
||||||
|
Unknown Type = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Detect returns the current platform type.
|
||||||
|
func Detect() Type {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return MacOS
|
||||||
|
case "linux":
|
||||||
|
return Linux
|
||||||
|
case "windows":
|
||||||
|
return Windows
|
||||||
|
default:
|
||||||
|
return Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSupported returns true if the platform supports sandboxing.
|
||||||
|
func IsSupported() bool {
|
||||||
|
p := Detect()
|
||||||
|
return p == MacOS || p == Linux
|
||||||
|
}
|
||||||
278
internal/proxy/http.go
Normal file
278
internal/proxy/http.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
// Package proxy provides HTTP and SOCKS5 proxy servers with domain filtering.
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FilterFunc determines if a connection to host:port should be allowed.
|
||||||
|
type FilterFunc func(host string, port int) bool
|
||||||
|
|
||||||
|
// HTTPProxy is an HTTP/HTTPS proxy server with domain filtering.
|
||||||
|
type HTTPProxy struct {
|
||||||
|
server *http.Server
|
||||||
|
listener net.Listener
|
||||||
|
filter FilterFunc
|
||||||
|
debug bool
|
||||||
|
mu sync.RWMutex
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPProxy creates a new HTTP proxy with the given filter.
|
||||||
|
func NewHTTPProxy(filter FilterFunc, debug bool) *HTTPProxy {
|
||||||
|
return &HTTPProxy{
|
||||||
|
filter: filter,
|
||||||
|
debug: debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the HTTP proxy on a random available port.
|
||||||
|
func (p *HTTPProxy) Start() (int, error) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.listener = listener
|
||||||
|
p.server = &http.Server{
|
||||||
|
Handler: http.HandlerFunc(p.handleRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.running = true
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
|
p.logDebug("HTTP proxy server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
addr := listener.Addr().(*net.TCPAddr)
|
||||||
|
p.logDebug("HTTP proxy listening on localhost:%d", addr.Port)
|
||||||
|
return addr.Port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the HTTP proxy.
|
||||||
|
func (p *HTTPProxy) Stop() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.running = false
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if p.server != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return p.server.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port returns the port the proxy is listening on.
|
||||||
|
func (p *HTTPProxy) Port() int {
|
||||||
|
if p.listener == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return p.listener.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTPProxy) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodConnect {
|
||||||
|
p.handleConnect(w, r)
|
||||||
|
} else {
|
||||||
|
p.handleHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConnect handles HTTPS CONNECT requests (tunnel).
|
||||||
|
func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host, portStr, err := net.SplitHostPort(r.Host)
|
||||||
|
if err != nil {
|
||||||
|
host = r.Host
|
||||||
|
portStr = "443"
|
||||||
|
}
|
||||||
|
|
||||||
|
port := 443
|
||||||
|
if portStr != "" {
|
||||||
|
fmt.Sscanf(portStr, "%d", &port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if allowed
|
||||||
|
if !p.filter(host, port) {
|
||||||
|
p.logDebug("CONNECT blocked: %s:%d", host, port)
|
||||||
|
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to target
|
||||||
|
targetConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
p.logDebug("CONNECT dial failed: %s:%d: %v", host, port, err)
|
||||||
|
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer targetConn.Close()
|
||||||
|
|
||||||
|
// Hijack the connection
|
||||||
|
hijacker, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientConn, _, err := hijacker.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to hijack connection", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer clientConn.Close()
|
||||||
|
|
||||||
|
clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
|
||||||
|
|
||||||
|
// Pipe data bidirectionally
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
io.Copy(targetConn, clientConn)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
io.Copy(clientConn, targetConn)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHTTP handles regular HTTP proxy requests.
|
||||||
|
func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetURL, err := url.Parse(r.RequestURI)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
host := targetURL.Hostname()
|
||||||
|
port := 80
|
||||||
|
if targetURL.Port() != "" {
|
||||||
|
fmt.Sscanf(targetURL.Port(), "%d", &port)
|
||||||
|
} else if targetURL.Scheme == "https" {
|
||||||
|
port = 443
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.filter(host, port) {
|
||||||
|
p.logDebug("HTTP blocked: %s:%d", host, port)
|
||||||
|
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new request and copy headers
|
||||||
|
proxyReq, err := http.NewRequest(r.Method, r.RequestURI, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, values := range r.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
proxyReq.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proxyReq.Host = targetURL.Host
|
||||||
|
|
||||||
|
// Remove hop-by-hop headers
|
||||||
|
proxyReq.Header.Del("Proxy-Connection")
|
||||||
|
proxyReq.Header.Del("Proxy-Authorization")
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(proxyReq)
|
||||||
|
if err != nil {
|
||||||
|
p.logDebug("HTTP request failed: %v", err)
|
||||||
|
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Copy response headers
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
w.Header().Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTPProxy) logDebug(format string, args ...interface{}) {
|
||||||
|
if p.debug {
|
||||||
|
fmt.Printf("[fence:http] "+format+"\n", args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDomainFilter creates a filter function from a config.
|
||||||
|
func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
|
||||||
|
return func(host string, port int) bool {
|
||||||
|
if cfg == nil {
|
||||||
|
// No config = deny all
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("[fence:filter] No config, denying: %s:%d\n", host, port)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check denied domains first
|
||||||
|
for _, denied := range cfg.Network.DeniedDomains {
|
||||||
|
if config.MatchesDomain(host, denied) {
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("[fence:filter] Denied by rule: %s:%d (matched %s)\n", host, port, denied)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check allowed domains
|
||||||
|
for _, allowed := range cfg.Network.AllowedDomains {
|
||||||
|
if config.MatchesDomain(host, allowed) {
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("[fence:filter] Allowed by rule: %s:%d (matched %s)\n", host, port, allowed)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("[fence:filter] No matching rule, denying: %s:%d\n", host, port)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHostFromRequest extracts the hostname from a request.
|
||||||
|
func GetHostFromRequest(r *http.Request) string {
|
||||||
|
host := r.Host
|
||||||
|
if h := r.URL.Hostname(); h != "" {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
// Strip port
|
||||||
|
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||||
|
host = host[:idx]
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
95
internal/proxy/socks.go
Normal file
95
internal/proxy/socks.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/things-go/go-socks5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SOCKSProxy is a SOCKS5 proxy server with domain filtering.
|
||||||
|
type SOCKSProxy struct {
|
||||||
|
server *socks5.Server
|
||||||
|
listener net.Listener
|
||||||
|
filter FilterFunc
|
||||||
|
debug bool
|
||||||
|
port int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSOCKSProxy creates a new SOCKS5 proxy with the given filter.
|
||||||
|
func NewSOCKSProxy(filter FilterFunc, debug bool) *SOCKSProxy {
|
||||||
|
return &SOCKSProxy{
|
||||||
|
filter: filter,
|
||||||
|
debug: debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fenceRuleSet implements socks5.RuleSet for domain filtering.
|
||||||
|
type fenceRuleSet struct {
|
||||||
|
filter FilterFunc
|
||||||
|
debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fenceRuleSet) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) {
|
||||||
|
host := req.DestAddr.FQDN
|
||||||
|
if host == "" {
|
||||||
|
host = req.DestAddr.IP.String()
|
||||||
|
}
|
||||||
|
port := req.DestAddr.Port
|
||||||
|
|
||||||
|
allowed := r.filter(host, port)
|
||||||
|
if r.debug {
|
||||||
|
if allowed {
|
||||||
|
fmt.Printf("[fence:socks] Allowed: %s:%d\n", host, port)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[fence:socks] Blocked: %s:%d\n", host, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx, allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the SOCKS5 proxy on a random available port.
|
||||||
|
func (p *SOCKSProxy) Start() (int, error) {
|
||||||
|
// Create listener first to get a random port
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to listen: %w", err)
|
||||||
|
}
|
||||||
|
p.listener = listener
|
||||||
|
p.port = listener.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
server := socks5.NewServer(
|
||||||
|
socks5.WithRule(&fenceRuleSet{
|
||||||
|
filter: p.filter,
|
||||||
|
debug: p.debug,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
p.server = server
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := p.server.Serve(p.listener); err != nil {
|
||||||
|
if p.debug {
|
||||||
|
fmt.Printf("[fence:socks] Server error: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if p.debug {
|
||||||
|
fmt.Printf("[fence:socks] SOCKS5 proxy listening on localhost:%d\n", p.port)
|
||||||
|
}
|
||||||
|
return p.port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the SOCKS5 proxy.
|
||||||
|
func (p *SOCKSProxy) Stop() error {
|
||||||
|
if p.listener != nil {
|
||||||
|
return p.listener.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port returns the port the proxy is listening on.
|
||||||
|
func (p *SOCKSProxy) Port() int {
|
||||||
|
return p.port
|
||||||
|
}
|
||||||
84
internal/sandbox/dangerous.go
Normal file
84
internal/sandbox/dangerous.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Package sandbox provides sandboxing functionality for macOS and Linux.
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DangerousFiles lists files that should be protected from writes.
|
||||||
|
// These files can be used for code execution or data exfiltration.
|
||||||
|
var DangerousFiles = []string{
|
||||||
|
".gitconfig",
|
||||||
|
".gitmodules",
|
||||||
|
".bashrc",
|
||||||
|
".bash_profile",
|
||||||
|
".zshrc",
|
||||||
|
".zprofile",
|
||||||
|
".profile",
|
||||||
|
".ripgreprc",
|
||||||
|
".mcp.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
// DangerousDirectories lists directories that should be protected from writes.
|
||||||
|
// Excludes .git since we need it writable for git operations.
|
||||||
|
var DangerousDirectories = []string{
|
||||||
|
".vscode",
|
||||||
|
".idea",
|
||||||
|
".claude/commands",
|
||||||
|
".claude/agents",
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultWritePaths returns system paths that should be writable for commands to work.
|
||||||
|
func GetDefaultWritePaths() []string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
|
paths := []string{
|
||||||
|
"/dev/stdout",
|
||||||
|
"/dev/stderr",
|
||||||
|
"/dev/null",
|
||||||
|
"/dev/tty",
|
||||||
|
"/dev/dtracehelper",
|
||||||
|
"/dev/autofs_nowait",
|
||||||
|
"/tmp/fence",
|
||||||
|
"/private/tmp/fence",
|
||||||
|
}
|
||||||
|
|
||||||
|
if home != "" {
|
||||||
|
paths = append(paths,
|
||||||
|
filepath.Join(home, ".npm/_logs"),
|
||||||
|
filepath.Join(home, ".fence/debug"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMandatoryDenyPatterns returns glob patterns for paths that must always be protected.
|
||||||
|
func GetMandatoryDenyPatterns(cwd string, allowGitConfig bool) []string {
|
||||||
|
var patterns []string
|
||||||
|
|
||||||
|
// Dangerous files - in CWD and all subdirectories
|
||||||
|
for _, f := range DangerousFiles {
|
||||||
|
patterns = append(patterns, filepath.Join(cwd, f))
|
||||||
|
patterns = append(patterns, "**/"+f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dangerous directories
|
||||||
|
for _, d := range DangerousDirectories {
|
||||||
|
patterns = append(patterns, filepath.Join(cwd, d))
|
||||||
|
patterns = append(patterns, "**/"+d+"/**")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git hooks are always blocked
|
||||||
|
patterns = append(patterns, filepath.Join(cwd, ".git/hooks"))
|
||||||
|
patterns = append(patterns, "**/.git/hooks/**")
|
||||||
|
|
||||||
|
// Git config is conditionally blocked
|
||||||
|
if !allowGitConfig {
|
||||||
|
patterns = append(patterns, filepath.Join(cwd, ".git/config"))
|
||||||
|
patterns = append(patterns, "**/.git/config")
|
||||||
|
}
|
||||||
|
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
303
internal/sandbox/linux.go
Normal file
303
internal/sandbox/linux.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LinuxBridge holds the socat bridge processes for Linux sandboxing (outbound).
|
||||||
|
type LinuxBridge struct {
|
||||||
|
HTTPSocketPath string
|
||||||
|
SOCKSSocketPath string
|
||||||
|
httpProcess *exec.Cmd
|
||||||
|
socksProcess *exec.Cmd
|
||||||
|
debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseBridge holds the socat bridge processes for inbound connections.
|
||||||
|
type ReverseBridge struct {
|
||||||
|
Ports []int
|
||||||
|
SocketPaths []string // Unix socket paths for each port
|
||||||
|
processes []*exec.Cmd
|
||||||
|
debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLinuxBridge creates Unix socket bridges to the proxy servers.
|
||||||
|
// This allows sandboxed processes to communicate with the host's proxy (outbound).
|
||||||
|
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
|
||||||
|
if _, err := exec.LookPath("socat"); err != nil {
|
||||||
|
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := make([]byte, 8)
|
||||||
|
rand.Read(id)
|
||||||
|
socketID := hex.EncodeToString(id)
|
||||||
|
|
||||||
|
tmpDir := os.TempDir()
|
||||||
|
httpSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-http-%s.sock", socketID))
|
||||||
|
socksSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-socks-%s.sock", socketID))
|
||||||
|
|
||||||
|
bridge := &LinuxBridge{
|
||||||
|
HTTPSocketPath: httpSocketPath,
|
||||||
|
SOCKSSocketPath: socksSocketPath,
|
||||||
|
debug: debug,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start HTTP bridge: Unix socket -> TCP proxy
|
||||||
|
httpArgs := []string{
|
||||||
|
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", httpSocketPath),
|
||||||
|
fmt.Sprintf("TCP:localhost:%d", httpProxyPort),
|
||||||
|
}
|
||||||
|
bridge.httpProcess = exec.Command("socat", httpArgs...)
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:linux] Starting HTTP bridge: socat %s\n", strings.Join(httpArgs, " "))
|
||||||
|
}
|
||||||
|
if err := bridge.httpProcess.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start HTTP bridge: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start SOCKS bridge: Unix socket -> TCP proxy
|
||||||
|
socksArgs := []string{
|
||||||
|
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socksSocketPath),
|
||||||
|
fmt.Sprintf("TCP:localhost:%d", socksProxyPort),
|
||||||
|
}
|
||||||
|
bridge.socksProcess = exec.Command("socat", socksArgs...)
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:linux] Starting SOCKS bridge: socat %s\n", strings.Join(socksArgs, " "))
|
||||||
|
}
|
||||||
|
if err := bridge.socksProcess.Start(); err != nil {
|
||||||
|
bridge.Cleanup()
|
||||||
|
return nil, fmt.Errorf("failed to start SOCKS bridge: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for sockets to be created
|
||||||
|
for i := 0; i < 50; i++ { // 5 seconds max
|
||||||
|
httpExists := fileExists(httpSocketPath)
|
||||||
|
socksExists := fileExists(socksSocketPath)
|
||||||
|
if httpExists && socksExists {
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges ready (HTTP: %s, SOCKS: %s)\n", httpSocketPath, socksSocketPath)
|
||||||
|
}
|
||||||
|
return bridge, nil
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge.Cleanup()
|
||||||
|
return nil, fmt.Errorf("timeout waiting for bridge sockets to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup stops the bridge processes and removes socket files.
|
||||||
|
func (b *LinuxBridge) Cleanup() {
|
||||||
|
if b.httpProcess != nil && b.httpProcess.Process != nil {
|
||||||
|
b.httpProcess.Process.Kill()
|
||||||
|
b.httpProcess.Wait()
|
||||||
|
}
|
||||||
|
if b.socksProcess != nil && b.socksProcess.Process != nil {
|
||||||
|
b.socksProcess.Process.Kill()
|
||||||
|
b.socksProcess.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up socket files
|
||||||
|
os.Remove(b.HTTPSocketPath)
|
||||||
|
os.Remove(b.SOCKSSocketPath)
|
||||||
|
|
||||||
|
if b.debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges cleaned up\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReverseBridge creates Unix socket bridges for inbound connections.
|
||||||
|
// Host listens on ports, forwards to Unix sockets that go into the sandbox.
|
||||||
|
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||||
|
if len(ports) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("socat"); err != nil {
|
||||||
|
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := make([]byte, 8)
|
||||||
|
rand.Read(id)
|
||||||
|
socketID := hex.EncodeToString(id)
|
||||||
|
|
||||||
|
tmpDir := os.TempDir()
|
||||||
|
bridge := &ReverseBridge{
|
||||||
|
Ports: ports,
|
||||||
|
debug: debug,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, port := range ports {
|
||||||
|
socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-rev-%d-%s.sock", port, socketID))
|
||||||
|
bridge.SocketPaths = append(bridge.SocketPaths, socketPath)
|
||||||
|
|
||||||
|
// Start reverse bridge: TCP listen on host port -> Unix socket
|
||||||
|
// The sandbox will create the Unix socket with UNIX-LISTEN
|
||||||
|
// We use retry to wait for the socket to be created by the sandbox
|
||||||
|
args := []string{
|
||||||
|
fmt.Sprintf("TCP-LISTEN:%d,fork,reuseaddr", port),
|
||||||
|
fmt.Sprintf("UNIX-CONNECT:%s,retry=50,interval=0.1", socketPath),
|
||||||
|
}
|
||||||
|
proc := exec.Command("socat", args...)
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:linux] Starting reverse bridge for port %d: socat %s\n", port, strings.Join(args, " "))
|
||||||
|
}
|
||||||
|
if err := proc.Start(); err != nil {
|
||||||
|
bridge.Cleanup()
|
||||||
|
return nil, fmt.Errorf("failed to start reverse bridge for port %d: %w", port, err)
|
||||||
|
}
|
||||||
|
bridge.processes = append(bridge.processes, proc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:linux] Reverse bridges ready for ports: %v\n", ports)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bridge, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup stops the reverse bridge processes and removes socket files.
|
||||||
|
func (b *ReverseBridge) Cleanup() {
|
||||||
|
for _, proc := range b.processes {
|
||||||
|
if proc != nil && proc.Process != nil {
|
||||||
|
proc.Process.Kill()
|
||||||
|
proc.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up socket files
|
||||||
|
for _, socketPath := range b.SocketPaths {
|
||||||
|
os.Remove(socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:linux] Reverse bridges cleaned up\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
|
||||||
|
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) {
|
||||||
|
// Check for bwrap
|
||||||
|
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||||
|
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find shell
|
||||||
|
shell := "bash"
|
||||||
|
shellPath, err := exec.LookPath(shell)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("shell %q not found: %w", shell, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build bwrap args
|
||||||
|
bwrapArgs := []string{
|
||||||
|
"bwrap",
|
||||||
|
"--new-session",
|
||||||
|
"--die-with-parent",
|
||||||
|
"--unshare-net", // Network namespace isolation
|
||||||
|
"--unshare-pid", // PID namespace isolation
|
||||||
|
"--bind", "/", "/", // Bind root filesystem
|
||||||
|
"--dev", "/dev", // Mount /dev
|
||||||
|
"--proc", "/proc", // Mount /proc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind the outbound Unix sockets into the sandbox
|
||||||
|
if bridge != nil {
|
||||||
|
bwrapArgs = append(bwrapArgs,
|
||||||
|
"--bind", bridge.HTTPSocketPath, bridge.HTTPSocketPath,
|
||||||
|
"--bind", bridge.SOCKSSocketPath, bridge.SOCKSSocketPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Reverse (inbound) Unix sockets don't need explicit binding
|
||||||
|
// because we use --bind / / which shares the entire filesystem.
|
||||||
|
// The sandbox-side socat creates the socket, which is visible to the host.
|
||||||
|
|
||||||
|
// Add environment variables for the sandbox
|
||||||
|
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
|
||||||
|
|
||||||
|
// Build the inner command that sets up socat listeners and runs the user command
|
||||||
|
var innerScript strings.Builder
|
||||||
|
|
||||||
|
if bridge != nil {
|
||||||
|
// Set up outbound socat listeners inside the sandbox
|
||||||
|
innerScript.WriteString(fmt.Sprintf(`
|
||||||
|
# Start HTTP proxy listener (port 3128 -> Unix socket -> host HTTP proxy)
|
||||||
|
socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||||
|
HTTP_PID=$!
|
||||||
|
|
||||||
|
# Start SOCKS proxy listener (port 1080 -> Unix socket -> host SOCKS proxy)
|
||||||
|
socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||||
|
SOCKS_PID=$!
|
||||||
|
|
||||||
|
# Set proxy environment variables
|
||||||
|
export HTTP_PROXY=http://127.0.0.1:3128
|
||||||
|
export HTTPS_PROXY=http://127.0.0.1:3128
|
||||||
|
export http_proxy=http://127.0.0.1:3128
|
||||||
|
export https_proxy=http://127.0.0.1:3128
|
||||||
|
export ALL_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
export all_proxy=socks5h://127.0.0.1:1080
|
||||||
|
export NO_PROXY=localhost,127.0.0.1
|
||||||
|
export no_proxy=localhost,127.0.0.1
|
||||||
|
export FENCE_SANDBOX=1
|
||||||
|
|
||||||
|
`, bridge.HTTPSocketPath, bridge.SOCKSSocketPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up reverse (inbound) socat listeners inside the sandbox
|
||||||
|
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
|
||||||
|
innerScript.WriteString("\n# Start reverse bridge listeners for inbound connections\n")
|
||||||
|
for i, port := range reverseBridge.Ports {
|
||||||
|
socketPath := reverseBridge.SocketPaths[i]
|
||||||
|
// Listen on Unix socket, forward to localhost:port inside the sandbox
|
||||||
|
innerScript.WriteString(fmt.Sprintf(
|
||||||
|
"socat UNIX-LISTEN:%s,fork,reuseaddr TCP:127.0.0.1:%d >/dev/null 2>&1 &\n",
|
||||||
|
socketPath, port,
|
||||||
|
))
|
||||||
|
innerScript.WriteString(fmt.Sprintf("REV_%d_PID=$!\n", port))
|
||||||
|
}
|
||||||
|
innerScript.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cleanup function
|
||||||
|
innerScript.WriteString(`
|
||||||
|
# Cleanup function
|
||||||
|
cleanup() {
|
||||||
|
jobs -p | xargs -r kill 2>/dev/null
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Small delay to ensure socat listeners are ready
|
||||||
|
sleep 0.1
|
||||||
|
|
||||||
|
# Run the user command
|
||||||
|
`)
|
||||||
|
innerScript.WriteString(command)
|
||||||
|
innerScript.WriteString("\n")
|
||||||
|
|
||||||
|
bwrapArgs = append(bwrapArgs, innerScript.String())
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:linux] Wrapping command with bwrap (network filtering + inbound ports: %v)\n", reverseBridge.Ports)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:linux] Wrapping command with bwrap (network filtering via socat bridges)\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ShellQuote(bwrapArgs), nil
|
||||||
|
}
|
||||||
558
internal/sandbox/macos.go
Normal file
558
internal/sandbox/macos.go
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionSuffix is a unique identifier for this process session.
|
||||||
|
var sessionSuffix = generateSessionSuffix()
|
||||||
|
|
||||||
|
func generateSessionSuffix() string {
|
||||||
|
bytes := make([]byte, 8)
|
||||||
|
rand.Read(bytes)
|
||||||
|
return "_" + hex.EncodeToString(bytes)[:9] + "_SBX"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MacOSSandboxParams contains parameters for macOS sandbox wrapping.
|
||||||
|
type MacOSSandboxParams struct {
|
||||||
|
Command string
|
||||||
|
NeedsNetworkRestriction bool
|
||||||
|
HTTPProxyPort int
|
||||||
|
SOCKSProxyPort int
|
||||||
|
AllowUnixSockets []string
|
||||||
|
AllowAllUnixSockets bool
|
||||||
|
AllowLocalBinding bool
|
||||||
|
ReadDenyPaths []string
|
||||||
|
WriteAllowPaths []string
|
||||||
|
WriteDenyPaths []string
|
||||||
|
AllowPty bool
|
||||||
|
AllowGitConfig bool
|
||||||
|
Shell string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlobToRegex converts a glob pattern to a regex for macOS sandbox profiles.
|
||||||
|
func GlobToRegex(glob string) string {
|
||||||
|
result := "^"
|
||||||
|
|
||||||
|
// Escape regex special characters (except glob chars)
|
||||||
|
escaped := regexp.QuoteMeta(glob)
|
||||||
|
|
||||||
|
// Restore glob patterns and convert them
|
||||||
|
// Order matters: ** before *
|
||||||
|
escaped = strings.ReplaceAll(escaped, `\*\*/`, "(.*/)?")
|
||||||
|
escaped = strings.ReplaceAll(escaped, `\*\*`, ".*")
|
||||||
|
escaped = strings.ReplaceAll(escaped, `\*`, "[^/]*")
|
||||||
|
escaped = strings.ReplaceAll(escaped, `\?`, "[^/]")
|
||||||
|
|
||||||
|
result += escaped + "$"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapePath escapes a path for sandbox profile using JSON encoding.
|
||||||
|
func escapePath(path string) string {
|
||||||
|
// Use Go's string quoting which handles escaping
|
||||||
|
return fmt.Sprintf("%q", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAncestorDirectories returns all ancestor directories of a path.
|
||||||
|
func getAncestorDirectories(pathStr string) []string {
|
||||||
|
var ancestors []string
|
||||||
|
current := filepath.Dir(pathStr)
|
||||||
|
|
||||||
|
for current != "/" && current != "." {
|
||||||
|
ancestors = append(ancestors, current)
|
||||||
|
parent := filepath.Dir(current)
|
||||||
|
if parent == current {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
return ancestors
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTmpdirParent gets the TMPDIR parent if it matches macOS pattern.
|
||||||
|
func getTmpdirParent() []string {
|
||||||
|
tmpdir := os.Getenv("TMPDIR")
|
||||||
|
if tmpdir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match /var/folders/XX/YYY/T/
|
||||||
|
pattern := regexp.MustCompile(`^/(private/)?var/folders/[^/]{2}/[^/]+/T/?$`)
|
||||||
|
if !pattern.MatchString(tmpdir) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := strings.TrimSuffix(tmpdir, "/")
|
||||||
|
parent = strings.TrimSuffix(parent, "/T")
|
||||||
|
|
||||||
|
// Return both /var/ and /private/var/ versions
|
||||||
|
if strings.HasPrefix(parent, "/private/var/") {
|
||||||
|
return []string{parent, strings.Replace(parent, "/private", "", 1)}
|
||||||
|
} else if strings.HasPrefix(parent, "/var/") {
|
||||||
|
return []string{parent, "/private" + parent}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{parent}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateReadRules generates filesystem read rules for the sandbox profile.
|
||||||
|
func generateReadRules(denyPaths []string, logTag string) []string {
|
||||||
|
var rules []string
|
||||||
|
|
||||||
|
// Allow all reads by default
|
||||||
|
rules = append(rules, "(allow file-read*)")
|
||||||
|
|
||||||
|
// Deny specific paths
|
||||||
|
for _, pathPattern := range denyPaths {
|
||||||
|
normalized := NormalizePath(pathPattern)
|
||||||
|
|
||||||
|
if ContainsGlobChars(normalized) {
|
||||||
|
regex := GlobToRegex(normalized)
|
||||||
|
rules = append(rules,
|
||||||
|
"(deny file-read*",
|
||||||
|
fmt.Sprintf(" (regex %s)", escapePath(regex)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rules = append(rules,
|
||||||
|
"(deny file-read*",
|
||||||
|
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block file movement to prevent bypass
|
||||||
|
rules = append(rules, generateMoveBlockingRules(denyPaths, logTag)...)
|
||||||
|
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateWriteRules generates filesystem write rules for the sandbox profile.
|
||||||
|
func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string {
|
||||||
|
var rules []string
|
||||||
|
|
||||||
|
// Allow TMPDIR parent on macOS
|
||||||
|
for _, tmpdirParent := range getTmpdirParent() {
|
||||||
|
normalized := NormalizePath(tmpdirParent)
|
||||||
|
rules = append(rules,
|
||||||
|
"(allow file-write*",
|
||||||
|
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate allow rules
|
||||||
|
for _, pathPattern := range allowPaths {
|
||||||
|
normalized := NormalizePath(pathPattern)
|
||||||
|
|
||||||
|
if ContainsGlobChars(normalized) {
|
||||||
|
regex := GlobToRegex(normalized)
|
||||||
|
rules = append(rules,
|
||||||
|
"(allow file-write*",
|
||||||
|
fmt.Sprintf(" (regex %s)", escapePath(regex)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rules = append(rules,
|
||||||
|
"(allow file-write*",
|
||||||
|
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine user-specified and mandatory deny patterns
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
allDenyPaths := append(denyPaths, GetMandatoryDenyPatterns(cwd, allowGitConfig)...)
|
||||||
|
|
||||||
|
for _, pathPattern := range allDenyPaths {
|
||||||
|
normalized := NormalizePath(pathPattern)
|
||||||
|
|
||||||
|
if ContainsGlobChars(normalized) {
|
||||||
|
regex := GlobToRegex(normalized)
|
||||||
|
rules = append(rules,
|
||||||
|
"(deny file-write*",
|
||||||
|
fmt.Sprintf(" (regex %s)", escapePath(regex)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rules = append(rules,
|
||||||
|
"(deny file-write*",
|
||||||
|
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block file movement
|
||||||
|
rules = append(rules, generateMoveBlockingRules(allDenyPaths, logTag)...)
|
||||||
|
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateMoveBlockingRules generates rules to prevent file movement bypasses.
|
||||||
|
func generateMoveBlockingRules(pathPatterns []string, logTag string) []string {
|
||||||
|
var rules []string
|
||||||
|
|
||||||
|
for _, pathPattern := range pathPatterns {
|
||||||
|
normalized := NormalizePath(pathPattern)
|
||||||
|
|
||||||
|
if ContainsGlobChars(normalized) {
|
||||||
|
regex := GlobToRegex(normalized)
|
||||||
|
rules = append(rules,
|
||||||
|
"(deny file-write-unlink",
|
||||||
|
fmt.Sprintf(" (regex %s)", escapePath(regex)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
|
||||||
|
// For globs, extract static prefix and block ancestor moves
|
||||||
|
staticPrefix := strings.Split(normalized, "*")[0]
|
||||||
|
if staticPrefix != "" && staticPrefix != "/" {
|
||||||
|
baseDir := staticPrefix
|
||||||
|
if strings.HasSuffix(baseDir, "/") {
|
||||||
|
baseDir = baseDir[:len(baseDir)-1]
|
||||||
|
} else {
|
||||||
|
baseDir = filepath.Dir(staticPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules = append(rules,
|
||||||
|
"(deny file-write-unlink",
|
||||||
|
fmt.Sprintf(" (literal %s)", escapePath(baseDir)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, ancestor := range getAncestorDirectories(baseDir) {
|
||||||
|
rules = append(rules,
|
||||||
|
"(deny file-write-unlink",
|
||||||
|
fmt.Sprintf(" (literal %s)", escapePath(ancestor)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rules = append(rules,
|
||||||
|
"(deny file-write-unlink",
|
||||||
|
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, ancestor := range getAncestorDirectories(normalized) {
|
||||||
|
rules = append(rules,
|
||||||
|
"(deny file-write-unlink",
|
||||||
|
fmt.Sprintf(" (literal %s)", escapePath(ancestor)),
|
||||||
|
fmt.Sprintf(" (with message %q))", logTag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSandboxProfile generates a complete macOS sandbox profile.
|
||||||
|
func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
||||||
|
logTag := "CMD64_" + EncodeSandboxedCommand(params.Command) + "_END" + sessionSuffix
|
||||||
|
|
||||||
|
var profile strings.Builder
|
||||||
|
|
||||||
|
// Header
|
||||||
|
profile.WriteString("(version 1)\n")
|
||||||
|
profile.WriteString(fmt.Sprintf("(deny default (with message %q))\n\n", logTag))
|
||||||
|
profile.WriteString(fmt.Sprintf("; LogTag: %s\n\n", logTag))
|
||||||
|
|
||||||
|
// Essential permissions - based on Chrome sandbox policy
|
||||||
|
profile.WriteString(`; Essential permissions - based on Chrome sandbox policy
|
||||||
|
; Process permissions
|
||||||
|
(allow process-exec)
|
||||||
|
(allow process-fork)
|
||||||
|
(allow process-info* (target same-sandbox))
|
||||||
|
(allow signal (target same-sandbox))
|
||||||
|
(allow mach-priv-task-port (target same-sandbox))
|
||||||
|
|
||||||
|
; User preferences
|
||||||
|
(allow user-preference-read)
|
||||||
|
|
||||||
|
; Mach IPC - specific services only
|
||||||
|
(allow mach-lookup
|
||||||
|
(global-name "com.apple.audio.systemsoundserver")
|
||||||
|
(global-name "com.apple.distributed_notifications@Uv3")
|
||||||
|
(global-name "com.apple.FontObjectsServer")
|
||||||
|
(global-name "com.apple.fonts")
|
||||||
|
(global-name "com.apple.logd")
|
||||||
|
(global-name "com.apple.lsd.mapdb")
|
||||||
|
(global-name "com.apple.PowerManagement.control")
|
||||||
|
(global-name "com.apple.system.logger")
|
||||||
|
(global-name "com.apple.system.notification_center")
|
||||||
|
(global-name "com.apple.trustd.agent")
|
||||||
|
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||||
|
(global-name "com.apple.system.opendirectoryd.membership")
|
||||||
|
(global-name "com.apple.bsd.dirhelper")
|
||||||
|
(global-name "com.apple.securityd.xpc")
|
||||||
|
(global-name "com.apple.coreservices.launchservicesd")
|
||||||
|
)
|
||||||
|
|
||||||
|
; POSIX IPC
|
||||||
|
(allow ipc-posix-shm)
|
||||||
|
(allow ipc-posix-sem)
|
||||||
|
|
||||||
|
; IOKit
|
||||||
|
(allow iokit-open
|
||||||
|
(iokit-registry-entry-class "IOSurfaceRootUserClient")
|
||||||
|
(iokit-registry-entry-class "RootDomainUserClient")
|
||||||
|
(iokit-user-client-class "IOSurfaceSendRight")
|
||||||
|
)
|
||||||
|
(allow iokit-get-properties)
|
||||||
|
|
||||||
|
; System socket for network info
|
||||||
|
(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2)))
|
||||||
|
|
||||||
|
; sysctl reads
|
||||||
|
(allow sysctl-read
|
||||||
|
(sysctl-name "hw.activecpu")
|
||||||
|
(sysctl-name "hw.busfrequency_compat")
|
||||||
|
(sysctl-name "hw.byteorder")
|
||||||
|
(sysctl-name "hw.cacheconfig")
|
||||||
|
(sysctl-name "hw.cachelinesize_compat")
|
||||||
|
(sysctl-name "hw.cpufamily")
|
||||||
|
(sysctl-name "hw.cpufrequency")
|
||||||
|
(sysctl-name "hw.cpufrequency_compat")
|
||||||
|
(sysctl-name "hw.cputype")
|
||||||
|
(sysctl-name "hw.l1dcachesize_compat")
|
||||||
|
(sysctl-name "hw.l1icachesize_compat")
|
||||||
|
(sysctl-name "hw.l2cachesize_compat")
|
||||||
|
(sysctl-name "hw.l3cachesize_compat")
|
||||||
|
(sysctl-name "hw.logicalcpu")
|
||||||
|
(sysctl-name "hw.logicalcpu_max")
|
||||||
|
(sysctl-name "hw.machine")
|
||||||
|
(sysctl-name "hw.memsize")
|
||||||
|
(sysctl-name "hw.ncpu")
|
||||||
|
(sysctl-name "hw.nperflevels")
|
||||||
|
(sysctl-name "hw.packages")
|
||||||
|
(sysctl-name "hw.pagesize_compat")
|
||||||
|
(sysctl-name "hw.pagesize")
|
||||||
|
(sysctl-name "hw.physicalcpu")
|
||||||
|
(sysctl-name "hw.physicalcpu_max")
|
||||||
|
(sysctl-name "hw.tbfrequency_compat")
|
||||||
|
(sysctl-name "hw.vectorunit")
|
||||||
|
(sysctl-name "kern.argmax")
|
||||||
|
(sysctl-name "kern.bootargs")
|
||||||
|
(sysctl-name "kern.hostname")
|
||||||
|
(sysctl-name "kern.maxfiles")
|
||||||
|
(sysctl-name "kern.maxfilesperproc")
|
||||||
|
(sysctl-name "kern.maxproc")
|
||||||
|
(sysctl-name "kern.ngroups")
|
||||||
|
(sysctl-name "kern.osproductversion")
|
||||||
|
(sysctl-name "kern.osrelease")
|
||||||
|
(sysctl-name "kern.ostype")
|
||||||
|
(sysctl-name "kern.osvariant_status")
|
||||||
|
(sysctl-name "kern.osversion")
|
||||||
|
(sysctl-name "kern.secure_kernel")
|
||||||
|
(sysctl-name "kern.tcsm_available")
|
||||||
|
(sysctl-name "kern.tcsm_enable")
|
||||||
|
(sysctl-name "kern.usrstack64")
|
||||||
|
(sysctl-name "kern.version")
|
||||||
|
(sysctl-name "kern.willshutdown")
|
||||||
|
(sysctl-name "machdep.cpu.brand_string")
|
||||||
|
(sysctl-name "machdep.ptrauth_enabled")
|
||||||
|
(sysctl-name "security.mac.lockdown_mode_state")
|
||||||
|
(sysctl-name "sysctl.proc_cputype")
|
||||||
|
(sysctl-name "vm.loadavg")
|
||||||
|
(sysctl-name-prefix "hw.optional.arm")
|
||||||
|
(sysctl-name-prefix "hw.optional.arm.")
|
||||||
|
(sysctl-name-prefix "hw.optional.armv8_")
|
||||||
|
(sysctl-name-prefix "hw.perflevel")
|
||||||
|
(sysctl-name-prefix "kern.proc.all")
|
||||||
|
(sysctl-name-prefix "kern.proc.pgrp.")
|
||||||
|
(sysctl-name-prefix "kern.proc.pid.")
|
||||||
|
(sysctl-name-prefix "machdep.cpu.")
|
||||||
|
(sysctl-name-prefix "net.routetable.")
|
||||||
|
)
|
||||||
|
|
||||||
|
; V8 thread calculations
|
||||||
|
(allow sysctl-write
|
||||||
|
(sysctl-name "kern.tcsm_enable")
|
||||||
|
)
|
||||||
|
|
||||||
|
; Distributed notifications
|
||||||
|
(allow distributed-notification-post)
|
||||||
|
|
||||||
|
; Security server
|
||||||
|
(allow mach-lookup (global-name "com.apple.SecurityServer"))
|
||||||
|
|
||||||
|
; Device I/O
|
||||||
|
(allow file-ioctl (literal "/dev/null"))
|
||||||
|
(allow file-ioctl (literal "/dev/zero"))
|
||||||
|
(allow file-ioctl (literal "/dev/random"))
|
||||||
|
(allow file-ioctl (literal "/dev/urandom"))
|
||||||
|
(allow file-ioctl (literal "/dev/dtracehelper"))
|
||||||
|
(allow file-ioctl (literal "/dev/tty"))
|
||||||
|
|
||||||
|
(allow file-ioctl file-read-data file-write-data
|
||||||
|
(require-all
|
||||||
|
(literal "/dev/null")
|
||||||
|
(vnode-type CHARACTER-DEVICE)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Network rules
|
||||||
|
profile.WriteString("; Network\n")
|
||||||
|
if !params.NeedsNetworkRestriction {
|
||||||
|
profile.WriteString("(allow network*)\n")
|
||||||
|
} else {
|
||||||
|
if params.AllowLocalBinding {
|
||||||
|
profile.WriteString(`(allow network-bind (local ip "localhost:*"))
|
||||||
|
(allow network-inbound (local ip "localhost:*"))
|
||||||
|
(allow network-outbound (local ip "localhost:*"))
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.AllowAllUnixSockets {
|
||||||
|
profile.WriteString("(allow network* (subpath \"/\"))\n")
|
||||||
|
} else if len(params.AllowUnixSockets) > 0 {
|
||||||
|
for _, socketPath := range params.AllowUnixSockets {
|
||||||
|
normalized := NormalizePath(socketPath)
|
||||||
|
profile.WriteString(fmt.Sprintf("(allow network* (subpath %s))\n", escapePath(normalized)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.HTTPProxyPort > 0 {
|
||||||
|
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
|
||||||
|
(allow network-inbound (local ip "localhost:%d"))
|
||||||
|
(allow network-outbound (remote ip "localhost:%d"))
|
||||||
|
`, params.HTTPProxyPort, params.HTTPProxyPort, params.HTTPProxyPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.SOCKSProxyPort > 0 {
|
||||||
|
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
|
||||||
|
(allow network-inbound (local ip "localhost:%d"))
|
||||||
|
(allow network-outbound (remote ip "localhost:%d"))
|
||||||
|
`, params.SOCKSProxyPort, params.SOCKSProxyPort, params.SOCKSProxyPort))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profile.WriteString("\n")
|
||||||
|
|
||||||
|
// Read rules
|
||||||
|
profile.WriteString("; File read\n")
|
||||||
|
for _, rule := range generateReadRules(params.ReadDenyPaths, logTag) {
|
||||||
|
profile.WriteString(rule + "\n")
|
||||||
|
}
|
||||||
|
profile.WriteString("\n")
|
||||||
|
|
||||||
|
// Write rules
|
||||||
|
profile.WriteString("; File write\n")
|
||||||
|
for _, rule := range generateWriteRules(params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) {
|
||||||
|
profile.WriteString(rule + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTY support
|
||||||
|
if params.AllowPty {
|
||||||
|
profile.WriteString(`
|
||||||
|
; Pseudo-terminal (pty) support
|
||||||
|
(allow pseudo-tty)
|
||||||
|
(allow file-ioctl
|
||||||
|
(literal "/dev/ptmx")
|
||||||
|
(regex #"^/dev/ttys")
|
||||||
|
)
|
||||||
|
(allow file-read* file-write*
|
||||||
|
(literal "/dev/ptmx")
|
||||||
|
(regex #"^/dev/ttys")
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
||||||
|
func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort int, exposedPorts []int, debug bool) (string, error) {
|
||||||
|
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
|
||||||
|
|
||||||
|
// Build allow paths: default + configured
|
||||||
|
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||||
|
|
||||||
|
// Enable local binding if ports are exposed or if explicitly configured
|
||||||
|
allowLocalBinding := cfg.Network.AllowLocalBinding || len(exposedPorts) > 0
|
||||||
|
|
||||||
|
params := MacOSSandboxParams{
|
||||||
|
Command: command,
|
||||||
|
NeedsNetworkRestriction: needsNetwork || len(cfg.Network.AllowedDomains) == 0, // Block if no domains allowed
|
||||||
|
HTTPProxyPort: httpPort,
|
||||||
|
SOCKSProxyPort: socksPort,
|
||||||
|
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
||||||
|
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||||
|
AllowLocalBinding: allowLocalBinding,
|
||||||
|
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
||||||
|
WriteAllowPaths: allowPaths,
|
||||||
|
WriteDenyPaths: cfg.Filesystem.DenyWrite,
|
||||||
|
AllowPty: cfg.AllowPty,
|
||||||
|
AllowGitConfig: cfg.Filesystem.AllowGitConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
if debug && len(exposedPorts) > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:macos] Enabling local binding for exposed ports: %v\n", exposedPorts)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := GenerateSandboxProfile(params)
|
||||||
|
|
||||||
|
// Find shell
|
||||||
|
shell := params.Shell
|
||||||
|
if shell == "" {
|
||||||
|
shell = "bash"
|
||||||
|
}
|
||||||
|
shellPath, err := exec.LookPath(shell)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("shell %q not found: %w", shell, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate proxy environment variables
|
||||||
|
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort)
|
||||||
|
|
||||||
|
// Build the command
|
||||||
|
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'
|
||||||
|
var parts []string
|
||||||
|
parts = append(parts, "env")
|
||||||
|
parts = append(parts, proxyEnvs...)
|
||||||
|
parts = append(parts, "sandbox-exec", "-p", profile, shellPath, "-c", command)
|
||||||
|
|
||||||
|
return ShellQuote(parts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellQuote quotes a slice of strings for shell execution.
|
||||||
|
func ShellQuote(args []string) string {
|
||||||
|
var quoted []string
|
||||||
|
for _, arg := range args {
|
||||||
|
if needsQuoting(arg) {
|
||||||
|
quoted = append(quoted, fmt.Sprintf("'%s'", strings.ReplaceAll(arg, "'", "'\\''")))
|
||||||
|
} else {
|
||||||
|
quoted = append(quoted, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(quoted, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsQuoting(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if c == ' ' || c == '\t' || c == '\n' || c == '"' || c == '\'' ||
|
||||||
|
c == '\\' || c == '$' || c == '`' || c == '!' || c == '*' ||
|
||||||
|
c == '?' || c == '[' || c == ']' || c == '(' || c == ')' ||
|
||||||
|
c == '{' || c == '}' || c == '<' || c == '>' || c == '|' ||
|
||||||
|
c == '&' || c == ';' || c == '#' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s) == 0
|
||||||
|
}
|
||||||
144
internal/sandbox/manager.go
Normal file
144
internal/sandbox/manager.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
|
"github.com/Use-Tusk/fence/internal/platform"
|
||||||
|
"github.com/Use-Tusk/fence/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager handles sandbox initialization and command wrapping.
|
||||||
|
type Manager struct {
|
||||||
|
config *config.Config
|
||||||
|
httpProxy *proxy.HTTPProxy
|
||||||
|
socksProxy *proxy.SOCKSProxy
|
||||||
|
linuxBridge *LinuxBridge
|
||||||
|
reverseBridge *ReverseBridge
|
||||||
|
httpPort int
|
||||||
|
socksPort int
|
||||||
|
exposedPorts []int
|
||||||
|
debug bool
|
||||||
|
initialized bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new sandbox manager.
|
||||||
|
func NewManager(cfg *config.Config, debug bool) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
config: cfg,
|
||||||
|
debug: debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetExposedPorts sets the ports to expose for inbound connections.
|
||||||
|
func (m *Manager) SetExposedPorts(ports []int) {
|
||||||
|
m.exposedPorts = ports
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize sets up the sandbox infrastructure (proxies, etc.).
|
||||||
|
func (m *Manager) Initialize() error {
|
||||||
|
if m.initialized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !platform.IsSupported() {
|
||||||
|
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := proxy.CreateDomainFilter(m.config, m.debug)
|
||||||
|
|
||||||
|
m.httpProxy = proxy.NewHTTPProxy(filter, m.debug)
|
||||||
|
httpPort, err := m.httpProxy.Start()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start HTTP proxy: %w", err)
|
||||||
|
}
|
||||||
|
m.httpPort = httpPort
|
||||||
|
|
||||||
|
m.socksProxy = proxy.NewSOCKSProxy(filter, m.debug)
|
||||||
|
socksPort, err := m.socksProxy.Start()
|
||||||
|
if err != nil {
|
||||||
|
m.httpProxy.Stop()
|
||||||
|
return fmt.Errorf("failed to start SOCKS proxy: %w", err)
|
||||||
|
}
|
||||||
|
m.socksPort = socksPort
|
||||||
|
|
||||||
|
// On Linux, set up the socat bridges
|
||||||
|
if platform.Detect() == platform.Linux {
|
||||||
|
bridge, err := NewLinuxBridge(m.httpPort, m.socksPort, m.debug)
|
||||||
|
if err != nil {
|
||||||
|
m.httpProxy.Stop()
|
||||||
|
m.socksProxy.Stop()
|
||||||
|
return fmt.Errorf("failed to initialize Linux bridge: %w", err)
|
||||||
|
}
|
||||||
|
m.linuxBridge = bridge
|
||||||
|
|
||||||
|
// Set up reverse bridge for exposed ports (inbound connections)
|
||||||
|
if len(m.exposedPorts) > 0 {
|
||||||
|
reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug)
|
||||||
|
if err != nil {
|
||||||
|
m.linuxBridge.Cleanup()
|
||||||
|
m.httpProxy.Stop()
|
||||||
|
m.socksProxy.Stop()
|
||||||
|
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
|
||||||
|
}
|
||||||
|
m.reverseBridge = reverseBridge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.initialized = true
|
||||||
|
m.logDebug("Sandbox manager initialized (HTTP proxy: %d, SOCKS proxy: %d)", m.httpPort, m.socksPort)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapCommand wraps a command with sandbox restrictions.
|
||||||
|
func (m *Manager) WrapCommand(command string) (string, error) {
|
||||||
|
if !m.initialized {
|
||||||
|
if err := m.Initialize(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plat := platform.Detect()
|
||||||
|
switch plat {
|
||||||
|
case platform.MacOS:
|
||||||
|
return WrapCommandMacOS(m.config, command, m.httpPort, m.socksPort, m.exposedPorts, m.debug)
|
||||||
|
case platform.Linux:
|
||||||
|
return WrapCommandLinux(m.config, command, m.linuxBridge, m.reverseBridge, m.debug)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported platform: %s", plat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup stops the proxies and cleans up resources.
|
||||||
|
func (m *Manager) Cleanup() {
|
||||||
|
if m.reverseBridge != nil {
|
||||||
|
m.reverseBridge.Cleanup()
|
||||||
|
}
|
||||||
|
if m.linuxBridge != nil {
|
||||||
|
m.linuxBridge.Cleanup()
|
||||||
|
}
|
||||||
|
if m.httpProxy != nil {
|
||||||
|
m.httpProxy.Stop()
|
||||||
|
}
|
||||||
|
if m.socksProxy != nil {
|
||||||
|
m.socksProxy.Stop()
|
||||||
|
}
|
||||||
|
m.logDebug("Sandbox manager cleaned up")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) logDebug(format string, args ...interface{}) {
|
||||||
|
if m.debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence] "+format+"\n", args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPPort returns the HTTP proxy port.
|
||||||
|
func (m *Manager) HTTPPort() int {
|
||||||
|
return m.httpPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// SOCKSPort returns the SOCKS proxy port.
|
||||||
|
func (m *Manager) SOCKSPort() int {
|
||||||
|
return m.socksPort
|
||||||
|
}
|
||||||
125
internal/sandbox/utils.go
Normal file
125
internal/sandbox/utils.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContainsGlobChars checks if a path pattern contains glob characters.
|
||||||
|
func ContainsGlobChars(pattern string) bool {
|
||||||
|
return strings.ContainsAny(pattern, "*?[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTrailingGlobSuffix removes trailing /** from a path pattern.
|
||||||
|
func RemoveTrailingGlobSuffix(pattern string) string {
|
||||||
|
return strings.TrimSuffix(pattern, "/**")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizePath normalizes a path for sandbox configuration.
|
||||||
|
// Handles tilde expansion and relative paths.
|
||||||
|
func NormalizePath(pathPattern string) string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
|
||||||
|
normalized := pathPattern
|
||||||
|
|
||||||
|
// Expand ~ to home directory
|
||||||
|
if pathPattern == "~" {
|
||||||
|
normalized = home
|
||||||
|
} else if strings.HasPrefix(pathPattern, "~/") {
|
||||||
|
normalized = filepath.Join(home, pathPattern[2:])
|
||||||
|
} else if strings.HasPrefix(pathPattern, "./") || strings.HasPrefix(pathPattern, "../") {
|
||||||
|
normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern))
|
||||||
|
} else if !filepath.IsAbs(pathPattern) && !ContainsGlobChars(pathPattern) {
|
||||||
|
normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-glob patterns, try to resolve symlinks
|
||||||
|
if !ContainsGlobChars(normalized) {
|
||||||
|
if resolved, err := filepath.EvalSymlinks(normalized); err == nil {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateProxyEnvVars creates environment variables for proxy configuration.
|
||||||
|
func GenerateProxyEnvVars(httpPort, socksPort int) []string {
|
||||||
|
envVars := []string{
|
||||||
|
"FENCE_SANDBOX=1",
|
||||||
|
"TMPDIR=/tmp/fence",
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpPort == 0 && socksPort == 0 {
|
||||||
|
return envVars
|
||||||
|
}
|
||||||
|
|
||||||
|
// NO_PROXY for localhost and private networks
|
||||||
|
noProxy := strings.Join([]string{
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1",
|
||||||
|
"*.local",
|
||||||
|
".local",
|
||||||
|
"169.254.0.0/16",
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
}, ",")
|
||||||
|
|
||||||
|
envVars = append(envVars,
|
||||||
|
"NO_PROXY="+noProxy,
|
||||||
|
"no_proxy="+noProxy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if httpPort > 0 {
|
||||||
|
proxyURL := "http://localhost:" + itoa(httpPort)
|
||||||
|
envVars = append(envVars,
|
||||||
|
"HTTP_PROXY="+proxyURL,
|
||||||
|
"HTTPS_PROXY="+proxyURL,
|
||||||
|
"http_proxy="+proxyURL,
|
||||||
|
"https_proxy="+proxyURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if socksPort > 0 {
|
||||||
|
socksURL := "socks5h://localhost:" + itoa(socksPort)
|
||||||
|
envVars = append(envVars,
|
||||||
|
"ALL_PROXY="+socksURL,
|
||||||
|
"all_proxy="+socksURL,
|
||||||
|
"FTP_PROXY="+socksURL,
|
||||||
|
"ftp_proxy="+socksURL,
|
||||||
|
)
|
||||||
|
// Git SSH through SOCKS
|
||||||
|
envVars = append(envVars,
|
||||||
|
"GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:"+itoa(socksPort)+" %h %p'",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return envVars
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeSandboxedCommand encodes a command for sandbox monitoring.
|
||||||
|
func EncodeSandboxedCommand(command string) string {
|
||||||
|
if len(command) > 100 {
|
||||||
|
command = command[:100]
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(command))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeSandboxedCommand decodes a base64-encoded command.
|
||||||
|
func DecodeSandboxedCommand(encoded string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
}
|
||||||
39
pkg/fence/fence.go
Normal file
39
pkg/fence/fence.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Package fence provides a public API for sandboxing commands.
|
||||||
|
package fence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
|
"github.com/Use-Tusk/fence/internal/sandbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the configuration for fence.
|
||||||
|
type Config = config.Config
|
||||||
|
|
||||||
|
// NetworkConfig defines network restrictions.
|
||||||
|
type NetworkConfig = config.NetworkConfig
|
||||||
|
|
||||||
|
// FilesystemConfig defines filesystem restrictions.
|
||||||
|
type FilesystemConfig = config.FilesystemConfig
|
||||||
|
|
||||||
|
// Manager handles sandbox initialization and command wrapping.
|
||||||
|
type Manager = sandbox.Manager
|
||||||
|
|
||||||
|
// NewManager creates a new sandbox manager.
|
||||||
|
func NewManager(cfg *Config, debug bool) *Manager {
|
||||||
|
return sandbox.NewManager(cfg, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns the default configuration with all network blocked.
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return config.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads configuration from a file.
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
return config.Load(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfigPath returns the default config file path.
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
return config.DefaultConfigPath()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user