Commit 77ff123033

77ff123033d5374a8e17162f96514342049c19df

parent: ead6796abe

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:56 UTC

wiki: unkeyed audit chain, stall kill, enforced limits, pack-limit gaps

Ref #275
Ref #262

Layout: unified · split

.gitbay/wiki/Admin.org +18 −6
@@ -218,15 +218,23 @@ push=.
218218 at most =pack_concurrency= − 1 slots when it is above 1, so a signed-in
219219 client (SSH key, bearer token or web session) can always get the last.
220220 Past that an SSH client gets "the server is busy…" and exit 1, HTTP
221 gets 503 with =Retry-After: 30=, git:// an =ERR= line. A queued
222 client that disconnects leaves the queue; a running clone whose
223 client disconnects is killed. Ref listings (info/refs, protocol v2
221 gets 503 with =Retry-After: 30=, git:// an =ERR= line. An SSH client
222 that disconnects while queued leaves the queue; an HTTP or git:// one
223 keeps its place until the wait runs out. A running clone is killed
224 when its client disconnects, or when no write to the client completes
225 for two minutes: a client reading below about 550 B/s, or an HTTP
226 request body that takes over two minutes with nothing written back,
227 is cut. Ref listings (info/refs, protocol v2
224228 =ls-refs=), pushes and =repo download= are outside the budget. For the
225229 three counts 0 means the default and a negative value turns that
226230 bound off. The defaults suit a four-core host; see [[Performance]].
227231 With =ssh.mode = "system"= each SSH session is its own process and
228232 SSH clones are not counted.
229- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced.
233- =max_pack_bytes= (2 GiB) — the largest pack one push may send,
234 enforced as =receive.maxInputSize= and lowered to what an owner's
235 storage quota has left.
236- =ssh_auth_rate= (10) — SSH authentication failures per client address
237 per minute on the embedded listener; see below.
230238
231239** [git_daemon]
232240- =enabled= (false), =port= (9418) — the anonymous =git://= listener.
@@ -294,7 +302,10 @@ Each row carries the SHA-256 of the row before it. =gitbayd admin audit
294302verify= opens the store as other admin commands do, applying pending
295303migrations, so run it with the binary that matches the daemon. It
296304recomputes the chain and exits 1 naming the first row that was
297edited or whose predecessor was removed. Retention removing the oldest
305edited or whose predecessor was removed. The chain is unkeyed: whoever
306can write the database can recompute every hash after an edit, and
307verify then finds nothing. It catches an edit only when the later
308hashes were not recomputed. Retention removing the oldest
298309rows is not a break. Rows written before the chain existed are counted
299310and skipped; when every row is such a row, verify warns and exits 1,
300311since clearing the hash columns looks the same. After an upgrade that
@@ -304,7 +315,8 @@ Removing the newest rows leaves no break, and neither do rows written
304315afterwards under the freed ids. The database cannot show either. The
305316daemon logs every row it writes to its journal, outside the database
306317(=journalctl -u gitbayd -g 'INFO audit '=), and verify prints the last
307id and hash: compare them with the newest journal line. Rows written by
318id and hash: comparing them with the newest journal line is the check
319for any change, recomputed hashes included. Rows written by
308320host =gitbayd admin= commands, and by =gitbayd shell= when =ssh.mode =
309321"system"=, are not copied to the journal.
310322
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -69,7 +69,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
6969| Security-relevant writes audited | in place | every successful mutating command (=control.go=) |
7070| Authentication failures audited | in place | =auth.failed=, =auth.throttled= |
7171| Denied attempts audited | in place | refused mutating commands and pushes, ten a minute per actor, 600 in all (=internal/control/auditrefusal.go=) |
72| Audit log tamper resistance | partial | hash chain checked by =gitbayd admin audit verify=; every row the daemon writes copied to its journal; the table is writable by the daemon user, and removing the newest rows (or reusing their ids) shows only by comparing verify's last id and hash with the journal |
72| Audit log tamper resistance | partial | unkeyed hash chain checked by =gitbayd admin audit verify=; every row the daemon writes copied to its journal; the table is writable by the daemon user, who can recompute the chain after an edit, so comparing verify's last id and hash with the journal is the check for any change |
7373
7474** Communications and integrations (V9, V10, V12)
7575
.gitbay/wiki/Architecture/10-Known-Gaps.org +4 −1
@@ -19,8 +19,11 @@ what the 2026-09-27 review found; remove a row when its issue closes.
1919
2020| Area | Gap | Severity |
2121|-------+-------------------------------------------------------------------------------------------------------------+----------|
22| Audit | Removing the newest audit rows, or writing new rows under their freed ids, is not detectable from the database; only comparing =gitbayd admin audit verify='s last id and hash with the daemon's journal shows it. Rows written by =gitbayd shell= (=ssh.mode = "system"=) and host admin commands have no journal copy, and the refusal caps are per process, so under that mode each connection counts separately | low |
22| Audit | The hash chain is unkeyed, so whoever can write the database can edit a row and recompute every later hash; removing the newest audit rows, or writing new rows under their freed ids, needs no recomputing at all. Neither is detectable from the database; only comparing =gitbayd admin audit verify='s last id and hash with the daemon's journal shows it. Rows written by =gitbayd shell= (=ssh.mode = "system"=) and host admin commands have no journal copy, and the refusal caps are per process, so under that mode each connection counts separately | low |
2323| Availability | Under =ssh.mode = "system"= each SSH session is a separate =gitbayd shell= process, so the pack-generation limit (=internal/packlimit=, #262) cannot count SSH clones across sessions; only HTTP and git:// share a budget there | low |
24| Availability | Pushes have no concurrency limit; =max_pack_bytes= bounds each one, not how many run at once | medium |
25| Availability | =repo download= (SSH, API) runs =git archive= outside the pack limit; only its two-minute deadline and 512 MiB cap bound it | low |
26| Availability | An HTTP or git:// client that disconnects while queued for a pack slot keeps its place until =pack_queue_wait= runs out; only SSH notices the disconnect | low |
2427
2528* Questions an auditor will ask that have no answer yet
2629
.gitbay/wiki/Performance.org +9 −7
@@ -47,13 +47,15 @@ The practical ceiling on this hardware is concurrent pack generation:
4747full clones of large repositories are CPU-bound in git itself (the 17s
4848clone ran git at ~156% CPU). =limits.pack_concurrency= bounds how many
4949run at once across SSH, HTTP and git://, with a queue behind it (see
50[[Admin]], =[limits]=); the measurements below set its default.
50[[Admin]], =[limits]=).
5151
5252* Concurrent clones
5353
54Measured with =deploy/clonebench.sh https://gitbay.org/krz/gitbay.git <n>=
55from a machine outside bay1 (four cores), before and after the pack
56limit was deployed with its defaults (=pack_concurrency= 3,
57=pack_per_principal= 2, =pack_queue= 32, =pack_queue_wait= 60s). All
58clones in one run come from one address, so the per-principal cap
59applies to them; the "limit off" run sets the counts to -1.
54The defaults (=pack_concurrency= 3, =pack_per_principal= 2,
55=pack_queue= 32, =pack_queue_wait= 60s) are set for a four-core host
56from the single-clone figure above; no concurrent-clone measurement
57backs them yet. =deploy/clonebench.sh <clone-url> <n>= starts n full
58clones at once from a machine other than the server. Clones from one
59address count against one principal, so run it from two addresses, or
60against an instance with =pack_per_principal = -1=, or it measures the
61per-principal cap instead of the global one.
.gitbay/wiki/Threat-Model.org +13 −9
@@ -249,8 +249,10 @@ is recorded here rather than in a closed issue:
249249 escape what is there. #144 covers the missing isolation.
250250- *Timing and traffic analysis.* Token comparison is a hash index lookup
251251 by design, but nothing has been measured.
252- *Denial of service by resource exhaustion* beyond rate: large pushes,
253 pathological diffs, deep histories, zip bombs in LFS.
252- *Denial of service by resource exhaustion* beyond rate. Concurrent
253 clones, fetches and web archives are bounded by the pack limit
254 (#262); pushes are not, beyond =max_pack_bytes= on each one. Nor are
255 pathological diffs, deep histories, or zip bombs in LFS.
254256
255257A sweep is a point in time. This section says what a reader should not
256258assume has been checked.
@@ -271,13 +273,15 @@ assume has been checked.
271273 present as objects no archived ref names, and a repository's refs may
272274 be newer than the database snapshot (see [[Admin]]).
273275- The audit log lives in the database the daemon writes, so anyone with
274 the daemon user's access can change it. The hash chain makes an edited
275 or removed row show as a break under =gitbayd admin audit verify=,
276 except at the end: removing the newest rows, and writing new rows
277 under their freed ids, leaves a valid chain. Only comparing verify's
278 last id and hash with the daemon's journal copy shows it, and rows
279 written outside the daemon (=gitbayd shell= under =ssh.mode =
280 "system"=, host =gitbayd admin= commands) have no journal copy.
276 the daemon user's access can change it. The hash chain is unkeyed:
277 whoever can write the database can edit a row and recompute every
278 later hash. =gitbayd admin audit verify= catches an edited or removed
279 row only when the later hashes were not recomputed, and never catches
280 removing the newest rows or writing new rows under their freed ids.
281 Comparing verify's last id and hash with the daemon's journal copy is
282 the check for any change; rows written outside the daemon (=gitbayd
283 shell= under =ssh.mode = "system"=, host =gitbayd admin= commands)
284 have no journal copy.
281285- A global signature-verification epoch over-invalidates the cache on any
282286 trust-input change. Correct, not a leak; a performance tradeoff.
283287- A build's secrets are environment variables inside its container, so
CHANGELOG.org +10 −4
@@ -99,8 +99,11 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
9999 before it (migration 0064), and the daemon logs a copy of every row it
100100 writes to its journal. =gitbayd admin audit verify= prints the row
101101 count and the last id and hash, and exits 1 naming the first row that
102 was edited or whose predecessor was removed. Removing the newest rows
103 shows only by comparing that last id and hash with the journal (#275).
102 was edited or whose predecessor was removed. The chain is unkeyed:
103 someone who can write the database can recompute the later hashes,
104 and verify catches an edit only when they were not recomputed.
105 Comparing verify's last id and hash with the journal is the check for
106 any change, including removing the newest rows (#275).
104107- Refused mutating commands (exit 3 or 4) are audited as =refused
105108 <command>=, and refused pushes as =refused git-receive-pack=, keeping
106109 flag names and the target but no values; ten a minute per account and
@@ -228,8 +231,11 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
228231 "the server is busy…" and exits 1, HTTP gets 503 with
229232 =Retry-After: 30=, and git:// gets an =ERR= line. *Operators:* the
230233 defaults are tuned for a four-core host; set the three counts to -1
231 to turn the limit off. Ref listings, pushes and =repo download= are
232 unaffected. Under =ssh.mode = "system"= SSH clones are not counted,
234 to turn the limit off. A running clone is killed when no write to
235 its client completes for two minutes: a client reading below about
236 550 B/s, or an HTTP request body that takes over two minutes with
237 nothing written back, is cut. Ref listings, pushes and =repo
238 download= are unaffected. Under =ssh.mode = "system"= SSH clones are not counted,
233239 since each session is its own process (#262).
234240- Anonymous clones are counted per IPv4 address or IPv6 /64, and
235241 together hold at most =pack_concurrency= − 1 slots, so a signed-in