Skip to content

Commit 742602d

Browse files
author
Alvaro Muñoz
authored
Merge pull request #101 from github/control_checks/toctou_split
Improve control checks to better account for toctou issues
2 parents a3cf876 + 860eda9 commit 742602d

16 files changed

Lines changed: 1399 additions & 60 deletions

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

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,44 @@
11
import actions
22

3-
string any_relevant_category() {
3+
string any_category() {
44
result =
55
[
66
"untrusted-checkout", "output-clobbering", "envpath-injection", "envvar-injection",
77
"command-injection", "argument-injection", "code-injection", "cache-poisoning",
8-
"untrusted-checkout-toctou", "artifact-poisoning"
8+
"untrusted-checkout-toctou", "artifact-poisoning", "artifact-poisoning-toctou"
99
]
1010
}
1111

12-
string any_non_toctou_category() {
13-
result = any_relevant_category() and not result = "untrusted-checkout-toctou"
12+
string non_toctou_category() {
13+
result = any_category() and not result = "untrusted-checkout-toctou"
1414
}
1515

16-
string any_relevant_event() {
16+
string toctou_category() { result = ["untrusted-checkout-toctou", "artifact-poisoning-toctou"] }
17+
18+
string any_event() { result = actor_not_attacker_event() or result = actor_is_attacker_event() }
19+
20+
string actor_is_attacker_event() {
1721
result =
1822
[
23+
// actor and attacker have to be the same
1924
"pull_request_target",
20-
"issue_comment",
21-
"pull_request_comment",
2225
"workflow_run",
26+
"discussion_comment",
27+
"discussion",
2328
"issues",
2429
"fork",
25-
"watch",
26-
"discussion_comment",
27-
"discussion"
30+
"watch"
31+
]
32+
}
33+
34+
string actor_not_attacker_event() {
35+
result =
36+
[
37+
// actor and attacker can be different
38+
// actor may be a collaborator, but the attacker is may be the author of the PR that gets commented
39+
// therefore it may be vulnerable to TOCTOU races where the actor reviews one thing and the attacker changes it
40+
"issue_comment",
41+
"pull_request_comment",
2842
]
2943
}
3044

@@ -81,7 +95,9 @@ abstract class AssociationCheck extends ControlCheck {
8195
// - they are effective against pull requests and workflow_run (since these are triggered by pull_requests) since they can control who is making the PR
8296
// - they are not effective against issue_comment since the author of the comment may not be the same as the author of the PR
8397
override predicate protectsCategoryAndEvent(string category, string event) {
84-
event = ["pull_request_target", "workflow_run"] and category = any_relevant_category()
98+
event = actor_is_attacker_event() and category = any_category()
99+
or
100+
event = actor_not_attacker_event() and category = non_toctou_category()
85101
}
86102
}
87103

@@ -90,7 +106,9 @@ abstract class ActorCheck extends ControlCheck {
90106
// - they are effective against pull requests and workflow_run (since these are triggered by pull_requests) since they can control who is making the PR
91107
// - they are not effective against issue_comment since the author of the comment may not be the same as the author of the PR
92108
override predicate protectsCategoryAndEvent(string category, string event) {
93-
event = ["pull_request_target", "workflow_run"] and category = any_relevant_category()
109+
event = actor_is_attacker_event() and category = any_category()
110+
or
111+
event = actor_not_attacker_event() and category = non_toctou_category()
94112
}
95113
}
96114

@@ -106,31 +124,36 @@ abstract class PermissionCheck extends ControlCheck {
106124
// - they are effective against pull requests/workflow_run since they can control who can make changes
107125
// - they are not effective against issue_comment since the author of the comment may not be the same as the author of the PR
108126
override predicate protectsCategoryAndEvent(string category, string event) {
109-
event = ["pull_request_target", "workflow_run", "issue_comment"] and
110-
category = any_relevant_category()
127+
event = actor_is_attacker_event() and category = any_category()
128+
or
129+
event = actor_not_attacker_event() and category = non_toctou_category()
111130
}
112131
}
113132

114133
abstract class LabelCheck extends ControlCheck {
115134
// checks if the issue/pull_request is labeled, which implies that it could have been approved
116135
// - they dont protect against mutation attacks
117136
override predicate protectsCategoryAndEvent(string category, string event) {
118-
event = ["pull_request_target", "workflow_run"] and category = any_non_toctou_category()
137+
event = actor_is_attacker_event() and category = any_category()
138+
or
139+
event = actor_not_attacker_event() and category = non_toctou_category()
119140
}
120141
}
121142

122143
class EnvironmentCheck extends ControlCheck instanceof Environment {
123144
// Environment checks are not effective against any mutable attacks
124145
// they do actually protect against untrusted code execution (sha)
125146
override predicate protectsCategoryAndEvent(string category, string event) {
126-
event = ["pull_request_target", "workflow_run"] and category = any_non_toctou_category()
147+
event = actor_is_attacker_event() and category = any_category()
148+
or
149+
event = actor_not_attacker_event() and category = non_toctou_category()
127150
}
128151
}
129152

130153
abstract class CommentVsHeadDateCheck extends ControlCheck {
131154
override predicate protectsCategoryAndEvent(string category, string event) {
132155
// by itself, this check is not effective against any attacks
133-
none()
156+
event = actor_not_attacker_event() and category = toctou_category()
134157
}
135158
}
136159

@@ -187,7 +210,7 @@ class PullRequestTargetRepositoryIfCheck extends RepositoryCheck instanceof If {
187210
}
188211

189212
override predicate protectsCategoryAndEvent(string category, string event) {
190-
event = "pull_request_target" and category = any_relevant_category()
213+
event = "pull_request_target" and category = any_category()
191214
}
192215
}
193216

@@ -205,7 +228,7 @@ class WorkflowRunRepositoryIfCheck extends RepositoryCheck instanceof If {
205228
}
206229

207230
override predicate protectsCategoryAndEvent(string category, string event) {
208-
event = "workflow_run" and category = any_relevant_category()
231+
event = "workflow_run" and category = any_category()
209232
}
210233
}
211234

@@ -250,6 +273,9 @@ class PermissionActionCheck extends PermissionCheck instanceof UsesStep {
250273
not exists(this.getArgument("permission-level")) or
251274
this.getArgument("permission-level") = ["write", "admin"]
252275
)
276+
or
277+
this.getCallee() = "actions/github-script" and
278+
this.getArgument("script").splitAt("\n").matches("%getCollaboratorPermissionLevel%")
253279
}
254280
}
255281

