internal/control/auditrefusal_test.go
195 lines · 6369 bytes
1package control
2
3import (
4 "slices"
5 "strings"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func TestRefusedWritesAreAudited(t *testing.T) {
14 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
15 st, repo, _ := newQueueTestRepo(t)
16 bobID, err := st.CreateUser("bob", false)
17 if err != nil {
18 t.Fatal(err)
19 }
20 bob := store.User{ID: bobID, Username: "bob"}
21
22 c, _ := pruneCtx(st, t.TempDir(), bob)
23 if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitDenied {
24 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
25 }
26 // A refused read is not a write attempt.
27 c, _ = pruneCtx(st, t.TempDir(), bob)
28 if code := Dispatch(c, []string{"audit"}); code != protocol.ExitDenied {
29 t.Fatalf("audit: exit %d", code)
30 }
31 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused", Limit: 10})
32 if err != nil {
33 t.Fatal(err)
34 }
35 if len(got) != 1 || got[0].Action != "refused repo delete" || got[0].Actor != "bob" {
36 t.Fatalf("entries: %+v", got)
37 }
38}
39
40func TestRefusalAuditIsRateLimited(t *testing.T) {
41 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
42 st, repo, _ := newQueueTestRepo(t)
43 bobID, err := st.CreateUser("bob", false)
44 if err != nil {
45 t.Fatal(err)
46 }
47 for range refusalsPerMinute + 5 {
48 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
49 Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"})
50 }
51 refused, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 100})
52 throttled, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused.throttled", Limit: 100})
53 if len(refused) != refusalsPerMinute || len(throttled) != 1 {
54 t.Fatalf("%d refused rows, %d throttled rows", len(refused), len(throttled))
55 }
56}
57
58// The #257 refusal of a minting command under an expiring credential is
59// a refused write like any other.
60func TestExpiringMintRefusalIsAudited(t *testing.T) {
61 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
62 st, _, _ := newQueueTestRepo(t)
63 bobID, err := st.CreateUser("bob", false)
64 if err != nil {
65 t.Fatal(err)
66 }
67 exp := time.Now().Add(time.Hour)
68 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
69 c.Expires = &exp
70 if code := Dispatch(c, []string{"token", "create", "--name", "x"}); code != protocol.ExitDenied {
71 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
72 }
73 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10})
74 if err != nil {
75 t.Fatal(err)
76 }
77 if len(got) != 1 || got[0].Action != "refused token create" {
78 t.Fatalf("entries: %+v", got)
79 }
80}
81
82// A gate refuses before parseFlags, so the row must not keep a value
83// glued to its flag, a value that looks like a flag, or a positional
84// past the target.
85func TestRefusalRowKeepsNoValues(t *testing.T) {
86 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
87 st, repo, _ := newQueueTestRepo(t)
88 bobID, err := st.CreateUser("bob", false)
89 if err != nil {
90 t.Fatal(err)
91 }
92 for _, argv := range [][]string{
93 {"issue", "create", repo.Path(), "--body=hunter2"},
94 {"issue", "create", repo.Path(), "--title", "--body=hunter2"},
95 {"repo", "secret", "set", repo.Path(), "NAME", "hunter2"},
96 } {
97 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
98 c.ReadOnly = true
99 if code := Dispatch(c, argv); code != protocol.ExitDenied {
100 t.Fatalf("%q: exit %d, want %d", argv, code, protocol.ExitDenied)
101 }
102 }
103 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10})
104 if err != nil {
105 t.Fatal(err)
106 }
107 if len(got) != 3 {
108 t.Fatalf("entries: %+v", got)
109 }
110 for _, e := range got {
111 if strings.Contains(e.Data, "hunter2") || strings.Contains(e.Data, "NAME") {
112 t.Errorf("%s kept a value: %s", e.Action, e.Data)
113 }
114 if !strings.Contains(e.Data, repo.Path()) {
115 t.Errorf("%s lost its target: %s", e.Action, e.Data)
116 }
117 }
118}
119
120func TestRefusalArgs(t *testing.T) {
121 for _, tc := range []struct{ in, want []string }{
122 {[]string{"o/r", "NAME", "value"}, []string{"o/r"}},
123 {[]string{"o/r", "--body=x", "--title", "--label=y"}, []string{"o/r", "--body", "--title", "--label"}},
124 {[]string{"o/r", "--title", "t", "--", "a", "b"}, []string{"o/r", "--title"}},
125 } {
126 if got := refusalArgs(tc.in); !slices.Equal(got, tc.want) {
127 t.Errorf("refusalArgs(%q) = %q, want %q", tc.in, got, tc.want)
128 }
129 }
130}
131
132func TestRefusalLimiterWindowResets(t *testing.T) {
133 l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
134 now := time.Unix(1_000_000, 0)
135 for i := range refusalsPerMinute {
136 if v := l.allow(1, now); v != refusalRecord {
137 t.Fatalf("refusal %d: %d", i, v)
138 }
139 }
140 if v := l.allow(1, now); v != refusalThrottleActor {
141 t.Fatalf("first past the limit: %d", v)
142 }
143 if v := l.allow(1, now.Add(59*time.Second)); v != refusalDrop {
144 t.Fatalf("second past the limit: %d", v)
145 }
146 if v := l.allow(2, now); v != refusalRecord {
147 t.Fatalf("another actor: %d", v)
148 }
149 if v := l.allow(1, now.Add(time.Minute)); v != refusalRecord {
150 t.Fatalf("next minute: %d", v)
151 }
152}
153
154func TestRefusalLimiterGlobalCeiling(t *testing.T) {
155 l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
156 now := time.Unix(1_000_000, 0)
157 var rec, global int
158 for actor := range int64(refusalsPerMinuteGlobal/refusalsPerMinute + 10) {
159 for range refusalsPerMinute {
160 switch l.allow(actor, now) {
161 case refusalRecord:
162 rec++
163 case refusalThrottleGlobal:
164 global++
165 case refusalThrottleActor:
166 t.Fatal("actor throttled under its own limit")
167 }
168 }
169 }
170 if rec != refusalsPerMinuteGlobal || global != 1 {
171 t.Fatalf("%d recorded, %d global throttle rows", rec, global)
172 }
173 if v := l.allow(9999, now.Add(time.Minute)); v != refusalRecord {
174 t.Fatalf("next minute: %d", v)
175 }
176}
177
178func TestRefusalLimiterPrunes(t *testing.T) {
179 l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
180 now := time.Unix(1_000_000, 0)
181 for actor := range int64(4097) {
182 l.seen[actor] = &refusalWindow{start: now, n: 1}
183 }
184 l.allow(5000, now.Add(time.Minute))
185 if len(l.seen) != 1 {
186 t.Fatalf("%d windows after prune, want 1", len(l.seen))
187 }
188 for actor := range int64(4097) {
189 l.seen[actor] = &refusalWindow{start: now.Add(time.Minute), n: 1}
190 }
191 l.allow(6000, now.Add(time.Minute+time.Second))
192 if len(l.seen) != 4099 {
193 t.Fatalf("%d windows, want 4099: a live window was pruned", len(l.seen))
194 }
195}