1package captureallow
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "github.com/bnema/zerowrap"
16 "golang.org/x/sys/unix"
17)
18
19// logBuf is a locked bytes.Buffer, a real io.Writer.
20type logBuf struct {
21 mu sync.Mutex
22 b bytes.Buffer
23}
24
25func (l *logBuf) Write(p []byte) (int, error) {
26 l.mu.Lock()
27 defer l.mu.Unlock()
28 return l.b.Write(p)
29}
30
31func (l *logBuf) String() string {
32 l.mu.Lock()
33 defer l.mu.Unlock()
34 return l.b.String()
35}
36
37func testLog(out *logBuf) zerowrap.Logger {
38 return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: out}).WithField("component", "captureallow")
39}
40
41// uid owns every file the tests create, standing in for root.
42var uid = uint32(os.Getuid())
43
44func newTestStore(path string, log zerowrap.Logger) *Store { return NewStoreOwnedBy(path, uid, log) }
45
46func TestLoad(t *testing.T) {
47 dir := t.TempDir()
48 p, err := load(filepath.Join(dir, "missing"), uid)
49 if !errors.Is(err, fs.ErrNotExist) || p.Mode() != ModeList || !p.Allows("/usr/bin/grim") || p.Allows("/usr/bin/other") {
50 t.Fatalf("missing: %v, %v", p.Mode(), err)
51 }
52 bad := filepath.Join(dir, "bad")
53 _ = os.WriteFile(bad, []byte("/ok\nrelative\n"), 0o644)
54 p, err = load(bad, uid)
55 var pe *ParseError
56 if !errors.As(err, &pe) || pe.Line != 2 || p.Mode() != ModeClosed || p.Allows("/ok") {
57 t.Fatalf("invalid: %v, %v", p.Mode(), err)
58 }
59 // A directory is unreadable as a file: fail closed, not disabled.
60 p, err = load(dir, uid)
61 if err == nil || p.Mode() != ModeClosed {
62 t.Fatalf("directory: %v, %v", p.Mode(), err)
63 }
64}
65
66func TestStoreStartupLogs(t *testing.T) {
67 dir := t.TempDir()
68 var out logBuf
69 s := newTestStore(filepath.Join(dir, "capture-allow"), testLog(&out))
70 if !s.Policy().Allows("/usr/bin/grim") || s.Policy().Allows("/any") {
71 t.Fatal("missing file must give the built-in list")
72 }
73 log := out.String()
74 if !strings.Contains(log, `"level":"info"`) || !strings.Contains(log, "capture allowlist file missing, using the built-in list") || !strings.Contains(log, `"component":"captureallow"`) || !strings.Contains(log, filepath.Join(dir, "capture-allow")) {
75 t.Fatalf("log = %s", log)
76 }
77 bad := filepath.Join(dir, "bad")
78 _ = os.WriteFile(bad, []byte("/ok\n\nnope\n"), 0o644)
79 out = logBuf{}
80 s = newTestStore(bad, testLog(&out))
81 if s.Policy().Allows("/ok") {
82 t.Fatal("invalid file must fail closed")
83 }
84 log = out.String()
85 if !strings.Contains(log, `"level":"error"`) || !strings.Contains(log, `"line":3`) {
86 t.Fatalf("log = %s", log)
87 }
88}
89
90func waitFor(t *testing.T, what string, cond func() bool) {
91 t.Helper()
92 deadline := time.Now().Add(5 * time.Second)
93 for !cond() {
94 if time.Now().After(deadline) {
95 t.Fatal("timeout: " + what)
96 }
97 time.Sleep(5 * time.Millisecond)
98 }
99}
100
101func TestStoreReloads(t *testing.T) {
102 dir := t.TempDir()
103 path := filepath.Join(dir, "capture-allow")
104 var out logBuf
105 s := newTestStore(path, testLog(&out))
106 ctx, cancel := context.WithCancel(context.Background())
107 done := make(chan error, 1)
108 go func() { done <- s.Run(ctx) }()
109 defer func() {
110 cancel()
111 if err := <-done; err != nil {
112 t.Errorf("Run: %v", err)
113 }
114 }()
115 write := func(content string) {
116 tmp := path + ".tmp"
117 if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
118 t.Fatal(err)
119 }
120 if err := os.Rename(tmp, path); err != nil { // atomic, as packages and editors do
121 t.Fatal(err)
122 }
123 }
124 write("/usr/bin/grim\n")
125 waitFor(t, "list", func() bool { return s.Policy().Mode() == ModeList && s.Policy().Allows("/usr/bin/grim") })
126 if s.Policy().Allows("/usr/bin/other") {
127 t.Fatal("unlisted allowed")
128 }
129 write("/usr/bin/other\nbroken\n")
130 waitFor(t, "closed", func() bool { return s.Policy().Mode() == ModeClosed })
131 write("*\n")
132 waitFor(t, "disabled", func() bool { return s.Policy().Mode() == ModeDisabled })
133 // In-place rewrite.
134 if err := os.WriteFile(path, []byte("/usr/bin/third\n"), 0o644); err != nil {
135 t.Fatal(err)
136 }
137 waitFor(t, "in place", func() bool { return s.Policy().Allows("/usr/bin/third") && !s.Policy().Allows("/x") })
138 if err := os.Remove(path); err != nil {
139 t.Fatal(err)
140 }
141 waitFor(t, "removed", func() bool { return s.Policy().Allows("/usr/bin/grim") && !s.Policy().Allows("/usr/bin/third") })
142 if s.Policy().Mode() != ModeList || s.Policy().Len() != Builtin().Len() {
143 t.Fatal("deleting the file must restore the built-in list")
144 }
145}
146
147func TestStoreWaitsForMissingDirectory(t *testing.T) {
148 dir := filepath.Join(t.TempDir(), "etc", "neferwl")
149 path := filepath.Join(dir, "capture-allow")
150 var out logBuf
151 s := newTestStore(path, testLog(&out))
152 s.retryInterval = 20 * time.Millisecond
153 ctx, cancel := context.WithCancel(context.Background())
154 done := make(chan error, 1)
155 go func() { done <- s.Run(ctx) }()
156 defer func() { cancel(); <-done }()
157 time.Sleep(60 * time.Millisecond)
158 if err := os.MkdirAll(dir, 0o755); err != nil {
159 t.Fatal(err)
160 }
161 if err := os.WriteFile(path, []byte("/usr/bin/other\n"), 0o644); err != nil {
162 t.Fatal(err)
163 }
164 waitFor(t, "list after mkdir", func() bool { return s.Policy().Mode() == ModeList && s.Policy().Allows("/usr/bin/other") })
165}
166
167func mustWrite(t *testing.T, path, content string, mode os.FileMode) {
168 t.Helper()
169 if err := os.WriteFile(path, []byte(content), mode); err != nil {
170 t.Fatal(err)
171 }
172 if err := os.Chmod(path, mode); err != nil { // umask
173 t.Fatal(err)
174 }
175}
176
177// Every way the file or its directories can be untrustworthy fails closed.
178func TestLoadRefusesUntrusted(t *testing.T) {
179 good := func(t *testing.T) (dir, path string) {
180 dir = filepath.Join(t.TempDir(), "neferwl")
181 if err := os.Mkdir(dir, 0o755); err != nil {
182 t.Fatal(err)
183 }
184 path = filepath.Join(dir, "capture-allow")
185 mustWrite(t, path, "/usr/bin/grim\n", 0o644)
186 return dir, path
187 }
188 if p, err := load(func() string { _, p := good(t); return p }(), uid); err != nil || !p.Allows("/usr/bin/grim") {
189 t.Fatalf("trusted file: %v", err)
190 }
191 for _, tc := range []struct {
192 name string
193 setup func(t *testing.T, dir, path string) string
194 owner uint32
195 want string
196 }{
197 {"group writable file", func(t *testing.T, _, path string) string { mustWrite(t, path, "*\n", 0o664); return path }, uid, "writable by group or others"},
198 {"other writable file", func(t *testing.T, _, path string) string { mustWrite(t, path, "*\n", 0o646); return path }, uid, "writable by group or others"},
199 {"wrong owner", func(t *testing.T, _, path string) string { return path }, uid + 1, "owned by uid"},
200 {"symlink file", func(t *testing.T, dir, path string) string {
201 link := filepath.Join(dir, "link")
202 if err := os.Symlink(path, link); err != nil {
203 t.Fatal(err)
204 }
205 return link
206 }, uid, "symbolic link"},
207 {"fifo", func(t *testing.T, dir, _ string) string {
208 p := filepath.Join(dir, "fifo")
209 if err := unix.Mkfifo(p, 0o644); err != nil {
210 t.Fatal(err)
211 }
212 return p
213 }, uid, "not a regular file"},
214 {"group writable directory", func(t *testing.T, dir, path string) string {
215 if err := os.Chmod(dir, 0o775); err != nil {
216 t.Fatal(err)
217 }
218 return path
219 }, uid, "writable by group or others"},
220 {"symlinked directory", func(t *testing.T, dir, path string) string {
221 link := filepath.Join(filepath.Dir(dir), "via")
222 if err := os.Symlink(dir, link); err != nil {
223 t.Fatal(err)
224 }
225 return filepath.Join(link, "capture-allow")
226 }, uid, "not a real directory"},
227 {"directory owned by someone else", func(t *testing.T, _, path string) string { return path }, 1, "owned by uid"},
228 {"missing ancestor", func(t *testing.T, dir, _ string) string {
229 return filepath.Join(dir, "gone", "deeper", "capture-allow")
230 }, uid, "no such file"},
231 } {
232 t.Run(tc.name, func(t *testing.T) {
233 dir, path := good(t)
234 path = tc.setup(t, dir, path)
235 p, err := load(path, tc.owner)
236 if err == nil || p.Mode() != ModeClosed || p.Allows("/usr/bin/grim") || !strings.Contains(err.Error(), tc.want) {
237 t.Fatalf("mode %v, err %v; want closed, %q", p.Mode(), err, tc.want)
238 }
239 })
240 }
241 // A sticky, world-writable ancestor (like /tmp) is fine; the directory
242 // holding the file being absent gives the built-in list.
243 t.Run("missing holding directory", func(t *testing.T) {
244 dir, _ := good(t)
245 p, err := load(filepath.Join(dir, "sub", "capture-allow"), uid)
246 if !errors.Is(err, fs.ErrNotExist) || p.Mode() != ModeList || !p.Allows("/usr/bin/grim") || p.Allows("/usr/bin/other") {
247 t.Fatalf("mode %v, err %v", p.Mode(), err)
248 }
249 })
250 t.Run("sticky directory", func(t *testing.T) {
251 dir, path := good(t)
252 if err := os.Chmod(dir, 0o777|os.ModeSticky); err != nil {
253 t.Fatal(err)
254 }
255 if p, err := load(path, uid); err != nil || p.Mode() != ModeList {
256 t.Fatalf("mode %v, err %v", p.Mode(), err)
257 }
258 })
259}
260
261// The real production check: this file is not root's, so load closes.
262func TestLoadProductionOwner(t *testing.T) {
263 if os.Getuid() == 0 {
264 t.Skip("running as root")
265 }
266 path := filepath.Join(t.TempDir(), "capture-allow")
267 mustWrite(t, path, "*\n", 0o644)
268 if p, err := load(path, 0); err == nil || p.Mode() != ModeClosed {
269 t.Fatalf("mode %v, err %v", p.Mode(), err)
270 }
271}
272
273func TestEventAction(t *testing.T) {
274 name := []byte("capture-allow\x00\x00\x00")
275 for _, tc := range []struct {
276 mask uint32
277 raw []byte
278 reload, lost bool
279 }{
280 {unix.IN_Q_OVERFLOW, nil, true, false},
281 {unix.IN_IGNORED, nil, true, true},
282 {unix.IN_CLOSE_WRITE, name, true, false},
283 {unix.IN_ATTRIB, name, true, false},
284 {unix.IN_ATTRIB, nil, true, false},
285 {unix.IN_CLOSE_WRITE, []byte("other\x00\x00\x00"), false, false},
286 } {
287 if reload, lost := eventAction(tc.mask, tc.raw, "capture-allow"); reload != tc.reload || lost != tc.lost {
288 t.Errorf("mask %#x %q = %v, %v; want %v, %v", tc.mask, tc.raw, reload, lost, tc.reload, tc.lost)
289 }
290 }
291}
292
293func TestStoreLogsUntrusted(t *testing.T) {
294 dir := t.TempDir()
295 path := filepath.Join(dir, "capture-allow")
296 mustWrite(t, path, "*\n", 0o666)
297 var out logBuf
298 s := newTestStore(path, testLog(&out))
299 if s.Policy().Allows("/any") || !strings.Contains(out.String(), `"level":"error"`) || !strings.Contains(out.String(), "writable by group or others") {
300 t.Fatalf("log = %s", out.String())
301 }
302}