Skip to content

Commit f95a3e5

Browse files
author
Alvaro Muñoz
committed
Refactor eventtrigger and privileged methods
Move them from Workflows to Jobs
1 parent ddf72a2 commit f95a3e5

File tree

4 files changed

+125
-69
lines changed

4 files changed

+125
-69
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. */
@@ -825,11 +873,14 @@ class UsesStepImpl extends StepImpl, UsesImpl {
825873

826874
/** Gets the owner and name of the repository where the Action comes from, e.g. `actions/checkout` in `actions/checkout@v2`. */
827875
override string getCallee() {
828-
result =
829-
(
830-
u.getValue().regexpCapture(usesParser(), 1) + "/" +
831-
u.getValue().regexpCapture(usesParser(), 2)
832-
).toLowerCase()
876+
if u.getValue().matches("./%")
877+
then result = u.getValue()
878+
else
879+
result =
880+
(
881+
u.getValue().regexpCapture(usesParser(), 1) + "/" +
882+
u.getValue().regexpCapture(usesParser(), 2)
883+
).toLowerCase()
833884
}
834885

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

ql/src/Security/CWE-829/PrivilegedUntrustedCheckoutCritical.ql

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import actions
1717
import codeql.actions.security.UntrustedCheckoutQuery
1818
import codeql.actions.security.PoisonableSteps
1919

20-
from Workflow w, PRHeadCheckoutStep checkout
20+
from LocalJob j, PRHeadCheckoutStep checkout
2121
where
22-
w.isPrivileged() and
23-
w.getAJob().(LocalJob).getAStep() = checkout and
22+
j = checkout.getEnclosingJob() and
23+
j.isPrivileged() and
24+
j.getAStep() = checkout and
2425
checkout.getAFollowingStep() instanceof PoisonableStep and
2526
not exists(ControlCheck check |
2627
checkout.getIf() = check or checkout.getEnclosingJob().getIf() = check

ql/src/Security/CWE-829/PrivilegedUntrustedCheckoutHigh.ql

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import actions
1717
import codeql.actions.security.UntrustedCheckoutQuery
1818
import codeql.actions.security.PoisonableSteps
1919

20-
from Workflow w, PRHeadCheckoutStep checkout
20+
from LocalJob j, PRHeadCheckoutStep checkout
2121
where
22-
w.isPrivileged() and
23-
w.getAJob().(LocalJob).getAStep() = checkout and
22+
j = checkout.getEnclosingJob() and
23+
j.isPrivileged() and
24+
j.getAStep() = checkout and
2425
not checkout.getAFollowingStep() instanceof PoisonableStep and
2526
not exists(ControlCheck check |
2627
checkout.getIf() = check or checkout.getEnclosingJob().getIf() = check

0 commit comments

Comments
 (0)