diff --git a/checks/checks.md b/checks/checks.md index 4b0ace54..71dac906 100644 --- a/checks/checks.md +++ b/checks/checks.md @@ -57,7 +57,7 @@ This check tries to determine if a project has a set of contributors from multip ## Frozen-Deps -This check tries to determine if a project has declared and pinned its dependencies. It works by (1) looking for the following files in the root directory: go.mod, go.sum (Golang), package-lock.json, npm-shrinkwrap.json (Javascript), requirements.txt, pipfile.lock (Python), gemfile.lock (Ruby), cargo.lock (Rust), yarn.lock (package manager), composer.lock (PHP), vendor/, third_party/, third-party/; (2) look for github actions under .github/workflows/ and verifies they are pinned by hash. If one of the files in (1) AND all the dependencies in (2) are pinned, the check succeds. This check does not currently look for docker image pinning and shell script pinning. +This check tries to determine if a project has declared and pinned its dependencies. It works by (1) looking for the following files in the root directory: go.mod, go.sum (Golang), package-lock.json, npm-shrinkwrap.json (Javascript), requirements.txt, pipfile.lock (Python), gemfile.lock (Ruby), cargo.lock (Rust), yarn.lock (package manager), composer.lock (PHP), vendor/, third_party/, third-party/; (2) look for github actions under .github/workflows/ and verifies they are pinned by hash; (3) looks for Dockerfiles and verifies FROM dependencies are pinned by hash. If one of the files in (1) AND all the dependencies in (2),(3) are pinned, the check succeds. This check does not currently look for shell script pinning (curl | bash) in scripts, Dockerfiles, Makefiles or system()-like code. **Remediation steps** - Declare all your dependencies with specific versions in your package format file (e.g. `package.json` for npm, `requirements.txt` for python). For C/C++, check in the code from a trusted source and add a `README` on the specific version used (and the archive SHA hashes). @@ -117,3 +117,17 @@ This check looks for cryptographically signed tags in the last 5 tags. The check - Publish the tag and then sign it with this key. - For GitHub, check out the steps [here](https://docs.github.com/en/github/authenticating-to-github/signing-tags#further-reading). +## Token-Permissions + +This check tries to determine if a project's GitHub workflows follow the principle of least privilege, i.e. if the GitHub tokens are set read-only by default. The check currently checks that the 'permission' keyword is used and set to read/none for the 'contents' permission for every workflow yaml file. If other permissions are set globally for the entire file, this check fails. Otherwise it succeeds. + +**Remediation steps** +- Use: ``` permissions: + contents: read +``` in all your .yaml files. +- If you need more permissions, declare them in the job itself, e.g. ``` jobs: create_commit: + runs-on: ubuntu-latest + permissions: + issues: write +``` + diff --git a/checks/checks.yaml b/checks/checks.yaml index 1e1540f7..4c108115 100644 --- a/checks/checks.yaml +++ b/checks/checks.yaml @@ -15,6 +15,31 @@ # This is the source of truth for all check descriptions and remediation steps. # Run `cd checks/main && go run /main` to generate `checks.json` and `checks.md`. checks: + Token-Permissions: + description: >- + This check tries to determine if a project's GitHub workflows + follow the principle of least privilege, i.e. if the GitHub tokens + are set read-only by default. The check currently checks that the 'permission' + keyword is used and set to read/none for the 'contents' permission for every workflow + yaml file. If other permissions are set globally for the entire file, this check fails. + Otherwise it succeeds. + remediation: + - >- + Use: + ``` + permissions: + contents: read + ``` + in all your .yaml files. + - >- + If you need more permissions, declare them in the job itself, e.g. + ``` + jobs: + create_commit: + runs-on: ubuntu-latest + permissions: + issues: write + ``` Security-Policy: description: >- This check tries to determine if a project has published a security @@ -50,10 +75,11 @@ checks: (Javascript), requirements.txt, pipfile.lock (Python), gemfile.lock (Ruby), cargo.lock (Rust), yarn.lock (package manager), composer.lock (PHP), vendor/, third_party/, third-party/; (2) look for github actions - under .github/workflows/ and verifies they are pinned by hash. If one of - the files in (1) AND all the dependencies in (2) are pinned, the check - succeds. This check does not currently look for docker image pinning and - shell script pinning. + under .github/workflows/ and verifies they are pinned by hash; (3) looks + for Dockerfiles and verifies FROM dependencies are pinned by hash. If one of + the files in (1) AND all the dependencies in (2),(3) are pinned, the check + succeds. This check does not currently look for shell script pinning (curl | bash) + in scripts, Dockerfiles, Makefiles or system()-like code. remediation: - >- Declare all your dependencies with specific versions in your package diff --git a/checks/frozen_deps_test.go b/checks/frozen_deps_test.go index bb50f88c..a62fb1fe 100644 --- a/checks/frozen_deps_test.go +++ b/checks/frozen_deps_test.go @@ -73,6 +73,7 @@ func TestGithubWorkflowPinning(t *testing.T) { }, }, } + //nolint for _, tt := range tests { tt := tt // Re-initializing variable so it is not changed while executing the closure below l.messages = []string{} diff --git a/checks/permissions.go b/checks/permissions.go new file mode 100644 index 00000000..93df256c --- /dev/null +++ b/checks/permissions.go @@ -0,0 +1,166 @@ +// Copyright 2021 Security Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checks + +import ( + "errors" + "fmt" + + "gopkg.in/yaml.v2" + + "github.com/ossf/scorecard/checker" +) + +const CheckPermissions = "Token-Permissions" + +// ErrInvalidGitHubWorkflowFile : Invalid GitHub workflow file. +var ErrInvalidGitHubWorkflowFile = errors.New("invalid GitHub workflow file") + +//nolint:gochecknoinits +func init() { + registerCheck(CheckPermissions, leastPrivilegedTokens) +} + +func leastPrivilegedTokens(c *checker.CheckRequest) checker.CheckResult { + return CheckFilesContent(CheckPermissions, ".github/workflows/*", true, c, validateGitHubActionTokenPermissions) +} + +func validatePermission(key string, value interface{}, path string, + logf func(s string, f ...interface{})) (bool, error) { + switch val := value.(type) { + case string: + if val == "write" { + logf("!! token-permissions/github-token - %v permission set to '%v' in %v", key, val, path) + return false, nil + } + default: + return false, ErrInvalidGitHubWorkflowFile + } + return true, nil +} + +func validateMapPermissions(values map[interface{}]interface{}, path string, + logf func(s string, f ...interface{})) (bool, error) { + permissionRead := true + var r bool + var err error + + // Iterate over the permission, verify keys and values are strings. + for k, v := range values { + switch key := k.(type) { + // String type. + case string: + if r, err = validatePermission(key, v, path, logf); err != nil { + return false, err + } + + if !r { + permissionRead = false + } + // Invalid type. + default: + return false, ErrInvalidGitHubWorkflowFile + } + } + return permissionRead, nil +} + +func validateReadPermissions(config map[interface{}]interface{}, path string, + logf func(s string, f ...interface{})) (bool, error) { + permissionFound := false + permissionRead := true + var err error + + // Iterate over the values. + for key, value := range config { + if key != "permissions" { + continue + } + + // We have found the permissions keyword. + permissionFound = true + + // Check the type of our values. + switch val := value.(type) { + // Empty string is nil type. + // It defaults to 'none' + case nil: + + // String type. + case string: + if val != "read-all" && val != "" { + logf("!! token-permissions/github-token - permission set to '%v' in %v", val, path) + return false, nil + } + + // Map type. + case map[interface{}]interface{}: + var res bool + if res, err = validateMapPermissions(val, path, logf); err != nil { + return false, err + } + if !res { + permissionRead = false + } + + // Invalid type. + default: + return false, ErrInvalidGitHubWorkflowFile + } + } + + // Did we find a permission at all? + if !permissionFound { + logf("!! token-permissions/github-token - no permission defined in %v", path) + return false, nil + } + + return permissionRead, nil +} + +// Check file content. +func validateGitHubActionTokenPermissions(path string, content []byte, + logf func(s string, f ...interface{})) (bool, error) { + if len(content) == 0 { + return false, ErrEmptyFile + } + + var workflow map[interface{}]interface{} + var r bool + var err error + err = yaml.Unmarshal(content, &workflow) + if err != nil { + return false, fmt.Errorf("!! token-permissions - cannot unmarshal file %v\n%v\n%v: %w", + path, content, string(content), err) + } + + // 1. Check that each file uses 'content: read' only or 'none'. + //nolint + // https://docs.github.com/en/actions/reference/authentication-in-a-workflow#example-1-passing-the-github_token-as-an-input, + // https://github.blog/changelog/2021-04-20-github-actions-control-permissions-for-github_token/, + // https://docs.github.com/en/actions/reference/authentication-in-a-workflow#modifying-the-permissions-for-the-github_token. + if r, err = validateReadPermissions(workflow, path, logf); err != nil { + return false, nil + } + if !r { + return r, nil + } + + // TODO(laurent): 2. Identify github actions that require write and add checks. + + // TODO(laurent): 3. Read a few runs and ensures they have the same permissions. + + return true, nil +} diff --git a/checks/permissions_test.go b/checks/permissions_test.go new file mode 100644 index 00000000..84b71c91 --- /dev/null +++ b/checks/permissions_test.go @@ -0,0 +1,145 @@ +// Copyright 2021 Security Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checks + +import ( + "errors" + "fmt" + "io/ioutil" + "testing" +) + +func TestGithubTokenPermissions(t *testing.T) { + t.Parallel() + type args struct { + Logf func(s string, f ...interface{}) + Filename string + } + + type returnValue struct { + Error error + Result bool + } + + l := log{} + tests := []struct { + args args + want returnValue + name string + }{ + { + name: "Write all test", + args: args{ + Filename: "./testdata/github-workflow-permissions-writeall.yaml", + Logf: l.Logf, + }, + want: returnValue{ + Error: nil, + Result: false, + }, + }, + { + name: "Read all test", + args: args{ + Filename: "./testdata/github-workflow-permissions-readall.yaml", + Logf: l.Logf, + }, + want: returnValue{ + Error: nil, + Result: true, + }, + }, + { + name: "No permission test", + args: args{ + Filename: "./testdata/github-workflow-permissions-absent.yaml", + Logf: l.Logf, + }, + want: returnValue{ + Error: nil, + Result: false, + }, + }, + { + name: "Writes test", + args: args{ + Filename: "./testdata/github-workflow-permissions-writes.yaml", + Logf: l.Logf, + }, + want: returnValue{ + Error: nil, + Result: false, + }, + }, + { + name: "Reads test", + args: args{ + Filename: "./testdata/github-workflow-permissions-reads.yaml", + Logf: l.Logf, + }, + want: returnValue{ + Error: nil, + Result: true, + }, + }, + { + name: "Nones test", + args: args{ + Filename: "./testdata/github-workflow-permissions-nones.yaml", + Logf: l.Logf, + }, + want: returnValue{ + Error: nil, + Result: true, + }, + }, + { + name: "None test", + args: args{ + Filename: "./testdata/github-workflow-permissions-none.yaml", + Logf: l.Logf, + }, + want: returnValue{ + Error: nil, + Result: true, + }, + }, + } + //nolint + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + l.messages = []string{} + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var content []byte + var err error + if tt.args.Filename == "" { + content = make([]byte, 0) + } else { + content, err = ioutil.ReadFile(tt.args.Filename) + if err != nil { + panic(fmt.Errorf("cannot read file: %w", err)) + } + } + r, err := validateGitHubActionTokenPermissions(tt.args.Filename, content, tt.args.Logf) + + if !errors.Is(err, tt.want.Error) || + r != tt.want.Result { + t.Errorf("TestGithubTokenPermissions:\"%v\": %v (%v,%v) want (%v, %v)", + tt.name, tt.args.Filename, r, err, tt.want.Result, tt.want.Error) + } + }) + } +} diff --git a/checks/testdata/github-workflow-permissions-absent.yaml b/checks/testdata/github-workflow-permissions-absent.yaml new file mode 100644 index 00000000..95ba2c8d --- /dev/null +++ b/checks/testdata/github-workflow-permissions-absent.yaml @@ -0,0 +1,21 @@ +# Copyright 2021 Security Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: absent workflow +on: [push] + +jobs: + Explore-GitHub-Actions: + runs-on: ubuntu-latest + steps: + - run: echo "absent workflow" \ No newline at end of file diff --git a/checks/testdata/github-workflow-permissions-none.yaml b/checks/testdata/github-workflow-permissions-none.yaml new file mode 100644 index 00000000..d1fddda0 --- /dev/null +++ b/checks/testdata/github-workflow-permissions-none.yaml @@ -0,0 +1,23 @@ +# Copyright 2021 Security Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: none workflow +on: [push] + +permissions: + +jobs: + Explore-GitHub-Actions: + runs-on: ubuntu-latest + steps: + - run: echo "none workflow" \ No newline at end of file diff --git a/checks/testdata/github-workflow-permissions-nones.yaml b/checks/testdata/github-workflow-permissions-nones.yaml new file mode 100644 index 00000000..26ef48f6 --- /dev/null +++ b/checks/testdata/github-workflow-permissions-nones.yaml @@ -0,0 +1,32 @@ +# Copyright 2021 Security Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: writes workflow +on: [push] +permissions: + actions: none + checks: none + contents: none + deployments: none + issues: none + packages: none + pull-requests: none + repository-projects: none + security-events: none + statuses: none + +jobs: + Explore-GitHub-Actions: + runs-on: ubuntu-latest + steps: + - run: echo "none workflow" \ No newline at end of file diff --git a/checks/testdata/github-workflow-permissions-readall.yaml b/checks/testdata/github-workflow-permissions-readall.yaml new file mode 100644 index 00000000..f040089f --- /dev/null +++ b/checks/testdata/github-workflow-permissions-readall.yaml @@ -0,0 +1,22 @@ +# Copyright 2021 Security Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: read all workflow +on: [push] +permissions: read-all + +jobs: + Explore-GitHub-Actions: + runs-on: ubuntu-latest + steps: + - run: echo "read all workflow" \ No newline at end of file diff --git a/checks/testdata/github-workflow-permissions-reads.yaml b/checks/testdata/github-workflow-permissions-reads.yaml new file mode 100644 index 00000000..2e02ec1e --- /dev/null +++ b/checks/testdata/github-workflow-permissions-reads.yaml @@ -0,0 +1,32 @@ +# Copyright 2021 Security Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: reads workflow +on: [push] +permissions: + actions: read + checks: read + contents: read + deployments: read + issues: read + packages: read + pull-requests: read + repository-projects: read + security-events: read + statuses: read + +jobs: + Explore-GitHub-Actions: + runs-on: ubuntu-latest + steps: + - run: echo "reads workflow" \ No newline at end of file diff --git a/checks/testdata/github-workflow-permissions-writeall.yaml b/checks/testdata/github-workflow-permissions-writeall.yaml new file mode 100644 index 00000000..2c05e76d --- /dev/null +++ b/checks/testdata/github-workflow-permissions-writeall.yaml @@ -0,0 +1,22 @@ +# Copyright 2021 Security Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: write all workflow +on: [push] +permissions: write-all + +jobs: + Explore-GitHub-Actions: + runs-on: ubuntu-latest + steps: + - run: echo "write all workflow" \ No newline at end of file diff --git a/checks/testdata/github-workflow-permissions-writes.yaml b/checks/testdata/github-workflow-permissions-writes.yaml new file mode 100644 index 00000000..5c144925 --- /dev/null +++ b/checks/testdata/github-workflow-permissions-writes.yaml @@ -0,0 +1,24 @@ +# Copyright 2021 Security Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: write-and-read workflow +on: [push] +permissions: + contents: read + pull-requests: write + +jobs: + Explore-GitHub-Actions: + runs-on: ubuntu-latest + steps: + - run: echo "write-and-read workflow" \ No newline at end of file