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 · 12d 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.

server/ssh/admin.go +40−16

@@ -8,7 +8,6 @@ import (
8 8 "encoding/json"
9 9 "errors"
10 10 "fmt"
11 "github.com/rodent-software/vole/internal/secure"
12 11 "io"
13 12 "strconv"
14 13 "strings"
@@ -19,6 +18,7 @@ import (
19 18 "github.com/rodent-software/vole/forge/access"
20 19 "github.com/rodent-software/vole/forge/identity"
21 20 "github.com/rodent-software/vole/forge/repo"
21 "github.com/rodent-software/vole/internal/audit"
22 22 )
23 23
24 24 const maxAdminKeyBytes = 1 << 20
@@ -59,6 +59,12 @@ func (s *Server) serveAdmin(ch xssh.Channel, argv []string, user, remote string)
59 59 user, remote)
60 60 }
61 61
62 // The command is recorded before it runs. An administrative change that cannot
63 // be recorded is not made: the log is the only history of the roster and the
64 // access rules, so a silent success would be a change nobody can explain.
65 if err := s.auditAdmin(user, remote, strings.Join(args, " ")); err != nil {
66 return s.adminUnavailable(ch, jsonOut, err)
67 }
62 68 data, render, err := s.runAdmin(args, user, ch)
63 69 if err != nil {
64 70 return s.adminError(ch, jsonOut, "failed", err, user, remote)
@@ -68,10 +74,23 @@ func (s *Server) serveAdmin(ch xssh.Channel, argv []string, user, remote string)
68 74 } else if render != nil {
69 75 render(ch, ch.Stderr())
70 76 }
71 s.auditAdmin(user, remote, strings.Join(args, " ")+" ok")
77 s.auditAdminOutcome(user, remote, strings.Join(args, " ")+" ok")
72 78 return 0
73 79 }
74 80
81 // adminUnavailable answers a command that was not run because it could not be
82 // recorded. It does not try to record the refusal: that is the thing that failed.
83 func (s *Server) adminUnavailable(ch xssh.Channel, jsonOut bool, err error) int {
84 if jsonOut {
85 _ = json.NewEncoder(ch).Encode(adminResponse{OK: false, Error: &adminError{
86 Code: "audit_unavailable", Message: "the audit log could not be written: " + err.Error()}})
87 } else {
88 fmt.Fprintf(ch.Stderr(), "vole: the audit log could not be written, so nothing was changed: %v\n", err)
89 }
90 s.cfg.Log.Error("admin command refused: audit log unwritable", "error", err)
91 return 1
92 }
93
75 94 func (s *Server) adminError(ch xssh.Channel, jsonOut bool, code string, err error, user, remote string) int {
76 95 exitCode := 1
77 96 if _, ok := err.(*adminUsageError); ok {
@@ -82,7 +101,7 @@ func (s *Server) adminError(ch xssh.Channel, jsonOut bool, code string, err erro
82 101 } else {
83 102 fmt.Fprintf(ch.Stderr(), "vole: %v\n", err)
84 103 }
85 s.auditAdmin(user, remote, "failed: "+err.Error())
104 s.auditAdminOutcome(user, remote, "failed: "+err.Error())
86 105 return exitCode
87 106 }
88 107
@@ -496,7 +515,7 @@ func (s *Server) adminRepo(action string, args []string, actor string) (any, adm
496 515 if len(args) != 1 {
497 516 return nil, nil, &adminUsageError{msg: "usage: vole-admin repo delete <name>"}
498 517 }
499 if err := s.cfg.Instance.DeleteRepo(args[0]); err != nil {
518 if err := s.cfg.Instance.DeleteRepo(args[0], actor); err != nil {
500 519 return nil, nil, err
501 520 }
502 521 return map[string]any{"name": args[0]}, func(out, _ io.Writer) {
@@ -878,19 +897,24 @@ func accountData(a *identity.Account) map[string]any {
878 897 }
879 898 }
880 899
881 func (s *Server) auditAdmin(user, remote, detail string) {
882 if s.cfg.AuditPath == "" {
883 return
884 }
885 // A command argument may contain a newline or tab; keep it on one audit line.
886 detail = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(detail)
887 f, err := secure.OpenAppend(s.cfg.AuditPath)
888 if err != nil {
889 return
900 // auditAdmin records one admin command and reports whether it was written. The
901 // caller refuses the command when it was not: field escaping is handled by
902 // internal/audit, so a command argument cannot forge a column or a record.
903 func (s *Server) auditAdmin(user, remote, detail string) error {
904 return audit.Append(s.cfg.AuditPath, audit.Record{
905 Action: "admin",
906 Actor: user,
907 Subject: remote,
908 Detail: detail,
909 })
910 }
911
912 // auditAdminOutcome records how a command ended. The decision it describes has
913 // already been made and applied, so this can only report its own failure.
914 func (s *Server) auditAdminOutcome(user, remote, detail string) {
915 if err := s.auditAdmin(user, remote, detail); err != nil {
916 s.cfg.Log.Error("audit log write failed", "account", user, "detail", detail, "error", err)
890 917 }
891 defer f.Close()
892 fmt.Fprintf(f, "%s\tadmin\t%s\t%s\t%s\n",
893 time.Now().UTC().Format(time.RFC3339), user, remote, detail)
894 918 }
895 919
896 920 // adminFlagSpec and parseAdminFlags are a small local flag parser for the