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.

server/web/writes.go +11−11

@@ -56,7 +56,7 @@ func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) {
56 56 w.serverError(rw, r, "cannot create the issue: "+err.Error())
57 57 return
58 58 }
59 w.audit("web.issue.create", ctx.Name, m.Name+" "+iss.ID)
59 w.audit("web.issue.create", ctx.Name, m.Name, iss.ID)
60 60 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
61 61 w.Logger("web: issue %s left the metadata tree invalid: %v", iss.ID, problems)
62 62 }
@@ -99,7 +99,7 @@ func (w *Web) SetIssueLabels(rw http.ResponseWriter, r *http.Request) {
99 99 w.fail(rw, r, http.StatusBadRequest, "cannot update the labels: "+err.Error(), labelsView)
100 100 return
101 101 }
102 w.audit("web.issue.labels", ctx.Name, m.Name+" "+id+" "+strings.Join(labels, ","))
102 w.audit("web.issue.labels", ctx.Name, m.Name, id+" "+strings.Join(labels, ","))
103 103 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
104 104 w.Logger("web: issue labels %s left the metadata tree invalid: %v", id, problems)
105 105 }
@@ -154,7 +154,7 @@ func (w *Web) SetIssueAssignees(rw http.ResponseWriter, r *http.Request) {
154 154 w.fail(rw, r, http.StatusBadRequest, "cannot update the assignees: "+err.Error(), assigneesView)
155 155 return
156 156 }
157 w.audit("web.issue.assignees", ctx.Name, m.Name+" "+id+" "+strings.Join(assignees, ","))
157 w.audit("web.issue.assignees", ctx.Name, m.Name, id+" "+strings.Join(assignees, ","))
158 158 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
159 159 w.Logger("web: issue assignees %s left the metadata tree invalid: %v", id, problems)
160 160 }
@@ -209,7 +209,7 @@ func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) {
209 209 w.fail(rw, r, http.StatusBadRequest, "cannot approve: "+err.Error(), sidebar)
210 210 return
211 211 }
212 w.audit("web.request.approve", ctx.Name, m.Name+" "+id+" "+req.Head)
212 w.audit("web.request.approve", ctx.Name, m.Name, id+" "+req.Head)
213 213 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
214 214 w.Logger("web: approval left the request tree invalid: %v", problems)
215 215 }
@@ -295,7 +295,7 @@ func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) {
295 295 w.serverError(rw, r, "cannot open the change request: "+err.Error())
296 296 return
297 297 }
298 w.audit("web.request.create", ctx.Name, m.Name+" "+req.ID+" "+headCommit)
298 w.audit("web.request.create", ctx.Name, m.Name, req.ID+" "+headCommit)
299 299 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
300 300 w.Logger("web: request %s left the request tree invalid: %v", req.ID, problems)
301 301 }
@@ -331,7 +331,7 @@ func (w *Web) ChangeIssueState(rw http.ResponseWriter, r *http.Request) {
331 331 w.serverError(rw, r, "cannot change the issue: "+err.Error())
332 332 return
333 333 }
334 w.audit("web.issue.state", ctx.Name, m.Name+" "+id+" "+state)
334 w.audit("web.issue.state", ctx.Name, m.Name, id+" "+state)
335 335 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
336 336 w.Logger("web: issue state %s left the metadata tree invalid: %v", id, problems)
337 337 }
@@ -366,7 +366,7 @@ func (w *Web) ChangeRequestState(rw http.ResponseWriter, r *http.Request) {
366 366 w.serverError(rw, r, "cannot change the change request: "+err.Error())
367 367 return
368 368 }
369 w.audit("web.request.state", ctx.Name, m.Name+" "+id+" "+state)
369 w.audit("web.request.state", ctx.Name, m.Name, id+" "+state)
370 370 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
371 371 w.Logger("web: request state %s left the request tree invalid: %v", id, problems)
372 372 }
@@ -450,8 +450,8 @@ func (w *Web) UpdateRequestTarget(rw http.ResponseWriter, r *http.Request) {
450 450 w.serverError(rw, r, "cannot update the change request's target: "+err.Error())
451 451 return
452 452 }
453 w.audit("web.request.target", ctx.Name,
454 m.Name+" "+id+" "+req.Base+"<-"+req.HeadRef+" -> "+base+"<-"+headRef)
453 w.audit("web.request.target", ctx.Name, m.Name,
454 id+" "+req.Base+"<-"+req.HeadRef+" -> "+base+"<-"+headRef)
455 455 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
456 456 w.Logger("web: request target %s left the request tree invalid: %v", id, problems)
457 457 }
@@ -466,8 +466,8 @@ func (w *Web) landGate(m *repo.Meta, acl *access.ACL, actor string) landing.Gate
466 466 return landing.Gate{
467 467 Repo: m.Git(), Name: m.Name, ACL: acl, Actor: actor, Role: acl.RoleFor(actor),
468 468 Runs: func() (*runs.Store, error) { return w.Runs, nil },
469 Audit: func(line string) {
470 w.audit("web.request.merge", actor, m.Name+" "+line)
469 Audit: func(action, actor, subject, detail string) {
470 w.audit("web."+action, actor, subject, detail)
471 471 },
472 472 }
473 473 }