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.go +197

@@ -0,0 +1,197 @@
1 // Package audit writes the instance's audit log: one line per privileged
2 // action, in one shape, written in one place.
3 //
4 // Every writer used to format its own line and open its own file, so the log
5 // held five different column layouts and a write that failed changed nothing.
6 // A forge that tells an operator "the audit log records who changed this" has to
7 // make that true, which means one shape a parser can rely on, one escaping rule
8 // so a field cannot forge a column or a line, and an error the caller cannot
9 // mistake for success.
10 //
11 // The file itself is a secret and is handled by internal/secure: created and
12 // kept at 0600, appended with O_APPEND. Long field values are truncated, because
13 // the fields are attacker-influenced (a key comment, an LFS lock path, an admin
14 // argument) and a record is meant to identify an action, not to store a payload.
15 package audit
16
17 import (
18 "errors"
19 "fmt"
20 "os"
21 "strings"
22 "syscall"
23 "time"
24 "unicode"
25 "unicode/utf8"
26
27 "github.com/rodent-software/vole/internal/secure"
28 )
29
30 // ErrNoSink means the instance has no audit path. It is an error rather than a
31 // no-op because a privileged change must not proceed unrecorded; only a test
32 // that is not exercising the log may treat it as one.
33 var ErrNoSink = errors.New("audit: no log path configured")
34
35 // FieldLimit is how many bytes of one field are kept. The fields carry names,
36 // refs and command lines, all of which are far shorter than this; anything
37 // longer is an attempt to make one action unreadable or to fill the disk.
38 const FieldLimit = 4096
39
40 // empty is what an absent actor or subject renders as, so every line has the
41 // same number of columns whether or not a value was supplied.
42 const empty = "-"
43
44 // Record is one audit entry. Action names the operation in lower-case dotted
45 // form ("acl.update", "push.force", "session.create"); Actor is the account that
46 // performed it; Subject is what it was performed on (a repository, a ref, an
47 // account); Detail is what changed.
48 type Record struct {
49 Action string
50 Actor string
51 Subject string
52 Detail string
53 }
54
55 // Append writes one record and reports whether it was written. Callers that
56 // enforce policy must treat an error as a failure of the action, not as a
57 // warning: the whole point of this package is that a privileged change never
58 // succeeds without leaving a trace.
59 func Append(path string, r Record) error {
60 if path == "" {
61 return ErrNoSink
62 }
63 if r.Action == "" {
64 return errors.New("audit: record with no action")
65 }
66 line := Format(r, time.Now().UTC())
67 f, err := secure.OpenAppend(path)
68 if err != nil {
69 return fmt.Errorf("audit: open %s: %w", path, err)
70 }
71 // One write for the whole line: O_APPEND makes a single write atomic against
72 // another process doing the same, and the hook, the SSH server and the web
73 // all append to this file.
74 _, err = f.Write([]byte(line))
75 if err != nil {
76 f.Close()
77 return fmt.Errorf("audit: write %s: %w", path, err)
78 }
79 // A full filesystem can accept the write into the page cache and fail at
80 // close, so the close error is part of the result too.
81 if err := f.Close(); err != nil {
82 return fmt.Errorf("audit: write %s: %w", path, err)
83 }
84 return nil
85 }
86
87 // Format renders one line, without the trailing newline. Fields are escaped and
88 // truncated; an absent actor or subject becomes "-" so the column count is
89 // constant and a reader can split on tabs.
90 func Format(r Record, at time.Time) string {
91 var b strings.Builder
92 b.WriteString(at.UTC().Format(time.RFC3339))
93 b.WriteByte('\t')
94 b.WriteString(field(r.Action))
95 b.WriteByte('\t')
96 b.WriteString(orEmpty(field(r.Actor)))
97 b.WriteByte('\t')
98 b.WriteString(orEmpty(field(r.Subject)))
99 b.WriteByte('\t')
100 b.WriteString(field(r.Detail))
101 b.WriteByte('\n')
102 return b.String()
103 }
104
105 // Sanitize makes one value safe to place in a column: a tab or a newline in a
106 // key comment, a lock path or a command line would otherwise invent a column or
107 // a whole record. Control characters are replaced with a space and long values
108 // are truncated at a rune boundary; everything else, including the original
109 // bytes of an invalid sequence, is preserved.
110 func Sanitize(s string) string {
111 if s == "" {
112 return ""
113 }
114 var b strings.Builder
115 b.Grow(len(s))
116 for i := 0; i < len(s); {
117 r, size := utf8.DecodeRuneInString(s[i:])
118 switch {
119 case r == utf8.RuneError && size == 1:
120 // Not valid UTF-8; keep the byte rather than rewriting the record.
121 b.WriteByte(s[i])
122 case r == '\u2028' || r == '\u2029':
123 b.WriteByte(' ')
124 case unicode.IsControl(r):
125 b.WriteByte(' ')
126 default:
127 b.WriteString(s[i : i+size])
128 }
129 i += size
130 }
131 out := b.String()
132 if len(out) > FieldLimit {
133 out = truncate(out, FieldLimit) + " (truncated)"
134 }
135 return out
136 }
137
138 // field is the escaping every column goes through. It is a named function so a
139 // new column cannot be added without deciding to use it.
140 func field(s string) string { return Sanitize(s) }
141
142 func orEmpty(s string) string {
143 if s == "" {
144 return empty
145 }
146 return s
147 }
148
149 // truncate cuts s to at most n bytes without splitting a rune.
150 func truncate(s string, n int) string {
151 if len(s) <= n {
152 return s
153 }
154 cut := n
155 for cut > 0 && !utf8.RuneStart(s[cut]) {
156 cut--
157 }
158 return s[:cut]
159 }
160
161 // Check reports whether the audit log can be written, so an instance that cannot
162 // keep its promise refuses to start instead of discovering it during an incident.
163 // It creates the file if it is missing, tightens it to 0600, and refuses a
164 // symlink or a file owned by another account: either would let someone outside
165 // the service decide whether privileged actions are recorded.
166 //
167 // It cannot prove the disk has space; that failure is caught by Append, which
168 // callers treat as a failed action.
169 func Check(path string) error {
170 if path == "" {
171 return ErrNoSink
172 }
173 if fi, err := os.Lstat(path); err == nil && fi.Mode()&os.ModeSymlink != 0 {
174 return fmt.Errorf("audit: %s is a symlink, expected a file", path)
175 } else if err != nil && !os.IsNotExist(err) {
176 return fmt.Errorf("audit: %w", err)
177 }
178 f, err := secure.OpenAppend(path)
179 if err != nil {
180 return fmt.Errorf("audit: %w", err)
181 }
182 defer f.Close()
183 st, err := f.Stat()
184 if err != nil {
185 return fmt.Errorf("audit: %w", err)
186 }
187 if !st.Mode().IsRegular() {
188 return fmt.Errorf("audit: %s is not a regular file", path)
189 }
190 if perm := st.Mode().Perm(); perm != secure.FileMode {
191 return fmt.Errorf("audit: %s is %04o, expected %04o", path, perm, secure.FileMode)
192 }
193 if sys, ok := st.Sys().(*syscall.Stat_t); ok && int(sys.Uid) != os.Geteuid() {
194 return fmt.Errorf("audit: %s is owned by uid %d, expected %d", path, sys.Uid, os.Geteuid())
195 }
196 return nil
197 }