Enhance violation monitoring
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Fence Architecture
|
# Architecture
|
||||||
|
|
||||||
Fence restricts network and filesystem access for arbitrary commands. It works by:
|
Fence restricts network and filesystem access for arbitrary commands. It works by:
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ fence/
|
|||||||
│ ├── manager.go # Orchestrates sandbox lifecycle
|
│ ├── manager.go # Orchestrates sandbox lifecycle
|
||||||
│ ├── macos.go # macOS sandbox-exec profiles
|
│ ├── macos.go # macOS sandbox-exec profiles
|
||||||
│ ├── linux.go # Linux bubblewrap + socat bridges
|
│ ├── linux.go # Linux bubblewrap + socat bridges
|
||||||
|
│ ├── monitor.go # macOS log stream violation monitoring
|
||||||
│ ├── dangerous.go # Protected file/directory lists
|
│ ├── dangerous.go # Protected file/directory lists
|
||||||
│ └── utils.go # Path normalization, shell quoting
|
│ └── utils.go # Path normalization, shell quoting
|
||||||
└── pkg/fence/ # Public Go API
|
└── pkg/fence/ # Public Go API
|
||||||
@@ -241,8 +242,63 @@ flowchart TD
|
|||||||
| Proxy routing | Environment variables | socat bridges + env vars |
|
| Proxy routing | Environment variables | socat bridges + env vars |
|
||||||
| Filesystem control | Profile rules | Bind mounts |
|
| Filesystem control | Profile rules | Bind mounts |
|
||||||
| Inbound connections | Profile rules (`network-bind`) | Reverse socat bridges |
|
| Inbound connections | Profile rules (`network-bind`) | Reverse socat bridges |
|
||||||
|
| Violation monitoring | log stream + proxy | proxy only |
|
||||||
| Requirements | Built-in | bwrap, socat |
|
| Requirements | Built-in | bwrap, socat |
|
||||||
|
|
||||||
|
## Violation Monitoring
|
||||||
|
|
||||||
|
The `-m` (monitor) flag enables real-time visibility into blocked operations.
|
||||||
|
|
||||||
|
### Output Prefixes
|
||||||
|
|
||||||
|
| Prefix | Source | Description |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| `[fence:http]` | Both | HTTP/HTTPS proxy (blocked requests only in monitor mode) |
|
||||||
|
| `[fence:socks]` | Both | SOCKS5 proxy (blocked requests only in monitor mode) |
|
||||||
|
| `[fence:logstream]` | macOS only | Kernel-level sandbox violations from `log stream` |
|
||||||
|
| `[fence:filter]` | Both | Domain filter rule matches (debug mode only) |
|
||||||
|
|
||||||
|
### macOS Log Stream
|
||||||
|
|
||||||
|
On macOS, fence spawns `log stream` with a predicate to capture sandbox violations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
log stream --predicate 'eventMessage ENDSWITH "_SBX"' --style compact
|
||||||
|
```
|
||||||
|
|
||||||
|
Violations include:
|
||||||
|
|
||||||
|
- `network-outbound` - blocked network connections
|
||||||
|
- `file-read*` - blocked file reads
|
||||||
|
- `file-write*` - blocked file writes
|
||||||
|
|
||||||
|
Filtered out (too noisy):
|
||||||
|
|
||||||
|
- `mach-lookup` - IPC service lookups
|
||||||
|
- `file-ioctl` - device control operations
|
||||||
|
- `/dev/tty*` writes - terminal output
|
||||||
|
- `mDNSResponder` - system DNS resolution
|
||||||
|
- `/private/var/run/syslog` - system logging
|
||||||
|
|
||||||
|
### Linux Limitations
|
||||||
|
|
||||||
|
Linux uses network namespace isolation (`--unshare-net`), which prevents connections at the namespace level rather than logging them. There's no kernel-level violation stream equivalent to macOS.
|
||||||
|
|
||||||
|
With `-m` on Linux, you only see proxy-level denials:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[fence:http] 14:30:01 ✗ CONNECT 403 evil.com (blocked by proxy)
|
||||||
|
[fence:socks] 14:30:02 ✗ CONNECT evil.com:22 BLOCKED
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug vs Monitor Mode
|
||||||
|
|
||||||
|
| Flag | Proxy logs | Filter rules | Log stream | Sandbox command |
|
||||||
|
|------|------------|--------------|------------|-----------------|
|
||||||
|
| `-m` | Blocked only | No | Yes (macOS) | No |
|
||||||
|
| `-d` | All | Yes | No | Yes |
|
||||||
|
| `-m -d` | All | Yes | Yes (macOS) | Yes |
|
||||||
|
|
||||||
## Security Model
|
## Security Model
|
||||||
|
|
||||||
### How Each Layer Works
|
### How Each Layer Works
|
||||||
@@ -268,7 +324,7 @@ Access control follows a deny-by-default model for writes:
|
|||||||
|
|
||||||
#### Dangerous File Protection
|
#### Dangerous File Protection
|
||||||
|
|
||||||
Certain paths are always protected regardless of config to prevent common attack vectors:
|
Certain paths are always protected from writes regardless of config to prevent common attack vectors:
|
||||||
|
|
||||||
- Shell configs: `.bashrc`, `.zshrc`, `.profile`, `.bash_profile`
|
- Shell configs: `.bashrc`, `.zshrc`, `.profile`, `.bash_profile`
|
||||||
- Git hooks: `.git/hooks/*` (can execute arbitrary code on git operations)
|
- Git hooks: `.git/hooks/*` (can execute arbitrary code on git operations)
|
||||||
@@ -329,10 +385,3 @@ Apple deprecated `sandbox-exec` but it still works on current macOS (including S
|
|||||||
#### Not for hostile code containment
|
#### 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.
|
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
|
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A Go implementation of process sandboxing with network and filesystem restrictions.
|
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.
|
**`fence`** wraps arbitrary commands in a security sandbox, blocking network access by default and restricting filesystem operations based on configurable rules.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> This is still a work in progress and may see significant changes.
|
> This is still a work in progress and may see significant changes.
|
||||||
@@ -12,9 +12,11 @@ A Go implementation of process sandboxing with network and filesystem restrictio
|
|||||||
- **Network Isolation**: All network access blocked by default
|
- **Network Isolation**: All network access blocked by default
|
||||||
- **Domain Allowlisting**: Configure which domains are allowed
|
- **Domain Allowlisting**: Configure which domains are allowed
|
||||||
- **Filesystem Restrictions**: Control read/write access to paths
|
- **Filesystem Restrictions**: Control read/write access to paths
|
||||||
|
- **Violation Monitoring**: Real-time logging of blocked requests and sandbox denials
|
||||||
- **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap)
|
- **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap)
|
||||||
- **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control
|
- **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control
|
||||||
- **Library + CLI**: Use as a Go package or command-line tool
|
|
||||||
|
You can use **`fence`** as a Go package or CLI tool.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -87,7 +89,9 @@ fence [flags] [command...]
|
|||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-c string Run command string directly (like sh -c)
|
-c string Run command string directly (like sh -c)
|
||||||
-d, --debug Enable debug logging
|
-d, --debug Enable debug logging (shows sandbox command, proxy activity, filter rules)
|
||||||
|
-m, --monitor Monitor mode (shows blocked requests and violations only)
|
||||||
|
-p, --port Expose port for inbound connections (can be repeated)
|
||||||
-s, --settings Path to settings file (default: ~/.fence.json)
|
-s, --settings Path to settings file (default: ~/.fence.json)
|
||||||
-h, --help Help for fence
|
-h, --help Help for fence
|
||||||
```
|
```
|
||||||
@@ -107,6 +111,12 @@ fence -c "git clone https://github.com/user/repo && cd repo && npm install"
|
|||||||
|
|
||||||
# Debug mode shows proxy activity
|
# Debug mode shows proxy activity
|
||||||
fence -d wget https://example.com
|
fence -d wget https://example.com
|
||||||
|
|
||||||
|
# Monitor mode shows violations/blocked requests only
|
||||||
|
fence -m npm install
|
||||||
|
|
||||||
|
# Expose a port for inbound connections
|
||||||
|
fence -p 3000 -c "npm run dev"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Library Usage
|
## Library Usage
|
||||||
@@ -130,8 +140,8 @@ func main() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create manager
|
// Create manager (debug=false, monitor=false)
|
||||||
manager := fence.NewManager(cfg, false)
|
manager := fence.NewManager(cfg, false, false)
|
||||||
defer manager.Cleanup()
|
defer manager.Cleanup()
|
||||||
|
|
||||||
// Initialize (starts proxies)
|
// Initialize (starts proxies)
|
||||||
@@ -180,7 +190,7 @@ For detailed security model, limitations, and architecture, see [ARCHITECTURE.md
|
|||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
- `bubblewrap` (bwrap)
|
- `bubblewrap` (for sandboxing)
|
||||||
- `socat` (for network bridging)
|
- `socat` (for network bridging)
|
||||||
|
|
||||||
Install on Ubuntu/Debian:
|
Install on Ubuntu/Debian:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
debug bool
|
debug bool
|
||||||
|
monitor bool
|
||||||
settingsPath string
|
settingsPath string
|
||||||
cmdString string
|
cmdString string
|
||||||
exposePorts []string
|
exposePorts []string
|
||||||
@@ -60,6 +61,7 @@ Configuration file format (~/.fence.json):
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
|
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations (macOS: log stream, all: proxy denials)")
|
||||||
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: ~/.fence.json)")
|
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().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().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
|
||||||
@@ -117,7 +119,7 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
|||||||
cfg = config.Default()
|
cfg = config.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := sandbox.NewManager(cfg, debug)
|
manager := sandbox.NewManager(cfg, debug, monitor)
|
||||||
manager.SetExposedPorts(ports)
|
manager.SetExposedPorts(ports)
|
||||||
defer manager.Cleanup()
|
defer manager.Cleanup()
|
||||||
|
|
||||||
@@ -125,6 +127,18 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("failed to initialize sandbox: %w", err)
|
return fmt.Errorf("failed to initialize sandbox: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var logMonitor *sandbox.LogMonitor
|
||||||
|
if monitor {
|
||||||
|
logMonitor = sandbox.NewLogMonitor(sandbox.GetSessionSuffix())
|
||||||
|
if logMonitor != nil {
|
||||||
|
if err := logMonitor.Start(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence] Warning: failed to start log monitor: %v\n", err)
|
||||||
|
} else {
|
||||||
|
defer logMonitor.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sandboxedCommand, err := manager.WrapCommand(command)
|
sandboxedCommand, err := manager.WrapCommand(command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to wrap command: %w", err)
|
return fmt.Errorf("failed to wrap command: %w", err)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,15 +25,19 @@ type HTTPProxy struct {
|
|||||||
listener net.Listener
|
listener net.Listener
|
||||||
filter FilterFunc
|
filter FilterFunc
|
||||||
debug bool
|
debug bool
|
||||||
|
monitor bool
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPProxy creates a new HTTP proxy with the given filter.
|
// NewHTTPProxy creates a new HTTP proxy with the given filter.
|
||||||
func NewHTTPProxy(filter FilterFunc, debug bool) *HTTPProxy {
|
// If monitor is true, only blocked requests are logged.
|
||||||
|
// If debug is true, all requests and filter rules are logged.
|
||||||
|
func NewHTTPProxy(filter FilterFunc, debug, monitor bool) *HTTPProxy {
|
||||||
return &HTTPProxy{
|
return &HTTPProxy{
|
||||||
filter: filter,
|
filter: filter,
|
||||||
debug: debug,
|
debug: debug,
|
||||||
|
monitor: monitor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +100,7 @@ func (p *HTTPProxy) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// handleConnect handles HTTPS CONNECT requests (tunnel).
|
// handleConnect handles HTTPS CONNECT requests (tunnel).
|
||||||
func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
host, portStr, err := net.SplitHostPort(r.Host)
|
host, portStr, err := net.SplitHostPort(r.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
host = r.Host
|
host = r.Host
|
||||||
@@ -108,11 +114,13 @@ func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Check if allowed
|
// Check if allowed
|
||||||
if !p.filter(host, port) {
|
if !p.filter(host, port) {
|
||||||
p.logDebug("CONNECT blocked: %s:%d", host, port)
|
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 403, "BLOCKED", time.Since(start))
|
||||||
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 200, "ALLOWED", time.Since(start))
|
||||||
|
|
||||||
// Connect to target
|
// Connect to target
|
||||||
targetConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 10*time.Second)
|
targetConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 10*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,6 +165,7 @@ func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// handleHTTP handles regular HTTP proxy requests.
|
// handleHTTP handles regular HTTP proxy requests.
|
||||||
func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
targetURL, err := url.Parse(r.RequestURI)
|
targetURL, err := url.Parse(r.RequestURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
@@ -172,7 +181,7 @@ func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !p.filter(host, port) {
|
if !p.filter(host, port) {
|
||||||
p.logDebug("HTTP blocked: %s:%d", host, port)
|
p.logRequest(r.Method, r.RequestURI, host, 403, "BLOCKED", time.Since(start))
|
||||||
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -203,7 +212,7 @@ func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
resp, err := client.Do(proxyReq)
|
resp, err := client.Do(proxyReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logDebug("HTTP request failed: %v", err)
|
p.logRequest(r.Method, r.RequestURI, host, 502, "ERROR", time.Since(start))
|
||||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -218,21 +227,57 @@ func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
w.WriteHeader(resp.StatusCode)
|
||||||
io.Copy(w, resp.Body)
|
io.Copy(w, resp.Body)
|
||||||
|
|
||||||
|
p.logRequest(r.Method, r.RequestURI, host, resp.StatusCode, "ALLOWED", time.Since(start))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPProxy) logDebug(format string, args ...interface{}) {
|
func (p *HTTPProxy) logDebug(format string, args ...interface{}) {
|
||||||
if p.debug {
|
if p.debug {
|
||||||
fmt.Printf("[fence:http] "+format+"\n", args...)
|
fmt.Fprintf(os.Stderr, "[fence:http] "+format+"\n", args...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// logRequest logs a detailed request entry.
|
||||||
|
// In monitor mode (-m), only blocked/error requests are logged.
|
||||||
|
// In debug mode (-d), all requests are logged.
|
||||||
|
func (p *HTTPProxy) logRequest(method, url, host string, status int, action string, duration time.Duration) {
|
||||||
|
isBlocked := action == "BLOCKED" || action == "ERROR"
|
||||||
|
|
||||||
|
if p.monitor && !p.debug && !isBlocked {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.debug && !p.monitor {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := time.Now().Format("15:04:05")
|
||||||
|
statusIcon := "✓"
|
||||||
|
switch action {
|
||||||
|
case "BLOCKED":
|
||||||
|
statusIcon = "✗"
|
||||||
|
case "ERROR":
|
||||||
|
statusIcon = "!"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "[fence:http] %s %s %-7s %d %s %s (%v)\n", timestamp, statusIcon, method, status, host, truncateURL(url, 60), duration.Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateURL shortens a URL for display.
|
||||||
|
func truncateURL(url string, maxLen int) string {
|
||||||
|
if len(url) <= maxLen {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return url[:maxLen-3] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
// CreateDomainFilter creates a filter function from a config.
|
// CreateDomainFilter creates a filter function from a config.
|
||||||
|
// When debug is true, logs filter rule matches to stderr.
|
||||||
func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
|
func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
|
||||||
return func(host string, port int) bool {
|
return func(host string, port int) bool {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
// No config = deny all
|
// No config = deny all
|
||||||
if debug {
|
if debug {
|
||||||
fmt.Printf("[fence:filter] No config, denying: %s:%d\n", host, port)
|
fmt.Fprintf(os.Stderr, "[fence:filter] No config, denying: %s:%d\n", host, port)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -241,7 +286,7 @@ func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
|
|||||||
for _, denied := range cfg.Network.DeniedDomains {
|
for _, denied := range cfg.Network.DeniedDomains {
|
||||||
if config.MatchesDomain(host, denied) {
|
if config.MatchesDomain(host, denied) {
|
||||||
if debug {
|
if debug {
|
||||||
fmt.Printf("[fence:filter] Denied by rule: %s:%d (matched %s)\n", host, port, denied)
|
fmt.Fprintf(os.Stderr, "[fence:filter] Denied by rule: %s:%d (matched %s)\n", host, port, denied)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -251,14 +296,14 @@ func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
|
|||||||
for _, allowed := range cfg.Network.AllowedDomains {
|
for _, allowed := range cfg.Network.AllowedDomains {
|
||||||
if config.MatchesDomain(host, allowed) {
|
if config.MatchesDomain(host, allowed) {
|
||||||
if debug {
|
if debug {
|
||||||
fmt.Printf("[fence:filter] Allowed by rule: %s:%d (matched %s)\n", host, port, allowed)
|
fmt.Fprintf(os.Stderr, "[fence:filter] Allowed by rule: %s:%d (matched %s)\n", host, port, allowed)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
fmt.Printf("[fence:filter] No matching rule, denying: %s:%d\n", host, port)
|
fmt.Fprintf(os.Stderr, "[fence:filter] No matching rule, denying: %s:%d\n", host, port)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/things-go/go-socks5"
|
"github.com/things-go/go-socks5"
|
||||||
)
|
)
|
||||||
@@ -14,21 +16,26 @@ type SOCKSProxy struct {
|
|||||||
listener net.Listener
|
listener net.Listener
|
||||||
filter FilterFunc
|
filter FilterFunc
|
||||||
debug bool
|
debug bool
|
||||||
|
monitor bool
|
||||||
port int
|
port int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSOCKSProxy creates a new SOCKS5 proxy with the given filter.
|
// NewSOCKSProxy creates a new SOCKS5 proxy with the given filter.
|
||||||
func NewSOCKSProxy(filter FilterFunc, debug bool) *SOCKSProxy {
|
// If monitor is true, only blocked connections are logged.
|
||||||
|
// If debug is true, all connections are logged.
|
||||||
|
func NewSOCKSProxy(filter FilterFunc, debug, monitor bool) *SOCKSProxy {
|
||||||
return &SOCKSProxy{
|
return &SOCKSProxy{
|
||||||
filter: filter,
|
filter: filter,
|
||||||
debug: debug,
|
debug: debug,
|
||||||
|
monitor: monitor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fenceRuleSet implements socks5.RuleSet for domain filtering.
|
// fenceRuleSet implements socks5.RuleSet for domain filtering.
|
||||||
type fenceRuleSet struct {
|
type fenceRuleSet struct {
|
||||||
filter FilterFunc
|
filter FilterFunc
|
||||||
debug bool
|
debug bool
|
||||||
|
monitor bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fenceRuleSet) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) {
|
func (r *fenceRuleSet) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) {
|
||||||
@@ -39,11 +46,14 @@ func (r *fenceRuleSet) Allow(ctx context.Context, req *socks5.Request) (context.
|
|||||||
port := req.DestAddr.Port
|
port := req.DestAddr.Port
|
||||||
|
|
||||||
allowed := r.filter(host, port)
|
allowed := r.filter(host, port)
|
||||||
if r.debug {
|
|
||||||
|
shouldLog := r.debug || (r.monitor && !allowed)
|
||||||
|
if shouldLog {
|
||||||
|
timestamp := time.Now().Format("15:04:05")
|
||||||
if allowed {
|
if allowed {
|
||||||
fmt.Printf("[fence:socks] Allowed: %s:%d\n", host, port)
|
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✓ CONNECT %s:%d ALLOWED\n", timestamp, host, port)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[fence:socks] Blocked: %s:%d\n", host, port)
|
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✗ CONNECT %s:%d BLOCKED\n", timestamp, host, port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ctx, allowed
|
return ctx, allowed
|
||||||
@@ -61,8 +71,9 @@ func (p *SOCKSProxy) Start() (int, error) {
|
|||||||
|
|
||||||
server := socks5.NewServer(
|
server := socks5.NewServer(
|
||||||
socks5.WithRule(&fenceRuleSet{
|
socks5.WithRule(&fenceRuleSet{
|
||||||
filter: p.filter,
|
filter: p.filter,
|
||||||
debug: p.debug,
|
debug: p.debug,
|
||||||
|
monitor: p.monitor,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
p.server = server
|
p.server = server
|
||||||
@@ -70,13 +81,13 @@ func (p *SOCKSProxy) Start() (int, error) {
|
|||||||
go func() {
|
go func() {
|
||||||
if err := p.server.Serve(p.listener); err != nil {
|
if err := p.server.Serve(p.listener); err != nil {
|
||||||
if p.debug {
|
if p.debug {
|
||||||
fmt.Printf("[fence:socks] Server error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "[fence:socks] Server error: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if p.debug {
|
if p.debug {
|
||||||
fmt.Printf("[fence:socks] SOCKS5 proxy listening on localhost:%d\n", p.port)
|
fmt.Fprintf(os.Stderr, "[fence:socks] SOCKS5 proxy listening on localhost:%d\n", p.port)
|
||||||
}
|
}
|
||||||
return p.port, nil
|
return p.port, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ type Manager struct {
|
|||||||
socksPort int
|
socksPort int
|
||||||
exposedPorts []int
|
exposedPorts []int
|
||||||
debug bool
|
debug bool
|
||||||
|
monitor bool
|
||||||
initialized bool
|
initialized bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new sandbox manager.
|
// NewManager creates a new sandbox manager.
|
||||||
func NewManager(cfg *config.Config, debug bool) *Manager {
|
func NewManager(cfg *config.Config, debug, monitor bool) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
debug: debug,
|
debug: debug,
|
||||||
|
monitor: monitor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,14 +50,14 @@ func (m *Manager) Initialize() error {
|
|||||||
|
|
||||||
filter := proxy.CreateDomainFilter(m.config, m.debug)
|
filter := proxy.CreateDomainFilter(m.config, m.debug)
|
||||||
|
|
||||||
m.httpProxy = proxy.NewHTTPProxy(filter, m.debug)
|
m.httpProxy = proxy.NewHTTPProxy(filter, m.debug, m.monitor)
|
||||||
httpPort, err := m.httpProxy.Start()
|
httpPort, err := m.httpProxy.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start HTTP proxy: %w", err)
|
return fmt.Errorf("failed to start HTTP proxy: %w", err)
|
||||||
}
|
}
|
||||||
m.httpPort = httpPort
|
m.httpPort = httpPort
|
||||||
|
|
||||||
m.socksProxy = proxy.NewSOCKSProxy(filter, m.debug)
|
m.socksProxy = proxy.NewSOCKSProxy(filter, m.debug, m.monitor)
|
||||||
socksPort, err := m.socksProxy.Start()
|
socksPort, err := m.socksProxy.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.httpProxy.Stop()
|
m.httpProxy.Stop()
|
||||||
|
|||||||
198
internal/sandbox/monitor.go
Normal file
198
internal/sandbox/monitor.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Package sandbox provides sandboxing functionality for macOS and Linux.
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Use-Tusk/fence/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogMonitor monitors sandbox violations via macOS log stream.
|
||||||
|
type LogMonitor struct {
|
||||||
|
sessionSuffix string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
cancel context.CancelFunc
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogMonitor creates a new log monitor for the given session suffix.
|
||||||
|
// Returns nil on non-macOS platforms.
|
||||||
|
func NewLogMonitor(sessionSuffix string) *LogMonitor {
|
||||||
|
if platform.Detect() != platform.MacOS {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &LogMonitor{
|
||||||
|
sessionSuffix: sessionSuffix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins monitoring the macOS unified log for sandbox violations.
|
||||||
|
func (m *LogMonitor) Start() error {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
m.cancel = cancel
|
||||||
|
|
||||||
|
// Build predicate to filter for our session's violations
|
||||||
|
// Note: We use the broader "_SBX" suffix to ensure we capture events
|
||||||
|
// even if there's a slight delay in log delivery
|
||||||
|
predicate := `eventMessage ENDSWITH "_SBX"`
|
||||||
|
|
||||||
|
m.cmd = exec.CommandContext(ctx, "log", "stream",
|
||||||
|
"--predicate", predicate,
|
||||||
|
"--style", "compact",
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, err := m.cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start log stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.running = true
|
||||||
|
|
||||||
|
// Parse log output in background
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if violation := parseViolation(line); violation != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", violation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give log stream a moment to initialize
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the log monitor.
|
||||||
|
func (m *LogMonitor) Stop() {
|
||||||
|
if m == nil || !m.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give a moment for any pending events to be processed
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
if m.cancel != nil {
|
||||||
|
m.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.cmd != nil && m.cmd.Process != nil {
|
||||||
|
m.cmd.Process.Kill()
|
||||||
|
m.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
m.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// violationPattern matches sandbox denial log entries
|
||||||
|
var violationPattern = regexp.MustCompile(`Sandbox: (\w+)\((\d+)\) deny\(\d+\) (\S+)(.*)`)
|
||||||
|
|
||||||
|
// parseViolation extracts and formats a sandbox violation from a log line.
|
||||||
|
// Returns empty string if the line should be filtered out.
|
||||||
|
func parseViolation(line string) string {
|
||||||
|
// Skip header lines
|
||||||
|
if strings.HasPrefix(line, "Filtering") || strings.HasPrefix(line, "Timestamp") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip duplicate report summaries
|
||||||
|
if strings.Contains(line, "duplicate report") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip CMD64 marker lines (they follow the actual violation)
|
||||||
|
if strings.HasPrefix(line, "CMD64_") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match violation pattern
|
||||||
|
matches := violationPattern.FindStringSubmatch(line)
|
||||||
|
if matches == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
process := matches[1]
|
||||||
|
pid := matches[2]
|
||||||
|
operation := matches[3]
|
||||||
|
details := strings.TrimSpace(matches[4])
|
||||||
|
|
||||||
|
// Filter: only show network and file operations
|
||||||
|
if !shouldShowViolation(operation) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out noisy violations
|
||||||
|
if isNoisyViolation(operation, details) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the output
|
||||||
|
timestamp := time.Now().Format("15:04:05")
|
||||||
|
|
||||||
|
if details != "" {
|
||||||
|
return fmt.Sprintf("[fence:logstream] %s ✗ %s %s (%s:%s)", timestamp, operation, details, process, pid)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[fence:logstream] %s ✗ %s (%s:%s)", timestamp, operation, process, pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldShowViolation returns true if this violation type should be displayed.
|
||||||
|
func shouldShowViolation(operation string) bool {
|
||||||
|
// Show network violations
|
||||||
|
if strings.HasPrefix(operation, "network-") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show file read/write violations
|
||||||
|
if strings.HasPrefix(operation, "file-read") ||
|
||||||
|
strings.HasPrefix(operation, "file-write") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out everything else (mach-lookup, file-ioctl, etc.)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNoisyViolation returns true if this violation is system noise that should be filtered.
|
||||||
|
func isNoisyViolation(operation, details string) bool {
|
||||||
|
// Filter out TTY/terminal writes (very noisy from any process that prints output)
|
||||||
|
if strings.HasPrefix(details, "/dev/tty") ||
|
||||||
|
strings.HasPrefix(details, "/dev/pts") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out mDNSResponder (system DNS resolution socket)
|
||||||
|
if strings.Contains(details, "mDNSResponder") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out other system sockets that are typically noise
|
||||||
|
if strings.HasPrefix(details, "/private/var/run/syslog") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionSuffix returns the session suffix used for filtering.
|
||||||
|
// This is the same suffix used in macOS sandbox-exec profiles.
|
||||||
|
func GetSessionSuffix() string {
|
||||||
|
return sessionSuffix // defined in macos.go
|
||||||
|
}
|
||||||
|
|
||||||
@@ -19,8 +19,10 @@ type FilesystemConfig = config.FilesystemConfig
|
|||||||
type Manager = sandbox.Manager
|
type Manager = sandbox.Manager
|
||||||
|
|
||||||
// NewManager creates a new sandbox manager.
|
// NewManager creates a new sandbox manager.
|
||||||
func NewManager(cfg *Config, debug bool) *Manager {
|
// If debug is true, verbose logging is enabled.
|
||||||
return sandbox.NewManager(cfg, debug)
|
// If monitor is true, only violations (blocked requests) are logged.
|
||||||
|
func NewManager(cfg *Config, debug, monitor bool) *Manager {
|
||||||
|
return sandbox.NewManager(cfg, debug, monitor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns the default configuration with all network blocked.
|
// DefaultConfig returns the default configuration with all network blocked.
|
||||||
|
|||||||
Reference in New Issue
Block a user