e2e/audit_test.go
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}