1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "net/http"
7 "strings"
8 "testing"
9
10 "connectrpc.com/connect"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 pb "bnema.dev/ermite-api/gen/ermite"
15 "bnema.dev/ermite/internal/domain"
16)
17
18// CI-3 D3 / AC3: the permissions of the job token, the push guard on workflow
19// files and the rule that a job token's push starts no run.
20
21const permsWorkflow = `on: push
22permissions:
23 contents: write
24jobs:
25 wf:
26 runs-on: ubuntu-latest
27 steps:
28 - run: echo wf
29 readonly:
30 runs-on: ubuntu-latest
31 permissions:
32 contents: read
33 pull-requests: write
34 steps:
35 - run: echo ro
36 none:
37 runs-on: ubuntu-latest
38 permissions: {}
39 steps:
40 - run: echo none
41 all:
42 runs-on: ubuntu-latest
43 permissions: write-all
44 steps:
45 - run: echo all
46`
47
48const defaultPermsWorkflow = "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
49
50// tokenPush pushes refspec to the repository with the job token, as a job that
51// runs git push with the GITHUB_TOKEN does.
52func (c *ciHarness) tokenPush(dir, token string, refspecs ...string) (string, error) {
53 args := []string{"-c", "http.extraheader=" + checkoutHeader(token), "push", c.url("/owner/proj")}
54 return c.git(dir, append(args, refspecs...)...)
55}
56
57// commitIn commits a file in dir on its current branch.
58func (c *ciHarness) commitIn(dir, path, content string) {
59 c.t.Helper()
60 c.writeFile(dir, path, []byte(content))
61}
62
63// jobsOf collects the jobs of the one run by key.
64func startedJobs(runner *fakeRunner, n int) map[string]*pb.Job {
65 return runner.jobsByKey(n)
66}
67
68func levelsOf(j *pb.Job) map[string]string { return j.GetPermissions().GetLevels() }
69
70func TestJobPermissionsAreResolvedStoredAndSent(t *testing.T) {
71 c := newCIHarness(t, nil)
72 runner := c.registerRunner("alpha", "ubuntu-latest")
73 c.push("main", map[string]string{".github/workflows/p.yml": permsWorkflow}, "")
74 jobs := startedJobs(runner, 4)
75
76 wf := levelsOf(jobs["wf"])
77 assert.Equal(t, map[string]string{"metadata": "read", "contents": "write"}, wf, "the workflow's permissions")
78 assert.True(t, jobs["wf"].GetTrusted())
79 assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "pull-requests": "write"}, levelsOf(jobs["readonly"]), "the job's replace the workflow's")
80 assert.Equal(t, map[string]string{"metadata": "read"}, levelsOf(jobs["none"]), "permissions: {} is all none")
81 assert.Equal(t, "write", levelsOf(jobs["all"])["issues"])
82 assert.Equal(t, "write", levelsOf(jobs["all"])["contents"])
83 assert.Equal(t, "read", levelsOf(jobs["all"])["models"], "write-all keeps models at read")
84
85 // The record keeps what the workflow asked for.
86 stored, err := c.forge.store.CI().ListJobs(c.ctx, c.runs()[0].GetId(), 1)
87 require.NoError(t, err)
88 byKey := map[string]domain.CIJob{}
89 for _, j := range stored {
90 byKey[j.Key] = j
91 }
92 assert.Equal(t, "write", byKey["wf"].Permissions["contents"])
93 assert.Equal(t, map[string]string{"metadata": "read"}, byKey["none"].Permissions)
94
95 // What each token may do on the repository.
96 push := func(j *pb.Job, branch string) (string, error) {
97 c.mustGit(c.wt, "checkout", "-q", "main")
98 c.commitIn(c.wt, "n-"+branch+".txt", branch)
99 return c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/main")
100 }
101 _, err = push(jobs["readonly"], "r")
102 require.Error(t, err, "contents: read cannot push")
103 _, err = push(jobs["none"], "n")
104 require.Error(t, err, "contents: none cannot push")
105 c.mustGit(c.wt, "reset", "-q", "--hard", "HEAD~2")
106 out, err := c.clone(jobs["none"].GetGithubToken(), c.url("/owner/proj"))
107 require.Error(t, err, "contents: none cannot even clone a private repository: "+out)
108 out, err = c.clone(jobs["readonly"].GetGithubToken(), c.url("/owner/proj"))
109 require.NoError(t, err, out)
110 out, err = push(jobs["wf"], "w")
111 require.NoError(t, err, out)
112 out, err = push(jobs["all"], "a")
113 require.NoError(t, err, out)
114}
115
116func TestDefaultTokenCannotPush(t *testing.T) {
117 c := newCIHarness(t, nil)
118 runner := c.registerRunner("alpha", "ubuntu-latest")
119 c.push("main", map[string]string{".github/workflows/p.yml": defaultPermsWorkflow}, "")
120 job := runner.await()
121 assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "packages": "read"}, levelsOf(job), "the restricted default")
122
123 c.commitIn(c.wt, "x.txt", "x")
124 before := c.bareGit("proj", "rev-parse", "refs/heads/main")
125 out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/main")
126 require.Error(t, err)
127 assert.Contains(t, out, "403")
128 assert.Equal(t, before, c.bareGit("proj", "rev-parse", "refs/heads/main"))
129 out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
130 require.NoError(t, err, "it reads: "+out)
131}
132
133func TestJobTokenWithContentsWritePushesButNotWorkflows(t *testing.T) {
134 c := newCIHarness(t, nil)
135 runner := c.registerRunner("alpha", "ubuntu-latest")
136 wfYAML := "on:\n push:\n create:\n delete:\npermissions:\n contents: write\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
137 c.push("main", map[string]string{".github/workflows/p.yml": wfYAML}, "")
138 c.push("feat", map[string]string{"feat.txt": "f"}, "")
139 job := runner.await()
140 tok := job.GetGithubToken()
141 require.Equal(t, "write", levelsOf(job)["contents"])
142 // push and create for main, then for feat.
143 c.waitFor("four runs", func() bool { return len(c.runs()) == 4 })
144 runsBefore := len(c.runs())
145 ref := func(r string) string { return c.bareGit("proj", "rev-parse", "--verify", "-q", r) }
146 refs := func() string { return c.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") }
147
148 // A commit on an existing branch is accepted.
149 c.mustGit(c.wt, "checkout", "-q", "main")
150 c.commitIn(c.wt, "notes.txt", "generated")
151 out, err := c.tokenPush(c.wt, tok, "HEAD:refs/heads/main")
152 require.NoError(t, err, out)
153 assert.Equal(t, strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")), ref("refs/heads/main"))
154
155 // A new branch and a new tag are accepted when their commits carry no
156 // workflow (a root commit with a README here).
157 orphan := c.workTree("orphan", "README")
158 out, err = c.tokenPush(orphan, tok, "orphan:refs/heads/orphan")
159 require.NoError(t, err, out)
160 require.NotEmpty(t, ref("refs/heads/orphan"))
161 out, err = c.tokenPush(orphan, tok, "orphan:refs/tags/v0")
162 require.NoError(t, err, out)
163 require.NotEmpty(t, ref("refs/tags/v0"))
164 // And the branch they made can be deleted, as can one that holds workflows.
165 out, err = c.tokenPush(orphan, tok, ":refs/heads/orphan")
166 require.NoError(t, err, out)
167 out, err = c.tokenPush(c.wt, tok, ":refs/heads/feat")
168 require.NoError(t, err, "deleting a branch changes no workflow: "+out)
169
170 // Refused: the whole push, whatever the ref, with GitHub's message.
171 refusal := func(path string) string {
172 return "refusing to allow a GitHub App to create or update workflow `" + path + "` without `workflows` permission"
173 }
174 before := refs()
175 c.mustGit(c.wt, "checkout", "-q", "-B", "w", "main")
176 c.commitIn(c.wt, ".github/workflows/new.yml", wfYAML) // create a file
177 out, err = c.tokenPush(c.wt, tok, "w:refs/heads/w")
178 require.Error(t, err)
179 assert.Contains(t, out, refusal(".github/workflows/new.yml"))
180 assert.Equal(t, before, refs())
181
182 c.mustGit(c.wt, "checkout", "-q", "-B", "m", "main")
183 c.commitIn(c.wt, ".github/workflows/p.yml", wfYAML+"# changed\n") // modify one
184 out, err = c.tokenPush(c.wt, tok, "m:refs/heads/main")
185 require.Error(t, err, "an update of main that touches a workflow")
186 assert.Contains(t, out, refusal(".github/workflows/p.yml"))
187 out, err = c.tokenPush(c.wt, tok, "+m:refs/heads/main")
188 require.Error(t, err, "forced too")
189 assert.Contains(t, out, refusal(".github/workflows/p.yml"))
190 assert.Equal(t, before, refs())
191
192 c.mustGit(c.wt, "checkout", "-q", "-B", "r", "main")
193 c.writeFile(c.wt, "moved.yml", []byte(wfYAML))
194 c.mustGit(c.wt, "mv", "moved.yml", ".github/workflows/moved.yml") // rename into
195 c.mustGit(c.wt, "commit", "-q", "-m", "mv")
196 out, err = c.tokenPush(c.wt, tok, "r:refs/heads/main")
197 require.Error(t, err, "a rename into .github/workflows")
198 assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
199
200 // A ref cut from the head of main carries main's workflows and changes none:
201 // a tag, an annotated tag and a branch there are allowed, as are new branches
202 // with commits that touch no workflow (staging run 855).
203 out, err = c.tokenPush(c.wt, tok, "main:refs/tags/v1")
204 require.NoError(t, err, "a tag on main's head: "+out)
205 c.mustGit(c.wt, "tag", "-a", "-m", "release", "va", "main")
206 out, err = c.tokenPush(c.wt, tok, "refs/tags/va:refs/tags/va")
207 require.NoError(t, err, "an annotated tag on main's head: "+out)
208 out, err = c.tokenPush(c.wt, tok, "main:refs/heads/copy")
209 require.NoError(t, err, "a branch at main's head: "+out)
210 c.mustGit(c.wt, "checkout", "-q", "-B", "smoke/perms-ok", "main")
211 c.commitIn(c.wt, "smoke.txt", "ok")
212 out, err = c.tokenPush(c.wt, tok, "smoke/perms-ok:refs/heads/smoke/perms-ok")
213 require.NoError(t, err, "a new branch with a commit that touches no workflow: "+out)
214 c.mustGit(c.wt, "checkout", "-q", "-B", "smoke/wf", "main")
215 c.commitIn(c.wt, ".github/workflows/smoke.yml", wfYAML)
216 out, err = c.tokenPush(c.wt, tok, "smoke/wf:refs/heads/smoke/wf")
217 require.Error(t, err, "a new branch with a commit that adds a workflow: "+out)
218 assert.Contains(t, out, refusal(".github/workflows/smoke.yml"))
219
220 // One ref that touches a workflow refuses a push of several.
221 c.mustGit(c.wt, "checkout", "-q", "-B", "ok", "main")
222 c.commitIn(c.wt, "fine.txt", "fine")
223 before = refs()
224 out, err = c.tokenPush(c.wt, tok, "ok:refs/heads/main", "r:refs/heads/other")
225 require.Error(t, err)
226 assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
227 assert.Equal(t, before, refs(), "nothing moved, not even the fine ref")
228
229 // The owner's token is not held to it.
230 out, err = c.git(c.wt, "push", c.urlAuth(adminToken, "/proj.git"), "r:refs/heads/rworkflow")
231 require.NoError(t, err, out)
232
233 // A job token cannot put a ref on that existing commit, whose workflows
234 // differ from the default branch's, nor move main back onto it.
235 out, err = c.tokenPush(c.wt, tok, "r:refs/heads/cut")
236 require.Error(t, err, "a new branch at an existing commit with other workflows: "+out)
237 assert.Contains(t, out, "without `workflows` permission")
238
239 // None of the pushes of the job token started a run; a push of the owner
240 // does, and queues after them.
241 c.push("sentinel", map[string]string{"s.txt": "s"}, "")
242 c.waitFor("the owner's runs", func() bool { return len(c.runs()) >= runsBefore+4 })
243 for _, r := range c.runs() {
244 assert.NotEqual(t, "refs/heads/orphan", r.GetRef(), "no run for a job token's push")
245 assert.NotEqual(t, "refs/tags/v0", r.GetRef())
246 assert.NotEqual(t, "delete", r.GetEvent(), "no run for a job token's deletes")
247 }
248 assert.Len(t, c.runs(), runsBefore+4, "push and create of the owner's rworkflow and sentinel only")
249}
250
251func TestJobTokenCannotPushToAnotherRepository(t *testing.T) {
252 c := newCIHarness(t, nil)
253 c.seed("other", domain.VisibilityPublic, "a.txt")
254 runner := c.registerRunner("alpha", "ubuntu-latest")
255 c.push("main", map[string]string{".github/workflows/p.yml": "on: push\npermissions:\n contents: write\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
256 job := runner.await()
257 require.Equal(t, "write", levelsOf(job)["contents"])
258 before := c.bareGit("other", "rev-parse", "refs/heads/main")
259 wt := c.workTree("main", "b.txt")
260 out, err := c.git(wt, "-c", "http.extraheader="+checkoutHeader(job.GetGithubToken()), "push", "--force", c.url("/owner/other"), "main:refs/heads/main")
261 require.Error(t, err, out)
262 assert.Equal(t, before, c.bareGit("other", "rev-parse", "refs/heads/main"))
263 status, _, _ := c.get("/owner/other/info/refs?service=git-receive-pack", basic(job.GetGithubToken()))
264 assert.Equal(t, http.StatusForbidden, status)
265 _ = out
266}
267
268// D1: the run of a token limited to branches is not trusted: whatever the
269// workflow asks, its job token is read-only.
270func TestBranchLimitedActorsJobTokenIsReadOnly(t *testing.T) {
271 c := newCIHarness(t, nil)
272 runner := c.registerRunner("alpha", "ubuntu-latest")
273 agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/x/"}, "proj")
274 c.push("main", map[string]string{".github/workflows/p.yml": "on: push\npermissions: write-all\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
275 owner := runner.await()
276 assert.Equal(t, "write", levelsOf(owner)["contents"])
277 runner.runJob(owner, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
278
279 c.mustGit(c.wt, "checkout", "-q", "main")
280 c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
281 job := runner.await()
282 assert.False(t, job.GetTrusted())
283 lv := levelsOf(job)
284 assert.Equal(t, "read", lv["contents"], "write lowered to read")
285 assert.Equal(t, "read", lv["issues"])
286 assert.Equal(t, "read", lv["models"])
287 assert.NotContains(t, lv, "id-token", "a permission with no read level is none")
288 assert.NotContains(t, lv, "copilot-requests")
289 for k, v := range lv {
290 assert.NotEqual(t, "write", v, k)
291 }
292
293 c.commitIn(c.wt, "evil.txt", "x")
294 out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/agent/x/a")
295 require.Error(t, err, "its token cannot push: "+out)
296 out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
297 require.NoError(t, err, "but it reads: "+out)
298}
299
300// D1 / D3 on re-runs: the trust of a run is kept or lowered and the token of the
301// new attempt follows it, whoever runs it again.
302func TestRerunsNeverRaiseTheJobTokenPermissions(t *testing.T) {
303 c := newCIHarness(t, nil)
304 runner := c.registerRunner("alpha", "ubuntu-latest")
305 agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/x/"}, "proj")
306 wf := "on: push\npermissions: write-all\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
307 rerun := func(api *clients, id int64) error {
308 _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
309 return err
310 }
311 byRef := func(ref string) *pb.Run {
312 for _, r := range c.runs() {
313 if r.GetRef() == ref {
314 return r
315 }
316 }
317 return nil
318 }
319 done := func(j *pb.Job) {
320 runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
321 }
322 noPush := func(j *pb.Job, ref string) {
323 t.Helper()
324 assert.False(t, j.GetTrusted())
325 assert.Equal(t, "read", levelsOf(j)["contents"])
326 for k, v := range levelsOf(j) {
327 assert.NotEqual(t, "write", v, k)
328 }
329 c.mustGit(c.wt, "checkout", "-q", "-B", "tmp", "main")
330 c.commitIn(c.wt, "x-"+ref[len(ref)-1:]+".txt", "x")
331 out, err := c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/"+ref)
332 require.Error(t, err, "the token cannot push: "+out)
333 }
334
335 // The owner's run: write-all, trusted.
336 c.push("main", map[string]string{".github/workflows/p.yml": wf}, "")
337 owner := runner.await()
338 assert.True(t, owner.GetTrusted())
339 assert.Equal(t, "write", levelsOf(owner)["contents"])
340 done(owner)
341 c.waitFor("owner run done", func() bool {
342 r := byRef("refs/heads/main")
343 return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
344 })
345 mainRun := byRef("refs/heads/main")
346
347 // The agent's run: untrusted, read-only, and the owner's re-run of it too.
348 c.mustGit(c.wt, "checkout", "-q", "main")
349 c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
350 first := runner.await()
351 noPush(first, "agent/x/a")
352 done(first)
353 c.waitFor("agent run done", func() bool {
354 r := byRef("refs/heads/agent/x/a")
355 return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
356 })
357 agentRun := byRef("refs/heads/agent/x/a")
358 require.NoError(t, rerun(c.root, agentRun.GetId()))
359 again := runner.await()
360 assert.Equal(t, int32(2), again.GetAttempt())
361 noPush(again, "agent/x/a")
362 done(again)
363
364 // The agent runs the owner's run again: refused, as it is outside its
365 // branches, and the run stays trusted with a token that writes.
366 require.Error(t, rerun(c.api(agent), mainRun.GetId()))
367 assert.True(t, c.getRun(mainRun.GetId()).GetRun().GetTrusted())
368
369 // A run of the owner on a branch the agent may touch: the agent's re-run of
370 // it is allowed, and its attempt is read-only.
371 c.mustGit(c.wt, "checkout", "-q", "-B", "agent/x/own", "main")
372 c.commitIn(c.wt, "own.txt", "own")
373 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "agent/x/own")
374 ownerOnBranch := runner.await()
375 assert.True(t, ownerOnBranch.GetTrusted())
376 assert.Equal(t, "write", levelsOf(ownerOnBranch)["contents"])
377 done(ownerOnBranch)
378 c.waitFor("owner branch run done", func() bool {
379 r := byRef("refs/heads/agent/x/own")
380 return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
381 })
382 branchRun := byRef("refs/heads/agent/x/own")
383 require.NoError(t, rerun(c.api(agent), branchRun.GetId()))
384 lowered := runner.await()
385 noPush(lowered, "agent/x/own")
386 assert.False(t, c.getRun(branchRun.GetId()).GetRun().GetTrusted(), "the re-run is not trusted")
387 done(lowered)
388}