This repository has no description
7.5 kB
223 lines
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 -> setgroups -> setresgid -> setresuid -> chdir -> execve;
64 // requires CAP_SETUID/CAP_SETGID on the caller.
65 //
66 // the primary GID is intentionally set to the virtual UID, NOT the
67 // repo's group ownership. repo dirs are owned by virtualUID:gitGroup
68 // with mode 0770 so the knot service (in gitGroup) can read them, but
69 // sandbox subprocesses must not inherit gitGroup or they would gain
70 // group access to every other repo and lose cross-owner isolation.
71 //
72 // Groups is an empty (non-nil) slice and NoSetGroups is false so the
73 // kernel calls setgroups(0, NULL) and clears supplementary groups.
74 // NoSetGroups: true would skip setgroups entirely and the subprocess
75 // would inherit the parent's supplementary groups (including gitGroup).
76 if l.lookup != nil {
77 if uid, _, err := l.lookup(paths[0]); err == nil && uid > 0 {
78 wrapped.SysProcAttr = &syscall.SysProcAttr{
79 Credential: &syscall.Credential{
80 Uid: uid,
81 Gid: uid,
82 Groups: []uint32{},
83 },
84 }
85 }
86 }
87
88 return wrapped, nil
89}
90
91func (l *LandlockBackend) Name() string { return "landlock" }
92
93// RuleSpec describes the paths a sandbox should grant access to, grouped by
94// access tier. It is the input to the Landlock ruleset construction and is
95// exposed so the path-derivation logic can be tested independently of any
96// actual kernel-level enforcement.
97type RuleSpec struct {
98 // SystemRO is the set of system directories granted read+execute.
99 SystemRO []string
100 // GitConfigRO is the global git config file, granted read-only access
101 // at file granularity. Empty when $HOME is not set.
102 GitConfigRO string
103 // DevRW is the set of device-file directories granted read/write +
104 // ioctl access (needed so /dev/null works under Landlock V5+).
105 DevRW []string
106 // TmpRW is the set of directories granted read/write for temporary
107 // patch and object files.
108 TmpRW []string
109 // RepoRW is the set of repository directories granted read/write
110 // access including the REFER right (for cross-directory rename in
111 // receive-pack's quarantine migration).
112 RepoRW []string
113}
114
115// BuildRuleSpec derives the set of paths the sandbox should grant to each
116// access tier given the repository paths the subprocess operates on.
117func BuildRuleSpec(repoPaths []string) RuleSpec {
118 return buildRuleSpec(repoPaths, os.Getenv("HOME"))
119}
120
121// buildRuleSpec is the testable variant of BuildRuleSpec that takes $HOME
122// explicitly instead of reading it from the environment.
123func buildRuleSpec(repoPaths []string, home string) RuleSpec {
124 var gitConfig string
125 if home != "" {
126 // the only thing the sandboxed git subprocess needs from $HOME is the
127 // global config file. granting just that one file (not the whole
128 // .config tree) keeps everything else under $HOME outside the ruleset.
129 gitConfig = filepath.Join(home, ".config", "git", "config")
130 }
131
132 return RuleSpec{
133 SystemRO: []string{"/usr", "/bin", "/lib", "/lib64", "/nix", "/etc"},
134 GitConfigRO: gitConfig,
135 DevRW: []string{"/dev"},
136 TmpRW: []string{"/tmp"},
137 RepoRW: append([]string(nil), repoPaths...),
138 }
139}
140
141// ApplyLandlock applies a Landlock ruleset to the current process then
142// exec's into gitArgs. Called from the hidden "sandbox-exec" subcommand.
143func ApplyLandlock(repoPaths []string, gitArgs []string) error {
144 if len(gitArgs) == 0 {
145 return fmt.Errorf("sandbox-exec: no command specified")
146 }
147
148 spec := BuildRuleSpec(repoPaths)
149
150 rules := []landlock.Rule{
151 landlock.RODirs(spec.SystemRO...).IgnoreIfMissing(),
152 landlock.RWFiles(spec.DevRW...).WithIoctlDev().IgnoreIfMissing(),
153 landlock.RWDirs(spec.TmpRW...).IgnoreIfMissing(),
154 }
155 if spec.GitConfigRO != "" {
156 rules = append(rules, landlock.ROFiles(spec.GitConfigRO).IgnoreIfMissing())
157 }
158 for _, p := range spec.RepoRW {
159 rules = append(rules, landlock.RWDirs(p).WithRefer())
160 }
161
162 // V8.BestEffort enforces the strongest ruleset the running kernel supports,
163 // up to V8. RestrictPaths also sets PR_SET_NO_NEW_PRIVS automatically.
164 if err := landlock.V8.BestEffort().RestrictPaths(rules...); err != nil {
165 return fmt.Errorf("sandbox-exec: restrict paths: %w", err)
166 }
167
168 gitBin := gitArgs[0]
169 if !filepath.IsAbs(gitBin) {
170 return fmt.Errorf("sandbox-exec: expected absolute path, got %q", gitBin)
171 }
172
173 return unix.Exec(gitBin, gitArgs, os.Environ())
174}
175
176func probeLandlock() bool {
177 _, err := landlockCreateRuleset(nil, unix.LANDLOCK_CREATE_RULESET_VERSION)
178 // EOPNOTSUPP and ENOSYS mean the kernel doesn't support landlock.
179 // Any other result (including EINVAL for the nil attr) means it's available.
180 return !errors.Is(err, unix.EOPNOTSUPP) && !errors.Is(err, unix.ENOSYS)
181}
182
183func platformNew(lookup LookupUID) (Backend, string) {
184 if probeLandlock() {
185 selfExe, err := os.Readlink("/proc/self/exe")
186 if err != nil {
187 selfExe = "/proc/self/exe"
188 }
189 return &LandlockBackend{selfExe: selfExe, lookup: lookup}, ""
190 }
191
192 return &NoopBackend{}, "landlock unavailable (kernel < 5.13); git subprocesses run unsandboxed"
193}
194
195func platformProbe() string {
196 if probeLandlock() {
197 return "landlock available (kernel >= 5.13)"
198 }
199 return "no sandbox backend available (kernel < 5.13)"
200}
201
202// landlockCreateRuleset wraps the landlock_create_ruleset(2) syscall.
203// Pass attr=nil and flags=LANDLOCK_CREATE_RULESET_VERSION to query ABI version.
204// Used only for the non-destructive probe in probeLandlock; all ruleset
205// construction is handled by go-landlock.
206func landlockCreateRuleset(attr *unix.LandlockRulesetAttr, flags uint) (int, error) {
207 var attrPtr unsafe.Pointer
208 var attrSize uintptr
209 if attr != nil {
210 attrPtr = unsafe.Pointer(attr)
211 attrSize = unsafe.Sizeof(*attr)
212 }
213 fd, _, errno := unix.Syscall(
214 unix.SYS_LANDLOCK_CREATE_RULESET,
215 uintptr(attrPtr),
216 attrSize,
217 uintptr(flags),
218 )
219 if errno != 0 {
220 return 0, errno
221 }
222 return int(fd), nil
223}