Commit ead6796abe

ead6796abebaaed89488c7ab91788799c54b71ed

parent: bd49b87fce

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:55 UTC

hookd: audit refused hook requests and refused pushes

Ref #275

Layout: unified · split

.gitbay/wiki/Admin.org +6 −2
@@ -276,8 +276,12 @@ meaningful — an unverified address never produces a =verified= badge.
276276The audit log is the security feed (events are the product feed): every
277277successful mutating command with its argv and source credential (SSH key
278278fingerprint or API), every refused one (exit 3 or 4) as =refused
279<command>=, refused pushes as =refused git-receive-pack=, registrations,
280admin actions, force-pushes, and auth failures/throttling. A refusal row
279<command>=, refused pushes as =refused git-receive-pack= (the access
280check) or =refused push= (a branch or tag rule, a release anchor or an
281unsigned commit, with the repository and ref names), hook socket
282requests failing the peer or push-token check as =refused hook= (never
283the token), registrations, admin actions, force-pushes, and auth
284failures/throttling. A refusal row
281285keeps the flag names and the first positional, not the values.
282286Refusals are recorded up to ten a minute per account and 600 a minute
283287across the instance; past either, one =refused.throttled= row stands
CHANGELOG.org +4
@@ -108,6 +108,10 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
108108 =refused.throttled= row stands for the rest of the minute. Under
109109 =ssh.mode = "system"= each =gitbayd shell= connection counts
110110 separately (#275).
111- Pushes refused in pre-receive (a branch or tag rule, a release
112 anchor, an unsigned commit) are audited as =refused push= with the
113 repository and ref names, and hook socket requests failing the peer
114 or push-token check as =refused hook=, under the same caps (#275).
111115- Audit retention deletes by id, up to the newest row older than the
112116 retention, so a clock step back cannot leave a gap in the chain (#275).
113117- =dashboard= and =feed= print activity as sentences
internal/hookd/hookd.go +30 −10
@@ -122,8 +122,10 @@ func (s *Server) handle(conn net.Conn) {
122122 defer conn.Close()
123123 dec := json.NewDecoder(conn)
124124 enc := json.NewEncoder(conn)
125 if err := checkPeer(conn); err != nil {
125 if err := peerCheck(conn); err != nil {
126126 slog.Warn("hook socket: refused connection", "err", err)
127 // Nothing about the request is known yet, and no account.
128 control.AuditRefused(s.st, 0, "refused hook", map[string]any{"reason": err.Error()})
127129 enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()})
128130 return
129131 }
@@ -132,7 +134,9 @@ func (s *Server) handle(conn net.Conn) {
132134 enc.Encode(Response{Allow: false, Message: "bad hook request"})
133135 return
134136 }
135 if msg := s.authorize(req); msg != "" {
137 if actor, msg := s.authorize(req); msg != "" {
138 control.AuditRefused(s.st, actor, "refused hook",
139 map[string]any{"repo_id": req.RepoID, "hook": req.Hook, "reason": msg})
136140 enc.Encode(Response{Allow: false, Message: msg})
137141 return
138142 }
@@ -149,18 +153,34 @@ func (s *Server) handle(conn net.Conn) {
149153
150154// authorize ties a request to a receive-pack sshd started: its token
151155// must be live and name the same repository, account and key scope.
152func (s *Server) authorize(req Request) string {
156// On a refusal actor is the token's account when the token is live,
157// and 0 otherwise: the request's own user id is only a claim.
158func (s *Server) authorize(req Request) (actor int64, msg string) {
153159 if req.Token == "" {
154 return "push not started by this server"
160 return 0, "push not started by this server"
155161 }
156162 tok, err := s.st.PushTokenByHash(store.HashToken(req.Token))
157163 if err != nil {
158 return "push not started by this server"
164 return 0, "push not started by this server"
159165 }
160166 if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope {
161 return "push token does not match this request"
167 return tok.UserID, "push token does not match this request"
162168 }
163 return ""
169 return 0, ""
170}
171
172// peerCheck is checkPeer; tests replace it.
173var peerCheck = checkPeer
174
175// refusePush answers a pre-receive refusal and audits it.
176func (s *Server) refusePush(enc *json.Encoder, req Request, repo store.Repo, msg string) {
177 refs := make([]string, len(req.Updates))
178 for i, u := range req.Updates {
179 refs[i] = u.Ref
180 }
181 control.AuditRefused(s.st, req.UserID, "refused push",
182 map[string]any{"repo": repo.Path(), "refs": refs, "reason": msg})
183 enc.Encode(Response{Allow: false, Message: msg})
164184}
165185
166186func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
@@ -170,11 +190,11 @@ func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
170190 return
171191 }
172192 if msg := policy.CheckPush(repo, req.Updates); msg != "" {
173 enc.Encode(Response{Allow: false, Message: msg})
193 s.refusePush(enc, req, repo, msg)
174194 return
175195 }
176196 if msg := s.releaseAnchors(repo, req.Updates); msg != "" {
177 enc.Encode(Response{Allow: false, Message: msg})
197 s.refusePush(enc, req, repo, msg)
178198 return
179199 }
180200 if !repo.Settings.RequireSignedCommits {
@@ -220,7 +240,7 @@ func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
220240 }
221241 }
222242 if refusal != "" {
223 enc.Encode(Response{Allow: false, Message: refusal})
243 s.refusePush(enc, req, repo, refusal)
224244 return
225245 }
226246 enc.Encode(Response{Allow: true})
internal/hookd/socket_test.go +58
@@ -1,12 +1,15 @@
11package hookd
22
33import (
4 "errors"
5 "net"
46 "os"
57 "path/filepath"
68 "strings"
79 "testing"
810
911 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/policy"
1013 "gitbay.org/gitbay/internal/store"
1114)
1215
@@ -103,3 +106,58 @@ func TestHookRequestNeedsItsPushToken(t *testing.T) {
103106 t.Fatalf("finished push: %+v, %v", resp, err)
104107 }
105108}
109
110func refusedRows(t *testing.T, st *store.Store, action string) []store.AuditEntry {
111 t.Helper()
112 rows, err := st.AuditEntries(store.AuditFilter{ActionPrefix: action, Limit: 10})
113 if err != nil {
114 t.Fatal(err)
115 }
116 return rows
117}
118
119// Refused hook requests and refused pushes are audited; the token never
120// lands in a row (#275).
121func TestHookRefusalsAreAudited(t *testing.T) {
122 sock, st, repoID, uid := serveSocket(t)
123
124 forged := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full", Token: "not-a-live-token"}
125 if resp, err := Ask(sock, forged, nil); err != nil || resp.Allow {
126 t.Fatalf("forged: %+v, %v", resp, err)
127 }
128 rows := refusedRows(t, st, "refused hook")
129 if len(rows) != 1 || rows[0].Actor != "" || strings.Contains(rows[0].Data, forged.Token) ||
130 !strings.Contains(rows[0].Data, "not started by this server") || !strings.Contains(rows[0].Data, `"hook":"pre-receive"`) {
131 t.Fatalf("refused hook rows: %+v", rows)
132 }
133
134 token, err := st.CreatePushToken(repoID, uid, "full")
135 if err != nil {
136 t.Fatal(err)
137 }
138 req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full", Token: token,
139 Updates: []policy.RefUpdate{{Ref: "refs/merge-requests/1/head", Old: zeroSHA40, New: strings.Repeat("a", 40)}}}
140 if resp, err := Ask(sock, req, nil); err != nil || resp.Allow {
141 t.Fatalf("push to a server-owned ref: %+v, %v", resp, err)
142 }
143 rows = refusedRows(t, st, "refused push")
144 if len(rows) != 1 || rows[0].Actor != "alice" || strings.Contains(rows[0].Data, token) ||
145 !strings.Contains(rows[0].Data, "alice/app") || !strings.Contains(rows[0].Data, "refs/merge-requests/1/head") {
146 t.Fatalf("refused push rows: %+v", rows)
147 }
148}
149
150// A connection from another uid is audited with no actor.
151func TestPeerRefusalIsAudited(t *testing.T) {
152 old := peerCheck
153 peerCheck = func(net.Conn) error { return errors.New("peer uid not permitted") }
154 t.Cleanup(func() { peerCheck = old })
155 sock, st, repoID, uid := serveSocket(t)
156 if resp, err := Ask(sock, Request{Hook: "pre-receive", RepoID: repoID, UserID: uid}, nil); err != nil || resp.Allow {
157 t.Fatalf("refused peer: %+v, %v", resp, err)
158 }
159 rows := refusedRows(t, st, "refused hook")
160 if len(rows) != 1 || rows[0].Actor != "" || !strings.Contains(rows[0].Data, "peer uid not permitted") {
161 t.Fatalf("refused hook rows: %+v", rows)
162 }
163}