audit: hash chain, refused writes audited, journal copy !499
20 files changed, +998 −26
Layout: unified · split
.gitbay/wiki/Admin.org +29 −3
| @@ -238,12 +238,38 @@ meaningful — an unverified address never produces a =verified= badge. | |||
| 238 | 238 | ||
| 239 | The audit log is the security feed (events are the product feed): every | 239 | The audit log is the security feed (events are the product feed): every |
| 240 | successful mutating command with its argv and source credential (SSH key | 240 | successful mutating command with its argv and source credential (SSH key |
| 241 | fingerprint or API), registrations, admin actions, force-pushes, and | 241 | fingerprint or API), every refused one (exit 3 or 4) as =refused |
| 242 | auth failures/throttling. Secrets never appear — they travel on stdin, | 242 | <command>=, refused pushes as =refused git-receive-pack=, registrations, |
| 243 | never in argv. | 243 | admin actions, force-pushes, and auth failures/throttling. A refusal row |
| 244 | keeps the flag names and the first positional, not the values. | ||
| 245 | Refusals are recorded up to ten a minute per account and 600 a minute | ||
| 246 | across the instance; past either, one =refused.throttled= row stands | ||
| 247 | for the rest of that minute. The caps bound the embedded listener, the | ||
| 248 | web and the API; under =ssh.mode = "system"= each =gitbayd shell= | ||
| 249 | connection counts separately. Secrets never appear — they travel on | ||
| 250 | stdin, never in argv. | ||
| 251 | |||
| 252 | Each row carries the SHA-256 of the row before it. =gitbayd admin audit | ||
| 253 | verify= opens the store as other admin commands do, applying pending | ||
| 254 | migrations, so run it with the binary that matches the daemon. It | ||
| 255 | recomputes the chain and exits 1 naming the first row that was | ||
| 256 | edited or whose predecessor was removed. Retention removing the oldest | ||
| 257 | rows is not a break. Rows written before the chain existed are counted | ||
| 258 | and skipped; when every row is such a row, verify warns and exits 1, | ||
| 259 | since clearing the hash columns looks the same. After an upgrade that | ||
| 260 | clears with the first new audit row. | ||
| 261 | |||
| 262 | Removing the newest rows leaves no break, and neither do rows written | ||
| 263 | afterwards under the freed ids. The database cannot show either. The | ||
| 264 | daemon logs every row it writes to its journal, outside the database | ||
| 265 | (=journalctl -u gitbayd -g 'INFO audit '=), and verify prints the last | ||
| 266 | id and hash: compare them with the newest journal line. Rows written by | ||
| 267 | host =gitbayd admin= commands, and by =gitbayd shell= when =ssh.mode = | ||
| 268 | "system"=, are not copied to the journal. | ||
| 244 | 269 | ||
| 245 | #+begin_src sh | 270 | #+begin_src sh |
| 246 | gitbayd admin audit [--actor u|-] [--action prefix] [--since 24h|7d|date] [--limit n] [--json] | 271 | gitbayd admin audit [--actor u|-] [--action prefix] [--since 24h|7d|date] [--limit n] [--json] |
| 272 | gitbayd admin audit verify # check the hash chain; exit 1 names the first bad row | ||
| 247 | ssh git@<host> audit ... # the same, from an admin session | 273 | ssh git@<host> audit ... # the same, from an admin session |
| 248 | ssh git@<host> admin user list [--state active|pending|disabled|admin] | 274 | ssh git@<host> admin user list [--state active|pending|disabled|admin] |
| 249 | ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions | 275 | ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions |
.gitbay/wiki/Architecture/06-Data-and-Cryptography.org +1 −1
| @@ -18,7 +18,7 @@ content (as confidential as the repository), *O* operational. | |||
| 18 | | Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | *plaintext* secrets and tokens | | 18 | | Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | *plaintext* secrets and tokens | |
| 19 | | Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue= | P | device tokens in clear | | 19 | | Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue= | P | device tokens in clear | |
| 20 | | Signatures | =commit_signatures=, =settings.key_epoch= | O | verification cache | | 20 | | Signatures | =commit_signatures=, =settings.key_epoch= | O | verification cache | |
| 21 | | Audit and feed | =audit_log=, =events= | O, P | actor ids, pruned argv, fingerprints and IPs in some audit rows | | 21 | | Audit and feed | =audit_log=, =events= | O, P | actor ids, pruned argv, fingerprints and IPs in some audit rows, a hash chain (=prev_hash=, =hash=) | |
| 22 | | Dependencies | =dep_checks=, =dep_reports= | O | | | 22 | | Dependencies | =dep_checks=, =dep_reports= | O | | |
| 23 | 23 | ||
| 24 | Outside the database: | 24 | Outside the database: |
.gitbay/wiki/Architecture/09-Controls.org +2 −2
| @@ -68,8 +68,8 @@ chapter names of OWASP ASVS 4.0 where one fits. | |||
| 68 | |---------------------------------------------+----------+------------------------------------------------------------------| | 68 | |---------------------------------------------+----------+------------------------------------------------------------------| |
| 69 | | Security-relevant writes audited | in place | every successful mutating command (=control.go=) | | 69 | | Security-relevant writes audited | in place | every successful mutating command (=control.go=) | |
| 70 | | Authentication failures audited | in place | =auth.failed=, =auth.throttled= | | 70 | | Authentication failures audited | in place | =auth.failed=, =auth.throttled= | |
| 71 | | Denied attempts audited | gap | refused commands are not recorded (#275) | | 71 | | 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 | gap | same database, writable by the daemon user (#275) | | 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 | |
| 73 | 73 | ||
| 74 | ** Communications and integrations (V9, V10, V12) | 74 | ** Communications and integrations (V9, V10, V12) |
| 75 | 75 | ||
.gitbay/wiki/Architecture/10-Known-Gaps.org +6 −1
| @@ -18,10 +18,15 @@ what the 2026-09-27 review found; remove a row when its issue closes. | |||
| 18 | | #262 | Availability | No limit on concurrent git pack generation | high | | 18 | | #262 | Availability | No limit on concurrent git pack generation | high | |
| 19 | | #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high | | 19 | | #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high | |
| 20 | | #274 | Backups | The local backup archive is not encrypted | medium | | 20 | | #274 | Backups | The local backup archive is not encrypted | medium | |
| 21 | | #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium | | ||
| 22 | | #298 | SSRF | =repo import --from= fetches without an address check | medium | | 21 | | #298 | SSRF | =repo import --from= fetches without an address check | medium | |
| 23 | | #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | | 22 | | #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | |
| 24 | 23 | ||
| 24 | * Not filed | ||
| 25 | |||
| 26 | | Area | Gap | Severity | | ||
| 27 | |-------+-------------------------------------------------------------------------------------------------------------+----------| | ||
| 28 | | 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 | | ||
| 29 | |||
| 25 | * Questions an auditor will ask that have no answer yet | 30 | * Questions an auditor will ask that have no answer yet |
| 26 | 31 | ||
| 27 | | Question | Status | | 32 | | Question | Status | |
.gitbay/wiki/Threat-Model.org +8
| @@ -233,6 +233,14 @@ assume has been checked. | |||
| 233 | - Backups are consistent per the DB-snapshot-first ordering but are not a | 233 | - Backups are consistent per the DB-snapshot-first ordering but are not a |
| 234 | single atomic snapshot; a few orphaned git objects are possible and | 234 | single atomic snapshot; a few orphaned git objects are possible and |
| 235 | harmless (see [[Admin]]). | 235 | harmless (see [[Admin]]). |
| 236 | - The audit log lives in the database the daemon writes, so anyone with | ||
| 237 | the daemon user's access can change it. The hash chain makes an edited | ||
| 238 | or removed row show as a break under =gitbayd admin audit verify=, | ||
| 239 | except at the end: removing the newest rows, and writing new rows | ||
| 240 | under their freed ids, leaves a valid chain. Only comparing verify's | ||
| 241 | last id and hash with the daemon's journal copy shows it, and rows | ||
| 242 | written outside the daemon (=gitbayd shell= under =ssh.mode = | ||
| 243 | "system"=, host =gitbayd admin= commands) have no journal copy. | ||
| 236 | - A global signature-verification epoch over-invalidates the cache on any | 244 | - A global signature-verification epoch over-invalidates the cache on any |
| 237 | trust-input change. Correct, not a leak; a performance tradeoff. | 245 | trust-input change. Correct, not a leak; a performance tradeoff. |
| 238 | - A build's secrets are environment variables inside its container, so | 246 | - A build's secrets are environment variables inside its container, so |
CHANGELOG.org +15
| @@ -54,6 +54,21 @@ must add =--scope full=. Existing tokens keep their scope. | |||
| 54 | daemon acts. *Operators:* deploy with no push in flight, since a | 54 | daemon acts. *Operators:* deploy with no push in flight, since a |
| 55 | receive-pack started by the old daemon has no token and its | 55 | receive-pack started by the old daemon has no token and its |
| 56 | post-receive will be refused by the new one (#282). | 56 | post-receive will be refused by the new one (#282). |
| 57 | - Audit rows are hash-chained, each carrying the SHA-256 of the one | ||
| 58 | before it (migration 0064), and the daemon logs a copy of every row it | ||
| 59 | writes to its journal. =gitbayd admin audit verify= prints the row | ||
| 60 | count and the last id and hash, and exits 1 naming the first row that | ||
| 61 | was edited or whose predecessor was removed. Removing the newest rows | ||
| 62 | shows only by comparing that last id and hash with the journal (#275). | ||
| 63 | - Refused mutating commands (exit 3 or 4) are audited as =refused | ||
| 64 | <command>=, and refused pushes as =refused git-receive-pack=, keeping | ||
| 65 | flag names and the target but no values; ten a minute per account and | ||
| 66 | 600 across the instance, counted per process, past which one | ||
| 67 | =refused.throttled= row stands for the rest of the minute. Under | ||
| 68 | =ssh.mode = "system"= each =gitbayd shell= connection counts | ||
| 69 | separately (#275). | ||
| 70 | - Audit retention deletes by id, up to the newest row older than the | ||
| 71 | retention, so a clock step back cannot leave a gap in the chain (#275). | ||
| 57 | 72 | ||
| 58 | * v1.36.0 — 2026-09-23 | 73 | * v1.36.0 — 2026-09-23 |
| 59 | 74 | ||
cmd/gitbayd/auditverify.go added +53
| @@ -0,0 +1,53 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/spf13/cobra" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/config" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // auditVerifyCmd recomputes the audit log's hash chain. A break names | ||
| 12 | // the first row that does not match. Rows removed from the end of the | ||
| 13 | // log, and rows written after that under the reused ids, leave no | ||
| 14 | // break: the last id and hash printed here are what an operator | ||
| 15 | // compares with the daemon's journal copy to see either. | ||
| 16 | func auditVerifyCmd() *cobra.Command { | ||
| 17 | return &cobra.Command{ | ||
| 18 | Use: "verify", | ||
| 19 | Short: "check the audit log's hash chain", | ||
| 20 | Args: cobra.NoArgs, | ||
| 21 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 22 | cfg, err := config.Load(configPath) | ||
| 23 | if err != nil { | ||
| 24 | return err | ||
| 25 | } | ||
| 26 | st, err := openStore(cfg) | ||
| 27 | if err != nil { | ||
| 28 | return err | ||
| 29 | } | ||
| 30 | defer st.Close() | ||
| 31 | res, err := st.VerifyAuditChain() | ||
| 32 | if err != nil { | ||
| 33 | return err | ||
| 34 | } | ||
| 35 | fmt.Printf("rows %d\nunchained %d\n", res.Rows, res.Unchained) | ||
| 36 | if res.BrokenAt != 0 { | ||
| 37 | return fmt.Errorf("chain broken at row %d: %s", res.BrokenAt, res.Reason) | ||
| 38 | } | ||
| 39 | // Every row unchained is what dropping and re-adding the hash | ||
| 40 | // columns leaves. It is also an upgrade from before migration | ||
| 41 | // 0064 with no row written since; the first new row clears it. | ||
| 42 | if res.Rows > 0 && res.Unchained == res.Rows { | ||
| 43 | return fmt.Errorf("no row carries a hash: the chain columns were cleared, or no audit row has been written since the upgrade") | ||
| 44 | } | ||
| 45 | if res.Last == 0 { | ||
| 46 | fmt.Println("no rows yet") | ||
| 47 | return nil | ||
| 48 | } | ||
| 49 | fmt.Printf("first %d\nlast %d\nlast hash %s\nchain intact\n", res.First, res.Last, res.LastHash) | ||
| 50 | return nil | ||
| 51 | }, | ||
| 52 | } | ||
| 53 | } | ||
cmd/gitbayd/main.go +7 −1
| @@ -140,6 +140,10 @@ func serveCmd() *cobra.Command { | |||
| 140 | return err | 140 | return err |
| 141 | } | 141 | } |
| 142 | defer st.Close() | 142 | defer st.Close() |
| 143 | // The daemon's stderr is the service journal: a copy of each | ||
| 144 | // audit row outside the database the daemon can write. Rows | ||
| 145 | // are logged at Info, which the default handler always emits. | ||
| 146 | st.AuditJournal = slog.Default() | ||
| 143 | 147 | ||
| 144 | // Regenerate hook scripts so a moved binary self-heals, then | 148 | // Regenerate hook scripts so a moved binary self-heals, then |
| 145 | // start the hook policy socket. | 149 | // start the hook policy socket. |
| @@ -406,6 +410,8 @@ func adminCmd() *cobra.Command { | |||
| 406 | ) | 410 | ) |
| 407 | configCmd := &cobra.Command{Use: "config", Short: "the configuration in effect"} | 411 | configCmd := &cobra.Command{Use: "config", Short: "the configuration in effect"} |
| 408 | configCmd.AddCommand(configShowCmd()) | 412 | configCmd.AddCommand(configShowCmd()) |
| 413 | auditCmd := hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit") | ||
| 414 | auditCmd.AddCommand(auditVerifyCmd()) | ||
| 409 | admin.AddCommand( | 415 | admin.AddCommand( |
| 410 | userCmd, | 416 | userCmd, |
| 411 | emailCmd, | 417 | emailCmd, |
| @@ -414,7 +420,7 @@ func adminCmd() *cobra.Command { | |||
| 414 | hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"), | 420 | hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"), |
| 415 | hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"), | 421 | hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"), |
| 416 | hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"), | 422 | hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"), |
| 417 | hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit"), | 423 | auditCmd, |
| 418 | backupCmd(), | 424 | backupCmd(), |
| 419 | gcCmd(), | 425 | gcCmd(), |
| 420 | adminMigrateCommitRefsCmd(), | 426 | adminMigrateCommitRefsCmd(), |
e2e/audit_test.go +47
| @@ -2,10 +2,13 @@ package e2e | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "crypto/rand" | 4 | "crypto/rand" |
| 5 | "fmt" | ||
| 5 | "os" | 6 | "os" |
| 6 | "path/filepath" | 7 | "path/filepath" |
| 7 | "strings" | 8 | "strings" |
| 8 | "testing" | 9 | "testing" |
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/store" | ||
| 9 | ) | 12 | ) |
| 10 | 13 | ||
| 11 | func TestAuditAndHardening(t *testing.T) { | 14 | func TestAuditAndHardening(t *testing.T) { |
| @@ -115,3 +118,47 @@ func TestAuditAndHardening(t *testing.T) { | |||
| 115 | t.Fatal("throttle audited more than once per window") | 118 | t.Fatal("throttle audited more than once per window") |
| 116 | } | 119 | } |
| 117 | } | 120 | } |
| 121 | |||
| 122 | // The audit log is a hash chain: gitbayd admin audit verify passes on | ||
| 123 | // an untouched log and names the first row that was edited (#275). | ||
| 124 | func TestAuditChainVerify(t *testing.T) { | ||
| 125 | t.Parallel() | ||
| 126 | inst := startInstance(t) | ||
| 127 | aliceKey := inst.newKey(t, "alice") | ||
| 128 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 129 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | ||
| 130 | t.Fatal("repo create failed") | ||
| 131 | } | ||
| 132 | if out := inst.admin(t, "admin", "audit", "verify"); !strings.Contains(out, "chain intact") { | ||
| 133 | t.Fatalf("verify: %s", out) | ||
| 134 | } | ||
| 135 | // The passthrough parent still takes its own flags. | ||
| 136 | if out := inst.admin(t, "admin", "audit", "--limit", "5"); !strings.Contains(out, "repo create") { | ||
| 137 | t.Fatalf("audit --limit: %s", out) | ||
| 138 | } | ||
| 139 | |||
| 140 | st, err := store.Open(filepath.Join(inst.root, "gitbay.db")) | ||
| 141 | if err != nil { | ||
| 142 | t.Fatal(err) | ||
| 143 | } | ||
| 144 | var id int64 | ||
| 145 | if err := st.DB.QueryRow("SELECT id FROM audit_log WHERE action = 'cmd repo create'").Scan(&id); err != nil { | ||
| 146 | t.Fatal(err) | ||
| 147 | } | ||
| 148 | if _, err := st.DB.Exec("UPDATE audit_log SET data_json = '{}' WHERE id = ?", id); err != nil { | ||
| 149 | t.Fatal(err) | ||
| 150 | } | ||
| 151 | out := inst.forgedAdminErr(t, "admin", "audit", "verify") | ||
| 152 | if !strings.Contains(out, fmt.Sprintf("chain broken at row %d:", id)) { | ||
| 153 | t.Fatalf("verify after edit: %s", out) | ||
| 154 | } | ||
| 155 | |||
| 156 | // With every hash cleared no row is chained, which is not a pass. | ||
| 157 | if _, err := st.DB.Exec("UPDATE audit_log SET prev_hash = '', hash = ''"); err != nil { | ||
| 158 | t.Fatal(err) | ||
| 159 | } | ||
| 160 | st.Close() | ||
| 161 | if out := inst.forgedAdminErr(t, "admin", "audit", "verify"); !strings.Contains(out, "no row carries a hash") { | ||
| 162 | t.Fatalf("verify with hashes cleared: %s", out) | ||
| 163 | } | ||
| 164 | } | ||
internal/control/auditrefusal.go added +95
| @@ -0,0 +1,95 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "sync" | ||
| 5 | "time" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/store" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // refusalsPerMinute bounds audit rows for refused writes per actor. A | ||
| 11 | // probe is what these rows record, and a loop of probes must not grow | ||
| 12 | // the table without bound. | ||
| 13 | const refusalsPerMinute = 10 | ||
| 14 | |||
| 15 | // refusalsPerMinuteGlobal bounds them across all actors. Registration is | ||
| 16 | // open and pending accounts reach Dispatch, so the per-actor bound alone | ||
| 17 | // scales with the number of accounts. | ||
| 18 | const refusalsPerMinuteGlobal = 600 | ||
| 19 | |||
| 20 | type refusalLimiter struct { | ||
| 21 | mu sync.Mutex | ||
| 22 | seen map[int64]*refusalWindow | ||
| 23 | global refusalWindow | ||
| 24 | } | ||
| 25 | |||
| 26 | type refusalWindow struct { | ||
| 27 | start time.Time | ||
| 28 | n int | ||
| 29 | } | ||
| 30 | |||
| 31 | var refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}} | ||
| 32 | |||
| 33 | // What to write for one refusal. | ||
| 34 | const ( | ||
| 35 | refusalDrop = iota | ||
| 36 | refusalRecord | ||
| 37 | refusalThrottleActor | ||
| 38 | refusalThrottleGlobal | ||
| 39 | ) | ||
| 40 | |||
| 41 | // allow decides what to write for this refusal. Every row written, | ||
| 42 | // throttle rows included, counts against the global bound. | ||
| 43 | func (l *refusalLimiter) allow(actor int64, now time.Time) int { | ||
| 44 | l.mu.Lock() | ||
| 45 | defer l.mu.Unlock() | ||
| 46 | if len(l.seen) > 4096 { | ||
| 47 | for k, w := range l.seen { | ||
| 48 | if now.Sub(w.start) >= time.Minute { | ||
| 49 | delete(l.seen, k) | ||
| 50 | } | ||
| 51 | } | ||
| 52 | } | ||
| 53 | w := l.seen[actor] | ||
| 54 | if w == nil || now.Sub(w.start) >= time.Minute { | ||
| 55 | w = &refusalWindow{start: now} | ||
| 56 | l.seen[actor] = w | ||
| 57 | } | ||
| 58 | w.n++ | ||
| 59 | want := refusalRecord | ||
| 60 | switch { | ||
| 61 | case w.n == refusalsPerMinute+1: | ||
| 62 | want = refusalThrottleActor | ||
| 63 | case w.n > refusalsPerMinute: | ||
| 64 | return refusalDrop | ||
| 65 | } | ||
| 66 | if now.Sub(l.global.start) >= time.Minute { | ||
| 67 | l.global = refusalWindow{start: now} | ||
| 68 | } | ||
| 69 | l.global.n++ | ||
| 70 | switch { | ||
| 71 | case l.global.n <= refusalsPerMinuteGlobal: | ||
| 72 | return want | ||
| 73 | case l.global.n == refusalsPerMinuteGlobal+1: | ||
| 74 | return refusalThrottleGlobal | ||
| 75 | } | ||
| 76 | return refusalDrop | ||
| 77 | } | ||
| 78 | |||
| 79 | // AuditRefused records a refused attempt to change something. Past the | ||
| 80 | // per-actor or the global limit it records one refused.throttled row a | ||
| 81 | // minute for that scope and drops the rest. Dispatcher tests run without | ||
| 82 | // a store. | ||
| 83 | func AuditRefused(st *store.Store, actorID int64, action string, data map[string]any) { | ||
| 84 | if st == nil { | ||
| 85 | return | ||
| 86 | } | ||
| 87 | switch refusals.allow(actorID, time.Now()) { | ||
| 88 | case refusalRecord: | ||
| 89 | st.Audit(actorID, action, data) | ||
| 90 | case refusalThrottleActor: | ||
| 91 | st.Audit(actorID, "refused.throttled", map[string]any{"scope": "actor", "limit_per_minute": refusalsPerMinute}) | ||
| 92 | case refusalThrottleGlobal: | ||
| 93 | st.Audit(actorID, "refused.throttled", map[string]any{"scope": "global", "limit_per_minute": refusalsPerMinuteGlobal}) | ||
| 94 | } | ||
| 95 | } | ||
internal/control/auditrefusal_test.go added +195
| @@ -0,0 +1,195 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "gitbay.org/gitbay/internal/protocol" | ||
| 10 | "gitbay.org/gitbay/internal/store" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func TestRefusedWritesAreAudited(t *testing.T) { | ||
| 14 | refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}} | ||
| 15 | st, repo, _ := newQueueTestRepo(t) | ||
| 16 | bobID, err := st.CreateUser("bob", false) | ||
| 17 | if err != nil { | ||
| 18 | t.Fatal(err) | ||
| 19 | } | ||
| 20 | bob := store.User{ID: bobID, Username: "bob"} | ||
| 21 | |||
| 22 | c, _ := pruneCtx(st, t.TempDir(), bob) | ||
| 23 | if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitDenied { | ||
| 24 | t.Fatalf("exit %d, want %d", code, protocol.ExitDenied) | ||
| 25 | } | ||
| 26 | // A refused read is not a write attempt. | ||
| 27 | c, _ = pruneCtx(st, t.TempDir(), bob) | ||
| 28 | if code := Dispatch(c, []string{"audit"}); code != protocol.ExitDenied { | ||
| 29 | t.Fatalf("audit: exit %d", code) | ||
| 30 | } | ||
| 31 | got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused", Limit: 10}) | ||
| 32 | if err != nil { | ||
| 33 | t.Fatal(err) | ||
| 34 | } | ||
| 35 | if len(got) != 1 || got[0].Action != "refused repo delete" || got[0].Actor != "bob" { | ||
| 36 | t.Fatalf("entries: %+v", got) | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | func TestRefusalAuditIsRateLimited(t *testing.T) { | ||
| 41 | refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}} | ||
| 42 | st, repo, _ := newQueueTestRepo(t) | ||
| 43 | bobID, err := st.CreateUser("bob", false) | ||
| 44 | if err != nil { | ||
| 45 | t.Fatal(err) | ||
| 46 | } | ||
| 47 | for range refusalsPerMinute + 5 { | ||
| 48 | c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"}) | ||
| 49 | Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}) | ||
| 50 | } | ||
| 51 | refused, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 100}) | ||
| 52 | throttled, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused.throttled", Limit: 100}) | ||
| 53 | if len(refused) != refusalsPerMinute || len(throttled) != 1 { | ||
| 54 | t.Fatalf("%d refused rows, %d throttled rows", len(refused), len(throttled)) | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | // The #257 refusal of a minting command under an expiring credential is | ||
| 59 | // a refused write like any other. | ||
| 60 | func TestExpiringMintRefusalIsAudited(t *testing.T) { | ||
| 61 | refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}} | ||
| 62 | st, _, _ := newQueueTestRepo(t) | ||
| 63 | bobID, err := st.CreateUser("bob", false) | ||
| 64 | if err != nil { | ||
| 65 | t.Fatal(err) | ||
| 66 | } | ||
| 67 | exp := time.Now().Add(time.Hour) | ||
| 68 | c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"}) | ||
| 69 | c.Expires = &exp | ||
| 70 | if code := Dispatch(c, []string{"token", "create", "--name", "x"}); code != protocol.ExitDenied { | ||
| 71 | t.Fatalf("exit %d, want %d", code, protocol.ExitDenied) | ||
| 72 | } | ||
| 73 | got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10}) | ||
| 74 | if err != nil { | ||
| 75 | t.Fatal(err) | ||
| 76 | } | ||
| 77 | if len(got) != 1 || got[0].Action != "refused token create" { | ||
| 78 | t.Fatalf("entries: %+v", got) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | // A gate refuses before parseFlags, so the row must not keep a value | ||
| 83 | // glued to its flag, a value that looks like a flag, or a positional | ||
| 84 | // past the target. | ||
| 85 | func TestRefusalRowKeepsNoValues(t *testing.T) { | ||
| 86 | refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}} | ||
| 87 | st, repo, _ := newQueueTestRepo(t) | ||
| 88 | bobID, err := st.CreateUser("bob", false) | ||
| 89 | if err != nil { | ||
| 90 | t.Fatal(err) | ||
| 91 | } | ||
| 92 | for _, argv := range [][]string{ | ||
| 93 | {"issue", "create", repo.Path(), "--body=hunter2"}, | ||
| 94 | {"issue", "create", repo.Path(), "--title", "--body=hunter2"}, | ||
| 95 | {"repo", "secret", "set", repo.Path(), "NAME", "hunter2"}, | ||
| 96 | } { | ||
| 97 | c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"}) | ||
| 98 | c.ReadOnly = true | ||
| 99 | if code := Dispatch(c, argv); code != protocol.ExitDenied { | ||
| 100 | t.Fatalf("%q: exit %d, want %d", argv, code, protocol.ExitDenied) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10}) | ||
| 104 | if err != nil { | ||
| 105 | t.Fatal(err) | ||
| 106 | } | ||
| 107 | if len(got) != 3 { | ||
| 108 | t.Fatalf("entries: %+v", got) | ||
| 109 | } | ||
| 110 | for _, e := range got { | ||
| 111 | if strings.Contains(e.Data, "hunter2") || strings.Contains(e.Data, "NAME") { | ||
| 112 | t.Errorf("%s kept a value: %s", e.Action, e.Data) | ||
| 113 | } | ||
| 114 | if !strings.Contains(e.Data, repo.Path()) { | ||
| 115 | t.Errorf("%s lost its target: %s", e.Action, e.Data) | ||
| 116 | } | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | func TestRefusalArgs(t *testing.T) { | ||
| 121 | for _, tc := range []struct{ in, want []string }{ | ||
| 122 | {[]string{"o/r", "NAME", "value"}, []string{"o/r"}}, | ||
| 123 | {[]string{"o/r", "--body=x", "--title", "--label=y"}, []string{"o/r", "--body", "--title", "--label"}}, | ||
| 124 | {[]string{"o/r", "--title", "t", "--", "a", "b"}, []string{"o/r", "--title"}}, | ||
| 125 | } { | ||
| 126 | if got := refusalArgs(tc.in); !slices.Equal(got, tc.want) { | ||
| 127 | t.Errorf("refusalArgs(%q) = %q, want %q", tc.in, got, tc.want) | ||
| 128 | } | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | func TestRefusalLimiterWindowResets(t *testing.T) { | ||
| 133 | l := &refusalLimiter{seen: map[int64]*refusalWindow{}} | ||
| 134 | now := time.Unix(1_000_000, 0) | ||
| 135 | for i := range refusalsPerMinute { | ||
| 136 | if v := l.allow(1, now); v != refusalRecord { | ||
| 137 | t.Fatalf("refusal %d: %d", i, v) | ||
| 138 | } | ||
| 139 | } | ||
| 140 | if v := l.allow(1, now); v != refusalThrottleActor { | ||
| 141 | t.Fatalf("first past the limit: %d", v) | ||
| 142 | } | ||
| 143 | if v := l.allow(1, now.Add(59*time.Second)); v != refusalDrop { | ||
| 144 | t.Fatalf("second past the limit: %d", v) | ||
| 145 | } | ||
| 146 | if v := l.allow(2, now); v != refusalRecord { | ||
| 147 | t.Fatalf("another actor: %d", v) | ||
| 148 | } | ||
| 149 | if v := l.allow(1, now.Add(time.Minute)); v != refusalRecord { | ||
| 150 | t.Fatalf("next minute: %d", v) | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 154 | func TestRefusalLimiterGlobalCeiling(t *testing.T) { | ||
| 155 | l := &refusalLimiter{seen: map[int64]*refusalWindow{}} | ||
| 156 | now := time.Unix(1_000_000, 0) | ||
| 157 | var rec, global int | ||
| 158 | for actor := range int64(refusalsPerMinuteGlobal/refusalsPerMinute + 10) { | ||
| 159 | for range refusalsPerMinute { | ||
| 160 | switch l.allow(actor, now) { | ||
| 161 | case refusalRecord: | ||
| 162 | rec++ | ||
| 163 | case refusalThrottleGlobal: | ||
| 164 | global++ | ||
| 165 | case refusalThrottleActor: | ||
| 166 | t.Fatal("actor throttled under its own limit") | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | ||
| 170 | if rec != refusalsPerMinuteGlobal || global != 1 { | ||
| 171 | t.Fatalf("%d recorded, %d global throttle rows", rec, global) | ||
| 172 | } | ||
| 173 | if v := l.allow(9999, now.Add(time.Minute)); v != refusalRecord { | ||
| 174 | t.Fatalf("next minute: %d", v) | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 178 | func TestRefusalLimiterPrunes(t *testing.T) { | ||
| 179 | l := &refusalLimiter{seen: map[int64]*refusalWindow{}} | ||
| 180 | now := time.Unix(1_000_000, 0) | ||
| 181 | for actor := range int64(4097) { | ||
| 182 | l.seen[actor] = &refusalWindow{start: now, n: 1} | ||
| 183 | } | ||
| 184 | l.allow(5000, now.Add(time.Minute)) | ||
| 185 | if len(l.seen) != 1 { | ||
| 186 | t.Fatalf("%d windows after prune, want 1", len(l.seen)) | ||
| 187 | } | ||
| 188 | for actor := range int64(4097) { | ||
| 189 | l.seen[actor] = &refusalWindow{start: now.Add(time.Minute), n: 1} | ||
| 190 | } | ||
| 191 | l.allow(6000, now.Add(time.Minute+time.Second)) | ||
| 192 | if len(l.seen) != 4099 { | ||
| 193 | t.Fatalf("%d windows, want 4099: a live window was pruned", len(l.seen)) | ||
| 194 | } | ||
| 195 | } | ||
internal/control/control.go +43 −10
| @@ -162,6 +162,23 @@ func Dispatch(c *Ctx, argv []string) int { | |||
| 162 | args = append(args, a) | 162 | args = append(args, a) |
| 163 | } | 163 | } |
| 164 | c.Argv = args | 164 | c.Argv = args |
| 165 | code := runChecked(c, cmd, args) | ||
| 166 | if !cmd.ReadOnly { | ||
| 167 | switch code { | ||
| 168 | case protocol.ExitOK: | ||
| 169 | // Every successful mutating command lands in the audit log. | ||
| 170 | c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{"argv": auditArgs(args), "source": c.Source}) | ||
| 171 | case protocol.ExitDenied, protocol.ExitNotFound: | ||
| 172 | // So does every refused one: probing leaves a trace. | ||
| 173 | AuditRefused(c.Store, c.User.ID, "refused "+joinPath(cmd.Path), | ||
| 174 | map[string]any{"argv": refusalArgs(args), "source": c.Source, "exit": code}) | ||
| 175 | } | ||
| 176 | } | ||
| 177 | return code | ||
| 178 | } | ||
| 179 | |||
| 180 | // runChecked applies the dispatcher's own gates, then runs the command. | ||
| 181 | func runChecked(c *Ctx, cmd Command, args []string) int { | ||
| 165 | // A runner-scoped key reaches the runner protocol and nothing else, so | 182 | // A runner-scoped key reaches the runner protocol and nothing else, so |
| 166 | // the key a CI host holds cannot administer the instance. | 183 | // the key a CI host holds cannot administer the instance. |
| 167 | if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { | 184 | if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { |
| @@ -195,15 +212,7 @@ func Dispatch(c *Ctx, argv []string) int { | |||
| 195 | if !cmd.ReadsStdin { | 212 | if !cmd.ReadsStdin { |
| 196 | c.Stdin = emptyReader{} | 213 | c.Stdin = emptyReader{} |
| 197 | } | 214 | } |
| 198 | code := cmd.Run(c, args) | 215 | return cmd.Run(c, args) |
| 199 | // Every successful mutating command lands in the audit log. | ||
| 200 | if code == protocol.ExitOK && !cmd.ReadOnly { | ||
| 201 | c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{ | ||
| 202 | "argv": auditArgs(args), | ||
| 203 | "source": c.Source, | ||
| 204 | }) | ||
| 205 | } | ||
| 206 | return code | ||
| 207 | } | 216 | } |
| 208 | 217 | ||
| 209 | // auditArgs is argv with flag values dropped. Secrets never reach argv — | 218 | // auditArgs is argv with flag values dropped. Secrets never reach argv — |
| @@ -220,7 +229,10 @@ func auditArgs(args []string) []string { | |||
| 220 | out = append(out, a) | 229 | out = append(out, a) |
| 221 | continue | 230 | continue |
| 222 | } | 231 | } |
| 223 | out = append(out, a) | 232 | // A gate refuses before parseFlags runs, so a "--name=value" |
| 233 | // token reaches here whole; only the name is kept. | ||
| 234 | name, _, _ := strings.Cut(a, "=") | ||
| 235 | out = append(out, name) | ||
| 224 | // "--" ends flag parsing; everything after it is positional. | 236 | // "--" ends flag parsing; everything after it is positional. |
| 225 | if a == "--" { | 237 | if a == "--" { |
| 226 | out = append(out, args[i+1:]...) | 238 | out = append(out, args[i+1:]...) |
| @@ -236,6 +248,27 @@ func auditArgs(args []string) []string { | |||
| 236 | return out | 248 | return out |
| 237 | } | 249 | } |
| 238 | 250 | ||
| 251 | // refusalArgs is what a refusal row keeps of argv: the flag names and the | ||
| 252 | // first positional, which names the target. A gate refuses before the | ||
| 253 | // handler checks its arguments, so later positionals may be anything the | ||
| 254 | // caller typed, a value meant for stdin included. | ||
| 255 | func refusalArgs(args []string) []string { | ||
| 256 | out := []string{} | ||
| 257 | target := false | ||
| 258 | for _, a := range auditArgs(args) { | ||
| 259 | if a == "--" { | ||
| 260 | break | ||
| 261 | } | ||
| 262 | if strings.HasPrefix(a, "--") { | ||
| 263 | out = append(out, a) | ||
| 264 | } else if !target { | ||
| 265 | out = append(out, a) | ||
| 266 | target = true | ||
| 267 | } | ||
| 268 | } | ||
| 269 | return out | ||
| 270 | } | ||
| 271 | |||
| 239 | // pendingAllowed lists what an unverified self-registered account may do. | 272 | // pendingAllowed lists what an unverified self-registered account may do. |
| 240 | // limitWrites spends one token of the account's write budget, and refuses | 273 | // limitWrites spends one token of the account's write budget, and refuses |
| 241 | // with the wait when it is empty. Returns -1 when the command may run. | 274 | // with the wait when it is empty. Returns -1 when the command may run. |
internal/sshd/refusal_test.go added +68
| @@ -0,0 +1,68 @@ | |||
| 1 | package sshd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "gitbay.org/gitbay/internal/config" | ||
| 10 | "gitbay.org/gitbay/internal/control" | ||
| 11 | "gitbay.org/gitbay/internal/protocol" | ||
| 12 | "gitbay.org/gitbay/internal/store" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // execFixture: alice owns the public alice/app; bob has no grant on it. | ||
| 16 | func execFixture(t *testing.T) (config.Config, *store.Store, store.User) { | ||
| 17 | t.Helper() | ||
| 18 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | ||
| 19 | if err != nil { | ||
| 20 | t.Fatal(err) | ||
| 21 | } | ||
| 22 | t.Cleanup(func() { st.Close() }) | ||
| 23 | if err := st.MigrateUp(); err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | alice, err := st.CreateUser("alice", false) | ||
| 27 | if err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | if _, err := st.CreateRepo("user", alice, "app", "public"); err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | bobID, err := st.CreateUser("bob", false) | ||
| 34 | if err != nil { | ||
| 35 | t.Fatal(err) | ||
| 36 | } | ||
| 37 | bob, err := st.UserByID(bobID) | ||
| 38 | if err != nil { | ||
| 39 | t.Fatal(err) | ||
| 40 | } | ||
| 41 | cfg := config.Default() | ||
| 42 | cfg.Server.Root = t.TempDir() | ||
| 43 | return cfg, st, bob | ||
| 44 | } | ||
| 45 | |||
| 46 | // A refused push leaves one row holding the target, the key and the exit | ||
| 47 | // code, whether runGit refused it or the account is not yet active. | ||
| 48 | func TestRefusedPushIsAudited(t *testing.T) { | ||
| 49 | for _, pending := range []bool{false, true} { | ||
| 50 | cfg, st, bob := execFixture(t) | ||
| 51 | bob.Pending = pending | ||
| 52 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} | ||
| 53 | var out, errOut bytes.Buffer | ||
| 54 | code := Exec(cfg, st, bob, key, control.Term{}, "git-receive-pack alice/app", | ||
| 55 | strings.NewReader(""), &out, &errOut, nil, nil, nil) | ||
| 56 | if code != protocol.ExitDenied { | ||
| 57 | t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String()) | ||
| 58 | } | ||
| 59 | got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused git-receive-pack", Limit: 5}) | ||
| 60 | if err != nil || len(got) != 1 || got[0].Actor != "bob" { | ||
| 61 | t.Fatalf("pending %v: entries %+v, %v", pending, got, err) | ||
| 62 | } | ||
| 63 | want := `{"argv":["alice/app"],"exit":4,"source":"SHA256:test"}` | ||
| 64 | if got[0].Data != want { | ||
| 65 | t.Fatalf("pending %v: data %s, want %s", pending, got[0].Data, want) | ||
| 66 | } | ||
| 67 | } | ||
| 68 | } | ||
internal/sshd/sshd.go +11 −2
| @@ -466,11 +466,20 @@ func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, | |||
| 466 | if len(argv) > 0 { | 466 | if len(argv) > 0 { |
| 467 | switch argv[0] { | 467 | switch argv[0] { |
| 468 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": | 468 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": |
| 469 | code := protocol.ExitDenied | ||
| 469 | if user.Pending { | 470 | if user.Pending { |
| 470 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") | 471 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") |
| 471 | return protocol.ExitDenied | 472 | } else { |
| 473 | code = runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked) | ||
| 474 | } | ||
| 475 | // A refused push is a refused write, audited like one. runGit | ||
| 476 | // refuses only with the path as the one argument, so argv[1:] | ||
| 477 | // holds no value beyond the target. | ||
| 478 | if argv[0] == "git-receive-pack" && (code == protocol.ExitDenied || code == protocol.ExitNotFound) { | ||
| 479 | control.AuditRefused(st, user.ID, "refused git-receive-pack", | ||
| 480 | map[string]any{"argv": argv[1:], "source": key.Fingerprint, "exit": code}) | ||
| 472 | } | 481 | } |
| 473 | return runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked) | 482 | return code |
| 474 | case "git-lfs-authenticate": | 483 | case "git-lfs-authenticate": |
| 475 | // Part of the git transport, not the control plane: usable by | 484 | // Part of the git transport, not the control plane: usable by |
| 476 | // git-scoped and deploy keys, with the transports' access rules. | 485 | // git-scoped and deploy keys, with the transports' access rules. |
internal/store/audit.go +128 −5
| @@ -1,21 +1,144 @@ | |||
| 1 | package store | 1 | package store |
| 2 | 2 | ||
| 3 | import "encoding/json" | 3 | import ( |
| 4 | "crypto/sha256" | ||
| 5 | "database/sql" | ||
| 6 | "encoding/hex" | ||
| 7 | "encoding/json" | ||
| 8 | "errors" | ||
| 9 | "time" | ||
| 10 | ) | ||
| 4 | 11 | ||
| 5 | // Audit appends to the security feed. Events are the product feed; this | 12 | // Audit appends to the security feed. Events are the product feed; this |
| 6 | // records who did what, from where, for an operator. actorID 0 means the | 13 | // records who did what, from where, for an operator. actorID 0 means the |
| 7 | // host admin (gitbayd admin commands) or an unauthenticated source. | 14 | // host admin (gitbayd admin commands) or an unauthenticated source. |
| 8 | func (s *Store) Audit(actorID int64, action string, data map[string]any) { | 15 | func (s *Store) Audit(actorID int64, action string, data map[string]any) { |
| 16 | raw, err := json.Marshal(data) | ||
| 17 | if err != nil { | ||
| 18 | raw = []byte("{}") | ||
| 19 | } | ||
| 20 | id, createdAt, hash, err := s.appendAudit(actorID, action, string(raw)) | ||
| 21 | if s.AuditJournal == nil { | ||
| 22 | return | ||
| 23 | } | ||
| 24 | if err != nil { | ||
| 25 | s.AuditJournal.Error("audit: append", "action", action, "err", err) | ||
| 26 | return | ||
| 27 | } | ||
| 28 | s.AuditJournal.Info("audit", "id", id, "actor", actorID, "action", action, | ||
| 29 | "data", string(raw), "created_at", createdAt, "hash", hash) | ||
| 30 | } | ||
| 31 | |||
| 32 | // appendAudit writes one row and its chain hash in one transaction. The | ||
| 33 | // store begins every transaction IMMEDIATE, so two writers — the daemon | ||
| 34 | // and a gitbayd admin command, say — cannot both read the same last | ||
| 35 | // hash. The timestamp is taken once the write lock is held, so created_at | ||
| 36 | // rises with id unless the clock steps back. | ||
| 37 | func (s *Store) appendAudit(actorID int64, action, data string) (int64, string, string, error) { | ||
| 38 | tx, err := s.DB.Begin() | ||
| 39 | if err != nil { | ||
| 40 | return 0, "", "", err | ||
| 41 | } | ||
| 42 | defer tx.Rollback() | ||
| 43 | var prev string | ||
| 44 | err = tx.QueryRow("SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prev) | ||
| 45 | if err != nil && !errors.Is(err, sql.ErrNoRows) { | ||
| 46 | return 0, "", "", err | ||
| 47 | } | ||
| 9 | var actor any | 48 | var actor any |
| 10 | if actorID != 0 { | 49 | if actorID != 0 { |
| 11 | actor = actorID | 50 | actor = actorID |
| 12 | } | 51 | } |
| 13 | raw, err := json.Marshal(data) | 52 | createdAt := fmtTime(time.Now()) |
| 53 | res, err := tx.Exec( | ||
| 54 | "INSERT INTO audit_log (actor_id, actor_ref, action, data_json, created_at, prev_hash) VALUES (?, ?, ?, ?, ?, ?)", | ||
| 55 | actor, actorID, action, data, createdAt, prev) | ||
| 14 | if err != nil { | 56 | if err != nil { |
| 15 | raw = []byte("{}") | 57 | return 0, "", "", err |
| 58 | } | ||
| 59 | id, err := res.LastInsertId() | ||
| 60 | if err != nil { | ||
| 61 | return 0, "", "", err | ||
| 62 | } | ||
| 63 | hash := auditHash(prev, id, actorID, action, createdAt, data) | ||
| 64 | if _, err := tx.Exec("UPDATE audit_log SET hash = ? WHERE id = ?", hash, id); err != nil { | ||
| 65 | return 0, "", "", err | ||
| 66 | } | ||
| 67 | return id, createdAt, hash, tx.Commit() | ||
| 68 | } | ||
| 69 | |||
| 70 | // auditHash covers every column an operator reads, plus the previous | ||
| 71 | // row's hash. A JSON array keeps field boundaries unambiguous. | ||
| 72 | func auditHash(prev string, id, actor int64, action, createdAt, data string) string { | ||
| 73 | b, _ := json.Marshal([]any{prev, id, actor, action, createdAt, data}) | ||
| 74 | sum := sha256.Sum256(b) | ||
| 75 | return hex.EncodeToString(sum[:]) | ||
| 76 | } | ||
| 77 | |||
| 78 | // AuditChain is what VerifyAuditChain found. | ||
| 79 | type AuditChain struct { | ||
| 80 | Rows int // rows read | ||
| 81 | Unchained int // rows from before migration 0064, which carry no hash | ||
| 82 | First int64 // first chained row; with no unchained rows before it, its prev_hash is taken as given, since retention may have removed the row it names | ||
| 83 | Last int64 | ||
| 84 | LastHash string | ||
| 85 | BrokenAt int64 // 0 when the chain is intact | ||
| 86 | Reason string | ||
| 87 | } | ||
| 88 | |||
| 89 | // VerifyAuditChain recomputes every row's hash in id order and stops at | ||
| 90 | // the first row that does not match. Rows removed from the end of the | ||
| 91 | // table cannot be detected from the database, nor can new rows written | ||
| 92 | // after that under the reused ids (id is not AUTOINCREMENT); the journal | ||
| 93 | // copy is the record that shows either. | ||
| 94 | func (s *Store) VerifyAuditChain() (AuditChain, error) { | ||
| 95 | rows, err := s.DB.Query(`SELECT id, actor_id, actor_ref, action, data_json, created_at, prev_hash, hash | ||
| 96 | FROM audit_log ORDER BY id`) | ||
| 97 | if err != nil { | ||
| 98 | return AuditChain{}, err | ||
| 99 | } | ||
| 100 | defer rows.Close() | ||
| 101 | var res AuditChain | ||
| 102 | for rows.Next() { | ||
| 103 | var ( | ||
| 104 | id, actor int64 | ||
| 105 | actorID sql.NullInt64 | ||
| 106 | action, data, createdAt, prev, hash string | ||
| 107 | ) | ||
| 108 | if err := rows.Scan(&id, &actorID, &actor, &action, &data, &createdAt, &prev, &hash); err != nil { | ||
| 109 | return res, err | ||
| 110 | } | ||
| 111 | res.Rows++ | ||
| 112 | switch { | ||
| 113 | case hash == "" && res.First == 0: | ||
| 114 | res.Unchained++ | ||
| 115 | continue | ||
| 116 | case hash == "": | ||
| 117 | res.BrokenAt, res.Reason = id, "row has no hash after the chain began" | ||
| 118 | // The first row appended after migration 0064 names the last | ||
| 119 | // unchained row's empty hash. Retention removes the oldest rows | ||
| 120 | // first, so unchained rows before a chained one with a non-empty | ||
| 121 | // prev_hash had their hashes blanked. | ||
| 122 | case res.First == 0 && res.Unchained > 0 && prev != "": | ||
| 123 | res.BrokenAt, res.Reason = id, "chained rows before it lost their hashes" | ||
| 124 | case res.First != 0 && prev != res.LastHash: | ||
| 125 | res.BrokenAt, res.Reason = id, "previous hash does not match: a row before it was removed or changed" | ||
| 126 | case auditHash(prev, id, actor, action, createdAt, data) != hash: | ||
| 127 | res.BrokenAt, res.Reason = id, "row contents do not match its hash" | ||
| 128 | // actor_id is not hashed; it may only be the actor_ref written | ||
| 129 | // with the row, or NULL once that account is deleted. | ||
| 130 | case actorID.Valid && actorID.Int64 != actor: | ||
| 131 | res.BrokenAt, res.Reason = id, "actor_id does not match the actor the row was written with" | ||
| 132 | } | ||
| 133 | if res.BrokenAt != 0 { | ||
| 134 | return res, nil | ||
| 135 | } | ||
| 136 | if res.First == 0 { | ||
| 137 | res.First = id | ||
| 138 | } | ||
| 139 | res.Last, res.LastHash = id, hash | ||
| 16 | } | 140 | } |
| 17 | s.DB.Exec("INSERT INTO audit_log (actor_id, action, data_json) VALUES (?, ?, ?)", | 141 | return res, rows.Err() |
| 18 | actor, action, string(raw)) | ||
| 19 | } | 142 | } |
| 20 | 143 | ||
| 21 | type AuditEntry struct { | 144 | type AuditEntry struct { |
internal/store/auditchain_test.go added +269
| @@ -0,0 +1,269 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "fmt" | ||
| 7 | "log/slog" | ||
| 8 | "strings" | ||
| 9 | "sync" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func chainStore(t *testing.T) *Store { | ||
| 15 | t.Helper() | ||
| 16 | s := open(t) | ||
| 17 | if err := s.MigrateUp(); err != nil { | ||
| 18 | t.Fatal(err) | ||
| 19 | } | ||
| 20 | return s | ||
| 21 | } | ||
| 22 | |||
| 23 | func TestAuditChainIntact(t *testing.T) { | ||
| 24 | s := chainStore(t) | ||
| 25 | s.Audit(0, "a", map[string]any{"n": 1}) | ||
| 26 | s.Audit(0, "b", nil) | ||
| 27 | s.Audit(0, "c", map[string]any{"n": 3}) | ||
| 28 | res, err := s.VerifyAuditChain() | ||
| 29 | if err != nil { | ||
| 30 | t.Fatal(err) | ||
| 31 | } | ||
| 32 | if res.Rows != 3 || res.BrokenAt != 0 || res.First != 1 || res.Last != 3 || len(res.LastHash) != 64 { | ||
| 33 | t.Fatalf("%+v", res) | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | // Concurrent writers each read the last hash and insert in one | ||
| 38 | // transaction; none may chain to a predecessor another already took. | ||
| 39 | func TestAuditChainConcurrentWriters(t *testing.T) { | ||
| 40 | s := chainStore(t) | ||
| 41 | const writers, each = 8, 25 | ||
| 42 | var wg sync.WaitGroup | ||
| 43 | for w := range writers { | ||
| 44 | wg.Add(1) | ||
| 45 | go func() { | ||
| 46 | defer wg.Done() | ||
| 47 | for i := range each { | ||
| 48 | s.Audit(0, fmt.Sprintf("w%d-%d", w, i), nil) | ||
| 49 | } | ||
| 50 | }() | ||
| 51 | } | ||
| 52 | wg.Wait() | ||
| 53 | res, err := s.VerifyAuditChain() | ||
| 54 | if err != nil { | ||
| 55 | t.Fatal(err) | ||
| 56 | } | ||
| 57 | if res.Rows != writers*each || res.Unchained != 0 || res.BrokenAt != 0 { | ||
| 58 | t.Fatalf("%+v", res) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | func TestAuditChainDetectsAnEditedRow(t *testing.T) { | ||
| 63 | for _, set := range []string{ | ||
| 64 | "action = 'x'", | ||
| 65 | "created_at = '2020-01-01T00:00:00.000Z'", | ||
| 66 | `data_json = '{"n":2}'`, | ||
| 67 | "actor_ref = 7", | ||
| 68 | } { | ||
| 69 | t.Run(set, func(t *testing.T) { | ||
| 70 | s := chainStore(t) | ||
| 71 | for _, a := range []string{"a", "b", "c"} { | ||
| 72 | s.Audit(0, a, nil) | ||
| 73 | } | ||
| 74 | if _, err := s.DB.Exec("UPDATE audit_log SET " + set + " WHERE id = 2"); err != nil { | ||
| 75 | t.Fatal(err) | ||
| 76 | } | ||
| 77 | res, err := s.VerifyAuditChain() | ||
| 78 | if err != nil { | ||
| 79 | t.Fatal(err) | ||
| 80 | } | ||
| 81 | if res.BrokenAt != 2 || !strings.Contains(res.Reason, "contents") { | ||
| 82 | t.Fatalf("%+v", res) | ||
| 83 | } | ||
| 84 | }) | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | // Blanking the hash of the oldest chained rows would make them read as | ||
| 89 | // rows from before the migration. | ||
| 90 | func TestAuditChainDetectsBlankedHashes(t *testing.T) { | ||
| 91 | s := chainStore(t) | ||
| 92 | for _, a := range []string{"a", "b", "c"} { | ||
| 93 | s.Audit(0, a, nil) | ||
| 94 | } | ||
| 95 | if _, err := s.DB.Exec("UPDATE audit_log SET hash = '', action = 'x' WHERE id = 1"); err != nil { | ||
| 96 | t.Fatal(err) | ||
| 97 | } | ||
| 98 | res, err := s.VerifyAuditChain() | ||
| 99 | if err != nil { | ||
| 100 | t.Fatal(err) | ||
| 101 | } | ||
| 102 | if res.BrokenAt != 2 { | ||
| 103 | t.Fatalf("%+v", res) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | // actor_id is not hashed; it must agree with actor_ref or be NULL. | ||
| 108 | func TestAuditChainDetectsAChangedActorID(t *testing.T) { | ||
| 109 | s := chainStore(t) | ||
| 110 | uid, err := s.CreateUser("alice", false) | ||
| 111 | if err != nil { | ||
| 112 | t.Fatal(err) | ||
| 113 | } | ||
| 114 | s.Audit(0, "a", nil) | ||
| 115 | s.Audit(0, "b", nil) | ||
| 116 | if _, err := s.DB.Exec("UPDATE audit_log SET actor_id = ? WHERE id = 2", uid); err != nil { | ||
| 117 | t.Fatal(err) | ||
| 118 | } | ||
| 119 | res, err := s.VerifyAuditChain() | ||
| 120 | if err != nil { | ||
| 121 | t.Fatal(err) | ||
| 122 | } | ||
| 123 | if res.BrokenAt != 2 || !strings.Contains(res.Reason, "actor_id") { | ||
| 124 | t.Fatalf("%+v", res) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | // A clock stepped back leaves created_at out of id order; retention | ||
| 129 | // still removes a prefix of the table, so the chain stays intact. | ||
| 130 | func TestAuditChainSurvivesRetentionWithClockStep(t *testing.T) { | ||
| 131 | s := chainStore(t) | ||
| 132 | for _, a := range []string{"a", "b", "c", "d"} { | ||
| 133 | s.Audit(0, a, nil) | ||
| 134 | } | ||
| 135 | // Rewrite created_at and the hashes as the rows would have been | ||
| 136 | // written: row 2 stamped after row 3, both older than the cutoff. | ||
| 137 | stamps := map[int64]string{ | ||
| 138 | 1: "2020-01-01T00:00:00.000Z", | ||
| 139 | 2: "2020-01-03T00:00:00.000Z", | ||
| 140 | 3: "2020-01-02T00:00:00.000Z", | ||
| 141 | 4: "2099-01-01T00:00:00.000Z", | ||
| 142 | } | ||
| 143 | prev := "" | ||
| 144 | for id := int64(1); id <= 4; id++ { | ||
| 145 | var action, data string | ||
| 146 | if err := s.DB.QueryRow("SELECT action, data_json FROM audit_log WHERE id = ?", id).Scan(&action, &data); err != nil { | ||
| 147 | t.Fatal(err) | ||
| 148 | } | ||
| 149 | h := auditHash(prev, id, 0, action, stamps[id], data) | ||
| 150 | if _, err := s.DB.Exec("UPDATE audit_log SET created_at = ?, prev_hash = ?, hash = ? WHERE id = ?", | ||
| 151 | stamps[id], prev, h, id); err != nil { | ||
| 152 | t.Fatal(err) | ||
| 153 | } | ||
| 154 | prev = h | ||
| 155 | } | ||
| 156 | if _, err := s.Sweep(Retention{Audit: time.Hour}, mustTime(t, "2020-01-02T12:00:00.000Z")); err != nil { | ||
| 157 | t.Fatal(err) | ||
| 158 | } | ||
| 159 | res, err := s.VerifyAuditChain() | ||
| 160 | if err != nil { | ||
| 161 | t.Fatal(err) | ||
| 162 | } | ||
| 163 | if res.BrokenAt != 0 || res.First != 4 || res.Rows != 1 { | ||
| 164 | t.Fatalf("%+v", res) | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | func mustTime(t *testing.T, v string) time.Time { | ||
| 169 | t.Helper() | ||
| 170 | tm, err := time.Parse(time.RFC3339, v) | ||
| 171 | if err != nil { | ||
| 172 | t.Fatal(err) | ||
| 173 | } | ||
| 174 | return tm | ||
| 175 | } | ||
| 176 | |||
| 177 | func TestAuditChainDetectsARemovedRow(t *testing.T) { | ||
| 178 | s := chainStore(t) | ||
| 179 | for _, a := range []string{"a", "b", "c"} { | ||
| 180 | s.Audit(0, a, nil) | ||
| 181 | } | ||
| 182 | if _, err := s.DB.Exec("DELETE FROM audit_log WHERE id = 2"); err != nil { | ||
| 183 | t.Fatal(err) | ||
| 184 | } | ||
| 185 | res, err := s.VerifyAuditChain() | ||
| 186 | if err != nil { | ||
| 187 | t.Fatal(err) | ||
| 188 | } | ||
| 189 | if res.BrokenAt != 3 || !strings.Contains(res.Reason, "previous hash") { | ||
| 190 | t.Fatalf("%+v", res) | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | // Retention removes the oldest rows, and deleting an account nulls | ||
| 195 | // actor_id; neither is tampering. | ||
| 196 | func TestAuditChainSurvivesRetentionAndAccountDeletion(t *testing.T) { | ||
| 197 | s := chainStore(t) | ||
| 198 | uid, err := s.CreateUser("alice", false) | ||
| 199 | if err != nil { | ||
| 200 | t.Fatal(err) | ||
| 201 | } | ||
| 202 | s.Audit(0, "a", nil) | ||
| 203 | s.Audit(uid, "b", nil) | ||
| 204 | s.Audit(0, "c", nil) | ||
| 205 | if _, err := s.DB.Exec("DELETE FROM audit_log WHERE id = 1"); err != nil { | ||
| 206 | t.Fatal(err) | ||
| 207 | } | ||
| 208 | if _, err := s.DB.Exec("DELETE FROM users WHERE id = ?", uid); err != nil { | ||
| 209 | t.Fatal(err) | ||
| 210 | } | ||
| 211 | res, err := s.VerifyAuditChain() | ||
| 212 | if err != nil { | ||
| 213 | t.Fatal(err) | ||
| 214 | } | ||
| 215 | if res.BrokenAt != 0 || res.First != 2 || res.Last != 3 { | ||
| 216 | t.Fatalf("%+v", res) | ||
| 217 | } | ||
| 218 | } | ||
| 219 | |||
| 220 | // Rows written before migration 0064 carry no hash; the chain starts | ||
| 221 | // after them, and a hashless row after that start is a break. | ||
| 222 | func TestAuditChainLegacyRows(t *testing.T) { | ||
| 223 | s := chainStore(t) | ||
| 224 | if _, err := s.DB.Exec("INSERT INTO audit_log (action) VALUES ('legacy')"); err != nil { | ||
| 225 | t.Fatal(err) | ||
| 226 | } | ||
| 227 | s.Audit(0, "a", nil) | ||
| 228 | res, err := s.VerifyAuditChain() | ||
| 229 | if err != nil { | ||
| 230 | t.Fatal(err) | ||
| 231 | } | ||
| 232 | if res.Unchained != 1 || res.BrokenAt != 0 || res.First != 2 { | ||
| 233 | t.Fatalf("%+v", res) | ||
| 234 | } | ||
| 235 | if _, err := s.DB.Exec("INSERT INTO audit_log (action) VALUES ('injected')"); err != nil { | ||
| 236 | t.Fatal(err) | ||
| 237 | } | ||
| 238 | if res, _ = s.VerifyAuditChain(); res.BrokenAt != 3 { | ||
| 239 | t.Fatalf("hashless row after the chain: %+v", res) | ||
| 240 | } | ||
| 241 | } | ||
| 242 | |||
| 243 | func TestAuditJournal(t *testing.T) { | ||
| 244 | s := chainStore(t) | ||
| 245 | var buf bytes.Buffer | ||
| 246 | s.AuditJournal = slog.New(slog.NewJSONHandler(&buf, nil)) | ||
| 247 | s.Audit(0, "cmd repo create", map[string]any{"argv": []string{"a/b"}}) | ||
| 248 | lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") | ||
| 249 | if len(lines) != 1 { | ||
| 250 | t.Fatalf("journal lines %q", lines) | ||
| 251 | } | ||
| 252 | var got map[string]any | ||
| 253 | if err := json.Unmarshal([]byte(lines[0]), &got); err != nil { | ||
| 254 | t.Fatal(err) | ||
| 255 | } | ||
| 256 | var id float64 | ||
| 257 | var data, createdAt, hash string | ||
| 258 | if err := s.DB.QueryRow("SELECT id, data_json, created_at, hash FROM audit_log").Scan(&id, &data, &createdAt, &hash); err != nil { | ||
| 259 | t.Fatal(err) | ||
| 260 | } | ||
| 261 | want := map[string]any{ | ||
| 262 | "level": "INFO", "msg": "audit", "id": id, "actor": float64(0), "action": "cmd repo create", | ||
| 263 | "data": data, "created_at": createdAt, "hash": hash, | ||
| 264 | } | ||
| 265 | delete(got, "time") | ||
| 266 | if fmt.Sprint(got) != fmt.Sprint(want) { | ||
| 267 | t.Fatalf("journal line %v, want %v", got, want) | ||
| 268 | } | ||
| 269 | } | ||
internal/store/migrations/0064_audit_chain.down.sql added +3
| @@ -0,0 +1,3 @@ | |||
| 1 | ALTER TABLE audit_log DROP COLUMN hash; | ||
| 2 | ALTER TABLE audit_log DROP COLUMN prev_hash; | ||
| 3 | ALTER TABLE audit_log DROP COLUMN actor_ref; | ||
internal/store/migrations/0064_audit_chain.up.sql added +8
| @@ -0,0 +1,8 @@ | |||
| 1 | -- Each row carries the hash of the row before it. actor_ref is the actor | ||
| 2 | -- id as written: actor_id is set to NULL when the account is deleted, | ||
| 3 | -- and the hash must not change with it. Rows written before this | ||
| 4 | -- migration keep an empty hash; the chain starts after them. | ||
| 5 | ALTER TABLE audit_log ADD COLUMN actor_ref INTEGER NOT NULL DEFAULT 0; | ||
| 6 | ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT ''; | ||
| 7 | ALTER TABLE audit_log ADD COLUMN hash TEXT NOT NULL DEFAULT ''; | ||
| 8 | UPDATE audit_log SET actor_ref = COALESCE(actor_id, 0); | ||
internal/store/retention.go +4 −1
| @@ -73,7 +73,10 @@ func (s *Store) Sweep(r Retention, now time.Time) (Swept, error) { | |||
| 73 | where string | 73 | where string |
| 74 | keep time.Duration | 74 | keep time.Duration |
| 75 | }{ | 75 | }{ |
| 76 | {"audit_log", "created_at < ?", r.Audit}, | 76 | // By id, so a backwards clock step cannot leave a newer row |
| 77 | // removed and an older one kept: the audit chain would read | ||
| 78 | // that as tampering. | ||
| 79 | {"audit_log", "id <= (SELECT MAX(id) FROM audit_log WHERE created_at < ?)", r.Audit}, | ||
| 77 | // Only deliveries that have finished: one still being retried is | 80 | // Only deliveries that have finished: one still being retried is |
| 78 | // live state, however old its first attempt. | 81 | // live state, however old its first attempt. |
| 79 | {"webhook_deliveries", "created_at < ? AND (delivered_at IS NOT NULL OR failed_at IS NOT NULL)", r.WebhookDeliveries}, | 82 | {"webhook_deliveries", "created_at < ? AND (delivered_at IS NOT NULL OR failed_at IS NOT NULL)", r.WebhookDeliveries}, |
internal/store/store.go +6
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | "errors" | 8 | "errors" |
| 9 | "fmt" | 9 | "fmt" |
| 10 | "io/fs" | 10 | "io/fs" |
| 11 | "log/slog" | ||
| 11 | "os" | 12 | "os" |
| 12 | "sort" | 13 | "sort" |
| 13 | "strconv" | 14 | "strconv" |
| @@ -31,6 +32,11 @@ type Store struct { | |||
| 31 | // onRevoke runs after each key revocation this process commits. | 32 | // onRevoke runs after each key revocation this process commits. |
| 32 | revokeMu sync.Mutex | 33 | revokeMu sync.Mutex |
| 33 | onRevoke []func(Revoked) | 34 | onRevoke []func(Revoked) |
| 35 | |||
| 36 | // AuditJournal, when set, receives a copy of every audit row. The | ||
| 37 | // daemon sets it to its own logger, whose output the service | ||
| 38 | // journal keeps outside the database. | ||
| 39 | AuditJournal *slog.Logger | ||
| 34 | } | 40 | } |
| 35 | 41 | ||
| 36 | // Open opens (creating if needed) the database at path with WAL mode and | 42 | // Open opens (creating if needed) the database at path with WAL mode and |