runner: a key scope that reaches the runner protocol and nothing else !188

merged merged by cmc on 2026-09-03 15:59 UTC · krz/gitbay:runner-scope into main

6 files changed, +82 −10

Layout: unified · split

deploy/gitbay-runner.override.conf +11
@@ -5,7 +5,18 @@
55# daemon instances, and with nothing holding it back a deploy's scp on
66# the admin sshd stalled at 1%. Lower CPU and IO weight keep sshd,
77# gitbayd and the backup timers responsive while a build runs.
8#
9# A build runs whatever the repository's ci.yml says, as the runner's
10# own user. Keep that user unprivileged: its key is added with
11# `keys add --scope runner`, which confines it to the runner protocol
12# and read-only git, and the sandboxing below keeps a step from
13# touching the system outside its workspace.
814[Service]
915Nice=10
1016CPUWeight=30
1117IOWeight=30
18NoNewPrivileges=yes
19ProtectSystem=full
20ProtectKernelTunables=yes
21ProtectControlGroups=yes
22RestrictSUIDSGID=yes
e2e/runner_scope_test.go +54
@@ -2,6 +2,7 @@ package e2e
22
33import (
44 "os"
5 "os/exec"
56 "path/filepath"
67 "strings"
78 "testing"
@@ -58,3 +59,56 @@ func TestRunnerNextScopedToRepos(t *testing.T) {
5859 t.Fatalf("unscoped claim did not take the remaining build:\n%s", out)
5960 }
6061}
62
63// A runner host holds a key added with --scope runner: it can claim and
64// report builds and clone what it builds, and nothing else. Neither the
65// same account's full key nor the runner key reaches the wrong side, so a
66// key stolen from a build step cannot administer the instance (#92).
67func TestRunnerScopedKey(t *testing.T) {
68 inst := startInstance(t)
69 aliceKey := inst.newKey(t, "alice")
70 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
71 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
72 t.Fatalf("repo create: %s", errOut)
73 }
74
75 // ci is an ordinary account. Its runner key is self-added.
76 ciKey := inst.newKey(t, "ci")
77 inst.admin(t, "admin", "user", "create", "ci", "--key", ciKey+".pub")
78 runnerKey := inst.newKey(t, "ci-runner")
79 pub, _ := os.ReadFile(runnerKey + ".pub")
80 if _, errOut, code := inst.ssh(t, ciKey, string(pub), "keys", "add", "--scope", "runner"); code != 0 {
81 t.Fatalf("keys add --scope runner: %s", errOut)
82 }
83
84 // The runner key claims (nothing is queued, which is a success).
85 out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "--json")
86 if code != 0 || !strings.Contains(out, "{}") {
87 t.Fatalf("runner key cannot claim: exit %d\n%s%s", code, out, errOut)
88 }
89 // The runner key reaches no other command, whoami included.
90 for _, argv := range [][]string{{"whoami"}, {"repo", "create", "ci/evil"}, {"admin", "user", "list"}} {
91 if _, _, code := inst.ssh(t, runnerKey, "", argv...); code != 4 {
92 t.Fatalf("runner key ran %v: exit %d, want 4", argv, code)
93 }
94 }
95 // The account's full key is not a runner.
96 if _, _, code := inst.ssh(t, ciKey, "", "runner", "next"); code != 4 {
97 t.Fatalf("full key of a non-admin claimed a build: exit %d, want 4", code)
98 }
99
100 // Git: the runner key clones and cannot push.
101 work := t.TempDir()
102 env := inst.gitEnv(runnerKey)
103 mustGit(t, work, env, "clone", "-q", inst.sshURL("alice/app"), "w")
104 dir := filepath.Join(work, "w")
105 os.WriteFile(filepath.Join(dir, "f"), []byte("x\n"), 0o644)
106 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
107 mustGit(t, dir, env, "add", ".")
108 mustGit(t, dir, env, "commit", "-q", "-m", "x")
109 push := exec.Command("git", "push", "-q", "origin", "main")
110 push.Dir, push.Env = dir, env
111 if pushOut, err := push.CombinedOutput(); err == nil || !strings.Contains(string(pushOut), "scope") {
112 t.Fatalf("runner key pushed, or was refused for another reason: err=%v\n%s", err, pushOut)
113 }
114}
internal/control/build.go +7 −5
@@ -53,9 +53,11 @@ func init() {
5353 Summary: "list build secret names",
5454 Usage: "repo secret list <owner/name>", ReadOnly: true, Run: runSecretList})
5555
56 // Runner commands: the claim/report loop for gitbay-runner. Admin-only —
57 // a runner executes arbitrary repo code, so handing out jobs is the
58 // instance operator's call.
56 // Runner commands: the claim/report loop for gitbay-runner. A runner
57 // executes arbitrary repo code, so handing out jobs is the instance
58 // operator's call: a key added with --scope runner, which the
59 // dispatcher confines to these three commands and read-only git, or
60 // an admin key, which a runner host should not hold (#92).
5961 register(Command{Path: []string{"runner", "next"},
6062 Summary: "claim the oldest pending build (runner protocol)",
6163 Usage: "runner next [<owner/name>...]", SSHOnly: true, Run: runRunnerNext})
@@ -307,8 +309,8 @@ func runSecretList(c *Ctx, args []string) int {
307309}
308310
309311func requireRunner(c *Ctx) int {
310 if !c.User.IsAdmin {
311 return c.fail(protocol.ExitDenied, "runner commands are for instance-admin runner accounts")
312 if c.Scope != "runner" && !c.User.IsAdmin {
313 return c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner")
312314 }
313315 return -1
314316}
internal/control/control.go +3 −1
@@ -83,7 +83,9 @@ func Dispatch(c *Ctx, argv []string) int {
8383 if !ok {
8484 return c.fail(protocol.ExitUsage, "unknown command %q", argv[0])
8585 }
86 if c.Scope != "full" {
86 // A runner-scoped key reaches the runner protocol and nothing else, so
87 // the key a CI host holds cannot administer the instance.
88 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
8789 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
8890 }
8991 if c.ViaAPI && cmd.SSHOnly {
internal/control/identity.go +4 −4
@@ -29,7 +29,7 @@ func init() {
2929 register(Command{
3030 Path: []string{"keys", "add"},
3131 Summary: "register an SSH public key (authorized_keys format)",
32 Usage: "keys add [--scope full|git] < key.pub",
32 Usage: "keys add [--scope full|git|runner] < key.pub",
3333 ReadsStdin: true,
3434 Run: runKeysAdd,
3535 })
@@ -91,12 +91,12 @@ func runKeysAdd(c *Ctx, args []string) int {
9191 scope = args[i+1]
9292 i++
9393 default:
94 return c.fail(protocol.ExitUsage, "usage: keys add [--scope full|git] < key.pub")
94 return c.fail(protocol.ExitUsage, "usage: keys add [--scope full|git|runner] < key.pub")
9595 }
9696 }
97 if scope != "full" && scope != "git" {
97 if scope != "full" && scope != "git" && scope != "runner" {
9898 // deploy:* scopes are granted via repo settings, not self-service.
99 return c.fail(protocol.ExitUsage, "scope must be full or git")
99 return c.fail(protocol.ExitUsage, "scope must be full, git or runner")
100100 }
101101 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
102102 if err != nil {
internal/policy/access.go +3
@@ -46,6 +46,9 @@ func ScopeAllowsGit(scope, repoPath string, write bool) bool {
4646 switch scope {
4747 case "full", "git":
4848 return true
49 case "runner":
50 // A CI runner clones what it builds and pushes nothing.
51 return !write
4952 }
5053 return false
5154}