Commit 0666c9f622
Verified · cmc ci/build: success ci/test: success
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 | 239 | The audit log is the security feed (events are the product feed): every |
| 240 | 240 | successful mutating command with its argv and source credential (SSH key |
| 241 | fingerprint or API), registrations, admin actions, force-pushes, and | |
| 242 | auth failures/throttling. Secrets never appear — they travel on stdin, | |
| 243 | never in argv. | |
| 241 | fingerprint or API), every refused one (exit 3 or 4) as =refused | |
| 242 | <command>=, refused pushes as =refused git-receive-pack=, registrations, | |
| 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 | 270 | #+begin_src sh |
| 246 | 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 | 273 | ssh git@<host> audit ... # the same, from an admin session |
| 248 | 274 | ssh git@<host> admin user list [--state active|pending|disabled|admin] |
| 249 | 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 | 18 | | Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | *plaintext* secrets and tokens | |
| 19 | 19 | | Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue= | P | device tokens in clear | |
| 20 | 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 | 22 | | Dependencies | =dep_checks=, =dep_reports= | O | | |
| 23 | 23 | |
| 24 | 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 | 69 | | Security-relevant writes audited | in place | every successful mutating command (=control.go=) | |
| 70 | 70 | | Authentication failures audited | in place | =auth.failed=, =auth.throttled= | |
| 71 | | Denied attempts audited | gap | refused commands are not recorded (#275) | | |
| 72 | | Audit log tamper resistance | gap | same database, writable by the daemon user (#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 | 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 | 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 | 18 | | #262 | Availability | No limit on concurrent git pack generation | high | |
| 19 | 19 | | #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high | |
| 20 | 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 | 21 | | #298 | SSRF | =repo import --from= fetches without an address check | medium | |
| 23 | 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 | 30 | * Questions an auditor will ask that have no answer yet |
| 26 | 31 | |
| 27 | 32 | | Question | Status | |
.gitbay/wiki/Threat-Model.org +8
| @@ -233,6 +233,14 @@ assume has been checked. | ||
| 233 | 233 | - Backups are consistent per the DB-snapshot-first ordering but are not a |
| 234 | 234 | single atomic snapshot; a few orphaned git objects are possible and |
| 235 | 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 | 244 | - A global signature-verification epoch over-invalidates the cache on any |
| 237 | 245 | trust-input change. Correct, not a leak; a performance tradeoff. |
| 238 | 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 | 54 | daemon acts. *Operators:* deploy with no push in flight, since a |
| 55 | 55 | receive-pack started by the old daemon has no token and its |
| 56 | 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 | 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 | 140 | return err |
| 141 | 141 | } |
| 142 | 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 | 148 | // Regenerate hook scripts so a moved binary self-heals, then |
| 145 | 149 | // start the hook policy socket. |
| @@ -406,6 +410,8 @@ func adminCmd() *cobra.Command { | ||
| 406 | 410 | ) |
| 407 | 411 | configCmd := &cobra.Command{Use: "config", Short: "the configuration in effect"} |
| 408 | 412 | configCmd.AddCommand(configShowCmd()) |
| 413 | auditCmd := hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit") | |
| 414 | auditCmd.AddCommand(auditVerifyCmd()) | |
| 409 | 415 | admin.AddCommand( |
| 410 | 416 | userCmd, |
| 411 | 417 | emailCmd, |
| @@ -414,7 +420,7 @@ func adminCmd() *cobra.Command { | ||
| 414 | 420 | hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"), |
| 415 | 421 | hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"), |
| 416 | 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 | 424 | backupCmd(), |
| 419 | 425 | gcCmd(), |
| 420 | 426 | adminMigrateCommitRefsCmd(), |
e2e/audit_test.go +47
| @@ -2,10 +2,13 @@ package e2e | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "crypto/rand" |
| 5 | "fmt" | |
| 5 | 6 | "os" |
| 6 | 7 | "path/filepath" |
| 7 | 8 | "strings" |
| 8 | 9 | "testing" |
| 10 | ||
| 11 | "gitbay.org/gitbay/internal/store" | |
| 9 | 12 | ) |
| 10 | 13 | |
| 11 | 14 | func TestAuditAndHardening(t *testing.T) { |
| @@ -115,3 +118,47 @@ func TestAuditAndHardening(t *testing.T) { | ||
| 115 | 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/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 | 466 | if len(argv) > 0 { |
| 467 | 467 | switch argv[0] { |
| 468 | 468 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": |
| 469 | code := protocol.ExitDenied | |
| 469 | 470 | if user.Pending { |
| 470 | 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 | 483 | case "git-lfs-authenticate": |
| 475 | 484 | // Part of the git transport, not the control plane: usable by |
| 476 | 485 | // git-scoped and deploy keys, with the transports' access rules. |
internal/store/auditchain_test.go +22 −6
| @@ -2,6 +2,7 @@ package store | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | "encoding/json" | |
| 5 | 6 | "fmt" |
| 6 | 7 | "log/slog" |
| 7 | 8 | "strings" |
| @@ -242,12 +243,27 @@ func TestAuditChainLegacyRows(t *testing.T) { | ||
| 242 | 243 | func TestAuditJournal(t *testing.T) { |
| 243 | 244 | s := chainStore(t) |
| 244 | 245 | var buf bytes.Buffer |
| 245 | s.AuditJournal = slog.New(slog.NewTextHandler(&buf, nil)) | |
| 246 | s.AuditJournal = slog.New(slog.NewJSONHandler(&buf, nil)) | |
| 246 | 247 | s.Audit(0, "cmd repo create", map[string]any{"argv": []string{"a/b"}}) |
| 247 | line := buf.String() | |
| 248 | for _, want := range []string{"msg=audit", "action=\"cmd repo create\"", "id=1", "hash="} { | |
| 249 | if !strings.Contains(line, want) { | |
| 250 | t.Fatalf("journal line %q lacks %q", line, want) | |
| 251 | } | |
| 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) | |
| 252 | 268 | } |
| 253 | 269 | } |