e2e/audit_test.go
164 lines · 6655 bytes
1package e2e
2
3import (
4 "crypto/rand"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/store"
12)
13
14func TestAuditAndHardening(t *testing.T) {
15 t.Parallel()
16 inst := startInstanceWith(t, "[limits]\nssh_auth_rate = 3\nmax_pack_bytes = 2000\n")
17 adminKey := inst.newKey(t, "root")
18 aliceKey := inst.newKey(t, "alice")
19 bobKey := inst.newKey(t, "bob")
20 inst.admin(t, "admin", "user", "create", "root", "--key", adminKey+".pub", "--admin")
21 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
22 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
23
24 // Mutating commands land in the audit log with source fingerprints;
25 // reads do not. Admin-only over SSH; host admin command works too.
26 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
27 t.Fatal("repo create failed")
28 }
29 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
30 t.Fatal("grant failed")
31 }
32 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "list"); code != 0 {
33 t.Fatal("repo list failed")
34 }
35 if _, _, code := inst.ssh(t, aliceKey, "", "audit"); code != 4 {
36 t.Fatal("non-admin read the audit log")
37 }
38 out, _, code := inst.ssh(t, adminKey, "", "audit", "--json")
39 if code != 0 || !strings.Contains(out, "cmd repo create") ||
40 !strings.Contains(out, "cmd repo access grant") ||
41 !strings.Contains(out, `SHA256:`) || // key fingerprint as source
42 !strings.Contains(out, "admin user.created") {
43 t.Fatalf("audit content: %s", out)
44 }
45 if strings.Contains(out, "cmd repo list") {
46 t.Fatal("read-only command audited")
47 }
48 if out := inst.admin(t, "admin", "audit", "--limit", "5"); !strings.Contains(out, "cmd repo") {
49 t.Fatalf("host audit: %s", out)
50 }
51
52 // Prose reaches argv through --title and --body. The entry records
53 // that the flags were given, not what was written: the issue itself is
54 // the record of its own text, and the audit log is not pruned by
55 // default (#122).
56 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app",
57 "--title", "'a short title'", "--body", "'prose that must not be copied'"); code != 0 {
58 t.Fatalf("issue create: %s", errOut)
59 }
60 out, _, code = inst.ssh(t, adminKey, "", "audit", "--json")
61 if code != 0 || !strings.Contains(out, "cmd issue create") {
62 t.Fatalf("issue create not audited: %s", out)
63 }
64 if strings.Contains(out, "prose that must not be copied") || strings.Contains(out, "a short title") {
65 t.Fatalf("audit log copied the issue text:\n%s", out)
66 }
67 if !strings.Contains(out, "--body") || !strings.Contains(out, "alice/app") {
68 t.Fatalf("audit log dropped the flag names or the target:\n%s", out)
69 }
70
71 // Disable: everything refused, sessions dropped, nothing deleted.
72 inst.admin(t, "admin", "user", "disable", "bob")
73 if _, errOut, code := inst.ssh(t, bobKey, "", "whoami"); code != 4 || !strings.Contains(errOut, "disabled") {
74 t.Fatalf("disabled ssh: exit %d, %s", code, errOut)
75 }
76 inst.admin(t, "admin", "user", "enable", "bob")
77 if _, _, code := inst.ssh(t, bobKey, "", "whoami"); code != 0 {
78 t.Fatal("re-enabled user still refused")
79 }
80
81 // max_pack_bytes: an oversized push is refused by receive-pack.
82 work := t.TempDir()
83 env := inst.gitEnv(aliceKey)
84 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
85 dir := filepath.Join(work, "w")
86 big := make([]byte, 200_000)
87 rand.Read(big) // incompressible: the pack must exceed max_pack_bytes
88 os.WriteFile(filepath.Join(dir, "big.bin"), big, 0o644)
89 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
90 mustGit(t, dir, env, "add", ".")
91 mustGit(t, dir, env, "commit", "-q", "-m", "big")
92 if out, code := gitRun(t, dir, env, "push", "origin", "main"); code == 0 || !strings.Contains(out, "max") {
93 t.Fatalf("oversized push accepted: exit %d\n%s", code, out)
94 }
95 // A normal-sized push still works.
96 mustGit(t, dir, env, "rm", "-q", "big.bin")
97 os.WriteFile(filepath.Join(dir, "small.txt"), []byte("ok\n"), 0o644)
98 mustGit(t, dir, env, "add", ".")
99 mustGit(t, dir, env, "commit", "-q", "--amend", "-m", "small")
100 mustGit(t, dir, env, "push", "-q", "origin", "main")
101
102 // Auth rate limit, LAST because it locks out this whole IP: a burst
103 // of unknown-key failures throttles further auth — even a valid key
104 // — until the window passes. (Registration is closed, so unknown
105 // keys fail auth.) The audit is read host-locally: SSH is locked.
106 strangerKey := inst.newKey(t, "stranger")
107 for i := 0; i < 5; i++ {
108 inst.ssh(t, strangerKey, "", "whoami")
109 }
110 if _, _, code := inst.ssh(t, adminKey, "", "whoami"); code == 0 {
111 t.Fatal("valid key not throttled after failure burst")
112 }
113 auditOut := inst.admin(t, "admin", "audit")
114 if !strings.Contains(auditOut, "auth.failed") || !strings.Contains(auditOut, "auth.throttled") {
115 t.Fatalf("burst not audited:\n%s", auditOut)
116 }
117 if strings.Count(auditOut, "auth.throttled") != 1 {
118 t.Fatal("throttle audited more than once per window")
119 }
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).
124func 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}