e2e/audit_test.go

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

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