This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/internal/sandbox/linux.go
Mathieu Virbel 70d0685c97 fix: use UDP instead of TCP for DNS bridge to host DNS server
The DnsBridge socat relay was forwarding queries via TCP, but the
GreyHaven DNS service (gost) only listens on UDP, causing DNS
resolution failures ("Could not resolve host") inside the sandbox.
2026-02-11 19:30:56 -06:00

1095 lines
39 KiB
Go

//go:build linux
package sandbox
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"gitea.app.monadical.io/monadical/greywall/internal/config"
)
// ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket.
type ProxyBridge struct {
SocketPath string // Unix socket path
ProxyHost string // Parsed from ProxyURL
ProxyPort string // Parsed from ProxyURL
ProxyUser string // Username from ProxyURL (if any)
ProxyPass string // Password from ProxyURL (if any)
HasAuth bool // Whether credentials were provided
process *exec.Cmd
debug bool
}
// DnsBridge bridges DNS queries from the sandbox to a host-side DNS server via Unix socket.
// Inside the sandbox, a socat relay converts UDP DNS queries (port 53) to the Unix socket.
// On the host, socat forwards from the Unix socket to the actual DNS server (UDP).
type DnsBridge struct {
SocketPath string // Unix socket path
DnsAddr string // Host-side DNS address (host:port)
process *exec.Cmd
debug bool
}
// NewDnsBridge creates a Unix socket bridge to a host-side DNS server.
func NewDnsBridge(dnsAddr string, debug bool) (*DnsBridge, error) {
if _, err := exec.LookPath("socat"); err != nil {
return nil, fmt.Errorf("socat is required for DNS bridge: %w", err)
}
id := make([]byte, 8)
if _, err := rand.Read(id); err != nil {
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
}
socketID := hex.EncodeToString(id)
tmpDir := os.TempDir()
socketPath := filepath.Join(tmpDir, fmt.Sprintf("greywall-dns-%s.sock", socketID))
bridge := &DnsBridge{
SocketPath: socketPath,
DnsAddr: dnsAddr,
debug: debug,
}
// Start bridge: Unix socket -> DNS server UDP
socatArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
fmt.Sprintf("UDP:%s", dnsAddr),
}
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
if debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Starting DNS bridge: socat %s\n", strings.Join(socatArgs, " "))
}
if err := bridge.process.Start(); err != nil {
return nil, fmt.Errorf("failed to start DNS bridge: %w", err)
}
// Wait for socket to be created
for range 50 {
if fileExists(socketPath) {
if debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS bridge ready (%s -> %s)\n", socketPath, dnsAddr)
}
return bridge, nil
}
time.Sleep(100 * time.Millisecond)
}
bridge.Cleanup()
return nil, fmt.Errorf("timeout waiting for DNS bridge socket to be created")
}
// Cleanup stops the DNS bridge and removes the socket file.
func (b *DnsBridge) Cleanup() {
if b.process != nil && b.process.Process != nil {
_ = b.process.Process.Kill()
_ = b.process.Wait()
}
_ = os.Remove(b.SocketPath)
if b.debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS bridge cleaned up\n")
}
}
// 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
}
// LinuxSandboxOptions contains options for the Linux sandbox.
type LinuxSandboxOptions struct {
// Enable Landlock filesystem restrictions (requires kernel 5.13+)
UseLandlock bool
// Enable seccomp syscall filtering
UseSeccomp bool
// Enable eBPF monitoring (requires CAP_BPF or root)
UseEBPF bool
// Enable violation monitoring
Monitor bool
// Debug mode
Debug bool
// Learning mode: permissive sandbox with strace tracing
Learning bool
// Path to host-side strace log file (bind-mounted into sandbox)
StraceLogPath string
}
// NewProxyBridge creates a Unix socket bridge to an external SOCKS5 proxy.
// The bridge uses socat to forward from a Unix socket to the external proxy's TCP address.
func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
if _, err := exec.LookPath("socat"); err != nil {
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
}
u, err := parseProxyURL(proxyURL)
if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %w", err)
}
id := make([]byte, 8)
if _, err := rand.Read(id); err != nil {
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
}
socketID := hex.EncodeToString(id)
tmpDir := os.TempDir()
socketPath := filepath.Join(tmpDir, fmt.Sprintf("greywall-proxy-%s.sock", socketID))
bridge := &ProxyBridge{
SocketPath: socketPath,
ProxyHost: u.Hostname(),
ProxyPort: u.Port(),
debug: debug,
}
// Capture credentials from the proxy URL (if any)
if u.User != nil {
bridge.HasAuth = true
bridge.ProxyUser = u.User.Username()
bridge.ProxyPass, _ = u.User.Password()
}
// Start bridge: Unix socket -> external SOCKS5 proxy TCP
socatArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
fmt.Sprintf("TCP:%s:%s", bridge.ProxyHost, bridge.ProxyPort),
}
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
if debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Starting proxy bridge: socat %s\n", strings.Join(socatArgs, " "))
}
if err := bridge.process.Start(); err != nil {
return nil, fmt.Errorf("failed to start proxy bridge: %w", err)
}
// Wait for socket to be created, up to 5 seconds
for range 50 {
if fileExists(socketPath) {
if debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Proxy bridge ready (%s)\n", socketPath)
}
return bridge, nil
}
time.Sleep(100 * time.Millisecond)
}
bridge.Cleanup()
return nil, fmt.Errorf("timeout waiting for proxy bridge socket to be created")
}
// Cleanup stops the bridge process and removes the socket file.
func (b *ProxyBridge) Cleanup() {
if b.process != nil && b.process.Process != nil {
_ = b.process.Process.Kill()
_ = b.process.Wait()
}
_ = os.Remove(b.SocketPath)
if b.debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Proxy bridge cleaned up\n")
}
}
// parseProxyURL parses a SOCKS5 proxy URL and returns the parsed URL.
func parseProxyURL(proxyURL string) (*url.URL, error) {
u, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
if u.Scheme != "socks5" && u.Scheme != "socks5h" {
return nil, fmt.Errorf("proxy URL must use socks5:// or socks5h:// scheme, got %s", u.Scheme)
}
if u.Hostname() == "" || u.Port() == "" {
return nil, fmt.Errorf("proxy URL must include hostname and port")
}
return u, nil
}
// 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)
if _, err := rand.Read(id); err != nil {
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
}
socketID := hex.EncodeToString(id)
tmpDir := os.TempDir()
bridge := &ReverseBridge{
Ports: ports,
debug: debug,
}
for _, port := range ports {
socketPath := filepath.Join(tmpDir, fmt.Sprintf("greywall-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...) //nolint:gosec // args constructed from trusted input
if debug {
fmt.Fprintf(os.Stderr, "[greywall: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, "[greywall: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, "[greywall:linux] Reverse bridges cleaned up\n")
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// isDirectory returns true if the path exists and is a directory.
func isDirectory(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
// isSymlink returns true if the path is a symbolic link.
func isSymlink(path string) bool {
info, err := os.Lstat(path) // Lstat doesn't follow symlinks
if err != nil {
return false
}
return info.Mode()&os.ModeSymlink != 0
}
// canMountOver returns true if bwrap can safely mount over this path.
// Returns false for symlinks (target may not exist in sandbox) and
// other special cases that could cause mount failures.
func canMountOver(path string) bool {
if isSymlink(path) {
return false
}
return fileExists(path)
}
// sameDevice returns true if both paths reside on the same filesystem (device).
func sameDevice(path1, path2 string) bool {
var s1, s2 syscall.Stat_t
if syscall.Stat(path1, &s1) != nil || syscall.Stat(path2, &s2) != nil {
return true // err on the side of caution
}
return s1.Dev == s2.Dev
}
// intermediaryDirs returns the chain of directories between root and targetDir,
// from shallowest to deepest. Used to create --dir entries so bwrap can set up
// mount points inside otherwise-empty mount-point stubs.
//
// Example: intermediaryDirs("/", "/run/systemd/resolve") ->
//
// ["/run", "/run/systemd", "/run/systemd/resolve"]
func intermediaryDirs(root, targetDir string) []string {
rel, err := filepath.Rel(root, targetDir)
if err != nil {
return []string{targetDir}
}
parts := strings.Split(rel, string(filepath.Separator))
dirs := make([]string, 0, len(parts))
current := root
for _, part := range parts {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
return dirs
}
// getMandatoryDenyPaths returns concrete paths (not globs) that must be protected.
// This expands the glob patterns from GetMandatoryDenyPatterns into real paths.
func getMandatoryDenyPaths(cwd string) []string {
var paths []string
// Dangerous files in cwd
for _, f := range DangerousFiles {
p := filepath.Join(cwd, f)
paths = append(paths, p)
}
// Dangerous directories in cwd
for _, d := range DangerousDirectories {
p := filepath.Join(cwd, d)
paths = append(paths, p)
}
// Git hooks in cwd
paths = append(paths, filepath.Join(cwd, ".git/hooks"))
// Git config in cwd
paths = append(paths, filepath.Join(cwd, ".git/config"))
// Also protect home directory dangerous files
home, err := os.UserHomeDir()
if err == nil {
for _, f := range DangerousFiles {
p := filepath.Join(home, f)
paths = append(paths, p)
}
}
return paths
}
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
// It uses available security features (Landlock, seccomp) with graceful fallback.
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, dnsBridge, reverseBridge, tun2socksPath, LinuxSandboxOptions{
UseLandlock: true, // Enabled by default, will fall back if not available
UseSeccomp: true, // Enabled by default
UseEBPF: true, // Enabled by default if available
Debug: debug,
})
}
// WrapCommandLinuxWithOptions wraps a command with configurable sandbox options.
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
if _, err := exec.LookPath("bwrap"); err != nil {
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
}
shell := "bash"
shellPath, err := exec.LookPath(shell)
if err != nil {
return "", fmt.Errorf("shell %q not found: %w", shell, err)
}
cwd, _ := os.Getwd()
features := DetectLinuxFeatures()
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Available features: %s\n", features.Summary())
}
// Build bwrap args with filesystem restrictions
bwrapArgs := []string{
"bwrap",
}
// --new-session calls setsid() which detaches from the controlling terminal.
// Skip it in learning mode so interactive programs (TUIs, prompts) can
// read from /dev/tty. Learning mode already relaxes security constraints
// (no seccomp, no landlock), so skipping new-session is acceptable.
if !opts.Learning {
bwrapArgs = append(bwrapArgs, "--new-session")
}
bwrapArgs = append(bwrapArgs, "--die-with-parent")
// Always use --unshare-net when available (network namespace isolation)
// Inside the namespace, tun2socks will provide transparent proxy access
if features.CanUnshareNet {
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
} else if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
}
bwrapArgs = append(bwrapArgs, "--unshare-pid") // PID namespace isolation
// Generate seccomp filter if available and requested
var seccompFilterPath string
if opts.UseSeccomp && features.HasSeccomp {
filter := NewSeccompFilter(opts.Debug)
filterPath, err := filter.GenerateBPFFilter()
if err != nil {
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Seccomp filter generation failed: %v\n", err)
}
} else {
seccompFilterPath = filterPath
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Seccomp filter enabled (blocking %d dangerous syscalls)\n", len(DangerousSyscalls))
}
// Add seccomp filter via fd 3 (will be set up via shell redirection)
bwrapArgs = append(bwrapArgs, "--seccomp", "3")
}
}
// Learning mode: permissive sandbox with home + cwd writable
if opts.Learning {
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Learning mode: binding root read-only, home + cwd writable\n")
}
// Bind entire root read-only as baseline
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
// Make home and cwd writable (overrides read-only)
home, _ := os.UserHomeDir()
if home != "" && fileExists(home) {
bwrapArgs = append(bwrapArgs, "--bind", home, home)
}
if cwd != "" && fileExists(cwd) && cwd != home {
bwrapArgs = append(bwrapArgs, "--bind", cwd, cwd)
}
}
defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead
if opts.Learning {
// Skip defaultDenyRead logic in learning mode (already set up above)
} else if defaultDenyRead {
// In defaultDenyRead mode, we only bind essential system paths read-only
// and user-specified allowRead paths. Everything else is inaccessible.
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
}
// Bind essential system paths read-only
// Skip /dev, /proc, /tmp as they're mounted with special options below
for _, systemPath := range GetDefaultReadablePaths() {
if systemPath == "/dev" || systemPath == "/proc" || systemPath == "/tmp" ||
systemPath == "/private/tmp" {
continue
}
if fileExists(systemPath) {
bwrapArgs = append(bwrapArgs, "--ro-bind", systemPath, systemPath)
}
}
// Bind user-specified allowRead paths
if cfg != nil && cfg.Filesystem.AllowRead != nil {
boundPaths := make(map[string]bool)
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead)
for _, p := range expandedPaths {
if fileExists(p) && !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] {
boundPaths[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.AllowRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) &&
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
boundPaths[normalized] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
}
}
}
} else {
// Default mode: bind entire root filesystem read-only
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
}
// Mount special filesystems
// Use --dev-bind for /dev instead of --dev to preserve host device permissions
// (the --dev minimal devtmpfs has permission issues when bwrap is setuid)
bwrapArgs = append(bwrapArgs, "--dev-bind", "/dev", "/dev")
bwrapArgs = append(bwrapArgs, "--proc", "/proc")
// /tmp needs to be writable for many programs
bwrapArgs = append(bwrapArgs, "--tmpfs", "/tmp")
// Bind strace log file into sandbox AFTER --tmpfs /tmp so it's visible
if opts.Learning && opts.StraceLogPath != "" {
bwrapArgs = append(bwrapArgs, "--bind", opts.StraceLogPath, opts.StraceLogPath)
}
// Ensure /etc/resolv.conf is readable inside the sandbox.
// On some systems (e.g., WSL), /etc/resolv.conf is a symlink to a path
// on a separate mount point (e.g., /mnt/wsl/resolv.conf) that isn't
// reachable after --ro-bind / / (non-recursive bind). When the target
// is on a different filesystem, we create intermediate directories and
// bind the real file at its original location so the symlink resolves.
if target, err := filepath.EvalSymlinks("/etc/resolv.conf"); err == nil && target != "/etc/resolv.conf" {
// Skip targets under specially-mounted dirs — a --tmpfs there would
// overwrite the --dev-bind or --proc mounts established above.
targetUnderSpecialMount := strings.HasPrefix(target, "/dev/") ||
strings.HasPrefix(target, "/proc/") ||
strings.HasPrefix(target, "/tmp/")
// In defaultDenyRead mode, also skip if the target is under a path
// already individually bound (e.g., /run, /sys) — a --tmpfs would
// overwrite that explicit bind. Targets under unbound paths like
// /mnt/wsl still need the fix.
if defaultDenyRead {
for _, p := range GetDefaultReadablePaths() {
if strings.HasPrefix(target, p+"/") {
targetUnderSpecialMount = true
break
}
}
}
if fileExists(target) && !sameDevice("/", target) && !targetUnderSpecialMount {
// Make the symlink target reachable by creating its parent dirs.
// Walk down from / to the target's parent: skip dirs on the root
// device (they have real content like /mnt/c, /mnt/d on WSL),
// apply --tmpfs at the mount boundary (first dir on a different
// device — an empty mount-point stub safe to replace), then --dir
// for any deeper subdirectories inside the now-writable tmpfs.
targetDir := filepath.Dir(target)
mountBoundaryFound := false
for _, dir := range intermediaryDirs("/", targetDir) {
if !mountBoundaryFound {
if !sameDevice("/", dir) {
bwrapArgs = append(bwrapArgs, "--tmpfs", dir)
mountBoundaryFound = true
}
// skip dirs still on root device
} else {
bwrapArgs = append(bwrapArgs, "--dir", dir)
}
}
if mountBoundaryFound {
bwrapArgs = append(bwrapArgs, "--ro-bind", target, target)
}
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Resolved /etc/resolv.conf symlink -> %s (cross-mount)\n", target)
}
}
}
// In learning mode, skip writable paths, deny rules, and mandatory deny
// (the sandbox is already permissive with home + cwd writable)
if !opts.Learning {
writablePaths := make(map[string]bool)
// Add default write paths (system paths needed for operation)
for _, p := range GetDefaultWritePaths() {
// Skip /dev paths (handled by --dev) and /tmp paths (handled by --tmpfs)
if strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/tmp/") || strings.HasPrefix(p, "/private/tmp/") {
continue
}
writablePaths[p] = true
}
// Add user-specified allowWrite paths
if cfg != nil && cfg.Filesystem.AllowWrite != nil {
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite)
for _, p := range expandedPaths {
writablePaths[p] = true
}
// Add non-glob paths
for _, p := range cfg.Filesystem.AllowWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) {
writablePaths[normalized] = true
}
}
}
// Make writable paths actually writable (override read-only root)
for p := range writablePaths {
if fileExists(p) {
bwrapArgs = append(bwrapArgs, "--bind", p, p)
}
}
// Handle denyRead paths - hide them
// For directories: use --tmpfs to replace with empty tmpfs
// For files: use --ro-bind /dev/null to mask with empty file
// Skip symlinks: they may point outside the sandbox and cause mount errors
if cfg != nil && cfg.Filesystem.DenyRead != nil {
expandedDenyRead := ExpandGlobPatterns(cfg.Filesystem.DenyRead)
for _, p := range expandedDenyRead {
if canMountOver(p) {
if isDirectory(p) {
bwrapArgs = append(bwrapArgs, "--tmpfs", p)
} else {
// Mask file with /dev/null (appears as empty, unreadable)
bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", p)
}
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && canMountOver(normalized) {
if isDirectory(normalized) {
bwrapArgs = append(bwrapArgs, "--tmpfs", normalized)
} else {
bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", normalized)
}
}
}
}
// Apply mandatory deny patterns (make dangerous files/dirs read-only)
// This overrides any writable mounts for these paths
//
// Note: We only use concrete paths from getMandatoryDenyPaths(), NOT glob expansion.
// GetMandatoryDenyPatterns() returns expensive **/pattern globs that require walking
// the entire directory tree - this can hang on large directories (see issue #27).
//
// The concrete paths cover dangerous files in cwd and home directory. Files like
// .bashrc in subdirectories are not protected, but this may be lower-risk since shell
// rc files in project subdirectories are uncommon and not automatically sourced.
//
// TODO: consider depth-limited glob expansion (e.g., max 3 levels) to protect
// subdirectory dangerous files without full tree walks that hang on large dirs.
mandatoryDeny := getMandatoryDenyPaths(cwd)
// Deduplicate
seen := make(map[string]bool)
for _, p := range mandatoryDeny {
if !seen[p] && fileExists(p) {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Handle explicit denyWrite paths (make them read-only)
if cfg != nil && cfg.Filesystem.DenyWrite != nil {
expandedDenyWrite := ExpandGlobPatterns(cfg.Filesystem.DenyWrite)
for _, p := range expandedDenyWrite {
if fileExists(p) && !seen[p] {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) && !seen[normalized] {
seen[normalized] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
}
}
}
} // end if !opts.Learning
// Bind the proxy bridge Unix socket into the sandbox (needs to be writable)
var dnsRelayResolvConf string // temp file path for custom resolv.conf
if proxyBridge != nil {
bwrapArgs = append(bwrapArgs,
"--bind", proxyBridge.SocketPath, proxyBridge.SocketPath,
)
if tun2socksPath != "" && features.CanUseTransparentProxy() {
// Bind /dev/net/tun for TUN device creation inside the sandbox
if features.HasDevNetTun {
bwrapArgs = append(bwrapArgs, "--dev-bind", "/dev/net/tun", "/dev/net/tun")
}
// Preserve CAP_NET_ADMIN (TUN device + network config) and
// CAP_NET_BIND_SERVICE (DNS relay on port 53) inside the namespace
bwrapArgs = append(bwrapArgs, "--cap-add", "CAP_NET_ADMIN")
bwrapArgs = append(bwrapArgs, "--cap-add", "CAP_NET_BIND_SERVICE")
// Bind the tun2socks binary into the sandbox (read-only)
bwrapArgs = append(bwrapArgs, "--ro-bind", tun2socksPath, "/tmp/greywall-tun2socks")
}
// Bind DNS bridge socket if available
if dnsBridge != nil {
bwrapArgs = append(bwrapArgs,
"--bind", dnsBridge.SocketPath, dnsBridge.SocketPath,
)
}
// Override /etc/resolv.conf for DNS resolution inside the sandbox.
if dnsBridge != nil || (tun2socksPath != "" && features.CanUseTransparentProxy()) {
tmpResolv, err := os.CreateTemp("", "greywall-resolv-*.conf")
if err == nil {
if dnsBridge != nil {
// DNS bridge: point at local socat relay (UDP :53 -> Unix socket -> host DNS server)
_, _ = tmpResolv.WriteString("nameserver 127.0.0.1\n")
} else {
// tun2socks: point at public DNS with TCP mode.
// tun2socks intercepts TCP traffic and forwards through the SOCKS5 proxy,
// but doesn't reliably handle UDP DNS. "options use-vc" forces the resolver
// to use TCP (RFC 1035 §4.2.2), which tun2socks handles natively.
// Supported by glibc, Go 1.21+, c-ares, and most DNS resolver libraries.
_, _ = tmpResolv.WriteString("nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions use-vc\n")
}
tmpResolv.Close()
dnsRelayResolvConf = tmpResolv.Name()
bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf")
if opts.Debug {
if dnsBridge != nil {
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 127.0.0.1 (bridge to %s)\n", dnsBridge.DnsAddr)
} else {
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 1.1.1.1 (TCP via tun2socks tunnel)\n")
}
}
}
}
}
// Bind reverse socket directory if needed (sockets created inside sandbox)
if reverseBridge != nil && len(reverseBridge.SocketPaths) > 0 {
// Get the temp directory containing the reverse sockets
tmpDir := filepath.Dir(reverseBridge.SocketPaths[0])
bwrapArgs = append(bwrapArgs, "--bind", tmpDir, tmpDir)
}
// Get greywall executable path for Landlock wrapper
greywallExePath, _ := os.Executable()
// Skip Landlock wrapper if executable is in /tmp (test binaries are built there)
// The wrapper won't work because --tmpfs /tmp hides the test binary
executableInTmp := strings.HasPrefix(greywallExePath, "/tmp/")
// Skip Landlock wrapper if greywall is being used as a library (executable is not greywall)
// The wrapper re-executes the binary with --landlock-apply, which only greywall understands
executableIsGreywall := strings.Contains(filepath.Base(greywallExePath), "greywall")
useLandlockWrapper := opts.UseLandlock && features.CanUseLandlock() && greywallExePath != "" && !executableInTmp && executableIsGreywall
if opts.Debug && executableInTmp {
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping Landlock wrapper (executable in /tmp, likely a test)\n")
}
if opts.Debug && !executableIsGreywall {
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping Landlock wrapper (running as library, not greywall CLI)\n")
}
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
// Build the inner command that sets up tun2socks and runs the user command
var innerScript strings.Builder
innerScript.WriteString("export GREYWALL_SANDBOX=1\n")
if proxyBridge != nil && tun2socksPath != "" && features.CanUseTransparentProxy() {
// Build the tun2socks proxy URL with credentials if available
// Many SOCKS5 proxies require the username/password auth flow even
// without real credentials (e.g., gost always selects method 0x02).
// Including userinfo ensures tun2socks offers both auth methods.
tun2socksProxyURL := "socks5://127.0.0.1:${PROXY_PORT}"
if proxyBridge.HasAuth {
userinfo := url.UserPassword(proxyBridge.ProxyUser, proxyBridge.ProxyPass)
tun2socksProxyURL = fmt.Sprintf("socks5://%s@127.0.0.1:${PROXY_PORT}", userinfo.String())
}
// Set up transparent proxy via TUN device + tun2socks
innerScript.WriteString(fmt.Sprintf(`
# Bring up loopback interface (needed for socat to bind on 127.0.0.1)
ip link set lo up
# Set up TUN device for transparent proxying
ip tuntap add dev tun0 mode tun
ip addr add 198.18.0.1/15 dev tun0
ip link set dev tun0 up
ip route add default via 198.18.0.1 dev tun0
# Bridge: local port -> Unix socket -> host -> external SOCKS5 proxy
PROXY_PORT=18321
socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
BRIDGE_PID=$!
# Start tun2socks (transparent proxy via gvisor netstack)
/tmp/greywall-tun2socks -device tun0 -proxy %s >/dev/null 2>&1 &
TUN2SOCKS_PID=$!
`, proxyBridge.SocketPath, tun2socksProxyURL))
// DNS relay: only needed when using a dedicated DNS bridge.
// When using tun2socks without a DNS bridge, resolv.conf is configured with
// "options use-vc" to force TCP DNS, which tun2socks handles natively.
if dnsBridge != nil {
// Dedicated DNS bridge: UDP :53 -> Unix socket -> host DNS server
innerScript.WriteString(fmt.Sprintf(`# DNS relay: UDP queries -> Unix socket -> host DNS server (%s)
socat UDP4-RECVFROM:53,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
DNS_RELAY_PID=$!
`, dnsBridge.DnsAddr, dnsBridge.SocketPath))
}
} else if proxyBridge != nil {
// Fallback: no TUN support, use env-var-based proxying
innerScript.WriteString(fmt.Sprintf(`
# Bring up loopback interface (needed for socat to bind on 127.0.0.1)
ip link set lo up 2>/dev/null
# Set up SOCKS5 bridge (no TUN available, env-var-based proxying)
PROXY_PORT=18321
socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
BRIDGE_PID=$!
export ALL_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
export all_proxy=socks5h://127.0.0.1:${PROXY_PORT}
export HTTP_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
export HTTPS_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
export http_proxy=socks5h://127.0.0.1:${PROXY_PORT}
export https_proxy=socks5h://127.0.0.1:${PROXY_PORT}
export NO_PROXY=localhost,127.0.0.1
export no_proxy=localhost,127.0.0.1
`, proxyBridge.SocketPath))
}
// 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 services are ready
sleep 0.3
# Run the user command
`)
// In learning mode, wrap the command with strace to trace syscalls.
// Run strace in the foreground so the traced command retains terminal
// access (stdin, /dev/tty) for interactive programs like TUIs.
// If the app spawns long-lived child processes, strace -f may hang
// after the main command exits; the user can Ctrl+C to stop it.
// A SIGCHLD trap kills strace once its direct child exits, handling
// the common case of background daemons (LSP servers, watchers).
if opts.Learning && opts.StraceLogPath != "" {
innerScript.WriteString(fmt.Sprintf(`# Learning mode: trace filesystem access (foreground for terminal access)
strace -f -qq -I2 -e trace=openat,open,creat,mkdir,mkdirat,unlinkat,renameat,renameat2,symlinkat,linkat -o %s -- %s
GREYWALL_STRACE_EXIT=$?
# Kill any orphaned child processes (LSP servers, file watchers, etc.)
# that were spawned by the traced command and reparented to PID 1.
kill -TERM -1 2>/dev/null
sleep 0.1
exit $GREYWALL_STRACE_EXIT
`,
ShellQuoteSingle(opts.StraceLogPath), command,
))
} else if useLandlockWrapper {
// Use Landlock wrapper if available
// Pass config via environment variable (serialized as JSON)
// This ensures allowWrite/denyWrite rules are properly applied
if cfg != nil {
configJSON, err := json.Marshal(cfg)
if err == nil {
innerScript.WriteString(fmt.Sprintf("export GREYWALL_CONFIG_JSON=%s\n", ShellQuoteSingle(string(configJSON))))
}
}
// Build wrapper command with proper quoting
// Use bash -c to preserve shell semantics (e.g., "echo hi && ls")
wrapperArgs := []string{greywallExePath, "--landlock-apply"}
if opts.Debug {
wrapperArgs = append(wrapperArgs, "--debug")
}
wrapperArgs = append(wrapperArgs, "--", "bash", "-c", command)
// Use exec to replace bash with the wrapper (which will exec the command)
innerScript.WriteString(fmt.Sprintf("exec %s\n", ShellQuote(wrapperArgs)))
} else {
innerScript.WriteString(command)
innerScript.WriteString("\n")
}
bwrapArgs = append(bwrapArgs, innerScript.String())
if opts.Debug {
var featureList []string
if features.CanUnshareNet {
featureList = append(featureList, "bwrap(network,pid,fs)")
} else {
featureList = append(featureList, "bwrap(pid,fs)")
}
if proxyBridge != nil && features.CanUseTransparentProxy() {
featureList = append(featureList, "tun2socks(transparent)")
} else if proxyBridge != nil {
featureList = append(featureList, "proxy(env-vars)")
}
if features.HasSeccomp && opts.UseSeccomp && seccompFilterPath != "" {
featureList = append(featureList, "seccomp")
}
if useLandlockWrapper {
featureList = append(featureList, fmt.Sprintf("landlock-v%d(wrapper)", features.LandlockABI))
} else if features.CanUseLandlock() && opts.UseLandlock {
featureList = append(featureList, fmt.Sprintf("landlock-v%d(unavailable)", features.LandlockABI))
}
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
featureList = append(featureList, fmt.Sprintf("inbound:%v", reverseBridge.Ports))
}
if opts.Learning {
featureList = append(featureList, "learning(strace)")
}
fmt.Fprintf(os.Stderr, "[greywall:linux] Sandbox: %s\n", strings.Join(featureList, ", "))
}
// Build the final command
bwrapCmd := ShellQuote(bwrapArgs)
// If seccomp filter is enabled, wrap with fd redirection
// bwrap --seccomp expects the filter on the specified fd
if seccompFilterPath != "" {
// Open filter file on fd 3, then run bwrap
// The filter file will be cleaned up after the sandbox exits
return fmt.Sprintf("exec 3<%s; %s", ShellQuoteSingle(seccompFilterPath), bwrapCmd), nil
}
return bwrapCmd, nil
}
// StartLinuxMonitor starts violation monitoring for a Linux sandbox.
// Returns monitors that should be stopped when the sandbox exits.
func StartLinuxMonitor(pid int, opts LinuxSandboxOptions) (*LinuxMonitors, error) {
monitors := &LinuxMonitors{}
features := DetectLinuxFeatures()
// Note: SeccompMonitor is disabled because our seccomp filter uses SECCOMP_RET_ERRNO
// which silently returns EPERM without logging to dmesg/audit.
// To enable seccomp logging, the filter would need to use SECCOMP_RET_LOG (allows syscall)
// or SECCOMP_RET_KILL (logs but kills process) or SECCOMP_RET_USER_NOTIF (complex).
// For now, we rely on the eBPF monitor to detect syscall failures.
if opts.Debug && opts.Monitor && features.SeccompLogLevel >= 1 {
fmt.Fprintf(os.Stderr, "[greywall:linux] Note: seccomp violations are blocked but not logged (SECCOMP_RET_ERRNO is silent)\n")
}
// Start eBPF monitor if available and requested
// This monitors syscalls that return EACCES/EPERM for sandbox descendants
if opts.Monitor && opts.UseEBPF && features.HasEBPF {
ebpfMon := NewEBPFMonitor(pid, opts.Debug)
if err := ebpfMon.Start(); err != nil {
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Failed to start eBPF monitor: %v\n", err)
}
} else {
monitors.EBPFMonitor = ebpfMon
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] eBPF monitor started for PID %d\n", pid)
}
}
} else if opts.Monitor && opts.Debug {
if !features.HasEBPF {
fmt.Fprintf(os.Stderr, "[greywall:linux] eBPF monitoring not available (need CAP_BPF or root)\n")
}
}
return monitors, nil
}
// LinuxMonitors holds all active monitors for a Linux sandbox.
type LinuxMonitors struct {
EBPFMonitor *EBPFMonitor
}
// Stop stops all monitors.
func (m *LinuxMonitors) Stop() {
if m.EBPFMonitor != nil {
m.EBPFMonitor.Stop()
}
}
// PrintLinuxFeatures prints available Linux sandbox features.
func PrintLinuxFeatures() {
features := DetectLinuxFeatures()
fmt.Printf("Linux Sandbox Features:\n")
fmt.Printf(" Kernel: %d.%d\n", features.KernelMajor, features.KernelMinor)
fmt.Printf(" Bubblewrap (bwrap): %v\n", features.HasBwrap)
fmt.Printf(" Socat: %v\n", features.HasSocat)
fmt.Printf(" Network namespace (--unshare-net): %v\n", features.CanUnshareNet)
fmt.Printf(" Seccomp: %v (log level: %d)\n", features.HasSeccomp, features.SeccompLogLevel)
fmt.Printf(" Landlock: %v (ABI v%d)\n", features.HasLandlock, features.LandlockABI)
fmt.Printf(" eBPF: %v (CAP_BPF: %v, root: %v)\n", features.HasEBPF, features.HasCapBPF, features.HasCapRoot)
fmt.Printf(" ip (iproute2): %v\n", features.HasIpCommand)
fmt.Printf(" /dev/net/tun: %v\n", features.HasDevNetTun)
fmt.Printf(" tun2socks: %v (embedded)\n", features.HasTun2Socks)
fmt.Printf("\nFeature Status:\n")
if features.MinimumViable() {
fmt.Printf(" ✓ Minimum requirements met (bwrap + socat)\n")
} else {
fmt.Printf(" ✗ Missing requirements: ")
if !features.HasBwrap {
fmt.Printf("bwrap ")
}
if !features.HasSocat {
fmt.Printf("socat ")
}
fmt.Println()
}
if features.CanUnshareNet {
fmt.Printf(" ✓ Network namespace isolation available\n")
} else if features.HasBwrap {
fmt.Printf(" ⚠ Network namespace unavailable (containerized environment?)\n")
fmt.Printf(" Sandbox will still work but with reduced network isolation.\n")
fmt.Printf(" This is common in Docker, GitHub Actions, and other CI systems.\n")
}
if features.CanUseTransparentProxy() {
fmt.Printf(" ✓ Transparent proxy available (tun2socks + TUN device)\n")
} else {
fmt.Printf(" ○ Transparent proxy not available (needs ip, /dev/net/tun, network namespace)\n")
}
if features.CanUseLandlock() {
fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n")
} else {
fmt.Printf(" ○ Landlock not available (kernel 5.13+ required)\n")
}
if features.CanMonitorViolations() {
fmt.Printf(" ✓ Violation monitoring available\n")
} else {
fmt.Printf(" ○ Violation monitoring limited (kernel 4.14+ for seccomp logging)\n")
}
if features.HasEBPF {
fmt.Printf(" ✓ eBPF monitoring available (enhanced visibility)\n")
} else {
fmt.Printf(" ○ eBPF monitoring not available (needs CAP_BPF or root)\n")
}
}