|
| 1 | +// Copyright 2021 Security Scorecard Authors |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +package evaluation |
| 16 | + |
| 17 | +import ( |
| 18 | + "fmt" |
| 19 | + |
| 20 | + "github.com/ossf/scorecard/v4/checker" |
| 21 | + sce "github.com/ossf/scorecard/v4/errors" |
| 22 | + "github.com/ossf/scorecard/v4/remediation" |
| 23 | +) |
| 24 | + |
| 25 | +type permissions struct { |
| 26 | + topLevelWritePermissions map[string]bool |
| 27 | + jobLevelWritePermissions map[string]bool |
| 28 | +} |
| 29 | + |
| 30 | +// TokenPermissions applies the score policy for the Token-Permissions check. |
| 31 | +func TokenPermissions(name string, dl checker.DetailLogger, r *checker.TokenPermissionsData) checker.CheckResult { |
| 32 | + if r == nil { |
| 33 | + e := sce.WithMessage(sce.ErrScorecardInternal, "empty raw data") |
| 34 | + return checker.CreateRuntimeErrorResult(name, e) |
| 35 | + } |
| 36 | + |
| 37 | + score, err := applyScorePolicy(r, dl) |
| 38 | + if err != nil { |
| 39 | + return checker.CreateRuntimeErrorResult(name, err) |
| 40 | + } |
| 41 | + |
| 42 | + if score != checker.MaxResultScore { |
| 43 | + return checker.CreateResultWithScore(name, |
| 44 | + "non read-only tokens detected in GitHub workflows", score) |
| 45 | + } |
| 46 | + |
| 47 | + return checker.CreateMaxScoreResult(name, |
| 48 | + "tokens are read-only in GitHub workflows") |
| 49 | +} |
| 50 | + |
| 51 | +func applyScorePolicy(results *checker.TokenPermissionsData, dl checker.DetailLogger) (int, error) { |
| 52 | + // See list https://github.blog/changelog/2021-04-20-github-actions-control-permissions-for-github_token/. |
| 53 | + // Note: there are legitimate reasons to use some of the permissions like checks, deployments, etc. |
| 54 | + // in CI/CD systems https://docs.travis-ci.com/user/github-oauth-scopes/. |
| 55 | + |
| 56 | + hm := make(map[string]permissions) |
| 57 | + |
| 58 | + for _, r := range results.TokenPermissions { |
| 59 | + var msg checker.LogMessage |
| 60 | + |
| 61 | + if r.File != nil { |
| 62 | + msg.Path = r.File.Path |
| 63 | + msg.Offset = r.File.Offset |
| 64 | + msg.Type = r.File.Type |
| 65 | + msg.Snippet = r.File.Snippet |
| 66 | + |
| 67 | + if msg.Path != "" { |
| 68 | + msg.Remediation = remediation.CreateWorkflowPermissionRemediation(r.File.Path) |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + text, err := createMessage(r) |
| 73 | + if err != nil { |
| 74 | + return checker.MinResultScore, err |
| 75 | + } |
| 76 | + msg.Text = text |
| 77 | + |
| 78 | + switch r.Type { |
| 79 | + case checker.PermissionLevelNone, checker.PermissionLevelRead: |
| 80 | + dl.Info(&msg) |
| 81 | + case checker.PermissionLevelUnknown: |
| 82 | + dl.Debug(&msg) |
| 83 | + |
| 84 | + case checker.PermissionLevelUndeclared: |
| 85 | + if r.LocationType == nil { |
| 86 | + return checker.InconclusiveResultScore, |
| 87 | + sce.WithMessage(sce.ErrScorecardInternal, "locationType is nil") |
| 88 | + } |
| 89 | + |
| 90 | + // We warn only for top-level. |
| 91 | + if *r.LocationType == checker.PermissionLocationTop { |
| 92 | + dl.Warn(&msg) |
| 93 | + } else { |
| 94 | + dl.Debug(&msg) |
| 95 | + } |
| 96 | + |
| 97 | + // Group results by workflow name for score computation. |
| 98 | + if err := updateWorkflowHashMap(hm, r); err != nil { |
| 99 | + return checker.InconclusiveResultScore, err |
| 100 | + } |
| 101 | + |
| 102 | + case checker.PermissionLevelWrite: |
| 103 | + dl.Warn(&msg) |
| 104 | + |
| 105 | + // Group results by workflow name for score computation. |
| 106 | + if err := updateWorkflowHashMap(hm, r); err != nil { |
| 107 | + return checker.InconclusiveResultScore, err |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + return calculateScore(hm), nil |
| 113 | +} |
| 114 | + |
| 115 | +func recordPermissionWrite(hm map[string]permissions, path string, |
| 116 | + locType checker.PermissionLocation, permName *string, |
| 117 | +) { |
| 118 | + if _, exists := hm[path]; !exists { |
| 119 | + hm[path] = permissions{ |
| 120 | + topLevelWritePermissions: make(map[string]bool), |
| 121 | + jobLevelWritePermissions: make(map[string]bool), |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + // Select the hash map to update. |
| 126 | + m := hm[path].jobLevelWritePermissions |
| 127 | + if locType == checker.PermissionLocationTop { |
| 128 | + m = hm[path].topLevelWritePermissions |
| 129 | + } |
| 130 | + |
| 131 | + // Set the permission name to record. |
| 132 | + name := "all" |
| 133 | + if permName != nil && *permName != "" { |
| 134 | + name = *permName |
| 135 | + } |
| 136 | + m[name] = true |
| 137 | +} |
| 138 | + |
| 139 | +func updateWorkflowHashMap(hm map[string]permissions, t checker.TokenPermission) error { |
| 140 | + if t.LocationType == nil { |
| 141 | + return sce.WithMessage(sce.ErrScorecardInternal, "locationType is nil") |
| 142 | + } |
| 143 | + |
| 144 | + if t.File == nil || t.File.Path == "" { |
| 145 | + return sce.WithMessage(sce.ErrScorecardInternal, "path is not set") |
| 146 | + } |
| 147 | + |
| 148 | + if t.Type != checker.PermissionLevelWrite && |
| 149 | + t.Type != checker.PermissionLevelUndeclared { |
| 150 | + return nil |
| 151 | + } |
| 152 | + |
| 153 | + recordPermissionWrite(hm, t.File.Path, *t.LocationType, t.Name) |
| 154 | + |
| 155 | + return nil |
| 156 | +} |
| 157 | + |
| 158 | +func createMessage(t checker.TokenPermission) (string, error) { |
| 159 | + // By default, use the message already present. |
| 160 | + if t.Msg != nil { |
| 161 | + return *t.Msg, nil |
| 162 | + } |
| 163 | + |
| 164 | + // Ensure there's no implementation bug. |
| 165 | + if t.LocationType == nil { |
| 166 | + return "", sce.WithMessage(sce.ErrScorecardInternal, "locationType is nil") |
| 167 | + } |
| 168 | + |
| 169 | + // Use a different message depending on the type. |
| 170 | + if t.Type == checker.PermissionLevelUndeclared { |
| 171 | + return fmt.Sprintf("no %s permission defined", *t.LocationType), nil |
| 172 | + } |
| 173 | + |
| 174 | + if t.Value == nil { |
| 175 | + return "", sce.WithMessage(sce.ErrScorecardInternal, "Value fields is nil") |
| 176 | + } |
| 177 | + |
| 178 | + if t.Name == nil { |
| 179 | + return fmt.Sprintf("%s permissions set to '%v'", *t.LocationType, |
| 180 | + *t.Value), nil |
| 181 | + } |
| 182 | + |
| 183 | + return fmt.Sprintf("%s '%v' permission set to '%v'", *t.LocationType, |
| 184 | + *t.Name, *t.Value), nil |
| 185 | +} |
| 186 | + |
| 187 | +// Calculate the score. |
| 188 | +func calculateScore(result map[string]permissions) int { |
| 189 | + // See list https://github.blog/changelog/2021-04-20-github-actions-control-permissions-for-github_token/. |
| 190 | + // Note: there are legitimate reasons to use some of the permissions like checks, deployments, etc. |
| 191 | + // in CI/CD systems https://docs.travis-ci.com/user/github-oauth-scopes/. |
| 192 | + |
| 193 | + // Start with a perfect score. |
| 194 | + score := float32(checker.MaxResultScore) |
| 195 | + |
| 196 | + // Retrieve the overall results. |
| 197 | + for _, perms := range result { |
| 198 | + // If no top level permissions are defined, all the permissions |
| 199 | + // are enabled by default. In this case, |
| 200 | + if permissionIsPresentInTopLevel(perms, "all") { |
| 201 | + if permissionIsPresentInRunLevel(perms, "all") { |
| 202 | + // ... give lowest score if no run level permissions are defined either. |
| 203 | + return checker.MinResultScore |
| 204 | + } |
| 205 | + // ... reduce score if run level permissions are defined. |
| 206 | + score -= 0.5 |
| 207 | + } |
| 208 | + |
| 209 | + // status: https://docs.github.com/en/rest/reference/repos#statuses. |
| 210 | + // May allow an attacker to change the result of pre-submit and get a PR merged. |
| 211 | + // Low risk: -0.5. |
| 212 | + if permissionIsPresent(perms, "statuses") { |
| 213 | + score -= 0.5 |
| 214 | + } |
| 215 | + |
| 216 | + // checks. |
| 217 | + // May allow an attacker to edit checks to remove pre-submit and introduce a bug. |
| 218 | + // Low risk: -0.5. |
| 219 | + if permissionIsPresent(perms, "checks") { |
| 220 | + score -= 0.5 |
| 221 | + } |
| 222 | + |
| 223 | + // secEvents. |
| 224 | + // May allow attacker to read vuln reports before patch available. |
| 225 | + // Low risk: -1 |
| 226 | + if permissionIsPresent(perms, "security-events") { |
| 227 | + score-- |
| 228 | + } |
| 229 | + |
| 230 | + // deployments: https://docs.github.com/en/rest/reference/repos#deployments. |
| 231 | + // May allow attacker to charge repo owner by triggering VM runs, |
| 232 | + // and tiny chance an attacker can trigger a remote |
| 233 | + // service with code they own if server accepts code/location var unsanitized. |
| 234 | + // Low risk: -1 |
| 235 | + if permissionIsPresent(perms, "deployments") { |
| 236 | + score-- |
| 237 | + } |
| 238 | + |
| 239 | + // contents. |
| 240 | + // Allows attacker to commit unreviewed code. |
| 241 | + // High risk: -10 |
| 242 | + if permissionIsPresent(perms, "contents") { |
| 243 | + score -= checker.MaxResultScore |
| 244 | + } |
| 245 | + |
| 246 | + // packages: https://docs.github.com/en/packages/learn-github-packages/about-permissions-for-github-packages. |
| 247 | + // Allows attacker to publish packages. |
| 248 | + // High risk: -10 |
| 249 | + if permissionIsPresent(perms, "packages") { |
| 250 | + score -= checker.MaxResultScore |
| 251 | + } |
| 252 | + |
| 253 | + // actions. |
| 254 | + // May allow an attacker to steal GitHub secrets by approving to run an action that needs approval. |
| 255 | + // High risk: -10 |
| 256 | + if permissionIsPresent(perms, "actions") { |
| 257 | + score -= checker.MaxResultScore |
| 258 | + } |
| 259 | + |
| 260 | + if score < checker.MinResultScore { |
| 261 | + break |
| 262 | + } |
| 263 | + } |
| 264 | + |
| 265 | + // We're done, calculate the final score. |
| 266 | + if score < checker.MinResultScore { |
| 267 | + return checker.MinResultScore |
| 268 | + } |
| 269 | + |
| 270 | + return int(score) |
| 271 | +} |
| 272 | + |
| 273 | +func permissionIsPresent(perms permissions, name string) bool { |
| 274 | + return permissionIsPresentInTopLevel(perms, name) || |
| 275 | + permissionIsPresentInRunLevel(perms, name) |
| 276 | +} |
| 277 | + |
| 278 | +func permissionIsPresentInTopLevel(perms permissions, name string) bool { |
| 279 | + _, ok := perms.topLevelWritePermissions[name] |
| 280 | + return ok |
| 281 | +} |
| 282 | + |
| 283 | +func permissionIsPresentInRunLevel(perms permissions, name string) bool { |
| 284 | + _, ok := perms.jobLevelWritePermissions[name] |
| 285 | + return ok |
| 286 | +} |
0 commit comments