Download
427 lines 15 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
package sandbox

import (
	"errors"
	"fmt"
	"io/fs"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strconv"
	"syscall"

	"blinker/internal/policy"
)

// Linux backend: bubblewrap builds the sandbox's view of the filesystem
// out of bind mounts in fresh namespaces. Nothing is visible unless
// mounted, so reads are denied by default. A path that must be hidden
// inside a visible tree is shadowed: an empty read-only tmpfs over a
// directory, /dev/null over a file.
//
// Commands see the scratch dir as /tmp, and nothing of the real one. In a
// project inside /tmp (not /tmp itself), though, the real /tmp is the project's surroundings,
// shown as the policy says like any other dir's, so commands see the
// scratch dir where it is instead.
//
// The network namespace is always new, so the sandbox has no network at
// all. A mode that may reach the network gets a bridge: blinker itself runs
// inside the sandbox ("blinker __bridge"), listens on 127.0.0.1:bridgePort,
// and forwards each connection to the proxy's Unix socket, which is the
// only way out. Programs that ignore HTTP(S)_PROXY get no network.

const (
	// sandboxProxyDir is where the proxy's socket directory appears inside
	// the sandbox. It is under /tmp because that is the one place bubblewrap
	// can always make its mount point: /tmp is the session's scratch dir,
	// where anywhere else may be bound from the host (a rule for "/", say),
	// and so not writable. In a project under /tmp it appears where it is,
	// under the scratch root, which the floor hides behind an empty tmpfs.
	sandboxProxyDir = "/tmp/.blinker-proxy"
	// bridgePort is the proxy port inside the sandbox; the network
	// namespace is private, so it cannot collide with the host's.
	bridgePort = 3128
)

// linuxPlatform runs commands under bubblewrap.
type linuxPlatform struct {
	bwrap string // the bwrap executable
	exe   string // blinker's own executable, for the bridge
}

func newLinuxPlatform(exe string) (platform, error) {
	bwrap, err := exec.LookPath("bwrap")
	if err != nil {
		return nil, errors.New("bubblewrap (bwrap) is not installed or not on $PATH; install it (e.g. `apt install bubblewrap`)")
	}
	return &linuxPlatform{bwrap: bwrap, exe: exe}, nil
}

func (l *linuxPlatform) Name() string { return "Linux bubblewrap" }

// ScratchRoot is a dir of the user's own in /tmp, not /tmp itself, so that
// the floor can hide every session's dir from a project under /tmp. Anyone
// can make it first, so it must be a real directory, the user's, and
// closed to everyone else.
func (l *linuxPlatform) ScratchRoot() (string, error) {
	root := fmt.Sprintf("/tmp/blinker-%d", os.Getuid())
	if err := os.Mkdir(root, 0o700); err != nil && !errors.Is(err, fs.ErrExist) {
		return "", err
	}
	fi, err := os.Lstat(root)
	if err != nil {
		return "", err
	}
	st, ok := fi.Sys().(*syscall.Stat_t)
	if !fi.IsDir() || !ok || int(st.Uid) != os.Getuid() || fi.Mode().Perm()&0o077 != 0 {
		return "", fmt.Errorf("%s is not a directory that only you can use", root)
	}
	return root, nil
}

// SystemReadPaths: bwrap gives each sandbox its own /dev, with only null,
// zero, full, random, urandom and tty, so /dev is not listed. Of /sys only
// the CPU list is shown, which glibc reads to count processors.
func (l *linuxPlatform) SystemReadPaths() []string {
	return []string{"/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32",
		"/etc", "/opt", "/var/lib", "/nix/store", "/run/current-system",
		"/sys/devices/system/cpu"}
}

func (l *linuxPlatform) BaselineWritePaths() []string { return nil }

