Skip to content

Commit c637fd4

Browse files
authored
Merge pull request #254 from JarLob/patchy
CodeQL queries for actions
2 parents 8c9e469 + 73925bb commit c637fd4

3 files changed

Lines changed: 697 additions & 0 deletions

File tree

CodeQL_Queries/actions/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Created by @adityasharad, extended by @jarlob.
2+
Read more on [https://securitylab.github.com/research/github-actions-untrusted-input](https://securitylab.github.com/research/github-actions-untrusted-input).
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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

Comments
 (0)