ql/src/Security/CWE-367/UntrustedCheckoutTOCTOUCritical.ql

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,14 @@ import codeql.actions.security.ControlChecks
1818

1919
query predicate edges(Step a, Step b) { a.getNextStep() = b }
2020

21-
from
22-
LocalJob job, MutableRefCheckoutStep checkout, PoisonableStep step, ControlCheck check,
23-
Event event
21+
from MutableRefCheckoutStep checkout, PoisonableStep step, Event event
2422
where
25-
job.getAStep() = checkout and
2623
// the checked-out code may lead to arbitrary code execution
2724
checkout.getAFollowingStep() = step and
2825
// the checkout occurs in a privileged context
2926
inPrivilegedContext(checkout, event) and
3027
// the mutable checkout step is protected by an Insufficient access check
31-
check.protects(checkout, event, "untrusted-checkout") and
32-
not check.protects(checkout, event, "untrusted-checkout-toctou")
28+
exists(ControlCheck check1 | check1.protects(checkout, event, "untrusted-checkout")) and
29+
not exists(ControlCheck check2 | check2.protects(checkout, event, "untrusted-checkout-toctou"))
3330
select step, checkout, step,
34-
"Insufficient protection against execution of untrusted code on a privileged workflow on check $@.",
35-
check, check.toString()
31+
"Insufficient protection against execution of untrusted code on a privileged workflow."

ql/src/Security/CWE-367/UntrustedCheckoutTOCTOUHigh.ql

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,14 @@ import codeql.actions.security.UntrustedCheckoutQuery
1616
import codeql.actions.security.PoisonableSteps
1717
import codeql.actions.security.ControlChecks
1818

19-
from LocalJob job, MutableRefCheckoutStep checkout, ControlCheck check, Event event
19+
from MutableRefCheckoutStep checkout, Event event
2020
where
21-
job.getAStep() = checkout and
2221
// there are no evidences that the checked-out gets executed
2322
not checkout.getAFollowingStep() instanceof PoisonableStep and
2423
// the checkout occurs in a privileged context
2524
inPrivilegedContext(checkout, event) and
26-
event = job.getATriggerEvent() and
2725
// the mutable checkout step is protected by an Insufficient access check
28-
check.protects(checkout, event, "untrusted-checkout") and
29-
not check.protects(checkout, event, "untrusted-checkout-toctou")
26+
exists(ControlCheck check1 | check1.protects(checkout, event, "untrusted-checkout")) and
27+
not exists(ControlCheck check2 | check2.protects(checkout, event, "untrusted-checkout-toctou"))
3028
select checkout,
31-
"Insufficient protection against execution of untrusted code on a privileged workflow on step $@.",
32-
check, check.toString()
29+
"Insufficient protection against execution of untrusted code on a privileged workflow."

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

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,6 @@ where
2626
checkout.getAFollowingStep() = step and
2727
// the checkout occurs in a privileged context
2828
inPrivilegedContext(step, event) and
29-
(
30-
// issue_comment: check for date comparison checks and actor/access control checks
31-
event.getName() = "issue_comment" and
32-
not exists(ControlCheck check, CommentVsHeadDateCheck date_check |
33-
(
34-
check instanceof ActorCheck or
35-
check instanceof AssociationCheck or
36-
check instanceof PermissionCheck
37-
) and
38-
check.dominates(step) and
39-
date_check.dominates(step)
40-
)
41-
or
42-
// not issue_comment triggered workflows
43-
not event.getName() = "issue_comment" and
44-
not exists(ControlCheck check | check.protects(step, event, "untrusted-checkout"))
45-
)
29+
not exists(ControlCheck check | check.protects(step, event, "untrusted-checkout"))
4630
select step, checkout, step, "Execution of untrusted code on a privileged workflow. $@", event,
4731
event.getLocation().getFile().toString()

