123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157 | package sandbox
import (
"errors"
"fmt"
"io/fs"
"net"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"blinker/internal/policy"
)
// platform is an OS's sandbox backend: everything about running a command
// in the sandbox that differs from one OS to another. Both implementations
// build on every OS, so each is checked, and tested, everywhere.
type platform interface {
// Name names the kernel mechanism, for the user and the model.
Name() string
// ScratchRoot is where session scratch dirs go unless configured.
ScratchRoot() (string, error)
// SystemReadPaths are readable in every mode unless a rule says
// otherwise: enough of the OS to run programs.
SystemReadPaths() []string
// BaselineWritePaths are writable in every mode unless a rule says
// otherwise.
BaselineWritePaths() []string
// SystemSecrets are system files that hold secrets, which the floor
// hides: the OS's permissions keep them from users, but not from root.
SystemSecrets() []string
// UserHomes are every user's home directory, for the floor.
UserHomes() ([]string, error)
// SystemTempDirs are the OS's shared temp dirs, which no command may
// touch: each session has its own (the scratch dir). The one holding
// the project, if any, is left to the policy, as any other dir is.
SystemTempDirs() []string
// TempDir is where commands see the scratch dir.
TempDir(scratch, project string) string
// PrepareScratch fills a new scratch dir with what commands would
// otherwise rebuild in each session. Failing costs only time.
PrepareScratch(scratch string) error
// ListenProxy listens for a mode's proxy. dir is an empty directory
// only blinker can write, for a socket.
ListenProxy(dir string) (net.Listener, error)
// Wrap returns the command line that runs w.Argv in the sandbox, the
// variables it adds to the command's environment, and the empty mount
// points the sandbox will make on the host, for Run to remove after.
Wrap(w wrap) (argv, env, made []string, err error)
}
var (
_ platform = (*linuxPlatform)(nil)
_ platform = (*darwinPlatform)(nil)
)
// wrap is a command for a platform to put in the sandbox.
type wrap struct {
Policy *policy.Compiled
Mode policy.Mode
Proxy net.Addr // the mode's proxy, from ListenProxy; nil for no network
Argv []string
// Create, set only by WriteFile, is a missing path the command may
// create (see Cmd.create).
Create string
// Empty is an empty read-only file of the session's, which bubblewrap
// mounts in place of protected paths that do not exist (see guard).
Empty string
// Terminal is the path of the command's pty (see Cmd.Terminal), or "".
Terminal string
// Preview, if set, is where to keep the command's writes instead of
// making them (see Cmd.Preview).
Preview *Preview
// Helper, if set, is a hidden subcommand of blinker's own to run in
// place of Argv (see Cmd.helper).
Helper string
}
// hostPlatform is the platform for the OS blinker runs on. exe is
// blinker's own executable.
func hostPlatform(exe string) (platform, error) {
switch runtime.GOOS {
case "linux":
return newLinuxPlatform(exe)
case "darwin":
return newDarwinPlatform()
default:
return nil, fmt.Errorf("blinker does not support %s: it needs bubblewrap (Linux) or sandbox-exec (macOS)", runtime.GOOS)
}
}
// passwdFile lists the users, for UserHomes.
var passwdFile = "/etc/passwd" // a variable for tests
// userHomes lists the home directories in a passwd file and the
// directories in dir (/home, /Users), which holds homes of users the
// passwd file may not list (from LDAP, say): by their real paths, and
// not /, a file, or in /proc, /sys or /dev.
func userHomes(passwd, dir string) ([]string, error) {
var homes []string
data, err := os.ReadFile(passwd)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
for _, line := range strings.Split(string(data), "\n") {
if fields := strings.Split(line, ":"); len(fields) >= 6 && !strings.HasPrefix(line, "#") {
homes = append(homes, fields[5])
}
}
entries, err := os.ReadDir(dir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
for _, e := range entries {
homes = append(homes, filepath.Join(dir, e.Name()))
}
var out []string
for _, h := range homes {
if !filepath.IsAbs(h) || h == "/" {
continue
}
// Real paths, as blinker's own home is, so it can be told apart. A
// missing home is kept: the floor keeps a command that may write
// where it would be from making it, with a .ssh of its own.
h = filepath.Clean(h)
if real, err := filepath.EvalSymlinks(h); err == nil {
if fi, err := os.Stat(real); err != nil || !fi.IsDir() {
continue // such as /dev/null
}
h = real
} else if !errors.Is(err, fs.ErrNotExist) {
continue // under a file, or unreadable: nothing to make there
}
// Not a pseudo file system: a system account's "home" (/proc), where
// nothing can be made.
pseudo := policy.Under(h, "/proc") || policy.Under(h, "/sys") || policy.Under(h, "/dev")
if h != "/" && !pseudo && !policy.IsGlob(h) && policy.ValidatePath(h) == nil && !slices.Contains(out, h) {
out = append(out, h)
}
}
return out, nil
}
// proxyEnv points a command's HTTP clients at its proxy, on a port of
// localhost.
func proxyEnv(port int) []string {
url := fmt.Sprintf("http://127.0.0.1:%d", port)
var env []string
for _, k := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"} {
env = append(env, k+"="+url)
}
// Node's fetch (24 and later) reads the variables only when told to;
// earlier ones ignore this.
return append(env, "NO_PROXY=", "no_proxy=", "NODE_USE_ENV_PROXY=1")
}
|