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.

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.

forge/session/session.go +47−32

@@ -21,11 +21,12 @@ import (
21 21 "encoding/hex"
22 22 "errors"
23 23 "fmt"
24 "github.com/rodent-software/vole/internal/secure"
25 24 "os"
26 25 "time"
27 26
28 27 _ "modernc.org/sqlite"
28
29 "github.com/rodent-software/vole/internal/audit"
29 30 )
30 31
31 32 const (
@@ -74,7 +75,9 @@ type Store struct {
74 75 Absolute time.Duration
75 76 // Now is injectable so expiry tests do not sleep.
76 77 Now func() time.Time
77 // AuditPath receives one line per session creation and revocation.
78 // AuditPath receives one line per session creation and revocation. A session
79 // is a credential, so a creation or revocation that cannot be recorded is
80 // refused rather than performed silently.
78 81 AuditPath string
79 82
80 83 db *sql.DB
@@ -142,6 +145,12 @@ func (s *Store) Create(name string, credentialID []byte, userAgent string) (*Ses
142 145 Expires: expires,
143 146 UserAgent: userAgent,
144 147 }
148 // Recorded before the row exists: a session that cannot be recorded is not
149 // issued, because a login nobody can account for is the one thing an audit
150 // log exists to prevent.
151 if err := s.audit("session.create", name, credentialDetail(credentialID)); err != nil {
152 return nil, err
153 }
145 154 // INSERT ... SELECT checks the account's status in the same statement that
146 155 // writes the row. A retire racing this create cannot leave a usable session
147 156 // behind, and Lookup checks status again on every request anyway.
@@ -160,7 +169,6 @@ SELECT ?, name, ?, ?, ?, ?, ? FROM users WHERE name=? AND status='active'`,
160 169 // Expired rows are cheap to remove and would otherwise accumulate forever on
161 170 // an instance nobody logs out of.
162 171 _, _ = s.DeleteExpired(now)
163 s.audit("session.create", name, credentialDetail(credentialID))
164 172 return sess, nil
165 173 }
166 174
@@ -230,40 +238,50 @@ func (s *Store) Revoke(token string) error {
230 238 if err != nil {
231 239 return fmt.Errorf("session: revoke: %w", err)
232 240 }
241 if err := s.audit("session.revoke", name, ""); err != nil {
242 return err
243 }
233 244 if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, hashToken(token)); err != nil {
234 245 return fmt.Errorf("session: revoke: %w", err)
235 246 }
236 s.audit("session.revoke", name, "")
237 247 return nil
238 248 }
239 249
240 250 // RevokeAccount deletes every session for an account, for the operator command
241 251 // and as a backstop after restoring an old database.
242 252 func (s *Store) RevokeAccount(name string) (int64, error) {
253 var n int64
254 _ = s.db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_name=?`, name).Scan(&n)
255 if n > 0 {
256 if err := s.audit("session.revoke-account", name, fmt.Sprintf("%d session(s)", n)); err != nil {
257 return 0, err
258 }
259 }
243 260 res, err := s.db.Exec(`DELETE FROM sessions WHERE user_name=?`, name)
244 261 if err != nil {
245 262 return 0, fmt.Errorf("session: revoke account: %w", err)
246 263 }
247 n, _ := res.RowsAffected()
248 if n > 0 {
249 s.audit("session.revoke-account", name, fmt.Sprintf("%d session(s)", n))
250 }
251 return n, nil
264 gone, _ := res.RowsAffected()
265 return gone, nil
252 266 }
253 267
254 268 // RevokeAll deletes every session on the instance. It is the recovery step after
255 269 // restoring an old users.db, where the restored rows would otherwise still name
256 270 // sessions whose cookies may exist.
257 271 func (s *Store) RevokeAll() (int64, error) {
272 var n int64
273 _ = s.db.QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&n)
274 if n > 0 {
275 if err := s.audit("session.revoke-all", "", fmt.Sprintf("%d session(s)", n)); err != nil {
276 return 0, err
277 }
278 }
258 279 res, err := s.db.Exec(`DELETE FROM sessions`)
259 280 if err != nil {
260 281 return 0, fmt.Errorf("session: revoke all: %w", err)
261 282 }
262 n, _ := res.RowsAffected()
263 if n > 0 {
264 s.audit("session.revoke-all", "-", fmt.Sprintf("%d session(s)", n))
265 }
266 return n, nil
283 gone, _ := res.RowsAffected()
284 return gone, nil
267 285 }
268 286
269 287 // RevokeCredential deletes the sessions created by one passkey. Removing the
@@ -274,15 +292,19 @@ func (s *Store) RevokeCredential(credentialID []byte) (int64, error) {
274 292 }
275 293 var name string
276 294 _ = s.db.QueryRow(`SELECT user_name FROM user_credentials WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&name)
295 var n int64
296 _ = s.db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&n)
297 if n > 0 {
298 if err := s.audit("session.revoke-credential", name, credentialDetail(credentialID)); err != nil {
299 return 0, err
300 }
301 }
277 302 res, err := s.db.Exec(`DELETE FROM sessions WHERE credential_id=?`, credentialIDDB(credentialID))
278 303 if err != nil {
279 304 return 0, fmt.Errorf("session: revoke passkey sessions: %w", err)
280 305 }
281 n, _ := res.RowsAffected()
282 if n > 0 {
283 s.audit("session.revoke-credential", name, credentialDetail(credentialID))
284 }
285 return n, nil
306 gone, _ := res.RowsAffected()
307 return gone, nil
286 308 }
287 309
288 310 // List returns an account's sessions, newest first. Expired rows are removed by
@@ -401,17 +423,10 @@ func trimToken(s string) string {
401 423 return s
402 424 }
403 425
404 func (s *Store) audit(action, account, detail string) {
405 if s.AuditPath == "" {
406 return
407 }
408 if account == "" {
409 account = "-"
410 }
411 f, err := secure.OpenAppend(s.AuditPath)
412 if err != nil {
413 return
414 }
415 fmt.Fprintf(f, "%s\t%s\t%s\t%s\n", time.Now().UTC().Format(time.RFC3339), action, account, detail)
416 f.Close()
426 func (s *Store) audit(action, account, detail string) error {
427 return audit.Append(s.AuditPath, audit.Record{
428 Action: action,
429 Actor: account,
430 Detail: detail,
431 })
417 432 }