diff --git a/pkg/model/github_context.go b/pkg/model/github_context.go index e2889bc1831..f2ed93265a2 100644 --- a/pkg/model/github_context.go +++ b/pkg/model/github_context.go @@ -40,6 +40,7 @@ type GithubContext struct { ServerURL string `json:"server_url"` APIURL string `json:"api_url"` GraphQLURL string `json:"graphql_url"` + Permissions map[string]string `json:"permissions"` } func asString(v interface{}) string { diff --git a/pkg/model/workflow.go b/pkg/model/workflow.go index 8dd5f6634db..2c1b77fb235 100644 --- a/pkg/model/workflow.go +++ b/pkg/model/workflow.go @@ -17,12 +17,13 @@ import ( // Workflow is the structure of the files in .github/workflows type Workflow struct { - File string - Name string `yaml:"name"` - RawOn yaml.Node `yaml:"on"` - Env map[string]string `yaml:"env"` - Jobs map[string]*Job `yaml:"jobs"` - Defaults Defaults `yaml:"defaults"` + File string + Name string `yaml:"name"` + RawOn yaml.Node `yaml:"on"` + Env map[string]string `yaml:"env"` + RawPermissions yaml.Node `yaml:"permissions"` + Jobs map[string]*Job `yaml:"jobs"` + Defaults Defaults `yaml:"defaults"` } // On events for the workflow @@ -209,6 +210,7 @@ type Job struct { Uses string `yaml:"uses"` With map[string]interface{} `yaml:"with"` RawSecrets yaml.Node `yaml:"secrets"` + RawPermissions yaml.Node `yaml:"permissions"` Result string } @@ -289,6 +291,19 @@ func (j *Job) Secrets() map[string]string { return val } +// Permissions returns the permissions as a map if declared as a mapping for the job, +// otherwise nil (e.g. unset or using read-all/write-all shorthand). +func (j *Job) Permissions() map[string]string { + if j.RawPermissions.Kind != yaml.MappingNode { + return nil + } + var val map[string]string + if !decodeNode(j.RawPermissions, &val) { + return nil + } + return val +} + // Container details for the job func (j *Job) Container() *ContainerSpec { var val *ContainerSpec @@ -747,6 +762,19 @@ func (w *Workflow) GetJobIDs() []string { return ids } +// Permissions returns the permissions as a map if declared as a mapping (e.g. contents: read), +// otherwise nil (e.g. when using read-all / write-all shorthand or unset). +func (w *Workflow) Permissions() map[string]string { + if w.RawPermissions.Kind != yaml.MappingNode { + return nil + } + var val map[string]string + if !decodeNode(w.RawPermissions, &val) { + return nil + } + return val +} + var OnDecodeNodeError = func(node yaml.Node, out interface{}, err error) { log.Fatalf("Failed to decode node %v into %T: %v", node, out, err) } diff --git a/pkg/model/workflow_test.go b/pkg/model/workflow_test.go index ca13caa625d..b772420c968 100644 --- a/pkg/model/workflow_test.go +++ b/pkg/model/workflow_test.go @@ -1,6 +1,7 @@ package model import ( + "strconv" "strings" "testing" @@ -612,3 +613,71 @@ on: push #*trigger assert.Equal(t, "actions/checkout@v5", job.Steps[0].Uses) } } + +func TestReadWorkflow_Permissions_CodeQuality(t *testing.T) { + // This test ensures that workflows using modern permissions (including + // code-quality, as used by GitHub Code Quality / coverage uploads) do not + // cause act to refuse to run the workflow due to schema validation errors. + // See: https://docs.github.com/en/code-security/how-tos/maintain-quality-code/set-up-code-coverage + yaml := ` +name: Code Coverage Example +on: + push: + branches: [main] + pull_request: + branches: [main] +permissions: + contents: read + code-quality: write +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + code-quality: write + steps: + - uses: actions/checkout@v4 + - name: Upload coverage + uses: actions/upload-code-coverage@v1 + with: + file: coverage.xml + language: Go +` + + for _, strict := range []bool{false, true} { + t.Run("strict="+strconv.FormatBool(strict), func(t *testing.T) { + w, err := ReadWorkflow(strings.NewReader(yaml), strict) + require.NoError(t, err, "ReadWorkflow must not refuse on code-quality permission (strict=%v)", strict) + + // Workflow-level + assert.Equal(t, map[string]string{ + "contents": "read", + "code-quality": "write", + }, w.Permissions()) + + // Job-level + j := w.GetJob("test") + require.NotNil(t, j) + assert.Equal(t, map[string]string{ + "contents": "read", + "code-quality": "write", + }, j.Permissions()) + }) + } +} + +func TestReadWorkflow_Permissions_Shorthand(t *testing.T) { + yaml := ` +on: push +permissions: read-all +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok +` + w, err := ReadWorkflow(strings.NewReader(yaml), true) + assert.NoError(t, err) + // Shorthand is stored in the raw node; the helper returns nil for non-mapping form + assert.Nil(t, w.Permissions()) +} diff --git a/pkg/runner/run_context.go b/pkg/runner/run_context.go index 5d4277123ed..535d8875810 100644 --- a/pkg/runner/run_context.go +++ b/pkg/runner/run_context.go @@ -978,6 +978,16 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext ghc.GraphQLURL = rc.Config.Env["GITHUB_GRAPHQL_URL"] } + // Populate permissions from job (preferred) or workflow level declaration (only mappings) + job := rc.Run.Job() + if job != nil { + if p := job.Permissions(); p != nil { + ghc.Permissions = p + } else if wp := rc.Run.Workflow.Permissions(); wp != nil { + ghc.Permissions = wp + } + } + return ghc } diff --git a/pkg/schema/schema_test.go b/pkg/schema/schema_test.go index ce571c96533..020be8eb796 100644 --- a/pkg/schema/schema_test.go +++ b/pkg/schema/schema_test.go @@ -90,3 +90,39 @@ jobs: }).UnmarshalYAML(&node) assert.NoError(t, err) } + +func TestCodeQualityPermission(t *testing.T) { + var node yaml.Node + err := yaml.Unmarshal([]byte(` +name: Code Coverage +on: + push: + branches: [main] + pull_request: + branches: [main] +permissions: + contents: read + code-quality: write +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run tests with coverage + run: go test -coverprofile=cover.out ./... + - name: Upload coverage report + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: actions/upload-code-coverage@v1 + with: + file: cover.out + language: Go +`), &node) + if !assert.NoError(t, err) { + return + } + err = (&Node{ + Definition: "workflow-root", + Schema: GetWorkflowSchema(), + }).UnmarshalYAML(&node) + assert.NoError(t, err) +} diff --git a/pkg/schema/workflow_schema.json b/pkg/schema/workflow_schema.json index b65bf6ff4d0..ebb9cab5059 100644 --- a/pkg/schema/workflow_schema.json +++ b/pkg/schema/workflow_schema.json @@ -1288,6 +1288,14 @@ "type": "permission-level-any", "description": "Check runs and check suites." }, + "artifact-metadata": { + "type": "permission-level-any", + "description": "Work with artifact metadata. For example, artifact-metadata: write permits an action to create storage records on behalf of a build artifact." + }, + "code-quality": { + "type": "permission-level-any", + "description": "Work with code quality. For example, code-quality: write permits an action to upload code coverage reports." + }, "contents": { "type": "permission-level-any", "description": "Repository contents, commits, branches, downloads, releases, and merges." @@ -1335,6 +1343,10 @@ "statuses": { "type": "permission-level-any", "description": "Commit statuses." + }, + "vulnerability-alerts": { + "type": "permission-level-read-or-no-access", + "description": "Read Dependabot alerts." } } }