123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 | package sandbox
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"blinker/internal/policy"
)
// mountOp is one mount in a bubblewrap command line, as the kernel stacks
// them: a later mount at or above an earlier one's point hides it.
type mountOp struct {
dst string
state state
}
// mountsOf reads the mounts from bubblewrap's arguments. A -try bind of a
// missing source is skipped, as bubblewrap does. Binding a different
// source (/dev/null, an empty file) over a path hides it.
func mountsOf(t *testing.T, args []string) []mountOp {
t.Helper()
var ops []mountOp
for i := 0; i < len(args); i++ {
switch a := args[i]; a {
case "--die-with-parent", "--new-session", "--unshare-all":
case "--cap-drop", "--proc", "--dev", "--chdir", "--remount-ro", "--dir":
i++
case "--tmpfs":
ops = append(ops, mountOp{args[i+1], none})
i++
case "--ro-bind-try", "--bind-try", "--ro-bind", "--bind":
src, dst := args[i+1], args[i+2]
i += 2
if _, err := os.Lstat(src); err != nil {
if strings.HasSuffix(a, "-try") {
continue
}
t.Fatalf("%s of missing %s", a, src)
}
s := ro
if !strings.HasPrefix(a, "--ro-") {
s = rw
}
if src != dst {
s = none
}
ops = append(ops, mountOp{dst, s})
default:
t.Fatalf("unknown argument %q in %q", a, args)
}
}
return ops
}
// seen is what a path shows through the mounts: the deepest mount above it
// that no later mount hides.
func seen(ops []mountOp, p string) (state, bool) {
best, found := -1, false
for i, op := range ops {
if !policy.Under(p, op.dst) {
continue
}
hidden := slices.ContainsFunc(ops[i+1:], func(later mountOp) bool { return policy.Under(op.dst, later.dst) })
if !hidden && (best < 0 || len(op.dst) > len(ops[best].dst)) {
best, found = i, true
}
}
if !found {
return none, false
}
return ops[best].state, ops[best].dst == p
}
// fuzzPatterns and fuzzNames build the policies and trees FuzzBwrapMatchesPolicy
// tries: names a rule may or may not match, and rules over them.
var (
fuzzNames = []string{"a", "b", ".env", "x.pem", ".git", "c d"}
fuzzPatterns = []string{".", "a", "a/b", "b/a", "**", "**/.env*", "*.pem", "**/b", "a/*", "~", "~/a", "~/p/a", "~/p", ".git", ".git/hooks", "missing/deep", "b/missing"}
fuzzPerms = [][2]policy.Perm{{policy.None, policy.None}, {policy.Read, policy.Read}, {policy.Read, policy.Write}, {policy.Write, policy.Write}, {policy.None, policy.Read}, {policy.None, policy.Write}}
)
// The bubblewrap command line enforces exactly what the policy says: for
// random trees and policies, every path shows through the mounts the
// access policy.Compiled.Access gives it. And guard's promises hold: no
// protected path can be created where it is missing, and the directories
// above a protected path cannot be renamed.
func FuzzBwrapMatchesPolicy(f *testing.F) {
f.Add([]byte{0, 1, 2, 3, 4, 5}, []byte{9, 1, 5, 0, 8, 2})
f.Add([]byte{1, 7, 13, 2, 22, 40}, []byte{2, 3, 14, 0, 15, 4, 16, 1})
f.Add([]byte{3, 3, 3, 9, 9, 9}, []byte{1, 3, 0, 1, 12, 5, 13, 0})
f.Fuzz(func(t *testing.T, tree, rules []byte) {
if len(tree) > 24 || len(rules) > 16 {
return
}
base, err := os.MkdirTemp(".", ".fuzz-") // outside /tmp, which the sandbox replaces
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(base)
if base, err = filepath.Abs(base); err != nil {
t.Fatal(err)
}
home := filepath.Join(base, "h")
project := filepath.Join(home, "p")
scratch := filepath.Join(base, "scratch")
for _, d := range []string{project, scratch} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
// Each pair of bytes makes a path: a parent among those made, and
// a name; odd names are files.
made := []string{project, home}
for i := 0; i+1 < len(tree); i += 2 {
parent := made[int(tree[i])%len(made)]
if fi, err := os.Stat(parent); err != nil || !fi.IsDir() {
continue
}
p := filepath.Join(parent, fuzzNames[int(tree[i+1])%len(fuzzNames)])
if tree[i+1]%2 == 1 {
err = os.WriteFile(p, nil, 0o644)
} else {
err = os.MkdirAll(p, 0o755)
}
if err == nil {
made = append(made, p)
}
}
// A policy for each mode, each rule giving one access in each.
var files [2]policy.File
for i := 0; i+1 < len(rules); i += 2 {
perm := fuzzPerms[int(rules[i+1])%len(fuzzPerms)]
for m := range files {
files[m].Files = append(files[m].Files, policy.FileRule{Path: fuzzPatterns[int(rules[i])%len(fuzzPatterns)], Access: perm[m]})
}
}
env := policy.Env{Home: home, Project: project, Scratch: scratch}
var cs [2]*policy.Compiled
for m := range cs {
var err error
if cs[m], err = policy.Compile(policy.Layer{Name: "fuzz", File: &files[m]}, policy.DefaultFloor(), env); err != nil {
t.Fatal(err)
}
}
var paths []string
if err := filepath.Walk(home, func(p string, _ os.FileInfo, err error) error {
if err == nil {
paths = append(paths, p)
}
return nil
}); err != nil {
t.Fatal(err)
}
for _, mode := range []policy.Mode{policy.SafeMode, policy.UnsafeMode} {
c, file := cs[mode], files[mode]
args, created, err := bwrapArgs(c, bwrapOpts{empty: "/dev/null"})
if err != nil {
t.Fatal(err)
}
ops := mountsOf(t, args)
for _, p := range paths {
if slices.Contains(created, p) {
continue // made for the command, which sees it as guard says
}
got, _ := seen(ops, p)
if want := stateOf(c.Access(p)); got != want {
t.Fatalf("%s mode: %s shows %v, the policy says %v\nrules %+v\nargs %q", mode, p, got, want, file.Files, args)
}
}
for _, r := range file.Files {
m, err := policy.NewMatcher(r.Path, home, project)
if err != nil || m.IsGlob() {
continue
}
// A missing protected path cannot be created: something is
// mounted there, or the directory holding it is not writable.
if _, err := os.Lstat(m.Path); err != nil && !slices.Contains(created, m.Path) && !c.Access(m.Path).Write {
// The nearest place above it that exists in the sandbox: on
// the host, or a mount point (a blocker guard added).
existing, blocked := filepath.Dir(m.Path), false
for {
if s, point := seen(ops, existing); point && s != rw {
blocked = true
break
}
if _, err := os.Lstat(existing); err == nil {
break
}
existing = filepath.Dir(existing)
}
if fi, err := os.Stat(existing); blocked || err != nil || !fi.IsDir() {
continue // under a file or a blocker: nothing can be created
}
if s, _ := seen(ops, existing); s == rw {
t.Fatalf("%s mode: missing %s can be created in %s\nargs %q", mode, m.Path, existing, args)
}
}
}
// Above every mount that gives less than writing, each directory
// in a writable one is a mount point.
for _, op := range ops {
if op.state == rw {
continue
}
for a := filepath.Dir(op.dst); a != "/"; a = filepath.Dir(a) {
if s, _ := seen(ops, filepath.Dir(a)); s != rw {
continue
}
if _, point := seen(ops, a); !point {
t.Fatalf("%s mode: %s, above protected %s, can be renamed\nargs %q", mode, a, op.dst, args)
}
}
}
for _, p := range slices.Backward(created) {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
}
}
})
}
|