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