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 | } | ||