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

Line attribution on main · 197 line(s)

b1499c72
Keenan Nemetz
13d ago
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
d37d301f
Keenan Nemetz
17h ago
27 "rodent.software/vole/internal/secure"
b1499c72
Keenan Nemetz
13d ago
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 }