func (l *linuxPlatform) SystemSecrets() []string {
	return []string{
		"/etc/shadow", "/etc/shadow-", "/etc/gshadow", "/etc/gshadow-",
		"/etc/security/opasswd",
		"/etc/ssh/ssh_host_rsa_key", "/etc/ssh/ssh_host_ecdsa_key",
		"/etc/ssh/ssh_host_ed25519_key", "/etc/ssh/ssh_host_dsa_key",
		"/etc/ssl/private", "/etc/pki/tls/private", "/etc/letsencrypt",
		"/etc/krb5.keytab", "/etc/ipsec.secrets", "/etc/ipsec.d/private",
		"/etc/wireguard", "/etc/NetworkManager/system-connections",
		"/etc/wpa_supplicant", "/var/lib/iwd", "/var/lib/bluetooth",
		"/var/lib/sss", "/etc/credstore", "/etc/credstore.encrypted",
		"/var/lib/systemd/credential.secret",
	}
}

func (l *linuxPlatform) UserHomes() ([]string, error) { return userHomes(passwdFile, "/home") }

func (l *linuxPlatform) SystemTempDirs() []string { return []string{"/tmp", "/var/tmp"} }

// TempDir: the scratch dir is mounted over /tmp, and nowhere else, unless
// the project is inside /tmp.
func (l *linuxPlatform) TempDir(scratch, project string) string {
	if inTmp(project) {
		return scratch
	}
	return "/tmp"
}

func (l *linuxPlatform) PrepareScratch(scratch string) error { return nil }

// ListenProxy listens on a Unix socket in dir, which the sandbox mounts;
// the bridge inside forwards to it.
func (l *linuxPlatform) ListenProxy(dir string) (net.Listener, error) {
	return net.Listen("unix", filepath.Join(dir, proxySocketName))
}

// hostWritable reports whether blinker can make files in dir on the host.
// Where it cannot (a read-only file system, or a directory of root's), a
// command cannot either: it runs as the same user, with no capabilities.
// So a missing path there that the policy protects needs no blocking,
// which bubblewrap could not do anyway: it would have to make the mount
// point.
func hostWritable(dir string) bool {
	const wOK = 2 // W_OK, for access(2)
	return syscall.Access(dir, wOK) == nil
}

func (l *linuxPlatform) Wrap(w wrap) (argv, env, made []string, err error) {
	opts := bwrapOpts{exe: l.exe, empty: w.Empty, terminal: w.Terminal != "", preview: w.Preview, helper: w.Helper != ""}
	if w.Helper != "" {
		w.Argv = []string{l.exe, w.Helper}
	}
	if w.Create != "" {
		// Creating a file needs its directory writable, and a mount cannot
		// make a directory writable for one name only. The command is
		// WriteFile's fixed script, though, with no code from the model,
		// so the nearest existing directory is writable for it.
		opts.createIn = filepath.Dir(w.Create)
	}
	if w.Proxy != nil {
		addr, ok := w.Proxy.(*net.UnixAddr)
		if !ok {
			return nil, nil, nil, fmt.Errorf("the proxy is not on a Unix socket: %s", w.Proxy)
		}
		opts.proxyDir = filepath.Dir(addr.Name)
		opts.proxyMount = sandboxProxyDir
		if inTmp(w.Policy.Env.Project) {
			opts.proxyMount = opts.proxyDir
		}
		env = append(env, proxyEnv(bridgePort)...)
	}
	args, made, err := bwrapArgs(w.Policy, opts)
	if err != nil {
		return nil, nil, made, err
	}
	argv = append([]string{l.bwrap}, args...)
	argv = append(argv, "--")
	if opts.proxyDir != "" {
		argv = append(argv, opts.exe, BridgeCommand,
			filepath.Join(opts.proxyMount, proxySocketName), strconv.Itoa(bridgePort), "--")
	}
	return append(argv, w.Argv...), env, made, nil
}

// inTmp reports whether a project is inside /tmp, where commands see the
// real one. /tmp itself is hidden (see Sandbox.Hidden).
func inTmp(project string) bool {
	return project != "/tmp" && policy.Under(project, "/tmp")
}

type bwrapOpts struct {
	exe        string   // blinker's own executable, for the bridge
	proxyDir   string   // the host directory holding the proxy socket; "" for no network
	proxyMount string   // where proxyDir appears in the sandbox
	createIn   string   // a directory made writable for WriteFile; see Cmd.create
	empty      string   // an empty read-only file (see wrap.Empty)
	terminal   bool     // the command has a pty of its own (see Cmd.Terminal)
	preview    *Preview // set to keep the command's writes off the host (see Cmd.Preview)
	helper     bool     // the command is blinker's own (see Cmd.helper)
}

