e2e/audit_test.go

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

116 lines · 4928 bytes

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