Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · internal/audit/audit_test.go

Line attribution on main · 231 line(s)

b1499c72
Keenan Nemetz
13d ago
1 package audit
2
3 import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12 "unicode/utf8"
13
d37d301f
Keenan Nemetz
17h ago
14 "rodent.software/vole/internal/secure"
b1499c72
Keenan Nemetz
13d ago
15 )
16
17 func TestFormatHasFiveColumns(t *testing.T) {
18 at := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
19 line := Format(Record{Action: "acl.update", Actor: "keenan", Subject: "vole", Detail: "role=admin"}, at)
20 if got, want := line, "2026-10-27T12:00:00Z\tacl.update\tkeenan\tvole\trole=admin\n"; got != want {
21 t.Fatalf("Format() = %q, want %q", got, want)
22 }
23 }
24
25 func TestFormatRendersEmptyActorAndSubject(t *testing.T) {
26 line := Format(Record{Action: "session.revoke-all", Detail: "2 session(s)"}, time.Now())
27 fields := strings.Split(strings.TrimSuffix(line, "\n"), "\t")
28 if len(fields) != 5 {
29 t.Fatalf("got %d columns in %q, want 5", len(fields), line)
30 }
31 if fields[2] != "-" || fields[3] != "-" {
32 t.Fatalf("empty actor/subject = %q/%q, want %q", fields[2], fields[3], "-")
33 }
34 }
35
36 // A key comment, an LFS lock path, an admin argument and a repository name are
37 // all user content. None of them may add a column or a second record.
38 func TestFormatEscapesFieldInjection(t *testing.T) {
39 injected := "evil\tadmin\troot\n2020-01-01T00:00:00Z\tauth.grant\troot\tall\r-carriage"
40 line := Format(Record{Action: "identity.add-key", Actor: injected, Subject: injected, Detail: injected}, time.Now())
41 if n := strings.Count(line, "\n"); n != 1 {
42 t.Fatalf("record has %d newlines, want 1:\n%q", n, line)
43 }
44 if strings.HasSuffix(line, "\n") == false {
45 t.Fatalf("record does not end with a newline: %q", line)
46 }
47 fields := strings.Split(strings.TrimSuffix(line, "\n"), "\t")
48 if len(fields) != 5 {
49 t.Fatalf("got %d columns, want 5:\n%q", len(fields), line)
50 }
51 for _, f := range fields[1:] {
52 if strings.ContainsAny(f, "\t\r\n") {
53 t.Fatalf("field %q still contains a separator", f)
54 }
55 }
56 }
57
58 func TestFormatEscapesControlCharacters(t *testing.T) {
59 line := Format(Record{Action: "x", Detail: "a\x00b\x1bc\x7fd\u009be\u2028f"}, time.Now())
60 for _, bad := range []string{"\x00", "\x1b", "\x7f", "\u009b", "\u2028"} {
61 if strings.Contains(line, bad) {
62 t.Fatalf("control character %q survived: %q", bad, line)
63 }
64 }
65 if !strings.Contains(line, "a b c d e f") {
66 t.Fatalf("control characters were not replaced with spaces: %q", line)
67 }
68 }
69
70 func TestFormatTruncatesLongFields(t *testing.T) {
71 detail := strings.Repeat("é", FieldLimit)
72 got := Sanitize(detail)
73 if !utf8.ValidString(got) {
74 t.Fatalf("truncation split a rune: %q", got)
75 }
76 if !strings.HasSuffix(got, " (truncated)") {
77 t.Fatalf("truncated field does not say so: %q", got)
78 }
79 if len(got) > FieldLimit+len(" (truncated)") {
80 t.Fatalf("truncated field is %d bytes, want at most %d", len(got), FieldLimit+len(" (truncated)"))
81 }
82 }
83
84 func TestAppendCreatesFileAtFileMode(t *testing.T) {
85 path := filepath.Join(t.TempDir(), "audit.log")
86 if err := Append(path, Record{Action: "acl.update", Actor: "keenan", Subject: "vole"}); err != nil {
87 t.Fatalf("Append: %v", err)
88 }
89 st, err := os.Stat(path)
90 if err != nil {
91 t.Fatalf("stat: %v", err)
92 }
93 if st.Mode().Perm() != secure.FileMode {
94 t.Fatalf("mode = %04o, want %04o", st.Mode().Perm(), secure.FileMode)
95 }
96 }
97
98 // An instance upgraded from a version that created the log 0644 must stop being
99 // world-readable the next time it is written, not only when it is recreated.
100 func TestAppendTightensExistingFile(t *testing.T) {
101 path := filepath.Join(t.TempDir(), "audit.log")
102 if err := os.WriteFile(path, []byte("old line\n"), 0o644); err != nil {
103 t.Fatalf("seed: %v", err)
104 }
105 if err := os.Chmod(path, 0o644); err != nil {
106 t.Fatalf("chmod: %v", err)
107 }
108 if err := Append(path, Record{Action: "acl.update"}); err != nil {
109 t.Fatalf("Append: %v", err)
110 }
111 st, err := os.Stat(path)
112 if err != nil {
113 t.Fatalf("stat: %v", err)
114 }
115 if st.Mode().Perm() != secure.FileMode {
116 t.Fatalf("mode = %04o after append, want %04o", st.Mode().Perm(), secure.FileMode)
117 }
118 }
119
120 func TestAppendRefusesMissingSinkAndAction(t *testing.T) {
121 if err := Append("", Record{Action: "acl.update"}); !errors.Is(err, ErrNoSink) {
122 t.Fatalf("Append with no path = %v, want ErrNoSink", err)
123 }
124 path := filepath.Join(t.TempDir(), "audit.log")
125 if err := Append(path, Record{}); err == nil {
126 t.Fatal("Append with no action succeeded, want an error")
127 }
128 if _, err := os.Stat(path); !os.IsNotExist(err) {
129 t.Fatalf("a refused record created %s", path)
130 }
131 }
132
133 func TestAppendReportsWriteFailure(t *testing.T) {
134 dir := t.TempDir()
135 blocker := filepath.Join(dir, "not-a-dir")
136 if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
137 t.Fatalf("seed: %v", err)
138 }
139 if err := Append(filepath.Join(blocker, "audit.log"), Record{Action: "acl.update"}); err == nil {
140 t.Fatal("Append under a non-directory succeeded, want an error")
141 }
142 }
143
144 // The web, the SSH server and every hook process append to one file. O_APPEND
145 // plus one write per line is what keeps their lines from interleaving.
146 func TestAppendKeepsConcurrentLinesWhole(t *testing.T) {
147 path := filepath.Join(t.TempDir(), "audit.log")
148 const writers, lines = 8, 25
149 var wg sync.WaitGroup
150 errs := make(chan error, writers)
151 for w := 0; w < writers; w++ {
152 wg.Add(1)
153 go func(w int) {
154 defer wg.Done()
155 for i := 0; i < lines; i++ {
156 err := Append(path, Record{
157 Action: "push.force",
158 Actor: fmt.Sprintf("acct-%d", w),
159 Subject: fmt.Sprintf("refs/heads/branch-%d", w),
160 Detail: fmt.Sprintf("line %d", i),
161 })
162 if err != nil {
163 errs <- err
164 return
165 }
166 }
167 }(w)
168 }
169 wg.Wait()
170 close(errs)
171 for err := range errs {
172 t.Fatalf("Append: %v", err)
173 }
174 raw, err := os.ReadFile(path)
175 if err != nil {
176 t.Fatalf("read: %v", err)
177 }
178 rows := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
179 if len(rows) != writers*lines {
180 t.Fatalf("got %d lines, want %d", len(rows), writers*lines)
181 }
182 for _, row := range rows {
183 cols := strings.Split(row, "\t")
184 if len(cols) != 5 {
185 t.Fatalf("interleaved line %q has %d columns", row, len(cols))
186 }
187 if _, err := time.Parse(time.RFC3339, cols[0]); err != nil {
188 t.Fatalf("line %q has no timestamp: %v", row, err)
189 }
190 }
191 }
192
193 func TestCheckCreatesAndTightens(t *testing.T) {
194 path := filepath.Join(t.TempDir(), "audit.log")
195 if err := Check(path); err != nil {
196 t.Fatalf("Check on a missing log: %v", err)
197 }
198 st, err := os.Stat(path)
199 if err != nil {
200 t.Fatalf("Check did not create the log: %v", err)
201 }
202 if st.Mode().Perm() != secure.FileMode {
203 t.Fatalf("mode = %04o, want %04o", st.Mode().Perm(), secure.FileMode)
204 }
205 if err := Check(""); !errors.Is(err, ErrNoSink) {
206 t.Fatalf("Check with no path = %v, want ErrNoSink", err)
207 }
208 }
209
210 // A symlink lets whoever can write the data directory point the audit log at
211 // /dev/null and keep every privileged change.
212 func TestCheckRefusesSymlink(t *testing.T) {
213 dir := t.TempDir()
214 target := filepath.Join(dir, "real.log")
215 if err := os.WriteFile(target, nil, 0o600); err != nil {
216 t.Fatalf("seed: %v", err)
217 }
218 link := filepath.Join(dir, "audit.log")
219 if err := os.Symlink(target, link); err != nil {
220 t.Fatalf("symlink: %v", err)
221 }
222 if err := Check(link); err == nil || !strings.Contains(err.Error(), "symlink") {
223 t.Fatalf("Check on a symlink = %v, want a symlink error", err)
224 }
225 }
226
227 func TestCheckRefusesDirectory(t *testing.T) {
228 if err := Check(t.TempDir()); err == nil {
229 t.Fatal("Check on a directory succeeded, want an error")
230 }
231 }