e2e/audit_test.go

v1.42.0
gitbay/e2e/audit_test.go history · blame · raw

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}