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.

#52

Make the audit log one shape that a privileged change cannot skip

merged · by keenan 13d ago · merged into main at b1499c72 by keenan 13d ago

keenan opened this 13d ago

Closes #6.

What was wrong

data/audit.log had six independent writers — server/web/audit.go, forge/identity, forge/session, forge/access, server/hook, server/ssh/admin.go and core/lfs/lockstore.go — with four different column layouts between them. Every one treated failure as best-effort, and two of them did not escape their fields, so a key comment or an LFS lock path containing a tab or a newline could forge a column or a whole record.

What this does

  • One writer. internal/audit renders every record as timestamp, action, actor, subject, detail; every field is escaped and capped at 4 KiB, and one write(2) per line under O_APPEND keeps concurrent processes from interleaving. secure.OpenAppend keeps the file at 0600, including an upgrade from a log created 0644.
  • Fail closed, before the change. The line is written before the mutation: roster/key/role changes (inside the transaction, before Commit), ACL changes, repository deletion, session create/revoke, forced LFS unlock, admin commands, and every push override (lock, approval, release, history). A failed write means the change did not happen, and the command says so.
  • Loud where refusal is not possible. Ordinary pushes and web actions write nothing they could roll back, so a failure is reported at error level (or on the pusher’s terminal from post-receive) instead of making a working push look rejected.
  • Startup check. Opening an instance verifies the log is a regular file at 0600, owned by the service account, and appendable, and refuses to start otherwise. A symlink is refused: it lets someone outside the service decide whether privileged actions are recorded.
  • No knob. There is no setting that turns the log off or back to best-effort, per the decision on the issue.

Log format change

This is a breaking change for anyone parsing the file, and the file is append-only, so old lines stay as they are:

before: 2026-10-27T00:00:00Z	a force-push happened
after:  2026-10-27T00:00:00Z	push.force	keenan	vole	refs/heads/vole aaaaaaaa -> bbbbbbbb

docs/operations.md#audit-log documents the columns, the fail-closed cases, and how to ship the log off the host.

Verification

  • gofmt, go vet ./..., go test -race ./... clean.
  • ./vole workflow check and ./vole workflow run verify pass.

Tests, each failing before its fix

  • internal/audit: column count, empty-field rendering, tab/newline/control injection, truncation at a rune boundary, 0600 on create and on upgrade, missing sink and missing action refused, write failure returned, 8 concurrent writers keeping 200 lines whole, Check refusing a symlink and a directory.
  • forge/access, forge/identity, forge/session, core/lfs, server/web: field injection and unwritable-sink refusals with the underlying state unchanged.
  • server/hook: an unwritable log refuses a lock override rather than granting it.
  • server: vole-admin account create fails with audit_unavailable when the log is a directory, and the account is absent; forge.Open refuses a symlinked or non-file audit path.

Discussion (0)

No discussion yet.