// bwrapArgs builds bubblewrap's arguments for a mode, up to the command,
// and lists the mount points bubblewrap will make on the host.
func bwrapArgs(c *policy.Compiled, opts bwrapOpts) (args, made []string, err error) {
	env := c.Env
	tmp := (&linuxPlatform{}).TempDir(env.Scratch, env.Project) // where commands see the scratch dir
	args = []string{"--die-with-parent"}
	if !opts.terminal {
		// No controlling terminal: no TIOCSTI keystroke injection. A
		// command with a pty of its own keeps that as its terminal.
		args = append(args, "--new-session")
	}
	args = append(args,
		"--unshare-all", // including the network
		"--cap-drop", "ALL",
		"--proc", "/proc",
		"--dev", "/dev",
	)
	// visible maps each mount point to what it shows; a path shows what its
	// deepest mounted ancestor does. Empty tmpfs mounts show nothing.
	visible := map[string]state{}
	var readOnly []string // tmpfs mounts to make read-only at the end
	homeUnderSystem := slices.ContainsFunc(env.SystemRead, func(p string) bool { return policy.Under(env.Home, p) })
	if !homeUnderSystem {
		args = append(args, "--tmpfs", env.Home)
		visible[env.Home] = none
		readOnly = append(readOnly, env.Home)
	}
	// at records a mount point, which a preview's reader skips: bubblewrap
	// makes those missing in a writable directory, not the command.
	at := func(p string) string {
		if opts.preview != nil {
			opts.preview.mounts[p] = true
		}
		return p
	}
	// bind mounts a writable directory: in a preview, an overlay, whose
	// writes are kept for the reader where they can touch the project, and
	// dropped elsewhere (caches, the temp dir, say).
	bind := func(src, dst string) error {
		if opts.preview == nil {
			args = append(args, "--bind-try", src, at(dst))
			return nil
		}
		fi, err := os.Stat(src)
		if errors.Is(err, fs.ErrNotExist) {
			return nil // as --bind-try would
		}
		if err != nil {
			return err
		}
		if !fi.IsDir() {
			// A file writable on its own, which an overlay cannot be: the
			// command's writes to it fail.
			args = append(args, "--ro-bind", src, at(dst))
			return nil
		}
		if src != dst || !policy.Under(src, env.Project) && !policy.Under(env.Project, src) {
			args = append(args, "--overlay-src", src, "--tmp-overlay", at(dst))
			return nil
		}
		upper, work, err := opts.preview.layer(src)
		if err != nil {
			return err
		}
		args = append(args, "--overlay-src", src, "--overlay", upper, work, at(dst))
		return nil
	}
	for _, p := range env.SystemRead {
		args = append(args, "--ro-bind-try", p, p)
		visible[p] = ro
	}
	shows := func(p string) state {
		for cur := p; ; cur = filepath.Dir(cur) {
			if v, ok := visible[cur]; ok {
				return v
			}
			if cur == "/" {
				return none
			}
		}
	}

	mounts, err := plan(c)
	if err != nil {
		return nil, nil, err
	}
	if opts.createIn != "" {
		// In order with the policy's mounts, so those under it still apply.
		i := slices.IndexFunc(mounts, func(m mount) bool { return m.path == opts.createIn })
		if i < 0 {
			mounts = append(mounts, mount{path: opts.createIn})
			i = len(mounts) - 1
		}
		mounts[i] = mount{path: opts.createIn, state: rw, exists: true, dir: true}
	}
	for _, m := range guard(c, mounts) {
		if policy.Under(m.path, tmp) || policy.Under(m.path, env.Scratch) {
			// The scratch dir is open to everything. Seen as /tmp, the real
			// /tmp and the scratch dir's host path are not mounted at all.
			continue
		}
		writableDir := shows(filepath.Dir(m.path)) == rw
		switch {
		case m.block:
			if !writableDir || !hostWritable(filepath.Dir(m.path)) {
				continue // nothing can create it anyway
			}
			if m.dir {
				// An empty directory, read-only at the end, so a program
				// that lists it finds nothing rather than a file.
				args = append(args, "--tmpfs", at(m.path))
				readOnly = append(readOnly, m.path)
				visible[m.path] = none
				made = append(made, m.path)
				continue
			}
			// A read-only empty file, made on the host (see guard). Not
			// /dev/null: binds are nodev, so opening that fails, which
			// programs reading an optional file (git, ~/.gitconfig) take
			// worse than an empty one.
			if opts.empty == "" {
				return nil, made, fmt.Errorf("no empty file to protect %s with", m.path)
			}
			args = append(args, "--ro-bind", opts.empty, at(m.path))
			visible[m.path] = none
			made = append(made, m.path)
			continue
		case m.pin && !m.exists:
			if !writableDir || m.state != rw {
				continue
			}
			if !hostWritable(filepath.Dir(m.path)) {
				// Nothing can make the directory, so nothing under it can
				// appear, and the blocks there are not needed.
				visible[m.path] = ro
				continue
			}
			// A directory above a blocked path, made on the host and pinned
			// (see guard). bubblewrap checks every source before it makes
			// anything, so blinker makes it, where the mode could anyway.
			if err := os.Mkdir(m.path, 0o755); err != nil {
				return nil, made, fmt.Errorf("protecting %s: %w", m.path, err)
			}
			made = append(made, m.path)
			if err := bind(m.path, m.path); err != nil {
				return nil, made, err
			}
			visible[m.path] = rw
			continue
		case m.state == shows(m.path) && !(m.pin && writableDir && m.exists && m.dir):
			continue // the enclosing mount already gives this access
		case !m.exists:
			// Absent from the host, so absent here too (guard blocks it if
			// it must stay so). A bind would do nothing, or fail where the
			// path is under a file: bubblewrap's -try forgives only ENOENT.
			continue
		}
		// The mount buries those under it, which then show nothing of
		// their own.
		for p := range visible {
			if p != m.path && policy.Under(p, m.path) {
				delete(visible, p)
			}
		}
		readOnly = slices.DeleteFunc(readOnly, func(p string) bool { return policy.Under(p, m.path) })
		switch m.state {
		case ro:
			args = append(args, "--ro-bind-try", m.path, at(m.path))
		case rw:
			if err := bind(m.path, m.path); err != nil {
				return nil, made, err
			}
		case none:
			if m.dir {
				args = append(args, "--tmpfs", at(m.path))
				readOnly = append(readOnly, m.path)
			} else {
				args = append(args, "--ro-bind", "/dev/null", at(m.path))
			}
		}
		visible[m.path] = m.state
	}

	// The links the rules followed, where the mounts hide them (home's
	// tmpfs, say): made again in the sandbox, after the mounts so none
	// buries them, and before the tmpfs mounts are made read-only. Not
	// where a mount already is (the system's /bin, a link to usr/bin):
	// bubblewrap made a directory there for it.
	mounted := func(p string) bool {
		for v := range visible {
			if policy.Under(v, p) {
				return true
			}
		}
		return false
	}
	for _, l := range shownLinks(c) {
		if policy.Under(l, tmp) || policy.Under(l, env.Scratch) || shows(filepath.Dir(l)) != none || mounted(l) {
			continue
		}
		target, err := os.Readlink(l)
		if err != nil {
			continue // gone: nothing to show
		}
		args = append(args, "--symlink", target, at(l))
	}

	// After the policy's mounts, so none of them (a rule for "/", say) can
	// cover it with the real /tmp, or hide it in the scratch root.
	if err := bind(env.Scratch, tmp); err != nil {
		return nil, made, err
	}

	// After the scratch dir, which may hold them.
	if opts.proxyDir != "" || opts.helper {
		args = append(args, "--ro-bind", opts.exe, at(opts.exe)) // for a hidden subcommand (see RunHidden)
	}
	if opts.proxyDir != "" {
		args = append(args, "--ro-bind", opts.proxyDir, at(opts.proxyMount))
	}
	for _, p := range readOnly {
		if visible[p] == none {
			args = append(args, "--remount-ro", p)
		}
	}
	dir := env.Project
	if !c.Access(env.Project).Read {
		dir = tmp
	}
	args = append(args, "--chdir", dir)
	return args, made, nil
}