Skip to content

Commit 3b684d8

Browse files
author
Alvaro Muñoz
authored
Merge pull request #19 from github/cache_poisoning_actions
Fix error in select
2 parents c39e802 + eb4eb4e commit 3b684d8

28 files changed

+348
-90
lines changed

ql/lib/codeql/actions/Ast.qll

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,6 @@ class Workflow extends AstNode instanceof WorkflowImpl {
261261
Permissions getPermissions() { result = super.getPermissions() }
262262

263263
Strategy getStrategy() { result = super.getStrategy() }
264-
265-
predicate isPrivileged() { super.isPrivileged() }
266264
}
267265

268266
class ReusableWorkflow extends Workflow instanceof ReusableWorkflowImpl {
@@ -288,6 +286,7 @@ class Outputs extends AstNode instanceof OutputsImpl {
288286
}
289287

290288
class Permissions extends AstNode instanceof PermissionsImpl {
289+
bindingset[perm]
291290
string getPermission(string perm) { result = super.getPermission(perm) }
292291

293292
string getAPermission() { result = super.getAPermission() }
@@ -329,6 +328,10 @@ abstract class Job extends AstNode instanceof JobImpl {
329328

330329
Permissions getPermissions() { result = super.getPermissions() }
331330

331+
predicate hasTriggerEvent(string trigger) { super.hasTriggerEvent(trigger) }
332+
333+
string getATriggerEvent() { result = super.getATriggerEvent() }
334+
332335
Strategy getStrategy() { result = super.getStrategy() }
333336

334337
predicate isPrivileged() { super.isPrivileged() }

ql/lib/codeql/actions/ast/internal/Ast.qll

Lines changed: 112 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ private newtype TAstNode =
6464
TInputsNode(YamlMapping n) { exists(YamlMapping m | m.lookup("inputs") = n) } or
6565
TInputNode(YamlValue n) { exists(YamlMapping m | m.lookup("inputs").(YamlMapping).maps(n, _)) } or
6666
TOutputsNode(YamlMapping n) { exists(YamlMapping m | m.lookup("outputs") = n) } or
67-
TPermissionsNode(YamlMapping n) { exists(YamlMapping m | m.lookup("permissions") = n) } or
67+
TPermissionsNode(YamlMappingLikeNode n) { exists(YamlMapping m | m.lookup("permissions") = n) } or
6868
TStrategyNode(YamlMapping n) { exists(YamlMapping m | m.lookup("strategy") = n) } or
6969
TNeedsNode(YamlMappingLikeNode n) { exists(YamlMapping m | m.lookup("needs") = n) } or
7070
TJobNode(YamlMapping n) { exists(YamlMapping w | w.lookup("jobs").(YamlMapping).lookup(_) = n) } or
@@ -320,6 +320,9 @@ class WorkflowImpl extends AstNodeImpl, TWorkflowNode {
320320
/** Gets a job within this workflow */
321321
JobImpl getAJob() { result = this.getJob(_) }
322322

323+
/** Gets the permissions granted to this workflow. */
324+
PermissionsImpl getPermissions() { result.getNode() = n.lookup("permissions") }
325+
323326
/** Workflow is triggered by given trigger event */
324327
predicate hasTriggerEvent(string trigger) {
325328
exists(YamlNode y | y = n.lookup("on").(YamlMappingLikeNode).getNode(trigger))
@@ -330,43 +333,8 @@ class WorkflowImpl extends AstNodeImpl, TWorkflowNode {
330333
exists(YamlNode y | y = n.lookup("on").(YamlMappingLikeNode).getNode(result))
331334
}
332335

333-
/** Gets the permissions granted to this workflow. */
334-
PermissionsImpl getPermissions() { result.getNode() = n.lookup("permissions") }
335-
336-
private predicate hasSingleTrigger(string trigger) {
337-
this.getATriggerEvent() = trigger and
338-
count(this.getATriggerEvent()) = 1
339-
}
340-
341336
/** Gets the strategy for this workflow. */
342337
StrategyImpl getStrategy() { result.getNode() = n.lookup("strategy") }
343-
344-
/** Holds if the workflow is privileged. */
345-
predicate isPrivileged() {
346-
// The Workflow has a permission to write to some scope
347-
this.getPermissions().getAPermission() = "write"
348-
or
349-
// The Workflow accesses a secret
350-
exists(SecretsExpressionImpl expr |
351-
expr.getEnclosingWorkflow() = this and not expr.getFieldName() = "GITHUB_TOKEN"
352-
)
353-
or
354-
// The Workflow is triggered by an event other than `pull_request`
355-
count(this.getATriggerEvent()) = 1 and
356-
not this.getATriggerEvent() = ["pull_request", "workflow_call"]
357-
or
358-
// The Workflow is only triggered by `workflow_call` and there is
359-
// a caller workflow triggered by an event other than `pull_request`
360-
this.hasSingleTrigger("workflow_call") and
361-
exists(ExternalJobImpl call, WorkflowImpl caller |
362-
call.getCallee() = this.getLocation().getFile().getRelativePath() and
363-
caller = call.getWorkflow() and
364-
caller.isPrivileged()
365-
)
366-
or
367-
// The Workflow has multiple triggers so at least one is not "pull_request"
368-
count(this.getATriggerEvent()) > 1
369-
}
370338
}
371339

372340
class ReusableWorkflowImpl extends AstNodeImpl, WorkflowImpl {
@@ -502,7 +470,7 @@ class OutputsImpl extends AstNodeImpl, TOutputsNode {
502470
}
503471

504472
class PermissionsImpl extends AstNodeImpl, TPermissionsNode {
505-
YamlMapping n;
473+
YamlMappingLikeNode n;
506474

507475
PermissionsImpl() { this = TPermissionsNode(n) }
508476

@@ -516,11 +484,41 @@ class PermissionsImpl extends AstNodeImpl, TPermissionsNode {
516484

517485
override Location getLocation() { result = n.getLocation() }
518486

519-
override YamlMapping getNode() { result = n }
487+
override YamlMappingLikeNode getNode() { result = n }
488+
489+
string getAScope() {
490+
result =
491+
[
492+
"actions", "attestations", "checks", "contents", "deployments", "discussions", "id-token",
493+
"issues", "packages", "pages", "pull-requests", "repository-projects", "security-events",
494+
"statuses"
495+
]
496+
}
520497

521-
string getPermission(string perm) { result = n.lookup(perm).(YamlScalar).getValue() }
498+
string getAPermission() {
499+
exists(YamlMapping mapping, string scope |
500+
mapping = n and
501+
result = scope + ": " + mapping.lookup(scope).(YamlScalar).getValue()
502+
)
503+
or
504+
exists(YamlScalar scalar |
505+
scalar = n and
506+
(
507+
scalar.getValue() = "write-all" and
508+
result = this.getAScope() + ":write"
509+
or
510+
scalar.getValue() = "read-all" and
511+
result = this.getAScope() + ":read"
512+
)
513+
)
514+
}
522515

523-
string getAPermission() { result = this.getPermission(_) }
516+
bindingset[perm]
517+
string getPermission(string perm) {
518+
exists(string p |
519+
p = this.getAPermission() and p.matches(perm + ":%") and result = p.splitAt(":", 1).trim()
520+
)
521+
}
524522
}
525523

526524
class StrategyImpl extends AstNodeImpl, TStrategyNode {
@@ -633,37 +631,87 @@ class JobImpl extends AstNodeImpl, TJobNode {
633631
/** Gets the strategy for this job. */
634632
StrategyImpl getStrategy() { result.getNode() = n.lookup("strategy") }
635633

636-
/** Holds if the workflow is privileged. */
634+
/** Holds if the job is privileged. */
637635
predicate isPrivileged() {
636+
// the job has privileged runtime permissions
637+
this.hasRuntimeWritePermissions()
638+
or
639+
// the job has an explicit secret accesses
640+
this.hasExplicitSecretAccess()
641+
or
638642
// the job has an explicit write permission
639-
this.getPermissions().getAPermission() = "write"
643+
this.hasExplicitWritePermission()
644+
or
645+
// the job has no explicit permissions but the workflow has write permissions
646+
not exists(this.getPermissions()) and
647+
this.hasImplicitWritePermission()
640648
or
649+
// neither the job nor the workflow have permissions but the job has a privileged trigger
650+
not exists(this.getPermissions()) and
651+
not exists(this.getEnclosingWorkflow().getPermissions()) and
652+
this.hasPrivilegedTrigger()
653+
}
654+
655+
private predicate hasExplicitSecretAccess() {
641656
// the job accesses a secret other than GITHUB_TOKEN
642657
exists(SecretsExpressionImpl expr |
643658
expr.getEnclosingJob() = this and not expr.getFieldName() = "GITHUB_TOKEN"
644659
)
645-
or
646-
// the effective permissions have write access
660+
}
661+
662+
private predicate hasExplicitWritePermission() {
663+
// the job has an explicit write permission
664+
this.getPermissions().getAPermission().matches("%write")
665+
}
666+
667+
private predicate hasImplicitWritePermission() {
668+
// the job has an explicit write permission
669+
this.getEnclosingWorkflow().getPermissions().getAPermission().matches("%write")
670+
}
671+
672+
private predicate hasRuntimeWritePermissions() {
673+
// the effective runtime permissions have write access
647674
exists(string path, string trigger, string name, string secrets_source, string perms |
648675
workflowDataModel(path, trigger, name, secrets_source, perms, _) and
649676
path.trim() = this.getLocation().getFile().getRelativePath() and
650677
name.trim().matches(this.getId() + "%") and
651678
// We cannot trust the permissions for pull_request events since they depend on the
652-
// location of the head branch
679+
// provenance of the head branch (local vs fork)
653680
not trigger.trim() = "pull_request" and
654-
(
655-
secrets_source.trim().toLowerCase() = "actions" or
656-
perms.toLowerCase().matches("%write%")
657-
)
681+
perms.toLowerCase().matches("%write%")
658682
)
683+
}
684+
685+
private predicate hasPrivilegedTrigger() {
686+
// For workflows that are triggered by the pull_request_target event, the GITHUB_TOKEN is granted read/write repository permission unless the permissions key is specified and the workflow can access secrets, even when it is triggered from a fork.
687+
// The Job is triggered by an event other than `pull_request`
688+
count(this.getATriggerEvent()) = 1 and
689+
not this.getATriggerEvent() = ["pull_request", "workflow_call"]
659690
or
660-
// The job has no expliclit permission, but the enclosing workflow is privileged
661-
not exists(this.getPermissions()) and
662-
not exists(SecretsExpressionImpl expr |
663-
expr.getEnclosingJob() = this and not expr.getFieldName() = "GITHUB_TOKEN"
664-
) and
665-
// The enclosing workflow is privileged
666-
this.getEnclosingWorkflow().isPrivileged()
691+
// The Workflow is only triggered by `workflow_call` and there is
692+
// a caller workflow triggered by an event other than `pull_request`
693+
this.hasSingleTrigger("workflow_call") and
694+
exists(ExternalJobImpl call, JobImpl caller |
695+
call.getCallee() = this.getLocation().getFile().getRelativePath() and
696+
caller = call.getEnclosingJob() and
697+
caller.isPrivileged()
698+
)
699+
or
700+
// The Workflow has multiple triggers so at least one is not "pull_request"
701+
count(this.getATriggerEvent()) > 1
702+
}
703+
704+
/** Workflow is triggered by given trigger event */
705+
predicate hasTriggerEvent(string trigger) {
706+
exists(YamlNode y | y = n.lookup("on").(YamlMappingLikeNode).getNode(trigger))
707+
}
708+
709+
/** Gets the trigger event that starts this workflow. */
710+
string getATriggerEvent() { result = this.getEnclosingWorkflow().getATriggerEvent() }
711+
712+
private predicate hasSingleTrigger(string trigger) {
713+
this.getATriggerEvent() = trigger and
714+
count(this.getATriggerEvent()) = 1
667715
}
668716

669717
/** Gets the runs-on field of the job. */
@@ -827,11 +875,14 @@ class UsesStepImpl extends StepImpl, UsesImpl {
827875

828876
/** Gets the owner and name of the repository where the Action comes from, e.g. `actions/checkout` in `actions/checkout@v2`. */
829877
override string getCallee() {
830-
result =
831-
(
832-
u.getValue().regexpCapture(usesParser(), 1) + "/" +
833-
u.getValue().regexpCapture(usesParser(), 2)
834-
).toLowerCase()
878+
if u.getValue().matches("./%")
879+
then result = u.getValue()
880+
else
881+
result =
882+
(
883+
u.getValue().regexpCapture(usesParser(), 1) + "/" +
884+
u.getValue().regexpCapture(usesParser(), 2)
885+
).toLowerCase()
835886
}
836887

837888
/** Gets the version reference used when checking out the Action, e.g. `2` in `actions/checkout@v2`. */

ql/lib/codeql/actions/controlflow/internal/Cfg.qll

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,5 +295,3 @@ private class InputTree extends LeafTree instanceof Input { }
295295
private class ScalarValueLeaf extends LeafTree instanceof ScalarValue { }
296296

297297
private class ExpressionLeaf extends LeafTree instanceof Expression { }
298-
299-
predicate test(ScalarValueLeaf f) { any() }

ql/lib/codeql/actions/security/CachePoisoningQuery.qll

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import actions
22

3+
string defaultBranchTriggerEvent() {
4+
result =
5+
[
6+
"check_run", "check_suite", "delete", "discussion", "discussion_comment", "fork", "gollum",
7+
"issue_comment", "issues", "label", "milestone", "project", "project_card", "project_column",
8+
"public", "pull_request_comment", "pull_request_target", "repository_dispatch", "schedule",
9+
"watch", "workflow_run"
10+
]
11+
}
12+
313
abstract class CacheWritingStep extends Step { }
414

515
class CacheActionUsesStep extends CacheWritingStep, UsesStep {
@@ -60,3 +70,10 @@ class SetupDotnetUsesStep extends CacheWritingStep, UsesStep {
6070
)
6171
}
6272
}
73+
74+
class SetupRubyUsesStep extends CacheWritingStep, UsesStep {
75+
SetupRubyUsesStep() {
76+
this.getCallee() = ["actions/setup-ruby", "ruby/setup-ruby"] and
77+
this.getArgument("bundler-cache") = "true"
78+
}
79+
}

ql/lib/codeql/actions/security/PoisonableSteps.qll

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,19 @@ class LocalCommandExecutionRunStep extends PoisonableStep, Run {
4343
or
4444
// sh xxxx
4545
cmd = line.regexpCapture("(^|\\s+)(ba|z|fi)?sh\\s+(.*)", 3)
46+
or
47+
// node xxxx
48+
cmd = line.regexpCapture("(^|\\s+)(node|python|ruby|go)\\s+(.*)", 3)
4649
)
4750
}
4851

4952
string getCommand() { result = cmd }
5053
}
5154

55+
class LocalActionUsesStep extends PoisonableStep, UsesStep {
56+
LocalActionUsesStep() { this.getCallee().matches("./%") }
57+
}
58+
5259
class EnvVarInjectionRunStep extends PoisonableStep, Run {
5360
EnvVarInjectionRunStep() {
5461
exists(string value |

ql/lib/qlpack.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
library: true
33
warnOnImplicitThis: true
44
name: githubsecuritylab/actions-all
5-
version: 0.0.21
5+
version: 0.0.22
66
dependencies:
77
codeql/util: ^0.2.0
88
codeql/yaml: ^0.1.2

ql/src/Security/CWE-349/CachePoisoning.ql

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,36 @@ import codeql.actions.security.UntrustedCheckoutQuery
1616
import codeql.actions.security.CachePoisoningQuery
1717
import codeql.actions.security.PoisonableSteps
1818

19-
from LocalJob j, PRHeadCheckoutStep checkout
19+
from LocalJob j, PRHeadCheckoutStep checkout, Step s
2020
where
21+
// Excluding privileged workflows since they can be easily exploited in similar circumstances
22+
not j.isPrivileged() and
2123
// The workflow runs in the context of the default branch
2224
// TODO: (require to collect trigger types)
2325
// - add push to default branch?
2426
// - exclude pull_request_target when branches_ignore includes default branch or when branches does not include the default branch
25-
j.getEnclosingWorkflow()
26-
.hasTriggerEvent([
27-
"check_run", "check_suite", "delete", "discussion", "discussion_comment", "fork",
28-
"gollum", "issue_comment", "issues", "label", "milestone", "project", "project_card",
29-
"project_column", "public", "pull_request_comment", "pull_request_target",
30-
"repository_dispatch", "schedule", "watch", "workflow_run"
31-
]) and
27+
(
28+
j.getEnclosingWorkflow().hasTriggerEvent(defaultBranchTriggerEvent())
29+
or
30+
j.getEnclosingWorkflow().hasTriggerEvent("workflow_call") and
31+
exists(ExternalJob call, Workflow caller |
32+
call.getCallee() = j.getLocation().getFile().getRelativePath() and
33+
caller = call.getWorkflow() and
34+
caller.hasTriggerEvent(defaultBranchTriggerEvent())
35+
)
36+
) and
3237
// The job checkouts untrusted code from a pull request
3338
j.getAStep() = checkout and
3439
(
3540
// The job writes to the cache
3641
// (No need to follow the checkout step as the cache writing is normally done after the job completes)
37-
j.getAStep() instanceof CacheWritingStep
42+
j.getAStep() = s and
43+
s instanceof CacheWritingStep
3844
or
3945
// The job executes checked-out code
4046
// (The cache specific token can be leaked even for non-privileged workflows)
41-
checkout.getAFollowingStep() instanceof PoisonableStep
47+
checkout.getAFollowingStep() = s and
48+
s instanceof PoisonableStep
4249
)
43-
select j.getAStep().(CacheWritingStep), "Potential cache poisoning on privileged workflow."
50+
select checkout, "Potential cache poisoning in the context of the default branch on step $@.", s,
51+
s.toString()

0 commit comments

Comments
 (0)