webhook add reads the secret from stdin; runner docs name real attachments !510

merged merged by cmc on 2026-09-29 00:19 UTC · krz/gitbay:webhook-secret-stdin into main

9 files changed, +105 −23

Layout: unified · split

.gitbay/wiki/API.org +5 −1
@@ -150,13 +150,17 @@ Per-repository outbound POSTs for repository events. Managed by repo
150150admins:
151151
152152#+begin_src sh
153gitbay webhook add <url> --secret s3cret [--events push,issue.created] # default *
153printf %s "$SECRET" | gitbay webhook add <url> --secret - [--events push,issue.created] # default *
154154gitbay webhook list
155155gitbay webhook deliveries [--limit 50] # status, attempts, last error
156156gitbay webhook redeliver <delivery-id> # requeue, including dead letters
157157gitbay webhook remove <id>
158158#+end_src
159159
160The signing secret is read from stdin with =--secret -=; a value on the
161command line is refused, since argv shows in process listings and shell
162history. Over the JSON API it goes in the request's =stdin= field.
163
160164** Events
161165
162166Every event this forge records, and so every name =--events= may take.
.gitbay/wiki/Admin.org +10 −9
@@ -925,16 +925,19 @@ at real ones.* Every deploy that switched the whole instance to
925925containers and failed took CI down with it. Instead: create a throwaway
926926repository the runner account can read (public, or granted read — a
927927private one is "not found" to the runner and the build stays pending),
928give it one job that names the CI image, and deploy the runner with
929=-repos= naming only that repository. The production unit, with its real
930hardening, then claims nothing else; other repositories' builds queue
931until =-repos= is switched back, which is a pause, not an outage.
928give it one job that names the CI image, attach the runner's key to it,
929and deploy the runner with =-repos= naming only that repository. The
930production unit, with its real hardening, then claims nothing else;
931other repositories' builds queue until =-repos= is removed again, which
932is a pause, not an outage.
932933
933934#+begin_src sh
934gitbay repo create cmc/ci-smoke # then push a .gitbay/ci.yml naming the image
935sed -i 's#-repos krz/gitbay #-repos cmc/ci-smoke #' /etc/systemd/system/gitbay-runner.service.d/override.conf
935gitbay repo create cmc/runner-scratch # then push a .gitbay/ci.yml naming the image
936# on the host:
937gitbay repo runner add cmc/runner-scratch < /var/lib/gitbay-runner/.ssh/id_ed25519.pub
938sed -i 's#^ExecStart=/usr/local/bin/gitbay-runner #&-repos cmc/runner-scratch #' /etc/systemd/system/gitbay-runner.service.d/override.conf
936939systemctl daemon-reload && systemctl restart gitbay-runner
937gitbay build log cmc/ci-smoke 1 # green: switch -repos back, redeploy
940gitbay build log cmc/runner-scratch 1 # green: remove -repos, redeploy, delete the scratch repository
938941#+end_src
939942
940943*Do not deploy an isolating runner to a host that has not been
@@ -950,8 +953,6 @@ management and =ReadWritePaths= for podman's store under
950953make read-only. Those paths are prefixed =-= so they are ignored when
951954absent: the drop-in installs on unprepared hosts too, and a unit that
952955refused to start would stop every build.
953The nightly canary on =cmc/ci-smoke= only runs if the runner's =-repos=
954names that repository too; a scoped runner claims nothing else.
955956=gitbay-runner-prune.timer= prunes unused images weekly, as the runner's
956957user: rootless storage belongs to that user, and root's prune would not
957958see it. An unpruned image store on a 40GB host is a slow outage.
CHANGELOG.org +5
@@ -6,6 +6,11 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
9- =webhook add= reads the signing secret from stdin with =--secret -=;
10 a value on the command line is refused, since argv shows in process
11 listings and shell history. A script that passed the value must pipe
12 it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -=
13 (#284).
914- The builds page's status badge section gives an org-mode snippet
1015 beside the Markdown one, for a README.org (#299).
1116- API tokens on the settings page: create with a scope and optional
cmd/gitbay/main.go +2 −2
@@ -357,7 +357,7 @@ func isEmptyReader(r io.Reader) bool {
357357// usesStdin reports whether the arguments request stdin content.
358358func usesStdin(args []string) bool {
359359 for i, a := range args {
360 if (a == "--file" || a == "--key") && i+1 < len(args) && args[i+1] == "-" {
360 if (a == "--file" || a == "--key" || a == "--secret") && i+1 < len(args) && args[i+1] == "-" {
361361 return true
362362 }
363363 if a == "--token-stdin" {
@@ -750,7 +750,7 @@ func webCmd() *cobra.Command {
750750
751751func webhookCmd() *cobra.Command {
752752 return group("webhook", "outbound event delivery",
753 pass("add", passOpts{server: []string{"webhook", "add"}, needsRepo: true}),
753 pass("add", passOpts{server: []string{"webhook", "add"}, needsRepo: true, stdinOK: true, stdinWhat: "the webhook secret", stdinSecret: true}),
754754 pass("list", passOpts{server: []string{"webhook", "list"}, needsRepo: true}),
755755 pass("remove", passOpts{server: []string{"webhook", "remove"}, needsRepo: true}),
756756 pass("deliveries", passOpts{server: []string{"webhook", "deliveries"}, needsRepo: true}),
cmd/gitbay/stdinpayload_test.go +11
@@ -114,3 +114,14 @@ func swapTerminal(f func(*os.File) bool) func() {
114114 isTerminal = f
115115 return func() { isTerminal = prev }
116116}
117
118// webhook add --secret - reads the secret on the server, so the CLI must
119// forward stdin for it the way it does for --file - (#284).
120func TestUsesStdinForSecretDash(t *testing.T) {
121 if !usesStdin([]string{"alice/app", "https://ci.example/hook", "--secret", "-"}) {
122 t.Error("--secret - does not forward stdin")
123 }
124 if usesStdin([]string{"alice/app", "https://ci.example/hook", "--events", "push"}) {
125 t.Error("forwarded stdin with no flag asking for it")
126 }
127}
deploy/gitbay-runner.override.conf +5 −3
@@ -34,9 +34,11 @@ After=gitbay-runner-egress.service
3434[Service]
3535# The runner polls as a non-admin account with a runner-scoped key, and
3636# claims only the repositories that key is attached to (`repo runner
37# add`): krz/gitbay and cmc/ci-smoke. The attachments are the boundary,
38# so ExecStart names no -repos. cmc/ci-smoke is the nightly isolation
39# canary; keep it attached or its scheduled build waits forever.
37# add`): krz/gitbay, krz/hutch, krz/keycask, krz/orgo, krz/skunky-art
38# and cmc/cleberg.net. The attachments are the boundary, so ExecStart
39# names no -repos. To validate a runner change, create a scratch
40# repository, attach this key to it, and run with -repos naming only
41# that repository until the change is proven (Admin wiki, CI runner).
4042#
4143# Two layers of resource caps. MemoryMax and CPUQuota bound the unit —
4244# the runner and every build together — which is what keeps the forge
e2e/webhook_test.go +2 −2
@@ -112,8 +112,8 @@ func TestWebhooks(t *testing.T) {
112112
113113 recv := startHookReceiver(t)
114114 hookURL := "http://" + recv.addr + "/hook"
115 if _, errOut, code := inst.ssh(t, aliceKey, "",
116 "webhook", "add", "alice/proj", hookURL, "--secret", "s3cret"); code != 0 {
115 if _, errOut, code := inst.ssh(t, aliceKey, "s3cret\n",
116 "webhook", "add", "alice/proj", hookURL, "--secret", "-"); code != 0 {
117117 t.Fatalf("webhook add: %s", errOut)
118118 }
119119
internal/control/webhook.go +26 −6
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "io"
77 "strconv"
8 "strings"
89
910 "gitbay.org/gitbay/internal/policy"
1011 "gitbay.org/gitbay/internal/protocol"
@@ -15,13 +16,17 @@ import (
1516func init() {
1617 register(Command{Path: []string{"webhook", "add"},
1718 Summary: "add a webhook",
18 Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]",
19 Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]",
1920 Flags: []Flag{
20 {"--secret", "<s>", "signs deliveries so the receiver can verify them", ""},
21 {"--secret", "-", "read the secret that signs deliveries from stdin", ""},
2122 {"--events", "push,issue.created|*", "which events to send", "*"},
2223 },
23 Examples: []string{"webhook add krz/gitbay https://ci.example.org/hook --events push"},
24 Run: runWebhookAdd})
24 Examples: []string{
25 "webhook add krz/gitbay https://ci.example.org/hook --events push",
26 "webhook add krz/gitbay https://ci.example.org/hook --secret - < secret.txt",
27 },
28 ReadsStdin: true,
29 Run: runWebhookAdd})
2530 register(Command{Path: []string{"webhook", "list"},
2631 Summary: "list webhooks",
2732 Usage: "webhook list <owner/name>",
@@ -45,17 +50,21 @@ func init() {
4550}
4651
4752func runWebhookAdd(c *Ctx, args []string) int {
48 f, err := c.parseArgs(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]"})
53 f, err := c.parseArgs(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]"})
4954 if err != nil {
5055 return c.fail(protocol.ExitUsage, "%v", err)
5156 }
52 path, url, secret, events := f.pos(0), f.pos(1), f.Value("--secret"), "*"
57 path, url, events := f.pos(0), f.pos(1), "*"
5358 if f.Has("--events") {
5459 events = f.Value("--events")
5560 }
5661 if path == "" || url == "" {
5762 return c.usage()
5863 }
64 // Secrets travel on stdin: argv shows in /proc and in shell history.
65 if f.Has("--secret") && f.Value("--secret") != "-" {
66 return c.fail(protocol.ExitUsage, "the secret is read from stdin, never argv: pipe it and pass --secret - (printf %%s SECRET | ... --secret -)")
67 }
5968 repo, code := resolveRepo(c, path, policy.CanAdmin)
6069 if code >= 0 {
6170 return code
@@ -71,6 +80,17 @@ func runWebhookAdd(c *Ctx, args []string) int {
7180 // Exit 1 carries the reason to every client verbatim (#187).
7281 return c.fail(protocol.ExitFailure, "%v", err)
7382 }
83 secret := ""
84 if f.Has("--secret") {
85 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
86 if err != nil {
87 return c.fail(protocol.ExitFailure, "reading secret: %v", err)
88 }
89 secret = strings.TrimRight(string(raw), "\n")
90 if secret == "" {
91 return c.fail(protocol.ExitUsage, "no secret on stdin (pipe it: printf %%s SECRET | ... --secret -)")
92 }
93 }
7494 id, err := c.Store.AddWebhook(repo.ID, url, secret, events)
7595 if err != nil {
7696 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/webhook_test.go +39
@@ -39,3 +39,42 @@ func TestWebhookAddRefusedURLIsAFailure(t *testing.T) {
3939 t.Errorf("missing url: exit %d, want %d", code, protocol.ExitUsage)
4040 }
4141}
42
43// The signing secret arrives on stdin with --secret -, like a build
44// secret: a value on the command line is refused before anything is
45// stored, since argv shows in /proc and in shell history (#284).
46func TestWebhookAddSecretFromStdin(t *testing.T) {
47 st, repo, uid := newQueueTestRepo(t)
48 run := func(stdin string, argv ...string) (string, int) {
49 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
50 c.Cfg.Limits.WriteRate = -1
51 c.Cfg.Webhooks.AllowLocal = true
52 c.Stdin = strings.NewReader(stdin)
53 code := Dispatch(c, argv)
54 return errOut.String(), code
55 }
56 msg, code := run("", "webhook", "add", repo.Path(), "http://127.0.0.1/hook", "--secret", "s3cret")
57 if code != protocol.ExitUsage || !strings.Contains(msg, "--secret -") {
58 t.Fatalf("literal secret: exit %d, %q", code, msg)
59 }
60 if msg, code := run("", "webhook", "add", repo.Path(), "http://127.0.0.1/hook", "--secret", "-"); code != protocol.ExitUsage || !strings.Contains(msg, "no secret on stdin") {
61 t.Fatalf("empty stdin: exit %d, %q", code, msg)
62 }
63 if hooks, err := st.ListWebhooks(repo.ID); err != nil || len(hooks) != 0 {
64 t.Fatalf("a refused add stored %+v (%v)", hooks, err)
65 }
66 if msg, code := run("s3cret\n", "webhook", "add", repo.Path(), "http://127.0.0.1/hook", "--secret", "-"); code != protocol.ExitOK {
67 t.Fatalf("piped secret: exit %d, %q", code, msg)
68 }
69 // Without --secret nothing reads stdin and the hook is unsigned.
70 if msg, code := run("not a secret\n", "webhook", "add", repo.Path(), "http://127.0.0.1/other"); code != protocol.ExitOK {
71 t.Fatalf("no secret: exit %d, %q", code, msg)
72 }
73 hooks, err := st.ListWebhooks(repo.ID)
74 if err != nil || len(hooks) != 2 {
75 t.Fatalf("hooks: %+v %v", hooks, err)
76 }
77 if hooks[0].Secret != "s3cret" || hooks[1].Secret != "" {
78 t.Fatalf("secrets: %q, %q", hooks[0].Secret, hooks[1].Secret)
79 }
80}