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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 6.5 KiB
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.
15package audit
16
17import (
18 "errors"
19 "fmt"
20 "os"
21 "strings"
22 "syscall"
23 "time"
24 "unicode"
25 "unicode/utf8"
26
27 "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.
33var 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.
38const 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.
42const 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.
48type 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.
59func 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.
90func 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.
110func 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.
140func field(s string) string { return Sanitize(s) }
141
142func 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.
150func 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.
169func 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}
198