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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Make the audit log one shape that a privileged change cannot skip

b1499c729a026cfda32d8a90374b3b8391ca0090 · Keenan Nemetz · 13d ago · parent d74d4de8

Make the audit log one shape that a privileged change cannot skip

Every writer used to format its own line and open its own file, so
data/audit.log held six different column layouts and a write that failed
changed nothing. A log that can be bypassed by a full disk, or by a key
comment containing a tab, is not the security promise the docs make.

internal/audit is now the only writer: one line per action, always
timestamp, action, actor, subject, detail. Every field is escaped and
capped at 4 KiB, so a key comment, an LFS lock path, a repository name or
an admin argument cannot add a column or a record. One write per line
under O_APPEND keeps the web, the SSH server and each hook process from
interleaving, and secure.OpenAppend keeps the file at 0600 including on an
upgrade from 0644.

Privileged changes now fail closed, and they are recorded before they
happen, so a failed write means the change did not occur:

  - roster, key and instance-role changes (inside the transaction)
  - access-rule changes, and a repository deletion, which records before
    removing content and forgets the rules only afterwards
  - session creation and revocation
  - a forced LFS unlock
  - an admin command over SSH, which reports audit_unavailable
  - a push that overrides a lock, an approval, a release or history

Ordinary pushes and web actions are reported instead: their changes are
already durable in git, so a write failure is logged at error level (or on
the pusher's terminal from post-receive) rather than making a working push
look rejected.

Opening an instance now checks the log is a regular file at 0600 owned by
the service account and appendable, and refuses to start otherwise. There
is no setting that disables the log or returns it to best effort.

Regression tests cover field injection through key comments, lock paths,
admin argv and repository names; 0600 on create and on upgrade; an
unwritable sink failing an ACL change, an account change, a session, a
forced unlock, an admin command and a push override while leaving the
underlying state unchanged; concurrent appends keeping whole lines; and
startup refusing a symlinked log.

internal/audit/audit_test.go +231

@@ -0,0 +1,231 @@
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
14 "github.com/rodent-software/vole/internal/secure"
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 }