| 1 | //go:build !windows |
| 2 | |
| 3 | package sandbox |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | func requireConfinedGit(t *testing.T) { |
| 17 | t.Helper() |
| 18 | if !Available() { |
| 19 | t.Skip("no usable OS sandbox backend") |
| 20 | } |
| 21 | requireGit(t) |
| 22 | } |
| 23 | |
| 24 | // runConfined runs command under the real OS sandbox for spec, in dir. |
| 25 | func runConfined(t *testing.T, spec Spec, dir, command string) (string, error) { |
| 26 | t.Helper() |
| 27 | argv, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "/bin/bash"}, command) |
| 28 | if !wrapped { |
| 29 | t.Fatal("expected a sandbox-wrapped command") |
| 30 | } |
| 31 | cmd := exec.Command(argv[0], argv[1:]...) |
| 32 | cmd.Dir = dir |
| 33 | cmd.Env = isolatedGitEnv() |
| 34 | out, err := cmd.CombinedOutput() |
| 35 | return string(out), err |
| 36 | } |
| 37 | |
| 38 | func snapshotFiles(t *testing.T, paths ...string) map[string]string { |
| 39 | t.Helper() |
| 40 | out := map[string]string{} |
| 41 | for _, p := range paths { |
| 42 | data, err := os.ReadFile(p) |
| 43 | if err != nil { |
| 44 | out[p] = "<absent>" |
| 45 | continue |
| 46 | } |
| 47 | out[p] = string(data) |
| 48 | } |
| 49 | return out |
| 50 | } |
| 51 | |
| 52 | func TestSandboxDeniesGitMetadataWrites(t *testing.T) { |
| 53 | requireConfinedGit(t) |
| 54 | root := realTempDir(t) |
| 55 | ws := filepath.Join(root, "ws") |
| 56 | newRepo(t, ws) |
| 57 | lib := filepath.Join(root, "lib") |
| 58 | newRepo(t, lib) |
| 59 | gitIn(t, ws, "-c", "protocol.file.allow=always", "submodule", "add", "-q", lib, "vendor/lib") |
| 60 | gitIn(t, ws, "commit", "-q", "-m", "submodule") |
| 61 | gitIn(t, ws, "worktree", "add", "-q", "-b", "linked", filepath.Join(ws, ".wt", "linked")) |
| 62 | git := filepath.Join(ws, ".git") |
| 63 | module := filepath.Join(git, "modules", "vendor", "lib") |
| 64 | wtMeta := filepath.Join(git, "worktrees", "linked") |
| 65 | spec := Spec{Mode: "enforce", WriteRoots: []string{ws}} |
| 66 | |
| 67 | watched := []string{ |
| 68 | filepath.Join(git, "config"), filepath.Join(git, "config.worktree"), filepath.Join(git, "hooks", "pre-commit"), |
| 69 | filepath.Join(wtMeta, "config"), filepath.Join(wtMeta, "config.worktree"), |
| 70 | filepath.Join(module, "config"), filepath.Join(module, "hooks", "post-checkout"), filepath.Join(ws, "vendor", "lib", ".git"), |
| 71 | } |
| 72 | before := snapshotFiles(t, watched...) |
| 73 | commands := []string{ |
| 74 | "git config core.fsmonitor /bin/sh", |
| 75 | "printf '[core]\\n\\tfsmonitor = /bin/sh\\n' >> .git/config", |
| 76 | "printf '#!/bin/sh\\n' > .git/hooks/pre-commit", |
| 77 | "mv .git/hooks .git/hooks.moved", |
| 78 | "cp .git/config /tmp/x-$$ && mv -f /tmp/x-$$ .git/config", |
| 79 | "ln -s /tmp .git/hooks2 && mv -f .git/hooks2 .git/hooks", |
| 80 | "printf '[core]\\n' >> .git/modules/vendor/lib/config", |
| 81 | "printf '#!/bin/sh\\n' > .git/modules/vendor/lib/hooks/post-checkout", |
| 82 | "mv .git .git.moved", |
| 83 | "rm -rf .git", |
| 84 | } |
| 85 | // Seatbelt matches paths, so it also refuses creating an absent file; |
| 86 | // bubblewrap can only mount over a path that exists. |
| 87 | if runtime.GOOS == "darwin" { |
| 88 | commands = append([]string{ |
| 89 | "printf '[core]\\n\\tfsmonitor = /bin/sh\\n' > .git/config.worktree", |
| 90 | "printf '[core]\\n' > .git/worktrees/linked/config", |
| 91 | "printf '[core]\\n' > .git/worktrees/linked/config.worktree", |
| 92 | }, commands...) |
| 93 | } |
| 94 | for _, command := range commands { |
| 95 | if out, err := runConfined(t, spec, ws, command); err == nil { |
| 96 | t.Errorf("%q must be denied, got success: %s", command, out) |
| 97 | } |
| 98 | } |
| 99 | if after := snapshotFiles(t, watched...); !mapsEqual(before, after) { |
| 100 | t.Fatalf("protected git metadata changed:\nbefore %v\nafter %v", before, after) |
| 101 | } |
| 102 | if info, err := os.Lstat(git); err != nil || !info.IsDir() { |
| 103 | t.Fatalf("the .git entry must survive rm -rf and mv: %v", err) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestSandboxDeniesRetargetingGitPointers(t *testing.T) { |
| 108 | requireConfinedGit(t) |
| 109 | root := realTempDir(t) |
| 110 | main := filepath.Join(root, "main") |
| 111 | newRepo(t, main) |
| 112 | linked := filepath.Join(root, "linked") |
| 113 | gitIn(t, main, "worktree", "add", "-q", "-b", "linked", linked) |
| 114 | sep := filepath.Join(root, "sep") |
| 115 | store := filepath.Join(root, "store.git") |
| 116 | if err := os.Mkdir(sep, 0o755); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | gitIn(t, sep, "init", "-q", "--separate-git-dir", store) |
| 120 | spec := Spec{Mode: "enforce", WriteRoots: []string{linked, sep, root}} |
| 121 | |
| 122 | for _, tc := range []struct{ dir, command string }{ |
| 123 | {linked, "printf 'gitdir: %s\\n' " + root + "/evil > .git"}, |
| 124 | {linked, "rm .git"}, |
| 125 | {linked, "printf '[core]\\n' >> " + filepath.Join(main, ".git", "config")}, |
| 126 | {linked, "printf '/tmp\\n' > " + filepath.Join(main, ".git", "worktrees", "linked", "commondir")}, |
| 127 | {sep, "printf 'gitdir: /tmp\\n' > .git"}, |
| 128 | {sep, "printf '[core]\\n' >> " + filepath.Join(store, "config")}, |
| 129 | {sep, "mv " + store + " " + store + ".moved && mkdir -p " + store}, |
| 130 | {sep, "printf '#!/bin/sh\\n' > " + filepath.Join(store, "hooks", "pre-commit")}, |
| 131 | } { |
| 132 | if out, err := runConfined(t, spec, tc.dir, tc.command); err == nil { |
| 133 | t.Errorf("in %s, %q must be denied, got success: %s", tc.dir, tc.command, out) |
| 134 | } |
| 135 | } |
| 136 | if out, err := runConfined(t, spec, linked, "git status --porcelain && git -C "+sep+" status --porcelain"); err != nil { |
| 137 | t.Fatalf("repositories must still resolve after the denied writes: %v: %s", err, out) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // Ordinary git work writes objects, refs, index, logs and lock files under |
| 142 | // .git, never its config or hooks, so it keeps working confined. |
| 143 | func TestSandboxKeepsOrdinaryGitWorking(t *testing.T) { |
| 144 | requireConfinedGit(t) |
| 145 | ws := filepath.Join(realTempDir(t), "ws") |
| 146 | newRepo(t, ws) |
| 147 | spec := Spec{Mode: "enforce", WriteRoots: []string{ws}} |
| 148 | for _, command := range []string{ |
| 149 | "echo one > a.txt && git add a.txt && git commit -q -m one", |
| 150 | "git branch topic && git checkout -q -b feature", |
| 151 | "echo two >> a.txt && git commit -q -am two", |
| 152 | "git checkout -q main && git merge -q --no-edit feature", |
| 153 | "git checkout -q topic && echo t > t.txt && git add t.txt && git commit -q -m t && git rebase -q main", |
| 154 | "echo wip >> a.txt && git stash -q && git stash pop -q && git checkout -q -- a.txt", |
| 155 | "git tag -a v1 -m v1 && git tag light", |
| 156 | "git branch -D feature", |
| 157 | "git worktree add -q -b side .wt/side && git worktree remove .wt/side", |
| 158 | "git cherry-pick --no-edit main~1 || git cherry-pick --abort", |
| 159 | "git gc -q", |
| 160 | "mkdir fresh && cd fresh && git init -q && echo f > f && git add f && git commit -q -m fresh", |
| 161 | "touch . && chmod 755 . && touch .git", |
| 162 | } { |
| 163 | if out, err := runConfined(t, spec, ws, command); err != nil { |
| 164 | t.Fatalf("%q must keep working confined: %v: %s", command, err, out) |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // These agent git operations write repository config or hooks and so fail |
| 170 | // confined. The error names the protected path, which is what lets the host |
| 171 | // attribute the denial. |
| 172 | func TestSandboxGitOperationsThatWriteMetadata(t *testing.T) { |
| 173 | requireConfinedGit(t) |
| 174 | ws := filepath.Join(realTempDir(t), "ws") |
| 175 | newRepo(t, ws) |
| 176 | gitIn(t, ws, "branch", "old") |
| 177 | gitIn(t, ws, "branch", "topic") |
| 178 | lib := filepath.Join(filepath.Dir(ws), "lib") |
| 179 | newRepo(t, lib) |
| 180 | gitIn(t, ws, "-c", "protocol.file.allow=always", "submodule", "add", "-q", lib, "vendor/lib") |
| 181 | gitIn(t, ws, "commit", "-q", "-m", "submodule") |
| 182 | gitIn(t, ws, "submodule", "deinit", "-q", "--all") |
| 183 | if err := os.RemoveAll(filepath.Join(ws, ".git", "modules")); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | spec := Spec{Mode: "enforce", WriteRoots: []string{ws}} |
| 187 | for _, command := range []string{ |
| 188 | "git config user.name Someone", |
| 189 | "git config --unset core.bare", |
| 190 | "git remote add origin https://example.invalid/repo.git", |
| 191 | "git branch -m old renamed", |
| 192 | "git config core.hooksPath .githooks", |
| 193 | "printf '#!/bin/sh\\n' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit", |
| 194 | "git -c protocol.file.allow=always submodule update --init", |
| 195 | } { |
| 196 | out, err := runConfined(t, spec, ws, command) |
| 197 | if err == nil { |
| 198 | t.Errorf("%q must fail confined: %s", command, out) |
| 199 | continue |
| 200 | } |
| 201 | if !strings.Contains(out, ".git/config") && !strings.Contains(out, ".git/hooks/") { |
| 202 | t.Errorf("%q failure must name the protected path: %s", command, out) |
| 203 | } |
| 204 | } |
| 205 | // git reports the refused write but exits 0 and claims the upstream is set. |
| 206 | out, err := runConfined(t, spec, ws, "git branch --set-upstream-to=main topic") |
| 207 | if err != nil || !strings.Contains(out, ".git/config") { |
| 208 | t.Fatalf("set-upstream-to: %v: %s", err, out) |
| 209 | } |
| 210 | if out, err := runConfined(t, spec, ws, "git config branch.topic.remote"); err == nil { |
| 211 | t.Fatalf("upstream must not have been recorded: %s", out) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | func mapsEqual(a, b map[string]string) bool { |
| 216 | if len(a) != len(b) { |
| 217 | return false |
| 218 | } |
| 219 | for k, v := range a { |
| 220 | if b[k] != v { |
| 221 | return false |
| 222 | } |
| 223 | } |
| 224 | return true |
| 225 | } |
| 226 | |
| 227 | // Submodule gitdirs are covered by pattern on macOS, so one created or moved |
| 228 | // after the rules were written is covered too. |
| 229 | func TestSeatbeltCoversSubmoduleGitDirsByPattern(t *testing.T) { |
| 230 | requireConfinedGit(t) |
| 231 | if runtime.GOOS != "darwin" { |
| 232 | t.Skip("patterns are the Seatbelt backend's") |
| 233 | } |
| 234 | ws := filepath.Join(realTempDir(t), "ws") |
| 235 | newRepo(t, ws) |
| 236 | if err := os.MkdirAll(filepath.Join(ws, ".git", "modules", "old", "hooks"), 0o755); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if err := os.WriteFile(filepath.Join(ws, ".git", "modules", "old", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | spec := Spec{Mode: "enforce", WriteRoots: []string{ws}} |
| 243 | for _, command := range []string{ |
| 244 | "mkdir -p .git/modules/new/deeper && printf '[core]\\n' > .git/modules/new/deeper/config", |
| 245 | "mkdir -p .git/modules/new && printf '#!/bin/sh\\n' > .git/modules/new/hooks", |
| 246 | "printf '#!/bin/sh\\n' > .git/modules/old/hooks/post-checkout", |
| 247 | "mv .git/modules/old .git/modules/moved", |
| 248 | "ln -s " + ws + " .git/modules/planted", |
| 249 | "printf '[core]\\n' > .git/worktrees/x/config.worktree || (mkdir -p .git/worktrees/x && printf '[core]\\n' > .git/worktrees/x/config.worktree)", |
| 250 | } { |
| 251 | if out, err := runConfined(t, spec, ws, command); err == nil { |
| 252 | t.Errorf("%q must be denied, got success: %s", command, out) |
| 253 | } |
| 254 | } |
| 255 | if out, err := runConfined(t, spec, ws, "mkdir -p .git/modules/new/objects && echo x > .git/modules/new/objects/o && rm .git/modules/new/objects/o"); err != nil { |
| 256 | t.Fatalf("other submodule gitdir contents stay writable: %v: %s", err, out) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // The reviewer's case: a `.git` file reaching its gitdir through a symlink that |
| 261 | // lives in the workspace. Swapping the symlink must be refused. |
| 262 | func TestSeatbeltDeniesSwappingASymlinkOnThePointerPath(t *testing.T) { |
| 263 | requireConfinedGit(t) |
| 264 | if runtime.GOOS != "darwin" { |
| 265 | t.Skip("a mount cannot pin a symlink; the host pins GIT_DIR instead") |
| 266 | } |
| 267 | base := realTempDir(t) |
| 268 | root := filepath.Join(base, "ws") |
| 269 | code := filepath.Join(root, "code") |
| 270 | store := filepath.Join(root, "store") |
| 271 | if err := os.MkdirAll(code, 0o755); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | gitIn(t, code, "init", "-q", "-b", "main", "--separate-git-dir", store) |
| 275 | if err := os.Symlink("store", filepath.Join(root, "link")); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | if err := os.WriteFile(filepath.Join(code, ".git"), []byte("gitdir: ../link\n"), 0o644); err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | spec := Spec{Mode: "enforce", WriteRoots: []string{code, root}} |
| 282 | out, err := runConfined(t, spec, code, "cd .. && mkdir evil && cp -R store/. evil/ && printf '[probe]\\n\\tx = 1\\n' >> evil/config && rm link && ln -s evil link") |
| 283 | if err == nil { |
| 284 | t.Fatalf("swapping the pointer's symlink must be denied: %s", out) |
| 285 | } |
| 286 | if target, err := os.Readlink(filepath.Join(root, "link")); err != nil || target != "store" { |
| 287 | t.Fatalf("link changed: %q %v", target, err) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | // Rules must not grow with the number of submodule gitdirs: a repository, or a |
| 292 | // confined command planting HEAD files, would otherwise overflow the argument |
| 293 | // list and break every command. |
| 294 | func TestSandboxRuleSizeIsBoundedBySubmoduleCount(t *testing.T) { |
| 295 | requireConfinedGit(t) |
| 296 | size := func(ws string) int { |
| 297 | argv, _ := Command(Spec{Mode: "enforce", WriteRoots: []string{ws}}, Shell{Kind: ShellBash, Path: "/bin/bash"}, "true") |
| 298 | n := 0 |
| 299 | for _, a := range argv { |
| 300 | n += len(a) |
| 301 | } |
| 302 | return n |
| 303 | } |
| 304 | ws := filepath.Join(realTempDir(t), "ws") |
| 305 | newRepo(t, ws) |
| 306 | plant := func(from, to int) { |
| 307 | for i := from; i < to; i++ { |
| 308 | m := filepath.Join(ws, ".git", "modules", fmt.Sprintf("g%d", i%20), fmt.Sprintf("m%d", i)) |
| 309 | if err := os.MkdirAll(filepath.Join(m, "hooks"), 0o755); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | if err := os.WriteFile(filepath.Join(m, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { |
| 313 | t.Fatal(err) |
| 314 | } |
| 315 | if err := os.WriteFile(filepath.Join(m, "config"), nil, 0o644); err != nil { |
| 316 | t.Fatal(err) |
| 317 | } |
| 318 | } |
| 319 | } |
| 320 | plant(0, 200) |
| 321 | atLimit := size(ws) |
| 322 | plant(200, 2000) |
| 323 | if after := size(ws); after > atLimit || after > 256<<10 { |
| 324 | t.Fatalf("sandbox arguments must stop growing past the limit: %d bytes at 200 gitdirs, %d at 2000", atLimit, after) |
| 325 | } |
| 326 | start := time.Now() |
| 327 | if out, err := runConfined(t, Spec{Mode: "enforce", WriteRoots: []string{ws}}, ws, "echo ok > f && git add f && git commit -q -m f"); err != nil { |
| 328 | t.Fatalf("commands keep running: %v: %s", err, out) |
| 329 | } |
| 330 | t.Logf("confined git commit with 2000 submodule gitdirs took %v", time.Since(start)) |
| 331 | if out, err := runConfined(t, Spec{Mode: "enforce", WriteRoots: []string{ws}}, ws, "printf '[core]\\n' >> .git/modules/g1/m1/config"); err == nil { |
| 332 | t.Fatalf("a planted submodule config stays protected: %s", out) |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | // The host adds its own linked worktrees under the workspace's .git/worktrees. |
| 337 | // Rewriting such an entry's commondir would point the host's git at a config |
| 338 | // the sandbox wrote, so existing entries' commondir stays protected. |
| 339 | func TestSandboxProtectsHostLinkedWorktreeCommondir(t *testing.T) { |
| 340 | requireConfinedGit(t) |
| 341 | base := realTempDir(t) |
| 342 | ws := filepath.Join(base, "ws") |
| 343 | newRepo(t, ws) |
| 344 | for i, linked := range []string{filepath.Join(base, "managed", "cand"), filepath.Join(ws, ".reasonix", "wt", "cand2")} { |
| 345 | gitIn(t, ws, "worktree", "add", "-q", "--detach", linked) |
| 346 | id := filepath.Base(linked) |
| 347 | evil := filepath.Join(ws, fmt.Sprintf("evil%d", i)) |
| 348 | command := "mkdir -p " + evil + " && cp -R .git/. " + evil + "/ && printf '[probe]\\n\\tx = 1\\n' >> " + evil + "/config && printf '" + evil + "\\n' > .git/worktrees/" + id + "/commondir" |
| 349 | if out, err := runConfined(t, Spec{Mode: "enforce", WriteRoots: []string{ws}}, ws, command); err == nil { |
| 350 | t.Fatalf("rewriting %s's commondir must be denied: %s", id, out) |
| 351 | } |
| 352 | cmd := exec.Command("git", "-C", linked, "config", "--get", "probe.x") |
| 353 | cmd.Env = isolatedGitEnv() |
| 354 | if out, _ := cmd.Output(); strings.TrimSpace(string(out)) != "" { |
| 355 | t.Fatalf("host git in %s reads a sandbox-written config", linked) |
| 356 | } |
| 357 | } |
| 358 | if out, err := runConfined(t, Spec{Mode: "enforce", WriteRoots: []string{ws}}, ws, "git worktree add -q -b fresh .wt/fresh && git worktree remove .wt/fresh"); err != nil { |
| 359 | t.Fatalf("new worktrees stay creatable: %v: %s", err, out) |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | // Refs and reflogs inside a submodule gitdir are not config: a branch or tag |
| 364 | // named config or hooks is ordinary git work. |
| 365 | func TestSandboxLeavesSubmoduleRefsNamedLikeMetadataWritable(t *testing.T) { |
| 366 | requireConfinedGit(t) |
| 367 | base := realTempDir(t) |
| 368 | ws := filepath.Join(base, "ws") |
| 369 | newRepo(t, ws) |
| 370 | lib := filepath.Join(base, "lib") |
| 371 | newRepo(t, lib) |
| 372 | gitIn(t, ws, "-c", "protocol.file.allow=always", "submodule", "add", "-q", lib, "vendor/lib") |
| 373 | gitIn(t, ws, "commit", "-q", "-m", "submodule") |
| 374 | spec := Spec{Mode: "enforce", WriteRoots: []string{ws}} |
| 375 | for _, command := range []string{ |
| 376 | "git -C vendor/lib branch config", |
| 377 | "git -C vendor/lib branch hooks", |
| 378 | "git -C vendor/lib branch feature/hooks/x", |
| 379 | "git -C vendor/lib tag config", |
| 380 | "git -C vendor/lib branch feature/y && git -C vendor/lib branch -D feature/y", |
| 381 | "git -C vendor/lib checkout -q -b topic && git -C vendor/lib commit -q --allow-empty -m t && git -C vendor/lib checkout -q -", |
| 382 | "git -C vendor/lib gc -q --prune=now", |
| 383 | } { |
| 384 | if out, err := runConfined(t, spec, ws, command); err != nil { |
| 385 | t.Errorf("%q must keep working confined: %v: %s", command, err, out) |
| 386 | } |
| 387 | } |
| 388 | if out, err := runConfined(t, spec, ws, "printf '[core]\\n' >> .git/modules/vendor/lib/config"); err == nil { |
| 389 | t.Fatalf("the submodule config stays protected: %s", out) |
| 390 | } |
| 391 | } |
| 392 |