|
| 1 | +/** |
| 2 | + * @name pull_request_target with explicit pull request checkout |
| 3 | + * @description Workflows triggered on `pull_request_target` have read/write tokens for the base repository and the access to secrets. |
| 4 | + * By explicitly checking out and running the build script from a fork the untrusted code is running in an environment |
| 5 | + * that is able to push to the base repository and to access secrets. |
| 6 | + * @id java/actions/pull_request_target |
| 7 | + * @kind problem |
| 8 | + * @problem.severity warning |
| 9 | + */ |
| 10 | + |
| 11 | +import javascript |
| 12 | + |
| 13 | +/** |
| 14 | + * Libraries for modelling GitHub Actions workflow files written in YAML. |
| 15 | + * See https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions. |
| 16 | + */ |
| 17 | +module Actions { |
| 18 | + /** A YAML node in a GitHub Actions workflow file. */ |
| 19 | + class Node extends YAMLNode { |
| 20 | + Node() { this.getLocation().getFile().getRelativePath().matches(".github/workflows/%") } |
| 21 | + } |
| 22 | + |
| 23 | + /** |
| 24 | + * An Actions workflow. This is a mapping at the top level of an Actions YAML workflow file. |
| 25 | + * See https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions. |
| 26 | + */ |
| 27 | + class Workflow extends Node, YAMLDocument, YAMLMapping { |
| 28 | + /** Gets the `jobs` mapping from job IDs to job definitions in this workflow. */ |
| 29 | + YAMLMapping getJobs() { result = this.lookup("jobs") } |
| 30 | + |
| 31 | + /** Gets the name of the workflow file. */ |
| 32 | + string getFileName() { result = this.getFile().getBaseName() } |
| 33 | + |
| 34 | + /** Gets the `on:` in this workflow. */ |
| 35 | + On getOn() { result = this.lookup("on") } |
| 36 | + |
| 37 | + /** Gets the job within this workflow with the given job ID. */ |
| 38 | + Job getJob(string jobId) { result.getWorkflow() = this and result.getId() = jobId } |
| 39 | + } |
| 40 | + |
| 41 | + /** |
| 42 | + * An Actions job within a workflow. |
| 43 | + * See https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobs. |
| 44 | + */ |
| 45 | + class Job extends YAMLNode, YAMLMapping { |
| 46 | + string jobId; |
| 47 | + Workflow workflow; |
| 48 | + |
| 49 | + Job() { this = workflow.getJobs().lookup(jobId) } |
| 50 | + |
| 51 | + /** |
| 52 | + * Gets the ID of this job, as a string. |
| 53 | + * This is the job's key within the `jobs` mapping. |
| 54 | + */ |
| 55 | + string getId() { result = jobId } |
| 56 | + |
| 57 | + /** |
| 58 | + * Gets the ID of this job, as a YAML scalar node. |
| 59 | + * This is the job's key within the `jobs` mapping. |
| 60 | + */ |
| 61 | + YAMLString getIdNode() { workflow.getJobs().maps(result, this) } |
| 62 | + |
| 63 | + /** Gets the human-readable name of this job, if any, as a string. */ |
| 64 | + string getName() { result = this.getNameNode().getValue() } |
| 65 | + |
| 66 | + /** Gets the human-readable name of this job, if any, as a YAML scalar node. */ |
| 67 | + YAMLString getNameNode() { result = this.lookup("name") } |
| 68 | + |
| 69 | + /** Gets the step at the given index within this job. */ |
| 70 | + Step getStep(int index) { result.getJob() = this and result.getIndex() = index } |
| 71 | + |
| 72 | + /** Gets the sequence of `steps` within this job. */ |
| 73 | + YAMLSequence getSteps() { result = this.lookup("steps") } |
| 74 | + |
| 75 | + /** Gets the workflow this job belongs to. */ |
| 76 | + Workflow getWorkflow() { result = workflow } |
| 77 | + |
| 78 | + /** Gets the value of the `if` field in this job, if any. */ |
| 79 | + JobIf getIf() { result.getJob() = this } |
| 80 | + } |
| 81 | + |
| 82 | + class JobIf extends YAMLNode, YAMLScalar { |
| 83 | + Job job; |
| 84 | + |
| 85 | + JobIf() { |
| 86 | + job.lookup("if") = this |
| 87 | + } |
| 88 | + |
| 89 | + /** Gets the step this field belongs to. */ |
| 90 | + Job getJob() { result = job } |
| 91 | + } |
| 92 | + |
| 93 | + /** |
| 94 | + * A step within an Actions job. |
| 95 | + * See https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idsteps. |
| 96 | + */ |
| 97 | + class Step extends YAMLNode, YAMLMapping { |
| 98 | + int index; |
| 99 | + Job job; |
| 100 | + |
| 101 | + Step() { this = job.getSteps().getElement(index) } |
| 102 | + |
| 103 | + /** Gets the 0-based position of this step within the sequence of `steps`. */ |
| 104 | + int getIndex() { result = index } |
| 105 | + |
| 106 | + /** Gets the job this step belongs to. */ |
| 107 | + Job getJob() { result = job } |
| 108 | + |
| 109 | + /** Gets the value of the `uses` field in this step, if any. */ |
| 110 | + Uses getUses() { result.getStep() = this } |
| 111 | + |
| 112 | + /** Gets the value of the `run` field in this step, if any. */ |
| 113 | + Run getRun() { result.getStep() = this } |
| 114 | + |
| 115 | + /** Gets the value of the `if` field in this step, if any. */ |
| 116 | + StepIf getIf() { result.getStep() = this } |
| 117 | + } |
| 118 | + |
| 119 | + class StepIf extends YAMLNode, YAMLScalar { |
| 120 | + Step step; |
| 121 | + |
| 122 | + StepIf() { |
| 123 | + step.lookup("if") = this |
| 124 | + } |
| 125 | + |
| 126 | + /** Gets the step this field belongs to. */ |
| 127 | + Step getStep() { result = step } |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * A `uses` field within an Actions job step, which references an action as a reusable unit of code. |
| 132 | + * See https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsuses. |
| 133 | + * |
| 134 | + * For example: |
| 135 | + * ``` |
| 136 | + * uses: actions/checkout@v2 |
| 137 | + * ``` |
| 138 | + * TODO: Does not currently handle local repository references, e.g. `.github/actions/action-name`. |
| 139 | + */ |
| 140 | + class Uses extends YAMLNode, YAMLScalar { |
| 141 | + Step step; |
| 142 | + /** The owner of the repository where the Action comes from, e.g. `actions` in `actions/checkout@v2`. */ |
| 143 | + string repositoryOwner; |
| 144 | + /** The name of the repository where the Action comes from, e.g. `checkout` in `actions/checkout@v2`. */ |
| 145 | + string repositoryName; |
| 146 | + /** The version reference used when checking out the Action, e.g. `v2` in `actions/checkout@v2`. */ |
| 147 | + string version; |
| 148 | + |
| 149 | + Uses() { |
| 150 | + step.lookup("uses") = this and |
| 151 | + // Simple regular expression to split up an Action reference `owner/repo@version` into its components. |
| 152 | + exists(string regexp | regexp = "([^/]+)/([^/@]+)@(.+)" | |
| 153 | + repositoryOwner = this.getValue().regexpCapture(regexp, 1) and |
| 154 | + repositoryName = this.getValue().regexpCapture(regexp, 2) and |
| 155 | + version = this.getValue().regexpCapture(regexp, 3) |
| 156 | + ) |
| 157 | + } |
| 158 | + |
| 159 | + /** Gets the step this field belongs to. */ |
| 160 | + Step getStep() { result = step } |
| 161 | + |
| 162 | + /** Gets the owner and name of the repository where the Action comes from, e.g. `actions/checkout` in `actions/checkout@v2`. */ |
| 163 | + string getGitHubRepository() { result = repositoryOwner + "/" + repositoryName } |
| 164 | + |
| 165 | + /** Gets the version reference used when checking out the Action, e.g. `v2` in `actions/checkout@v2`. */ |
| 166 | + string getVersion() { result = version } |
| 167 | + } |
| 168 | + |
| 169 | + class MappingOrSequenceOrScalar extends YAMLNode { |
| 170 | + MappingOrSequenceOrScalar() { |
| 171 | + this instanceof YAMLMapping |
| 172 | + or |
| 173 | + this instanceof YAMLSequence |
| 174 | + or |
| 175 | + this instanceof YAMLScalar |
| 176 | + } |
| 177 | + |
| 178 | + YAMLNode getNode(string name) { |
| 179 | + exists(YAMLMapping mapping | |
| 180 | + mapping = this and |
| 181 | + result = mapping.lookup(name) |
| 182 | + ) |
| 183 | + or |
| 184 | + exists(YAMLSequence sequence, YAMLNode node | |
| 185 | + sequence = this and |
| 186 | + sequence.getAChildNode() = node and |
| 187 | + node.eval().toString() = name and |
| 188 | + result = node |
| 189 | + ) |
| 190 | + or |
| 191 | + exists(YAMLScalar scalar | |
| 192 | + scalar = this and |
| 193 | + scalar.getValue() = name and |
| 194 | + result = scalar |
| 195 | + ) |
| 196 | + } |
| 197 | + |
| 198 | + int getElementCount() { |
| 199 | + exists(YAMLMapping mapping | |
| 200 | + mapping = this and |
| 201 | + result = mapping.getNumChild() / 2 |
| 202 | + ) |
| 203 | + or |
| 204 | + exists(YAMLSequence sequence | |
| 205 | + sequence = this and |
| 206 | + result = sequence.getNumChild() |
| 207 | + ) |
| 208 | + or |
| 209 | + exists(YAMLScalar scalar | |
| 210 | + scalar = this and |
| 211 | + result = 1 |
| 212 | + ) |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + class On extends YAMLNode, MappingOrSequenceOrScalar { |
| 217 | + Workflow workflow; |
| 218 | + |
| 219 | + On() { workflow.lookup("on") = this } |
| 220 | + |
| 221 | + Workflow getWorkflow() { result = workflow } |
| 222 | + } |
| 223 | + |
| 224 | + class With extends YAMLNode, YAMLMapping { |
| 225 | + Step step; |
| 226 | + |
| 227 | + With() { step.lookup("with") = this } |
| 228 | + |
| 229 | + /** Gets the step this field belongs to. */ |
| 230 | + Step getStep() { result = step } |
| 231 | + } |
| 232 | + |
| 233 | + class Ref extends YAMLNode, YAMLString { |
| 234 | + With with; |
| 235 | + |
| 236 | + Ref() { with.lookup("ref") = this } |
| 237 | + |
| 238 | + With getWith() { result = with } |
| 239 | + } |
| 240 | + |
| 241 | + /** |
| 242 | + * A `run` field within an Actions job step, which runs command-line programs using an operating system shell. |
| 243 | + * See https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsrun. |
| 244 | + */ |
| 245 | + class Run extends YAMLNode, YAMLString { |
| 246 | + Step step; |
| 247 | + |
| 248 | + Run() { step.lookup("run") = this } |
| 249 | + |
| 250 | + /** Gets the step that executes this `run` command. */ |
| 251 | + Step getStep() { result = step } |
| 252 | + |
| 253 | + /** |
| 254 | + * Holds if `${{ e }}` is a GitHub Actions expression evaluated within this `run` command. |
| 255 | + * See https://docs.github.com/en/free-pro-team@latest/actions/reference/context-and-expression-syntax-for-github-actions. |
| 256 | + */ |
| 257 | + string getAReferencedExpression() { |
| 258 | + // We use `regexpFind` to obtain *all* matches of `${{...}}`, |
| 259 | + // not just the last (greedy match) or first (reluctant match). |
| 260 | + // TODO: This only handles expression strings that refer to contexts. |
| 261 | + // It does not handle operators within the expression. |
| 262 | + result = |
| 263 | + this.getValue().regexpFind("(?<=\\$\\{\\{\\s*)[A-Za-z0-9_\\.\\-]+(?=\\s*\\}\\})", _, _) |
| 264 | + } |
| 265 | + } |
| 266 | +} |
| 267 | + |
| 268 | +// TODO: Cannot yet treat these as DataFlow::Nodes, because YAMLValue is inconvertible to Expr. |
| 269 | +/** |
| 270 | + * Holds if `child` is the qualified name of a GitHub Actions context nested as |
| 271 | + * a property of the GitHub Actions context with qualified name `parent`. |
| 272 | + * For example, `github.event.issue.body` is a child of `github.event.issue`. |
| 273 | + */ |
| 274 | +bindingset[child] |
| 275 | +predicate nestedContext(string parent, string child) { |
| 276 | + parent = child.regexpCapture("([A-Za-z0-9_\\.\\-]+)\\.[A-Za-z0-9_\\-]+", 1) |
| 277 | +} |
| 278 | + |
| 279 | +bindingset[context] |
| 280 | +private predicate isExternalUserControlledIssue(string context) { |
| 281 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*issue\\s*\\.\\s*title\\b") or |
| 282 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*issue\\s*\\.\\s*body\\b") |
| 283 | +} |
| 284 | + |
| 285 | +bindingset[context] |
| 286 | +private predicate isExternalUserControlledPullRequest(string context) { |
| 287 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*pull_request\\s*\\.\\s*title\\b") or |
| 288 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*pull_request\\s*\\.\\s*body\\b") or |
| 289 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*pull_request\\s*\\.\\s*head\\s*\\.\\s*label\\b") or |
| 290 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*pull_request\\s*\\.\\s*head\\s*\\.\\s*repo\\s*\\.\\s*default_branch\\b") or |
| 291 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*pull_request\\s*\\.\\s*head\\s*\\.\\s*ref\\b") |
| 292 | +} |
| 293 | + |
| 294 | +bindingset[context] |
| 295 | +private predicate isExternalUserControlledReview(string context) { |
| 296 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*review\\s*\\.\\s*body\\b") or |
| 297 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*review_comment\\s*\\.\\s*body\\b") |
| 298 | +} |
| 299 | + |
| 300 | +bindingset[context] |
| 301 | +private predicate isExternalUserControlledComment(string context) { |
| 302 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*comment\\s*\\.\\s*body\\b") |
| 303 | +} |
| 304 | + |
| 305 | +bindingset[context] |
| 306 | +private predicate isExternalUserControlledGollum(string context) { |
| 307 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*pages(?:\\[[0-9]\\]|\\s*\\.\\s*\\*)+\\s*\\.\\s*page_name\\b") |
| 308 | +} |
| 309 | + |
| 310 | +/** |
| 311 | + * Holds if `context` is a GitHub Actions context object containing values |
| 312 | + * that may be controlled by an external user with public access to the repository. |
| 313 | + */ |
| 314 | +bindingset[context] |
| 315 | +private predicate isExternalUserControlledCommit(string context) { |
| 316 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*commits(?:\\[[0-9]\\]|\\s*\\.\\s*\\*)+\\s*\\.\\s*message\\b") or |
| 317 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*head_commit\\s*\\.\\s*message\\b") or |
| 318 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*head_commit\\s*\\.\\s*author\\s*\\.\\s*email\\b") or |
| 319 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*head_commit\\s*\\.\\s*author\\s*\\.\\s*name\\b") or |
| 320 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*commits(?:\\[[0-9]\\]|\\s*\\.\\s*\\*)+\\s*\\.\\s*author\\s*\\.\\s*email\\b") or |
| 321 | + context.regexpMatch("\\bgithub\\s*\\.\\s*event\\s*\\.\\s*commits(?:\\[[0-9]\\]|\\s*\\.\\s*\\*)+\\s*\\.\\s*author\\s*\\.\\s*name\\b") or |
| 322 | + context.regexpMatch("\\bgithub\\s*\\.\\s*head_ref\\b") |
| 323 | +} |
| 324 | + |
| 325 | +from Actions::Ref ref, Actions::Uses uses, Actions::Step step, Actions::Job job, |
| 326 | + ProbablePullRequestTarget pullRequestTarget |
| 327 | +where |
| 328 | + pullRequestTarget.getWorkflow() = job.getWorkflow() |
| 329 | + and uses.getStep() = step |
| 330 | + and ref.getWith().getStep() = step |
| 331 | + and step.getJob() = job |
| 332 | + and uses.getGitHubRepository() = "actions/checkout" |
| 333 | + and ( |
| 334 | + ref.getValue().matches("%github.event.pull_request.head.ref%") or |
| 335 | + ref.getValue().matches("%github.event.pull_request.head.sha%") or |
| 336 | + ref.getValue().matches("%github.event.pull_request.number%") or |
| 337 | + ref.getValue().matches("%github.event.number%") or |
| 338 | + ref.getValue().matches("%github.head_ref%") |
| 339 | + ) |
| 340 | + and step instanceof ProbableStep |
| 341 | + and job instanceof ProbableJob |
| 342 | +select step, "Potential unsafe checkout of untrusted pull request on `pull_request_target`" |
0 commit comments