Commit b15d84acd6
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
deploy/gitbay-runner.override.conf +11
| @@ -5,7 +5,18 @@ | ||
| 5 | 5 | # daemon instances, and with nothing holding it back a deploy's scp on |
| 6 | 6 | # the admin sshd stalled at 1%. Lower CPU and IO weight keep sshd, |
| 7 | 7 | # 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. | |
| 8 | 14 | [Service] |
| 9 | 15 | Nice=10 |
| 10 | 16 | CPUWeight=30 |
| 11 | 17 | IOWeight=30 |
| 18 | NoNewPrivileges=yes | |
| 19 | ProtectSystem=full | |
| 20 | ProtectKernelTunables=yes | |
| 21 | ProtectControlGroups=yes | |
| 22 | RestrictSUIDSGID=yes | |
e2e/runner_scope_test.go +54
| @@ -2,6 +2,7 @@ package e2e | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "os" |
| 5 | "os/exec" | |
| 5 | 6 | "path/filepath" |
| 6 | 7 | "strings" |
| 7 | 8 | "testing" |
| @@ -58,3 +59,56 @@ func TestRunnerNextScopedToRepos(t *testing.T) { | ||
| 58 | 59 | t.Fatalf("unscoped claim did not take the remaining build:\n%s", out) |
| 59 | 60 | } |
| 60 | 61 | } |
| 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). | |
| 67 | func 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() { | ||
| 53 | 53 | Summary: "list build secret names", |
| 54 | 54 | Usage: "repo secret list <owner/name>", ReadOnly: true, Run: runSecretList}) |
| 55 | 55 | |
| 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). | |
| 59 | 61 | register(Command{Path: []string{"runner", "next"}, |
| 60 | 62 | Summary: "claim the oldest pending build (runner protocol)", |
| 61 | 63 | Usage: "runner next [<owner/name>...]", SSHOnly: true, Run: runRunnerNext}) |
| @@ -307,8 +309,8 @@ func runSecretList(c *Ctx, args []string) int { | ||
| 307 | 309 | } |
| 308 | 310 | |
| 309 | 311 | func 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") | |
| 312 | 314 | } |
| 313 | 315 | return -1 |
| 314 | 316 | } |
internal/control/control.go +3 −1
| @@ -83,7 +83,9 @@ func Dispatch(c *Ctx, argv []string) int { | ||
| 83 | 83 | if !ok { |
| 84 | 84 | return c.fail(protocol.ExitUsage, "unknown command %q", argv[0]) |
| 85 | 85 | } |
| 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") { | |
| 87 | 89 | return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) |
| 88 | 90 | } |
| 89 | 91 | if c.ViaAPI && cmd.SSHOnly { |
internal/control/identity.go +4 −4
| @@ -29,7 +29,7 @@ func init() { | ||
| 29 | 29 | register(Command{ |
| 30 | 30 | Path: []string{"keys", "add"}, |
| 31 | 31 | 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", | |
| 33 | 33 | ReadsStdin: true, |
| 34 | 34 | Run: runKeysAdd, |
| 35 | 35 | }) |
| @@ -91,12 +91,12 @@ func runKeysAdd(c *Ctx, args []string) int { | ||
| 91 | 91 | scope = args[i+1] |
| 92 | 92 | i++ |
| 93 | 93 | 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") | |
| 95 | 95 | } |
| 96 | 96 | } |
| 97 | if scope != "full" && scope != "git" { | |
| 97 | if scope != "full" && scope != "git" && scope != "runner" { | |
| 98 | 98 | // 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") | |
| 100 | 100 | } |
| 101 | 101 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) |
| 102 | 102 | if err != nil { |
internal/policy/access.go +3
| @@ -46,6 +46,9 @@ func ScopeAllowsGit(scope, repoPath string, write bool) bool { | ||
| 46 | 46 | switch scope { |
| 47 | 47 | case "full", "git": |
| 48 | 48 | return true |
| 49 | case "runner": | |
| 50 | // A CI runner clones what it builds and pushes nothing. | |
| 51 | return !write | |
| 49 | 52 | } |
| 50 | 53 | return false |
| 51 | 54 | } |