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

forge/identity/identity.go +19−17

@@ -36,7 +36,6 @@ import (
36 36 "encoding/json"
37 37 "errors"
38 38 "fmt"
39 "github.com/rodent-software/vole/internal/secure"
40 39 "os"
41 40 "path/filepath"
42 41 "regexp"
@@ -48,6 +47,8 @@ import (
48 47 _ "modernc.org/sqlite"
49 48
50 49 "github.com/rodent-software/vole/forge/access"
50 "github.com/rodent-software/vole/internal/audit"
51 "github.com/rodent-software/vole/internal/secure"
51 52 "golang.org/x/crypto/ssh"
52 53 )
53 54
@@ -107,7 +108,8 @@ func (s *Snapshot) Lookup(fingerprint string) (string, bool) {
107 108 }
108 109
109 110 type Store struct {
110 // AuditPath, when set, receives one line per roster change.
111 // AuditPath receives one line per roster change. A change that cannot be
112 // recorded is refused, because this file is the only history of the roster.
111 113 AuditPath string
112 114 db *sql.DB
113 115 path string
@@ -830,13 +832,18 @@ func (s *Store) mutate(action, actor, account, detail string, fn func(tx *sql.Tx
830 832 if err := fn(tx); err != nil {
831 833 return err
832 834 }
835 // The record is written before the commit, so a roster change that cannot be
836 // recorded does not happen either. A failed commit leaves an extra line; a
837 // line nobody could write would leave a change nobody can explain.
838 if err := s.audit(action, actor, account, detail); err != nil {
839 return err
840 }
833 841 if err := tx.Commit(); err != nil {
834 842 return fmt.Errorf("identity: %w", err)
835 843 }
836 844 if err := s.Reload(); err != nil {
837 845 return err
838 846 }
839 s.audit(action, actor, account, detail)
840 847 return nil
841 848 }
842 849
@@ -1104,18 +1111,13 @@ func findTx(tx *sql.Tx, name string) (*Account, error) {
1104 1111 }
1105 1112
1106 1113 // audit appends one line describing a roster change. The database holds the
1107 // roster; this file is the only record of who changed it and when.
1108 func (s *Store) audit(action, actor, account, detail string) {
1109 if s.AuditPath == "" {
1110 return
1111 }
1112 if actor == "" {
1113 actor = "unknown"
1114 }
1115 f, err := secure.OpenAppend(s.AuditPath)
1116 if err != nil {
1117 return
1118 }
1119 fmt.Fprintf(f, "%s\t%s\t%s\t%s\t%s\n", time.Now().UTC().Format(time.RFC3339), action, actor, account, detail)
1120 f.Close()
1114 // roster; this file is the only record of who changed it and when, so a failure
1115 // to write it is returned to the caller and the change is refused.
1116 func (s *Store) audit(action, actor, account, detail string) error {
1117 return audit.Append(s.AuditPath, audit.Record{
1118 Action: action,
1119 Actor: actor,
1120 Subject: account,
1121 Detail: detail,
1122 })
1121 1123 }