feat(executor): print the GITHUB_TOKEN permissions GitHub grants
The group in Set up job was printed only without a permissions key. It is now
worked out from the job's effective permissions (the job's replace the
workflow's) with the rules recorded in ermite-conformance's permissions-display
scenario: names as GitHub prints them (PullRequests, SecurityEvents), sorted,
Metadata always read, none scopes and id-token not listed, {} lists Metadata
only, read-all and write-all list every scope (models and vulnerability-alerts
stay read under write-all, copilot-requests only under write-all).- commit
- 9a7971c8fbd28d8403deb92f88ce489f38bb678e
- author
- brice · · 2026-10-12 23:43 UTC
- parent
- 0cc0cf3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +235 -39
M internal/executor/corpus_test.go +71 -2
| @@ -23,11 +23,20 @@ import ( | |||
| 23 | 23 | // they skip without it. | |
| 24 | 24 | ||
| 25 | 25 | func conformanceDir(t *testing.T) string { | |
| 26 | + | t.Helper() | |
| 27 | + | return conformanceDirFor(t, "") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // conformanceDirFor is conformanceDir for a checkout that has the recording of | |
| 31 | + | // scenario (any checkout with a corpus when scenario is empty): the scenario | |
| 32 | + | // may be on a branch that is not merged yet. | |
| 33 | + | func conformanceDirFor(t *testing.T, scenario string) string { | |
| 26 | 34 | t.Helper() | |
| 27 | 35 | cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")} | |
| 28 | 36 | wd, _ := os.Getwd() | |
| 29 | 37 | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | |
| 30 | 38 | cands = append(cands, | |
| 39 | + | filepath.Join(d, "ermite-conformance", ".worktrees", "permissions"), | |
| 31 | 40 | filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"), | |
| 32 | 41 | filepath.Join(d, "ermite-conformance")) | |
| 33 | 42 | } | |
| @@ -35,9 +44,15 @@ func conformanceDir(t *testing.T) string { | |||
| 35 | 44 | if c == "" { | |
| 36 | 45 | continue | |
| 37 | 46 | } | |
| 38 | - | if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() { | |
| 39 | - | return c | |
| 47 | + | if st, err := os.Stat(filepath.Join(c, "corpus")); err != nil || !st.IsDir() { | |
| 48 | + | continue | |
| 40 | 49 | } | |
| 50 | + | if scenario != "" { | |
| 51 | + | if _, err := os.Stat(filepath.Join(c, "recordings", scenario+".json")); err != nil { | |
| 52 | + | continue | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | return c | |
| 41 | 56 | } | |
| 42 | 57 | t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)") | |
| 43 | 58 | return "" | |
| @@ -163,6 +178,7 @@ func readFile(t *testing.T, p string) string { | |||
| 163 | 178 | type recording struct { | |
| 164 | 179 | Pushes []struct { | |
| 165 | 180 | Runs []struct { | |
| 181 | + | Workflow string `json:"workflow"` | |
| 166 | 182 | Conclusion string `json:"conclusion"` | |
| 167 | 183 | Jobs []struct { | |
| 168 | 184 | Name string `json:"name"` | |
| @@ -324,3 +340,56 @@ func TestCorpusStatusInEnvIsRejected(t *testing.T) { | |||
| 324 | 340 | t.Fatalf("%+v %v", res, rec.names()) | |
| 325 | 341 | } | |
| 326 | 342 | } | |
| 343 | + | ||
| 344 | + | // TestCorpusPermissionsDisplay runs every job of every workflow of the | |
| 345 | + | // permissions-display scenario and compares the GITHUB_TOKEN Permissions group | |
| 346 | + | // of Set up job with the recording. The scenario has one workflow file per | |
| 347 | + | // case, so compareWithRecording does not apply. Workflows GitHub rejects have | |
| 348 | + | // no jobs in the recording and are not run. | |
| 349 | + | func TestCorpusPermissionsDisplay(t *testing.T) { | |
| 350 | + | needTools(t, "bash") | |
| 351 | + | dir := conformanceDirFor(t, "permissions-display") | |
| 352 | + | var rec recording | |
| 353 | + | if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", "permissions-display.json"))), &rec); err != nil { | |
| 354 | + | t.Fatal(err) | |
| 355 | + | } | |
| 356 | + | n := 0 | |
| 357 | + | for _, run := range rec.Pushes[0].Runs { | |
| 358 | + | if len(run.Jobs) == 0 { | |
| 359 | + | continue | |
| 360 | + | } | |
| 361 | + | file := run.Workflow | |
| 362 | + | yaml := readFile(t, filepath.Join(dir, "corpus", "permissions-display", "tree", filepath.FromSlash(file))) | |
| 363 | + | for _, jr := range run.Jobs { | |
| 364 | + | t.Run(strings.TrimSuffix(filepath.Base(file), ".yml")+"/"+jr.Name, func(t *testing.T) { | |
| 365 | + | want := permissionsGroup(jr.Steps[0].Log) | |
| 366 | + | if want == nil { | |
| 367 | + | t.Fatalf("no group in the recording: %v", jr.Steps[0].Log) | |
| 368 | + | } | |
| 369 | + | got, _ := runLocal(t, yaml, jr.Name, nil) | |
| 370 | + | if have := permissionsGroup(got.steps[0].Log); strings.Join(have, "\n") != strings.Join(want, "\n") { | |
| 371 | + | t.Errorf("got\n%s\nwant\n%s", strings.Join(have, "\n"), strings.Join(want, "\n")) | |
| 372 | + | } | |
| 373 | + | }) | |
| 374 | + | n++ | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | if n < 20 { | |
| 378 | + | t.Fatalf("only %d jobs in the recording", n) | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // permissionsGroup returns the lines of the GITHUB_TOKEN Permissions group. | |
| 383 | + | func permissionsGroup(log []string) []string { | |
| 384 | + | for i, l := range log { | |
| 385 | + | if l != "##[group]GITHUB_TOKEN Permissions" { | |
| 386 | + | continue | |
| 387 | + | } | |
| 388 | + | for k := i + 1; k < len(log); k++ { | |
| 389 | + | if log[k] == "##[endgroup]" { | |
| 390 | + | return log[i+1 : k] | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | return nil | |
| 395 | + | } | |
| @@ -23,11 +23,20 @@ import ( | |||||
| 23 | // they skip without it. | 23 | // they skip without it. | ||
| 24 | 24 | ||||
| 25 | func conformanceDir(t *testing.T) string { | 25 | func conformanceDir(t *testing.T) string { | ||
| 26 | + | t.Helper() | |||
| 27 | + | return conformanceDirFor(t, "") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // conformanceDirFor is conformanceDir for a checkout that has the recording of | |||
| 31 | + | // scenario (any checkout with a corpus when scenario is empty): the scenario | |||
| 32 | + | // may be on a branch that is not merged yet. | |||
| 33 | + | func conformanceDirFor(t *testing.T, scenario string) string { | |||
| 26 | t.Helper() | 34 | t.Helper() | ||
| 27 | cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")} | 35 | cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")} | ||
| 28 | wd, _ := os.Getwd() | 36 | wd, _ := os.Getwd() | ||
| 29 | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | 37 | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | ||
| 30 | cands = append(cands, | 38 | cands = append(cands, | ||
| 39 | + | filepath.Join(d, "ermite-conformance", ".worktrees", "permissions"), | |||
| 31 | filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"), | 40 | filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"), | ||
| 32 | filepath.Join(d, "ermite-conformance")) | 41 | filepath.Join(d, "ermite-conformance")) | ||
| 33 | } | 42 | } | ||
| @@ -35,9 +44,15 @@ func conformanceDir(t *testing.T) string { | |||||
| 35 | if c == "" { | 44 | if c == "" { | ||
| 36 | continue | 45 | continue | ||
| 37 | } | 46 | } | ||
| 38 | - | if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() { | 47 | + | if st, err := os.Stat(filepath.Join(c, "corpus")); err != nil || !st.IsDir() { |
| 39 | - | return c | 48 | + | continue |
| 40 | } | 49 | } | ||
| 50 | + | if scenario != "" { | |||
| 51 | + | if _, err := os.Stat(filepath.Join(c, "recordings", scenario+".json")); err != nil { | |||
| 52 | + | continue | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | return c | |||
| 41 | } | 56 | } | ||
| 42 | t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)") | 57 | t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)") | ||
| 43 | return "" | 58 | return "" | ||
| @@ -163,6 +178,7 @@ func readFile(t *testing.T, p string) string { | |||||
| 163 | type recording struct { | 178 | type recording struct { | ||
| 164 | Pushes []struct { | 179 | Pushes []struct { | ||
| 165 | Runs []struct { | 180 | Runs []struct { | ||
| 181 | + | Workflow string `json:"workflow"` | |||
| 166 | Conclusion string `json:"conclusion"` | 182 | Conclusion string `json:"conclusion"` | ||
| 167 | Jobs []struct { | 183 | Jobs []struct { | ||
| 168 | Name string `json:"name"` | 184 | Name string `json:"name"` | ||
| @@ -324,3 +340,56 @@ func TestCorpusStatusInEnvIsRejected(t *testing.T) { | |||||
| 324 | t.Fatalf("%+v %v", res, rec.names()) | 340 | t.Fatalf("%+v %v", res, rec.names()) | ||
| 325 | } | 341 | } | ||
| 326 | } | 342 | } | ||
| 343 | + | ||||
| 344 | + | // TestCorpusPermissionsDisplay runs every job of every workflow of the | |||
| 345 | + | // permissions-display scenario and compares the GITHUB_TOKEN Permissions group | |||
| 346 | + | // of Set up job with the recording. The scenario has one workflow file per | |||
| 347 | + | // case, so compareWithRecording does not apply. Workflows GitHub rejects have | |||
| 348 | + | // no jobs in the recording and are not run. | |||
| 349 | + | func TestCorpusPermissionsDisplay(t *testing.T) { | |||
| 350 | + | needTools(t, "bash") | |||
| 351 | + | dir := conformanceDirFor(t, "permissions-display") | |||
| 352 | + | var rec recording | |||
| 353 | + | if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", "permissions-display.json"))), &rec); err != nil { | |||
| 354 | + | t.Fatal(err) | |||
| 355 | + | } | |||
| 356 | + | n := 0 | |||
| 357 | + | for _, run := range rec.Pushes[0].Runs { | |||
| 358 | + | if len(run.Jobs) == 0 { | |||
| 359 | + | continue | |||
| 360 | + | } | |||
| 361 | + | file := run.Workflow | |||
| 362 | + | yaml := readFile(t, filepath.Join(dir, "corpus", "permissions-display", "tree", filepath.FromSlash(file))) | |||
| 363 | + | for _, jr := range run.Jobs { | |||
| 364 | + | t.Run(strings.TrimSuffix(filepath.Base(file), ".yml")+"/"+jr.Name, func(t *testing.T) { | |||
| 365 | + | want := permissionsGroup(jr.Steps[0].Log) | |||
| 366 | + | if want == nil { | |||
| 367 | + | t.Fatalf("no group in the recording: %v", jr.Steps[0].Log) | |||
| 368 | + | } | |||
| 369 | + | got, _ := runLocal(t, yaml, jr.Name, nil) | |||
| 370 | + | if have := permissionsGroup(got.steps[0].Log); strings.Join(have, "\n") != strings.Join(want, "\n") { | |||
| 371 | + | t.Errorf("got\n%s\nwant\n%s", strings.Join(have, "\n"), strings.Join(want, "\n")) | |||
| 372 | + | } | |||
| 373 | + | }) | |||
| 374 | + | n++ | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | if n < 20 { | |||
| 378 | + | t.Fatalf("only %d jobs in the recording", n) | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | // permissionsGroup returns the lines of the GITHUB_TOKEN Permissions group. | |||
| 383 | + | func permissionsGroup(log []string) []string { | |||
| 384 | + | for i, l := range log { | |||
| 385 | + | if l != "##[group]GITHUB_TOKEN Permissions" { | |||
| 386 | + | continue | |||
| 387 | + | } | |||
| 388 | + | for k := i + 1; k < len(log); k++ { | |||
| 389 | + | if log[k] == "##[endgroup]" { | |||
| 390 | + | return log[i+1 : k] | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | return nil | |||
| 395 | + | } | |||
M internal/executor/job.go +9 -26
| @@ -580,37 +580,20 @@ const ( | |||
| 580 | 580 | defaultCacheMode = "write" | |
| 581 | 581 | ) | |
| 582 | 582 | ||
| 583 | - | // defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a | |
| 584 | - | // "permissions" key, as recorded (JobExtension.cs:157-166 prints the | |
| 585 | - | // permissions in the order of the server's JSON). | |
| 586 | - | var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}} | |
| 587 | - | ||
| 588 | 583 | // printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source | |
| 589 | 584 | // and the cache mode. | |
| 590 | 585 | // | |
| 591 | - | // Without a "permissions" key the group is the recorded default. With one | |
| 592 | - | // (including {} and read-all/write-all) the group is omitted, on purpose: the | |
| 593 | - | // runner only prints what the server gives it (system.github.token.permissions, | |
| 594 | - | // a JSON object, JobExtension.cs:154-166), and the sources at hand do not say | |
| 595 | - | // what the server gives for those cases: the display names of the scopes | |
| 596 | - | // ("Pull requests" or "PullRequests", "Id-token" or "IdToken"), their order, the | |
| 597 | - | // levels the server grants on a push (Metadata is always read; write-all and | |
| 598 | - | // id-token depend on the repository's policy, the Permissions class of the | |
| 599 | - | // runner only models the workflow side, Sdk/WorkflowParser/Permissions.cs and | |
| 600 | - | // Conversion/PermissionsHelper.cs) and whether "none" scopes are listed. Only | |
| 601 | - | // the default is in the recordings (221 of 221 jobs; no corpus scenario sets | |
| 602 | - | // permissions). A group made up from the workflow would be a guess; add the | |
| 603 | - | // mapping once a recording of a scenario with permissions exists. The | |
| 604 | - | // complete fix is the server sending the effective permissions in the Job | |
| 605 | - | // message. | |
| 586 | + | // GitHub's runner prints what the server sends as | |
| 587 | + | // system.github.token.permissions (JobExtension.cs:154-166); ermite's Job | |
| 588 | + | // message does not carry it, so the group is worked out from the workflow | |
| 589 | + | // with the rules recorded on github.com (tokenPermissionLines). It is always | |
| 590 | + | // printed: Metadata is listed even for "permissions: {}". | |
| 606 | 591 | func (j *job) printTokenInfo() { | |
| 607 | - | if j.wf.Permissions == nil && j.jb.Permissions == nil { | |
| 608 | - | j.out(1, "##[group]GITHUB_TOKEN Permissions") | |
| 609 | - | for _, p := range defaultTokenPermissions { | |
| 610 | - | j.out(1, p[0]+": "+p[1]) | |
| 611 | - | } | |
| 612 | - | j.out(1, "##[endgroup]") | |
| 592 | + | j.out(1, "##[group]GITHUB_TOKEN Permissions") | |
| 593 | + | for _, p := range tokenPermissionLines(j.wf, j.jb) { | |
| 594 | + | j.out(1, p.name+": "+p.level) | |
| 613 | 595 | } | |
| 596 | + | j.out(1, "##[endgroup]") | |
| 614 | 597 | // secret_source is a github context key (JobExtension.cs:172); the context | |
| 615 | 598 | // of a job from ermite lacks it, so it is added with the recorded value. | |
| 616 | 599 | src := j.state.GitHub("secret_source") | |
| @@ -580,37 +580,20 @@ const ( | |||||
| 580 | defaultCacheMode = "write" | 580 | defaultCacheMode = "write" | ||
| 581 | ) | 581 | ) | ||
| 582 | 582 | ||||
| 583 | - | // defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a | |||
| 584 | - | // "permissions" key, as recorded (JobExtension.cs:157-166 prints the | |||
| 585 | - | // permissions in the order of the server's JSON). | |||
| 586 | - | var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}} | |||
| 587 | - | ||||
| 588 | // printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source | 583 | // printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source | ||
| 589 | // and the cache mode. | 584 | // and the cache mode. | ||
| 590 | // | 585 | // | ||
| 591 | - | // Without a "permissions" key the group is the recorded default. With one | 586 | + | // GitHub's runner prints what the server sends as |
| 592 | - | // (including {} and read-all/write-all) the group is omitted, on purpose: the | 587 | + | // system.github.token.permissions (JobExtension.cs:154-166); ermite's Job |
| 593 | - | // runner only prints what the server gives it (system.github.token.permissions, | 588 | + | // message does not carry it, so the group is worked out from the workflow |
| 594 | - | // a JSON object, JobExtension.cs:154-166), and the sources at hand do not say | 589 | + | // with the rules recorded on github.com (tokenPermissionLines). It is always |
| 595 | - | // what the server gives for those cases: the display names of the scopes | 590 | + | // printed: Metadata is listed even for "permissions: {}". |
| 596 | - | // ("Pull requests" or "PullRequests", "Id-token" or "IdToken"), their order, the | |||
| 597 | - | // levels the server grants on a push (Metadata is always read; write-all and | |||
| 598 | - | // id-token depend on the repository's policy, the Permissions class of the | |||
| 599 | - | // runner only models the workflow side, Sdk/WorkflowParser/Permissions.cs and | |||
| 600 | - | // Conversion/PermissionsHelper.cs) and whether "none" scopes are listed. Only | |||
| 601 | - | // the default is in the recordings (221 of 221 jobs; no corpus scenario sets | |||
| 602 | - | // permissions). A group made up from the workflow would be a guess; add the | |||
| 603 | - | // mapping once a recording of a scenario with permissions exists. The | |||
| 604 | - | // complete fix is the server sending the effective permissions in the Job | |||
| 605 | - | // message. | |||
| 606 | func (j *job) printTokenInfo() { | 591 | func (j *job) printTokenInfo() { | ||
| 607 | - | if j.wf.Permissions == nil && j.jb.Permissions == nil { | 592 | + | j.out(1, "##[group]GITHUB_TOKEN Permissions") |
| 608 | - | j.out(1, "##[group]GITHUB_TOKEN Permissions") | 593 | + | for _, p := range tokenPermissionLines(j.wf, j.jb) { |
| 609 | - | for _, p := range defaultTokenPermissions { | 594 | + | j.out(1, p.name+": "+p.level) |
| 610 | - | j.out(1, p[0]+": "+p[1]) | |||
| 611 | - | } | |||
| 612 | - | j.out(1, "##[endgroup]") | |||
| 613 | } | 595 | } | ||
| 596 | + | j.out(1, "##[endgroup]") | |||
| 614 | // secret_source is a github context key (JobExtension.cs:172); the context | 597 | // secret_source is a github context key (JobExtension.cs:172); the context | ||
| 615 | // of a job from ermite lacks it, so it is added with the recorded value. | 598 | // of a job from ermite lacks it, so it is added with the recorded value. | ||
| 616 | src := j.state.GitHub("secret_source") | 599 | src := j.state.GitHub("secret_source") | ||
M internal/executor/job_test.go +48 -11
| @@ -696,19 +696,56 @@ func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) { | |||
| 696 | 696 | } | |
| 697 | 697 | } | |
| 698 | 698 | ||
| 699 | - | // Permissions the workflow sets are not the default ones: no group is printed | |
| 700 | - | // rather than a wrong one. | |
| 701 | - | func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) { | |
| 699 | + | // The GITHUB_TOKEN Permissions group follows the effective permissions of the | |
| 700 | + | // job (the job's replace the workflow's), as recorded on github.com | |
| 701 | + | // (recordings/permissions-display.json). | |
| 702 | + | func TestSetUpJobPrintsTheTokenPermissionsGitHubGrants(t *testing.T) { | |
| 702 | 703 | needTools(t, "bash") | |
| 703 | - | for name, yaml := range map[string]string{ | |
| 704 | - | "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n", | |
| 705 | - | "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n", | |
| 706 | - | "empty": "permissions: {}\n" + wfHead + " steps:\n - run: echo hi\n", | |
| 707 | - | "write": "permissions: write-all\n" + wfHead + " steps:\n - run: echo hi\n", | |
| 704 | + | steps := " steps:\n - run: echo hi\n" | |
| 705 | + | for _, tc := range []struct { | |
| 706 | + | name, yaml, want string | |
| 707 | + | }{ | |
| 708 | + | {"no key", wfHead + steps, "Contents: read\nMetadata: read\nPackages: read"}, | |
| 709 | + | {"workflow {}", "permissions: {}\n" + wfHead + steps, "Metadata: read"}, | |
| 710 | + | {"job {}", "permissions: write-all\n" + wfHead + " permissions: {}\n" + steps, "Metadata: read"}, | |
| 711 | + | {"workflow map", "permissions:\n packages: read\n contents: write\n" + wfHead + steps, | |
| 712 | + | "Contents: write\nMetadata: read\nPackages: read"}, | |
| 713 | + | {"none and id-token are not listed", wfHead + " permissions:\n issues: none\n id-token: write\n checks: read\n" + steps, | |
| 714 | + | "Checks: read\nMetadata: read"}, | |
| 715 | + | {"job replaces workflow", "permissions:\n contents: write\n issues: write\n" + wfHead + " permissions:\n pull-requests: read\n" + steps, | |
| 716 | + | "Metadata: read\nPullRequests: read"}, | |
| 717 | + | {"job read-all over workflow map", "permissions:\n contents: write\n" + wfHead + " permissions: read-all\n" + steps, "read-all"}, | |
| 718 | + | {"workflow write-all, job narrows", "permissions: write-all\n" + wfHead + " permissions:\n contents: read\n" + steps, | |
| 719 | + | "Contents: read\nMetadata: read"}, | |
| 720 | + | {"write-all", "permissions: write-all\n" + wfHead + steps, "write-all"}, | |
| 721 | + | {"metadata is not asked for", wfHead + " permissions:\n copilot-requests: write\n security-events: read\n pull-requests: write\n" + steps, | |
| 722 | + | "CopilotRequests: write\nMetadata: read\nPullRequests: write\nSecurityEvents: read"}, | |
| 708 | 723 | } { | |
| 709 | - | got := setupLog(t, yaml, "j", nil) | |
| 710 | - | if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") { | |
| 711 | - | t.Errorf("%s:\n%s", name, got) | |
| 724 | + | got := setupLog(t, tc.yaml, "j", nil) | |
| 725 | + | start := strings.Index(got, "##[group]GITHUB_TOKEN Permissions\n") | |
| 726 | + | end := strings.Index(got, "\n##[endgroup]") | |
| 727 | + | if start < 0 || end < 0 || !strings.Contains(got, "##[endgroup]\nSecret source: Actions\nCache mode: write") { | |
| 728 | + | t.Errorf("%s:\n%s", tc.name, got) | |
| 729 | + | continue | |
| 730 | + | } | |
| 731 | + | have := strings.Split(got[start+len("##[group]GITHUB_TOKEN Permissions\n"):end], "\n") | |
| 732 | + | switch tc.want { | |
| 733 | + | case "read-all": | |
| 734 | + | if len(have) != 19 || have[0] != "Actions: read" || strings.Contains(strings.Join(have, ","), "write") || strings.Contains(strings.Join(have, ","), "CopilotRequests") { | |
| 735 | + | t.Errorf("%s: %v", tc.name, have) | |
| 736 | + | } | |
| 737 | + | case "write-all": | |
| 738 | + | // recordings: p-write-all | |
| 739 | + | wantAll := "Actions: write,ArtifactMetadata: write,Attestations: write,Checks: write,CodeQuality: write,Contents: write,CopilotRequests: write," + | |
| 740 | + | "Deployments: write,Discussions: write,Drives: write,Issues: write,Metadata: read,Models: read,Packages: write,Pages: write,PullRequests: write," + | |
| 741 | + | "RepositoryProjects: write,SecurityEvents: write,Statuses: write,VulnerabilityAlerts: read" | |
| 742 | + | if strings.Join(have, ",") != wantAll { | |
| 743 | + | t.Errorf("%s: %v", tc.name, have) | |
| 744 | + | } | |
| 745 | + | default: | |
| 746 | + | if strings.Join(have, "\n") != tc.want { | |
| 747 | + | t.Errorf("%s:\ngot %v\nwant %q", tc.name, have, tc.want) | |
| 748 | + | } | |
| 712 | 749 | } | |
| 713 | 750 | } | |
| 714 | 751 | } | |
| @@ -696,19 +696,56 @@ func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) { | |||||
| 696 | } | 696 | } | ||
| 697 | } | 697 | } | ||
| 698 | 698 | ||||
| 699 | - | // Permissions the workflow sets are not the default ones: no group is printed | 699 | + | // The GITHUB_TOKEN Permissions group follows the effective permissions of the |
| 700 | - | // rather than a wrong one. | 700 | + | // job (the job's replace the workflow's), as recorded on github.com |
| 701 | - | func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) { | 701 | + | // (recordings/permissions-display.json). |
| 702 | + | func TestSetUpJobPrintsTheTokenPermissionsGitHubGrants(t *testing.T) { | |||
| 702 | needTools(t, "bash") | 703 | needTools(t, "bash") | ||
| 703 | - | for name, yaml := range map[string]string{ | 704 | + | steps := " steps:\n - run: echo hi\n" |
| 704 | - | "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n", | 705 | + | for _, tc := range []struct { |
| 705 | - | "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n", | 706 | + | name, yaml, want string |
| 706 | - | "empty": "permissions: {}\n" + wfHead + " steps:\n - run: echo hi\n", | 707 | + | }{ |
| 707 | - | "write": "permissions: write-all\n" + wfHead + " steps:\n - run: echo hi\n", | 708 | + | {"no key", wfHead + steps, "Contents: read\nMetadata: read\nPackages: read"}, |
| 709 | + | {"workflow {}", "permissions: {}\n" + wfHead + steps, "Metadata: read"}, | |||
| 710 | + | {"job {}", "permissions: write-all\n" + wfHead + " permissions: {}\n" + steps, "Metadata: read"}, | |||
| 711 | + | {"workflow map", "permissions:\n packages: read\n contents: write\n" + wfHead + steps, | |||
| 712 | + | "Contents: write\nMetadata: read\nPackages: read"}, | |||
| 713 | + | {"none and id-token are not listed", wfHead + " permissions:\n issues: none\n id-token: write\n checks: read\n" + steps, | |||
| 714 | + | "Checks: read\nMetadata: read"}, | |||
| 715 | + | {"job replaces workflow", "permissions:\n contents: write\n issues: write\n" + wfHead + " permissions:\n pull-requests: read\n" + steps, | |||
| 716 | + | "Metadata: read\nPullRequests: read"}, | |||
| 717 | + | {"job read-all over workflow map", "permissions:\n contents: write\n" + wfHead + " permissions: read-all\n" + steps, "read-all"}, | |||
| 718 | + | {"workflow write-all, job narrows", "permissions: write-all\n" + wfHead + " permissions:\n contents: read\n" + steps, | |||
| 719 | + | "Contents: read\nMetadata: read"}, | |||
| 720 | + | {"write-all", "permissions: write-all\n" + wfHead + steps, "write-all"}, | |||
| 721 | + | {"metadata is not asked for", wfHead + " permissions:\n copilot-requests: write\n security-events: read\n pull-requests: write\n" + steps, | |||
| 722 | + | "CopilotRequests: write\nMetadata: read\nPullRequests: write\nSecurityEvents: read"}, | |||
| 708 | } { | 723 | } { | ||
| 709 | - | got := setupLog(t, yaml, "j", nil) | 724 | + | got := setupLog(t, tc.yaml, "j", nil) |
| 710 | - | if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") { | 725 | + | start := strings.Index(got, "##[group]GITHUB_TOKEN Permissions\n") |
| 711 | - | t.Errorf("%s:\n%s", name, got) | 726 | + | end := strings.Index(got, "\n##[endgroup]") |
| 727 | + | if start < 0 || end < 0 || !strings.Contains(got, "##[endgroup]\nSecret source: Actions\nCache mode: write") { | |||
| 728 | + | t.Errorf("%s:\n%s", tc.name, got) | |||
| 729 | + | continue | |||
| 730 | + | } | |||
| 731 | + | have := strings.Split(got[start+len("##[group]GITHUB_TOKEN Permissions\n"):end], "\n") | |||
| 732 | + | switch tc.want { | |||
| 733 | + | case "read-all": | |||
| 734 | + | if len(have) != 19 || have[0] != "Actions: read" || strings.Contains(strings.Join(have, ","), "write") || strings.Contains(strings.Join(have, ","), "CopilotRequests") { | |||
| 735 | + | t.Errorf("%s: %v", tc.name, have) | |||
| 736 | + | } | |||
| 737 | + | case "write-all": | |||
| 738 | + | // recordings: p-write-all | |||
| 739 | + | wantAll := "Actions: write,ArtifactMetadata: write,Attestations: write,Checks: write,CodeQuality: write,Contents: write,CopilotRequests: write," + | |||
| 740 | + | "Deployments: write,Discussions: write,Drives: write,Issues: write,Metadata: read,Models: read,Packages: write,Pages: write,PullRequests: write," + | |||
| 741 | + | "RepositoryProjects: write,SecurityEvents: write,Statuses: write,VulnerabilityAlerts: read" | |||
| 742 | + | if strings.Join(have, ",") != wantAll { | |||
| 743 | + | t.Errorf("%s: %v", tc.name, have) | |||
| 744 | + | } | |||
| 745 | + | default: | |||
| 746 | + | if strings.Join(have, "\n") != tc.want { | |||
| 747 | + | t.Errorf("%s:\ngot %v\nwant %q", tc.name, have, tc.want) | |||
| 748 | + | } | |||
| 712 | } | 749 | } | ||
| 713 | } | 750 | } | ||
| 714 | } | 751 | } | ||
A internal/executor/token_permissions.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "sort" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite-actions/workflow" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it. | |
| 13 | + | type tokenScope struct { | |
| 14 | + | // key is the name in a workflow's "permissions" mapping. | |
| 15 | + | key string | |
| 16 | + | // display is the name "Set up job" prints. | |
| 17 | + | display string | |
| 18 | + | // writeAll is the level "write-all" grants: GitHub keeps models and | |
| 19 | + | // vulnerability-alerts at read, whatever the workflow asks. | |
| 20 | + | writeAll string | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for | |
| 24 | + | // "read-all" and "write-all" (recordings/permissions-display.json: p-read-all, | |
| 25 | + | // p-write-all). "id-token" is not here: it is never listed, with any level | |
| 26 | + | // (p-job-mixed asks for id-token: write). "copilot-requests" is listed for | |
| 27 | + | // write-all only (read-all grants nothing for it, and "read" is rejected). | |
| 28 | + | // "metadata" is not a scope a workflow can set (rejected); it is always read. | |
| 29 | + | var tokenScopes = []tokenScope{ | |
| 30 | + | {"actions", "Actions", "write"}, | |
| 31 | + | {"artifact-metadata", "ArtifactMetadata", "write"}, | |
| 32 | + | {"attestations", "Attestations", "write"}, | |
| 33 | + | {"checks", "Checks", "write"}, | |
| 34 | + | {"code-quality", "CodeQuality", "write"}, | |
| 35 | + | {"contents", "Contents", "write"}, | |
| 36 | + | {"copilot-requests", "CopilotRequests", "write"}, | |
| 37 | + | {"deployments", "Deployments", "write"}, | |
| 38 | + | {"discussions", "Discussions", "write"}, | |
| 39 | + | {"drives", "Drives", "write"}, | |
| 40 | + | {"issues", "Issues", "write"}, | |
| 41 | + | {"models", "Models", "read"}, | |
| 42 | + | {"packages", "Packages", "write"}, | |
| 43 | + | {"pages", "Pages", "write"}, | |
| 44 | + | {"pull-requests", "PullRequests", "write"}, | |
| 45 | + | {"repository-projects", "RepositoryProjects", "write"}, | |
| 46 | + | {"security-events", "SecurityEvents", "write"}, | |
| 47 | + | {"statuses", "Statuses", "write"}, | |
| 48 | + | {"vulnerability-alerts", "VulnerabilityAlerts", "read"}, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // permissionLine is one line of the group: "<Display>: <level>". | |
| 52 | + | type permissionLine struct{ name, level string } | |
| 53 | + | ||
| 54 | + | // tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions" | |
| 55 | + | // group for the effective permissions of a job, as GitHub prints them on a | |
| 56 | + | // push by a repository owner (the recorded case): | |
| 57 | + | // | |
| 58 | + | // - the permissions of the job replace those of the workflow (no merge); | |
| 59 | + | // without either key the repository default applies; | |
| 60 | + | // - lines are sorted by display name (ordinal), Metadata included; | |
| 61 | + | // - Metadata is always listed, as read, whatever the workflow says; | |
| 62 | + | // - a scope set to "none" or left out is not listed, nor is id-token; | |
| 63 | + | // - "permissions: {}" lists Metadata only; | |
| 64 | + | // - "read-all" lists every scope but copilot-requests as read; | |
| 65 | + | // - "write-all" lists every scope as write except models and | |
| 66 | + | // vulnerability-alerts (read); | |
| 67 | + | // - a mapping lists its scopes with the given level; a scope that GitHub | |
| 68 | + | // does not know is left out (it rejects the workflow before this point | |
| 69 | + | // for the invalid ones it was shown). | |
| 70 | + | // | |
| 71 | + | // Not recorded: a private repository, a fork pull request, an organisation | |
| 72 | + | // whose default token is restricted differently, a "pull_request_target" | |
| 73 | + | // event. They change what the server grants; the workflow side is the same. | |
| 74 | + | func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine { | |
| 75 | + | p := jb.Permissions | |
| 76 | + | if p == nil { | |
| 77 | + | p = wf.Permissions | |
| 78 | + | } | |
| 79 | + | if p == nil { | |
| 80 | + | return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}} | |
| 81 | + | } | |
| 82 | + | lines := []permissionLine{{"Metadata", "read"}} | |
| 83 | + | switch strings.ToLower(p.All) { | |
| 84 | + | case "read-all": | |
| 85 | + | for _, s := range tokenScopes { | |
| 86 | + | if s.key != "copilot-requests" { | |
| 87 | + | lines = append(lines, permissionLine{s.display, "read"}) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | case "write-all": | |
| 91 | + | for _, s := range tokenScopes { | |
| 92 | + | lines = append(lines, permissionLine{s.display, s.writeAll}) | |
| 93 | + | } | |
| 94 | + | default: | |
| 95 | + | level := map[string]string{} | |
| 96 | + | for _, sc := range p.Scopes { | |
| 97 | + | level[strings.ToLower(sc.Scope)] = strings.ToLower(sc.Level) | |
| 98 | + | } | |
| 99 | + | for _, s := range tokenScopes { | |
| 100 | + | if l := level[s.key]; l == "read" || l == "write" { | |
| 101 | + | lines = append(lines, permissionLine{s.display, l}) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name }) | |
| 106 | + | return lines | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite-actions/workflow" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it. | |||
| 13 | + | type tokenScope struct { | |||
| 14 | + | // key is the name in a workflow's "permissions" mapping. | |||
| 15 | + | key string | |||
| 16 | + | // display is the name "Set up job" prints. | |||
| 17 | + | display string | |||
| 18 | + | // writeAll is the level "write-all" grants: GitHub keeps models and | |||
| 19 | + | // vulnerability-alerts at read, whatever the workflow asks. | |||
| 20 | + | writeAll string | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for | |||
| 24 | + | // "read-all" and "write-all" (recordings/permissions-display.json: p-read-all, | |||
| 25 | + | // p-write-all). "id-token" is not here: it is never listed, with any level | |||
| 26 | + | // (p-job-mixed asks for id-token: write). "copilot-requests" is listed for | |||
| 27 | + | // write-all only (read-all grants nothing for it, and "read" is rejected). | |||
| 28 | + | // "metadata" is not a scope a workflow can set (rejected); it is always read. | |||
| 29 | + | var tokenScopes = []tokenScope{ | |||
| 30 | + | {"actions", "Actions", "write"}, | |||
| 31 | + | {"artifact-metadata", "ArtifactMetadata", "write"}, | |||
| 32 | + | {"attestations", "Attestations", "write"}, | |||
| 33 | + | {"checks", "Checks", "write"}, | |||
| 34 | + | {"code-quality", "CodeQuality", "write"}, | |||
| 35 | + | {"contents", "Contents", "write"}, | |||
| 36 | + | {"copilot-requests", "CopilotRequests", "write"}, | |||
| 37 | + | {"deployments", "Deployments", "write"}, | |||
| 38 | + | {"discussions", "Discussions", "write"}, | |||
| 39 | + | {"drives", "Drives", "write"}, | |||
| 40 | + | {"issues", "Issues", "write"}, | |||
| 41 | + | {"models", "Models", "read"}, | |||
| 42 | + | {"packages", "Packages", "write"}, | |||
| 43 | + | {"pages", "Pages", "write"}, | |||
| 44 | + | {"pull-requests", "PullRequests", "write"}, | |||
| 45 | + | {"repository-projects", "RepositoryProjects", "write"}, | |||
| 46 | + | {"security-events", "SecurityEvents", "write"}, | |||
| 47 | + | {"statuses", "Statuses", "write"}, | |||
| 48 | + | {"vulnerability-alerts", "VulnerabilityAlerts", "read"}, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // permissionLine is one line of the group: "<Display>: <level>". | |||
| 52 | + | type permissionLine struct{ name, level string } | |||
| 53 | + | ||||
| 54 | + | // tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions" | |||
| 55 | + | // group for the effective permissions of a job, as GitHub prints them on a | |||
| 56 | + | // push by a repository owner (the recorded case): | |||
| 57 | + | // | |||
| 58 | + | // - the permissions of the job replace those of the workflow (no merge); | |||
| 59 | + | // without either key the repository default applies; | |||
| 60 | + | // - lines are sorted by display name (ordinal), Metadata included; | |||
| 61 | + | // - Metadata is always listed, as read, whatever the workflow says; | |||
| 62 | + | // - a scope set to "none" or left out is not listed, nor is id-token; | |||
| 63 | + | // - "permissions: {}" lists Metadata only; | |||
| 64 | + | // - "read-all" lists every scope but copilot-requests as read; | |||
| 65 | + | // - "write-all" lists every scope as write except models and | |||
| 66 | + | // vulnerability-alerts (read); | |||
| 67 | + | // - a mapping lists its scopes with the given level; a scope that GitHub | |||
| 68 | + | // does not know is left out (it rejects the workflow before this point | |||
| 69 | + | // for the invalid ones it was shown). | |||
| 70 | + | // | |||
| 71 | + | // Not recorded: a private repository, a fork pull request, an organisation | |||
| 72 | + | // whose default token is restricted differently, a "pull_request_target" | |||
| 73 | + | // event. They change what the server grants; the workflow side is the same. | |||
| 74 | + | func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine { | |||
| 75 | + | p := jb.Permissions | |||
| 76 | + | if p == nil { | |||
| 77 | + | p = wf.Permissions | |||
| 78 | + | } | |||
| 79 | + | if p == nil { | |||
| 80 | + | return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}} | |||
| 81 | + | } | |||
| 82 | + | lines := []permissionLine{{"Metadata", "read"}} | |||
| 83 | + | switch strings.ToLower(p.All) { | |||
| 84 | + | case "read-all": | |||
| 85 | + | for _, s := range tokenScopes { | |||
| 86 | + | if s.key != "copilot-requests" { | |||
| 87 | + | lines = append(lines, permissionLine{s.display, "read"}) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | case "write-all": | |||
| 91 | + | for _, s := range tokenScopes { | |||
| 92 | + | lines = append(lines, permissionLine{s.display, s.writeAll}) | |||
| 93 | + | } | |||
| 94 | + | default: | |||
| 95 | + | level := map[string]string{} | |||
| 96 | + | for _, sc := range p.Scopes { | |||
| 97 | + | level[strings.ToLower(sc.Scope)] = strings.ToLower(sc.Level) | |||
| 98 | + | } | |||
| 99 | + | for _, s := range tokenScopes { | |||
| 100 | + | if l := level[s.key]; l == "read" || l == "write" { | |||
| 101 | + | lines = append(lines, permissionLine{s.display, l}) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name }) | |||
| 106 | + | return lines | |||
| 107 | + | } | |||