123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 | package sandbox
import (
"errors"
"fmt"
"io"
"io/fs"
"net"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"blinker/internal/policy"
)
// macOS backend: a Seatbelt profile run with sandbox-exec. Seatbelt
// evaluates allow and deny rules over the real filesystem, the last
// matching rule winning, so the plan's mounts become one rule each, in
// order. It can also deny network connections, so a mode may connect only
// to its proxy on localhost. The floor's literal paths are rendered last
// again, so nothing can override them. As on Linux, globs are expanded
// against the filesystem when the command starts.
// darwinPlatform runs commands under sandbox-exec.
type darwinPlatform struct {
sandboxExec string
userTemp string // the user's own temp dir, under /private/var/folders
}
func newDarwinPlatform() (platform, error) {
const sandboxExec = "/usr/bin/sandbox-exec"
if _, err := os.Stat(sandboxExec); err != nil {
return nil, errors.New("sandbox-exec is missing")
}
// Asked of the system, not taken from $TMPDIR, which could name
// anything.
out, err := exec.Command("/usr/bin/getconf", "DARWIN_USER_TEMP_DIR").Output()
if err != nil {
return nil, fmt.Errorf("finding the user's temp dir: %w", err)
}
userTemp, err := filepath.EvalSymlinks(strings.TrimSpace(string(out)))
if err != nil {
return nil, fmt.Errorf("finding the user's temp dir: %w", err)
}
if !policy.Under(userTemp, "/private/var/folders") || userTemp == "/private/var/folders" {
return nil, fmt.Errorf("unexpected user temp dir %q", userTemp)
}
return &darwinPlatform{sandboxExec: sandboxExec, userTemp: userTemp}, nil
}
func (d *darwinPlatform) Name() string { return "macOS Seatbelt" }
// ScratchRoot is the user's own temp dir, which, unlike /private/tmp, is
// not shared with other users.
func (d *darwinPlatform) ScratchRoot() (string, error) {
if d.userTemp == "" {
return "", errors.New("no user temp dir")
}
return d.userTemp, nil
}
// SystemReadPaths lists devices one by one: /dev also holds the user's
// other terminals and disks.
func (d *darwinPlatform) SystemReadPaths() []string {
return []string{"/usr", "/bin", "/sbin", "/opt/homebrew", "/opt/local", "/System",
"/Library", "/Applications", "/private/etc", "/private/var/db",
"/private/var/folders",
"/dev/null", "/dev/zero", "/dev/random", "/dev/urandom",
"/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/fd", "/dev/dtracehelper"}
}
// BaselineWritePaths: the user's caches are under /private/var/folders,
// beside their temp dir, which SystemTempDirs takes back out.
func (d *darwinPlatform) BaselineWritePaths() []string {
return []string{"/private/var/folders"}
}
func (d *darwinPlatform) SystemSecrets() []string {
return []string{
"/private/var/db/dslocal", // password hashes
"/private/var/db/SystemKey", "/private/var/db/sudo",
"/Library/Keychains/System.keychain",
"/private/etc/ssh/ssh_host_rsa_key", "/private/etc/ssh/ssh_host_ecdsa_key",
"/private/etc/ssh/ssh_host_ed25519_key", "/private/etc/ssh/ssh_host_dsa_key",
}
}
// UserHomes: macOS keeps its users in Directory Services, not
// /etc/passwd, which lists root's (/var/root) and the system's.
func (d *darwinPlatform) UserHomes() ([]string, error) { return userHomes(passwdFile, "/Users") }
func (d *darwinPlatform) SystemTempDirs() []string {
dirs := []string{"/private/tmp", "/private/var/tmp"}
if d.userTemp != "" {
dirs = append(dirs, d.userTemp)
}
return dirs
}
// TempDir: Seatbelt cannot move paths, so commands see the scratch dir
// where it is, through $TMPDIR.
func (d *darwinPlatform) TempDir(scratch, project string) string { return scratch }
// PrepareScratch copies xcrun's cache from the user's own temp dir.
// Without it, the first developer tool a session runs through /usr/bin
// (git, make, cc) has xcrun run xcodebuild, which takes a second and
// complains on stderr about the services the profile denies. The cache
// holds only tools' paths. Never through a symlink, so nothing else can
// land in the scratch dir this way.
func (d *darwinPlatform) PrepareScratch(scratch string) error {
if d.userTemp == "" {
return nil
}
const maxSize = 1 << 20
src, err := os.OpenFile(filepath.Join(d.userTemp, "xcrun_db"), os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if errors.Is(err, fs.ErrNotExist) {
return nil // xcrun has not run yet; it will build its own
}
if err != nil {
return err
}
defer src.Close()
fi, err := src.Stat()
if err != nil {
return err
}
if !fi.Mode().IsRegular() || fi.Size() > maxSize {
return fmt.Errorf("%s is not a small regular file", src.Name())
}
data, err := io.ReadAll(io.LimitReader(src, maxSize))
if err != nil {
return err
}
return os.WriteFile(filepath.Join(scratch, "xcrun_db"), data, 0o600)
}
// ListenProxy listens on a TCP port on localhost, the one place the
// profile lets the command connect to. dir is not needed.
func (d *darwinPlatform) ListenProxy(dir string) (net.Listener, error) {
return net.Listen("tcp", "127.0.0.1:0")
}
func (d *darwinPlatform) Wrap(w wrap) (argv, env, made []string, err error) {
if w.Preview != nil || w.Helper != "" {
return nil, nil, nil, errors.New("previews need Linux's overlay filesystem")
}
port := 0
if w.Proxy != nil {
addr, ok := w.Proxy.(*net.TCPAddr)
if !ok {
return nil, nil, nil, fmt.Errorf("the proxy is not on a TCP port: %s", w.Proxy)
}
port = addr.Port
env = proxyEnv(port)
}
profile, dirs, err := seatbeltProfile(w.Policy, w.Mode, port)
if err != nil {
return nil, nil, nil, err
}
// The missing directories the profile pins (see guard), made here, where
// the mode could make them anyway, so a command can still write into
// them (git init, under .git/hooks). Failing to is safe: the pin denies
// creating the name too, by mkdir or by a rename that would bring a
// protected path along.
for _, p := range dirs {
if err := os.Mkdir(p, 0o755); err == nil {
made = append(made, p)
}
}
if w.Create != "" {
// Exactly the missing part of the path, so the command can create
// it and nothing else.
if err := safeSBPL(w.Create); err != nil {
return nil, nil, made, err
}
profile += fmt.Sprintf("\n;; WriteFile creates %s\n(allow file-write* (subpath %s))\n", w.Create, sbplString(w.Create))
}
if w.Terminal != "" {
// The command's own pty (see Cmd.Terminal), and /dev/tty, which is
// whatever its controlling terminal is: that pty.
if err := safeSBPL(w.Terminal); err != nil || !strings.HasPrefix(w.Terminal, "/dev/tty") {
return nil, nil, made, fmt.Errorf("the terminal %q cannot be used in a sandbox profile", w.Terminal)
}
profile += fmt.Sprintf("\n;; The command's terminal\n(allow file-read* %s file-write* (literal %s) (literal \"/dev/tty\"))\n", readMetaOps, sbplString(w.Terminal))
}
return append([]string{d.sandboxExec, "-p", profile}, w.Argv...), env, made, nil
}
// readMetaOps are the operations that reveal a file's existence: stat and
// access(F_OK). The kernel also consults file-test-existence when name
// resolution fails, so denying both makes a missing file fail with EPERM
// like a present one, which hides what exists inside denied regions.
const readMetaOps = "file-read-metadata file-test-existence"
// sbplString quotes a path for a profile. Every path is checked by
// safeSBPL first: rule paths cannot hold a quote or backslash
// (policy.ValidatePath), but what globs match on disk, and where symlinks
// lead, can.
func sbplString(p string) string { return `"` + p + `"` }
func safeSBPL(p string) error {
if strings.ContainsAny(p, "\"\\\n\r\x00") {
return fmt.Errorf("path %q cannot be used in a sandbox profile", p)
}
return nil
}
// metadataAncestors lists the ancestor directories of the readable mounts,
// as exact paths: tools that realpath() a path component by component
// (python, PATH lookups through /opt) need to stat each ancestor of a
// place they may enter. Exact paths, not subtrees, so files in between
// stay hidden.
func metadataAncestors(paths []string) []string {
set := map[string]bool{}
for _, p := range paths {
for cur := filepath.Dir(p); cur != "/" && cur != "."; cur = filepath.Dir(cur) {
set[cur] = true
}
}
var out []string
for p := range set {
if safeSBPL(p) == nil {
out = append(out, p)
}
}
slices.Sort(out)
return out
}
// seatbeltProfile renders the profile for a mode, and lists the missing
// directories it pins, for Wrap to make. proxyPort is the mode's proxy on
// localhost, or 0 for no network.
func seatbeltProfile(c *policy.Compiled, mode policy.Mode, proxyPort int) (profile string, dirs []string, err error) {
env := c.Env
for _, p := range []string{env.Home, env.Project, env.Scratch} {
if err := safeSBPL(p); err != nil {
return "", nil, err
}
}
mounts, err := plan(c)
if err != nil {
return "", nil, err
}
// Rules judge a rename by the names it changes, not the paths under
// them, so a writable directory above a protected path is pinned: it
// cannot be moved aside, or made by moving another into its place.
// A copy: guard marks the mounts it is given.
var pins []string
for _, m := range guard(c, slices.Clone(mounts)) {
if !m.pin || m.state != rw || policy.Under(m.path, env.Scratch) {
continue // a path the mode cannot write cannot be renamed either
}
pins = append(pins, m.path)
if !m.exists && c.Access(filepath.Dir(m.path)).Write {
dirs = append(dirs, m.path)
}
}
// A file named to close the string would add rules of its own, so a
// path that cannot be quoted stops the command: leaving its rule out
// would fail open.
blocklist, neverWrite := c.FloorMatchers()
paths := slices.Concat(env.SystemRead, pins)
for _, m := range mounts {
paths = append(paths, m.path)
}
for _, m := range slices.Concat(blocklist, neverWrite) {
if !m.IsGlob() {
paths = append(paths, m.Path)
}
}
for _, p := range paths {
if err := safeSBPL(p); err != nil {
return "", nil, fmt.Errorf("%w; rename it, or narrow the policy's globs so they do not match it", err)
}
}
var b strings.Builder
line := func(format string, args ...any) { fmt.Fprintf(&b, format+"\n", args...) }
line("(version 1)")
line(";; blinker %s mode", mode)
line(";; Everything not allowed below is denied. System services run outside")
line(";; the sandbox, so one a command may reach does what the command could")
line(";; not: LaunchServices (open) and Apple Events start programs unsandboxed,")
line(";; cfprefsd reads and writes every app's preferences, and the pasteboard")
line(";; and keychain hold the user's secrets.")
line("(deny default)")
line("(allow process-exec process-fork)")
line(";; Other processes' arguments and environment (ps eww, KERN_PROCARGS2):")
line(";; blinker's include its API key. Signals stay within the sandbox too.")
line("(allow signal process-info* (target same-sandbox))")
line("(allow sysctl-read)")
line(`(deny sysctl-read (sysctl-name "kern.procargs") (sysctl-name "kern.procargs2"))`)
line(";; On files the rules below let the command open.")
line("(allow file-ioctl)")
line(";; Named semaphores, for Python's multiprocessing.")
line("(allow ipc-posix-sem)")
line(";; User and group lookups, notifications, logging, and certificate checks")
line(";; (crypto/x509 on macOS asks trustd).")
line("(allow mach-lookup")
line(` (global-name "com.apple.system.opendirectoryd.libinfo")`)
line(` (global-name "com.apple.system.notification_center")`)
line(` (global-name "com.apple.system.logger")`)
line(` (global-name "com.apple.logd")`)
line(` (global-name "com.apple.trustd")`)
line(` (global-name "com.apple.trustd.agent")`)
line(")")
line("\n;; Reads: denied by default, existence included.")
line("(deny file-read*)")
line("(deny %s)", readMetaOps)
line(";; fstat() on inherited pipes.")
line("(allow %s (vnode-type FIFO))", readMetaOps)
line(";; The system config surface: existence checks see both the symlinked")
line(";; paths and /private (mkstemp under $TMPDIR needs it). The floor takes")
line(";; the system's temp dirs back out.")
line(`(allow %s (subpath "/var") (subpath "/tmp") (subpath "/etc") (subpath "/private"))`, readMetaOps)
readable := []string{env.Project, env.Scratch}
readable = append(readable, env.SystemRead...)
for _, m := range mounts {
if m.state != none {
readable = append(readable, m.path)
}
}
if anc := metadataAncestors(readable); len(anc) > 0 {
line("(allow %s", readMetaOps)
for _, p := range anc {
line(" (literal %s)", sbplString(p))
}
line(")")
}
line("(allow file-read* %s", readMetaOps)
line(` (literal "/") ; processes read "/" at startup`)
line(" (subpath %s)", sbplString(env.Scratch))
for _, p := range env.SystemRead {
line(" (subpath %s)", sbplString(p))
}
line(")")
line("\n;; Writes: the scratch dir, caches and terminal devices.")
line("(deny file-write*)")
line("(allow file-write*")
line(" (subpath %s)", sbplString(env.Scratch))
for _, p := range env.BaselineWrite {
line(" (subpath %s)", sbplString(p))
}
// Not /dev/tty: commands have no controlling terminal unless they have a
// pty of their own (see Run and Wrap).
for _, p := range []string{"/dev/null", "/dev/zero", "/dev/stdout", "/dev/stderr", "/dev/dtracehelper"} {
line(" (literal %s)", sbplString(p))
}
line(")")
line("\n;; The policy, ancestors first; the last matching rule wins.")
for _, m := range mounts {
f := fmt.Sprintf("(subpath %s)", sbplString(m.path))
switch m.state {
case none:
line("(deny file-read* %s file-write* %s)", readMetaOps, f)
case ro:
line("(allow file-read* %s %s)", readMetaOps, f)
line("(deny file-write* %s)", f)
case rw:
line("(allow file-read* %s file-write* %s)", readMetaOps, f)
}
}
if len(pins) > 0 {
line(";; Directories above protected paths: written into, never renamed.")
line("(deny file-write*")
for _, p := range pins {
line(" (literal %s)", sbplString(p))
}
line(")")
}
if links := shownLinks(c); len(links) > 0 {
line(";; The links the rules followed, and their ancestors: metadata only,")
line(";; as their targets' rules decide the rest.")
line("(allow %s", readMetaOps)
for _, p := range append(metadataAncestors(links), links...) {
if safeSBPL(p) == nil {
line(" (literal %s)", sbplString(p))
}
}
line(")")
}
line("\n;; Network: only the mode's proxy. DNS stays denied: CONNECT needs no")
line(";; local resolution, and open DNS would be a way to leak data.")
line("(deny network*)")
if proxyPort > 0 {
line(`(allow network-outbound (remote ip "localhost:%d"))`, proxyPort)
}
line("\n;; The floor, last so no rule overrides it.")
for _, m := range neverWrite {
if !m.IsGlob() {
line("(deny file-write* (subpath %s))", sbplString(m.Path))
}
}
for _, m := range blocklist {
if !m.IsGlob() {
line("(deny file-read* %s file-write* (subpath %s))", readMetaOps, sbplString(m.Path))
}
}
line("\n;; The session's temp dir, open whatever else says, even inside a")
line(";; system temp dir.")
line("(allow file-read* %s file-write* (subpath %s))", readMetaOps, sbplString(env.Scratch))
line(";; Its ancestors' metadata only, as mkdir -p stats each: listing the")
line(";; system temp dir needs file-read-data, which stays denied.")
line("(allow %s", readMetaOps)
for _, p := range metadataAncestors([]string{env.Scratch}) {
line(" (literal %s)", sbplString(p))
}
line(")")
return b.String(), dirs, nil
}
|