Enhance violation monitoring

This commit is contained in:
JY Tan
2025-12-18 15:49:05 -08:00
parent c02c91f051
commit 35d1f1ea22
8 changed files with 377 additions and 46 deletions

View File

@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -24,15 +25,19 @@ type HTTPProxy struct {
listener net.Listener
filter FilterFunc
debug bool
monitor bool
mu sync.RWMutex
running bool
}
// 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{
filter: filter,
debug: debug,
filter: filter,
debug: debug,
monitor: monitor,
}
}
@@ -95,6 +100,7 @@ func (p *HTTPProxy) handleRequest(w http.ResponseWriter, r *http.Request) {
// handleConnect handles HTTPS CONNECT requests (tunnel).
func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
start := time.Now()
host, portStr, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
@@ -108,11 +114,13 @@ func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
// Check if allowed
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)
return
}
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 200, "ALLOWED", time.Since(start))
// Connect to target
targetConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 10*time.Second)
if err != nil {
@@ -157,6 +165,7 @@ func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
// handleHTTP handles regular HTTP proxy requests.
func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
targetURL, err := url.Parse(r.RequestURI)
if err != nil {
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) {
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)
return
}
@@ -203,7 +212,7 @@ func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
resp, err := client.Do(proxyReq)
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)
return
}
@@ -218,21 +227,57 @@ func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(resp.StatusCode)
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{}) {
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.
// When debug is true, logs filter rule matches to stderr.
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)
fmt.Fprintf(os.Stderr, "[fence:filter] No config, denying: %s:%d\n", host, port)
}
return false
}
@@ -241,7 +286,7 @@ func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
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)
fmt.Fprintf(os.Stderr, "[fence:filter] Denied by rule: %s:%d (matched %s)\n", host, port, denied)
}
return false
}
@@ -251,14 +296,14 @@ func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
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)
fmt.Fprintf(os.Stderr, "[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)
fmt.Fprintf(os.Stderr, "[fence:filter] No matching rule, denying: %s:%d\n", host, port)
}
return false
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"net"
"os"
"time"
"github.com/things-go/go-socks5"
)
@@ -14,21 +16,26 @@ type SOCKSProxy struct {
listener net.Listener
filter FilterFunc
debug bool
monitor bool
port int
}
// 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{
filter: filter,
debug: debug,
filter: filter,
debug: debug,
monitor: monitor,
}
}
// fenceRuleSet implements socks5.RuleSet for domain filtering.
type fenceRuleSet struct {
filter FilterFunc
debug bool
filter FilterFunc
debug bool
monitor 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
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 {
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 {
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
@@ -61,8 +71,9 @@ func (p *SOCKSProxy) Start() (int, error) {
server := socks5.NewServer(
socks5.WithRule(&fenceRuleSet{
filter: p.filter,
debug: p.debug,
filter: p.filter,
debug: p.debug,
monitor: p.monitor,
}),
)
p.server = server
@@ -70,13 +81,13 @@ func (p *SOCKSProxy) Start() (int, error) {
go func() {
if err := p.server.Serve(p.listener); err != nil {
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 {
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
}

View File

@@ -20,14 +20,16 @@ type Manager struct {
socksPort int
exposedPorts []int
debug bool
monitor bool
initialized bool
}
// 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{
config: cfg,
debug: debug,
config: cfg,
debug: debug,
monitor: monitor,
}
}
@@ -48,14 +50,14 @@ func (m *Manager) Initialize() error {
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()
if err != nil {
return fmt.Errorf("failed to start HTTP proxy: %w", err)
}
m.httpPort = httpPort
m.socksProxy = proxy.NewSOCKSProxy(filter, m.debug)
m.socksProxy = proxy.NewSOCKSProxy(filter, m.debug, m.monitor)
socksPort, err := m.socksProxy.Start()
if err != nil {
m.httpProxy.Stop()

198
internal/sandbox/monitor.go Normal file
View 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
}