Monorepo for Tangled
tangled.org
1//go:build linux
2
3package sandbox
4
5import (
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "syscall"
12 "unsafe"
13
14 "github.com/landlock-lsm/go-landlock/landlock"
15 "golang.org/x/sys/unix"
16)
17
18var ErrUnsupportedPlatform = errors.New("no sandbox backend available")
19
20// LandlockBackend uses the Linux Landlock LSM via a re-exec pattern.
21// landlock_restrict_self only affects the calling OS thread, so we re-exec
22// the binary as "sandbox-exec" which runs single-threaded before exec'ing git.
23type LandlockBackend struct {
24 selfExe string
25 lookup LookupUID
26}
27
28func (l *LandlockBackend) Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) {
29 return l.WrapMulti([]string{repoPath}, cmd)
30}
31
32func (l *LandlockBackend) WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) {
33 if len(paths) == 0 {
34 return cmd, nil
35 }
36
37 // resolve the executable to an absolute path now, while $PATH is still
38 // intact; the re-exec'd sandbox-exec subprocess inherits the env we pass
39 // via cmd.Env, which may not include the wrappers that set up $PATH.
40 args := cmd.Args
41 if len(args) > 0 {
42 if abs, err := exec.LookPath(args[0]); err == nil {
43 args = append([]string{abs}, args[1:]...)
44 }
45 }
46
47 var sandboxArgs []string
48 sandboxArgs = append(sandboxArgs, "sandbox-exec")
49 for _, p := range paths {
50 sandboxArgs = append(sandboxArgs, "--repo-path="+p)
51 }
52 sandboxArgs = append(sandboxArgs, "--")
53 sandboxArgs = append(sandboxArgs, args...)
54
55 wrapped := exec.Command(l.selfExe, sandboxArgs...)
56 wrapped.Env = cmd.Env
57 wrapped.Dir = paths[0] // kernel chdir's here after setuid, before execve
58 wrapped.Stdin = cmd.Stdin
59 wrapped.Stdout = cmd.Stdout
60 wrapped.Stderr = cmd.Stderr
61
62 // drop to the virtual UID if we can resolve one. the kernel handles
63 // fork -> setresuid -> chdir -> execve; requires CAP_SETUID/GID on the caller.
64 //
65 // the primary GID is intentionally set to the virtual UID, NOT the
66 // repo's group ownership. repo dirs are owned by virtualUID:gitGroup
67 // with mode 0770 so the knot service (in gitGroup) can read them, but
68 // sandbox subprocesses must not inherit gitGroup or they would gain
69 // group access to every other repo and lose cross-owner isolation.
70 if l.lookup != nil {
71 if uid, _, err := l.lookup(paths[0]); err == nil && uid > 0 {
72 wrapped.SysProcAttr = &syscall.SysProcAttr{
73 Credential: &syscall.Credential{Uid: uid, Gid: uid, NoSetGroups: true},
74 }
75 }
76 }
77
78 return wrapped, nil
79}
80
81func (l *LandlockBackend) Name() string { return "landlock" }
82
83// ApplyLandlock applies a Landlock ruleset to the current process then
84// exec's into gitArgs. Called from the hidden "sandbox-exec" subcommand.
85func ApplyLandlock(repoPaths []string, gitArgs []string) error {
86 if len(gitArgs) == 0 {
87 return fmt.Errorf("sandbox-exec: no command specified")
88 }
89
90 // collect unique parent directories so git can read global config
91 // under $HOME/.config/git/config. repo contents stay DAC-locked
92 // (0700) so other repos can't actually be read.
93 parents := map[string]struct{}{}
94 for _, p := range repoPaths {
95 parents[filepath.Dir(p)] = struct{}{}
96 }
97 parentSlice := make([]string, 0, len(parents))
98 for p := range parents {
99 parentSlice = append(parentSlice, p)
100 }
101
102 // each repo gets full read/write plus REFER (needed for git's quarantine
103 // rename in receive-pack, which moves objects across directories).
104 repoRules := make([]landlock.Rule, len(repoPaths))
105 for i, p := range repoPaths {
106 repoRules[i] = landlock.RWDirs(p).WithRefer()
107 }
108
109 rules := append([]landlock.Rule{
110 // system dirs: read + execute only, no writes
111 landlock.RODirs("/usr", "/bin", "/lib", "/lib64", "/nix", "/etc").IgnoreIfMissing(),
112 // /dev/null and friends: read/write files + ioctl (V5+ restricts ioctl
113 // on device files; WithIoctlDev keeps /dev/null fully accessible)
114 landlock.RWFiles("/dev").WithIoctlDev().IgnoreIfMissing(),
115 // parent dirs: read + execute so git can traverse to the repo and read
116 // global git config; 0700 DAC permissions prevent cross-repo reads
117 landlock.RODirs(parentSlice...).IgnoreIfMissing(),
118 // /tmp: read/write for temporary patch and object files
119 landlock.RWDirs("/tmp").IgnoreIfMissing(),
120 }, repoRules...)
121
122 // V8.BestEffort enforces the strongest ruleset the running kernel supports,
123 // up to V8. RestrictPaths also sets PR_SET_NO_NEW_PRIVS automatically.
124 if err := landlock.V8.BestEffort().RestrictPaths(rules...); err != nil {
125 return fmt.Errorf("sandbox-exec: restrict paths: %w", err)
126 }
127
128 gitBin := gitArgs[0]
129 if !filepath.IsAbs(gitBin) {
130 return fmt.Errorf("sandbox-exec: expected absolute path, got %q", gitBin)
131 }
132
133 return unix.Exec(gitBin, gitArgs, os.Environ())
134}
135
136func probeLandlock() bool {
137 _, err := landlockCreateRuleset(nil, unix.LANDLOCK_CREATE_RULESET_VERSION)
138 // EOPNOTSUPP and ENOSYS mean the kernel doesn't support landlock.
139 // Any other result (including EINVAL for the nil attr) means it's available.
140 return !errors.Is(err, unix.EOPNOTSUPP) && !errors.Is(err, unix.ENOSYS)
141}
142
143func platformNew(lookup LookupUID) (Backend, string) {
144 if probeLandlock() {
145 selfExe, err := os.Readlink("/proc/self/exe")
146 if err != nil {
147 selfExe = "/proc/self/exe"
148 }
149 return &LandlockBackend{selfExe: selfExe, lookup: lookup}, ""
150 }
151
152 return &NoopBackend{}, "landlock unavailable (kernel < 5.13); git subprocesses run unsandboxed"
153}
154
155func platformProbe() string {
156 if probeLandlock() {
157 return "landlock available (kernel >= 5.13)"
158 }
159 return "no sandbox backend available (kernel < 5.13)"
160}
161
162// landlockCreateRuleset wraps the landlock_create_ruleset(2) syscall.
163// Pass attr=nil and flags=LANDLOCK_CREATE_RULESET_VERSION to query ABI version.
164// Used only for the non-destructive probe in probeLandlock; all ruleset
165// construction is handled by go-landlock.
166func landlockCreateRuleset(attr *unix.LandlockRulesetAttr, flags uint) (int, error) {
167 var attrPtr unsafe.Pointer
168 var attrSize uintptr
169 if attr != nil {
170 attrPtr = unsafe.Pointer(attr)
171 attrSize = unsafe.Sizeof(*attr)
172 }
173 fd, _, errno := unix.Syscall(
174 unix.SYS_LANDLOCK_CREATE_RULESET,
175 uintptr(attrPtr),
176 attrSize,
177 uintptr(flags),
178 )
179 if errno != 0 {
180 return 0, errno
181 }
182 return int(fd), nil
183}