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/access/store.go +35−30

@@ -14,16 +14,18 @@ package access
14 14 import (
15 15 "database/sql"
16 16 "fmt"
17 "github.com/rodent-software/vole/internal/secure"
18 _ "modernc.org/sqlite"
19 17 "time"
18
19 _ "modernc.org/sqlite"
20
21 "github.com/rodent-software/vole/internal/audit"
20 22 )
21 23
22 24 type Store struct {
23 25 db *sql.DB
24 // AuditPath receives one line per change. Empty means no audit sink, which is only
25 // acceptable for tests: an access change is the one administrative act that must
26 // leave a trace, since the old copy was a commit message.
26 // AuditPath receives one line per change. It is required in practice: an
27 // access change is the one administrative act that must leave a trace, so a
28 // change that cannot be recorded is refused rather than applied silently.
27 29 AuditPath string
28 30 // InstanceRole, when set, resolves an account's instance-wide role. The
29 31 // roster owns it; the store attaches it to every ACL it returns so RoleFor
@@ -91,42 +93,45 @@ func (s *Store) Set(repoID string, a *ACL, actor string, now time.Time) error {
91 93 if err != nil {
92 94 return err
93 95 }
96 // Recorded before the write, so a rule set that cannot be audited is not
97 // applied. A failed insert leaves an extra line, which is the safe direction.
98 if err := s.audit(audit.Record{Action: "acl.update", Actor: actor, Subject: repoID}); err != nil {
99 return err
100 }
94 101 if _, err := s.db.Exec(`INSERT INTO repo_acl (repo_id, acl, updated, updated_by) VALUES (?,?,?,?)
95 102 ON CONFLICT(repo_id) DO UPDATE SET acl = excluded.acl,
96 103 updated = excluded.updated, updated_by = excluded.updated_by`,
97 104 repoID, string(raw), now.UTC().Unix(), actor); err != nil {
98 105 return err
99 106 }
100 s.audit(fmt.Sprintf("acl %s updated by %s", repoID, actorOr(actor)))
101 107 return nil
102 108 }
103 109
104 // Delete forgets a repository's rules, which is what deleting the repository does.
105 func (s *Store) Delete(repoID string) error {
106 var actor string
107 _ = s.db.QueryRow(`SELECT updated_by FROM repo_acl WHERE repo_id = ?`, repoID).Scan(&actor)
108 if _, err := s.db.Exec(`DELETE FROM repo_acl WHERE repo_id = ?`, repoID); err != nil {
109 return err
110 // RecordDelete writes the audit line for a repository deletion and reports
111 // whether it was written. Deleting a repository is a two-step operation on
112 // purpose: the line is recorded, the content is removed, and only then are the
113 // rules forgotten. A deletion that cannot be recorded therefore never starts,
114 // and a removal that fails leaves the rules in place rather than a repository
115 // nobody can administer.
116 func (s *Store) RecordDelete(repoID, actor string) error {
117 var lastChangedBy string
118 _ = s.db.QueryRow(`SELECT updated_by FROM repo_acl WHERE repo_id = ?`, repoID).Scan(&lastChangedBy)
119 detail := "repository deleted"
120 if lastChangedBy != "" {
121 detail += "; last changed by " + lastChangedBy
110 122 }
111 s.audit(fmt.Sprintf("acl %s deleted with the repository (last changed by %s)", repoID, actorOr(actor)))
112 return nil
123 return s.audit(audit.Record{Action: "acl.delete", Actor: actor, Subject: repoID, Detail: detail})
113 124 }
114 125
115 // audit is best-effort: failing to write the log must not change policy.
116 func (s *Store) audit(line string) {
117 if s.AuditPath == "" {
118 return
119 }
120 f, err := secure.OpenAppend(s.AuditPath)
121 if err != nil {
122 return
123 }
124 defer f.Close()
125 fmt.Fprintf(f, "%s\t%s\n", time.Now().UTC().Format(time.RFC3339), line)
126 // Delete forgets a repository's rules, which is what deleting the repository
127 // does. RecordDelete has already recorded the change.
128 func (s *Store) Delete(repoID string) error {
129 _, err := s.db.Exec(`DELETE FROM repo_acl WHERE repo_id = ?`, repoID)
130 return err
126 131 }
127 func actorOr(actor string) string {
128 if actor == "" {
129 return "unknown"
130 }
131 return actor
132
133 // audit appends one access change. The error is returned to the caller: a rule set
134 // that cannot be audited is not changed.
135 func (s *Store) audit(r audit.Record) error {
136 return audit.Append(s.AuditPath, r)
132 137 }