ql/test/query-tests/Security/CWE-367/.github/workflows/deployment.yml renamed to ql/test/query-tests/Security/CWE-367/.github/workflows/deployment1.yml

File renamed without changes.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# https://github.com/AdnaneKhan/ActionsTOCTOU/blob/main/.github/workflows/deployment_victim.yml
2+
name: Environment PR Check
3+
4+
on:
5+
pull_request_target:
6+
branches:
7+
- main
8+
paths:
9+
- 'README.md'
10+
workflow_dispatch:
11+
jobs:
12+
test:
13+
environment: Public CI
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout from PR branch
17+
uses: actions/checkout@v4
18+
with:
19+
repository: ${{ github.event.pull_request.head.repo.full_name }}
20+
ref: ${{ github.event.pull_request.head.sha }}
21+
22+
- name: Set Node.js 20.x for GitHub Action
23+
uses: actions/setup-node@v4
24+
with:
25+
node-version: 20.x
26+
27+
- name: installing node_modules
28+
run: cd deployment_example && npm install
29+
30+
- name: Build GitHub Action
31+
run: cd deployment_example && npm run build
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# https://github.com/AdnaneKhan/ActionsTOCTOU/blob/main/.github/workflows/comment_victim.yml
2+
name: Comment Triggered Test
3+
on:
4+
issue_comment:
5+
types: [created]
6+
permissions: 'write-all'
7+
jobs:
8+
test1:
9+
if: ${{ github.event.issue.pull_request && contains(fromJson('["MEMBER", "OWNER"]'), github.event.comment.author_association) && startsWith(github.event.comment.body, '/run-tests ') }}
10+
runs-on: ubuntu-latest
11+
steps:
12+
13+
- uses: actions/github-script@v6
14+
name: Get PR branch
15+
id: issue
16+
with:
17+
script: |
18+
const pr = context.payload.issue.number
19+
const data = await github.rest.pulls.get({
20+
owner: context.repo.owner,
21+
repo: context.repo.repo,
22+
pull_number: pr
23+
})
24+
return {
25+
ref: data.data.head.ref,
26+
sha: data.data.head.sha,
27+
}
28+
- uses: actions/checkout@v4
29+
with:
30+
submodules: recursive
31+
ref: ${{ fromJson(steps.issue.outputs.result).sha }}
32+
- run: bash comment_example/tests.sh
33+
34+
test2:
35+
if: ${{ github.event.issue.pull_request && contains(fromJson('["MEMBER", "OWNER"]'), github.event.comment.author_association) && startsWith(github.event.comment.body, '/run-tests ') }}
36+
runs-on: ubuntu-latest
37+
steps:
38+
39+
- uses: actions/github-script@v6
40+
name: Get PR branch
41+
id: issue
42+
with:
43+
script: |
44+
const pr = context.payload.issue.number
45+
const data = await github.rest.pulls.get({
46+
owner: context.repo.owner,
47+
repo: context.repo.repo,
48+
pull_number: pr
49+
})
50+
return {
51+
ref: data.data.head.ref,
52+
sha: data.data.head.sha,
53+
}
54+
- uses: actions/checkout@v4
55+
with:
56+
submodules: recursive
57+
ref: ${{ fromJson(steps.issue.outputs.result).ref }}
58+
- run: bash comment_example/tests.sh
59+
60+
test3:
61+
if: ${{ github.event.issue.pull_request && contains(fromJson('["MEMBER", "OWNER"]'), github.event.comment.author_association) && startsWith(github.event.comment.body, '/run-tests ') }}
62+
runs-on: ubuntu-latest
63+
steps:
64+
- uses: actions/checkout@v4
65+
with:
66+
submodules: recursive
67+
ref: "refs/pull/${{ github.event.number }}/merge"
68+
- run: bash comment_example/tests.sh

0 commit comments

Comments
 (0)