Initial commit
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user