Skip to content

Commit 05933be

Browse files
committed
Normalize untrusted checkout paths
1 parent a39eab3 commit 05933be

5 files changed

Lines changed: 271 additions & 3 deletions

File tree

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

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,96 @@
1414
*/
1515

1616
import actions
17+
private import codeql.util.FilePath
1718
import codeql.actions.security.UntrustedCheckoutQuery
1819
import codeql.actions.security.PoisonableSteps
1920
import codeql.actions.security.ControlChecks
2021

2122
query predicate edges(Step a, Step b) { a.getNextStep() = b }
2223

24+
private class ActionsCheckoutPathInput extends NormalizableFilepath {
25+
ActionsCheckoutPathInput() {
26+
exists(PRHeadCheckoutStep checkout |
27+
checkout instanceof UsesStep and
28+
checkout.(UsesStep).getCallee() = "actions/checkout" and
29+
this = trimQuotes(checkout.(UsesStep).getArgument("path"))
30+
)
31+
}
32+
}
33+
34+
/** Gets the modeled script operand before shared path normalization can discard dot segments. */
35+
private string getUnnormalizedLocalScriptPath(LocalScriptExecutionRunStep step) {
36+
exists(string regexp, int pathGroup |
37+
poisonableLocalScriptsDataModel(regexp, pathGroup) and
38+
result = step.getScript().getACommand().regexpCapture(regexp, pathGroup).splitAt(" ")
39+
)
40+
}
41+
42+
private class ExecutionPathInput extends NormalizableFilepath {
43+
ExecutionPathInput() {
44+
exists(LocalScriptExecutionRunStep step |
45+
this = trimQuotes(getUnnormalizedLocalScriptPath(step))
46+
)
47+
or
48+
exists(LocalActionUsesStep step | this = step.getCallee())
49+
}
50+
}
51+
52+
private string getNormalizedActionsCheckoutPath(PRHeadCheckoutStep checkout) {
53+
exists(ActionsCheckoutPathInput checkoutPath, string normalized |
54+
checkout instanceof UsesStep and
55+
checkout.(UsesStep).getCallee() = "actions/checkout" and
56+
checkoutPath = trimQuotes(checkout.(UsesStep).getArgument("path")) and
57+
not checkoutPath.regexpMatch(".*\\$\\{\\{.*") and
58+
normalized = checkoutPath.getNormalizedPath() and
59+
not normalized.matches("/%") and
60+
normalized != ".." and
61+
not normalized.matches("../%") and
62+
if normalized = "."
63+
then result = "GITHUB_WORKSPACE"
64+
else result = "GITHUB_WORKSPACE/" + normalized
65+
)
66+
}
67+
68+
bindingset[path]
69+
private string getNormalizedExecutionPath(string path) {
70+
exists(ExecutionPathInput executionPath, string normalized |
71+
executionPath = trimQuotes(path) and
72+
not executionPath.regexpMatch(".*\\$\\{\\{.*") and
73+
normalized = executionPath.getNormalizedPath() and
74+
not normalized.matches("/%") and
75+
normalized != ".." and
76+
not normalized.matches("../%") and
77+
if normalized = "."
78+
then result = "GITHUB_WORKSPACE"
79+
else (
80+
normalized.regexpMatch("^[^$/~].*") and
81+
result = "GITHUB_WORKSPACE/" + normalized
82+
)
83+
)
84+
}
85+
86+
bindingset[checkout, rawPath, path]
87+
private predicate checkoutContainsPath(PRHeadCheckoutStep checkout, string rawPath, string path) {
88+
exists(string root, string candidate |
89+
root = getNormalizedActionsCheckoutPath(checkout) and
90+
candidate = getNormalizedExecutionPath(rawPath) and
91+
// Canonicalize both paths so dot segments cannot enter or escape the checkout while still
92+
// passing a lexical prefix check.
93+
(candidate = root or candidate.indexOf(root + "/") = 0)
94+
)
95+
or
96+
not exists(getNormalizedActionsCheckoutPath(checkout)) and
97+
isSubpath(path, checkout.getPath())
98+
}
99+
100+
private predicate checkoutUsesWorkspaceRoot(PRHeadCheckoutStep checkout) {
101+
getNormalizedActionsCheckoutPath(checkout) = "GITHUB_WORKSPACE"
102+
or
103+
not exists(getNormalizedActionsCheckoutPath(checkout)) and
104+
checkout.getPath() = "GITHUB_WORKSPACE/"
105+
}
106+
23107
from PRHeadCheckoutStep checkout, PoisonableStep poisonable, Event event
24108
where
25109
// the checkout is followed by a known poisonable step
@@ -29,7 +113,9 @@ where
29113
(
30114
// Check if the poisonable step is a local script execution step
31115
// and the path of the command or script matches the path of the downloaded artifact
32-
isSubpath(poisonable.(LocalScriptExecutionRunStep).getPath(), checkout.getPath())
116+
checkoutContainsPath(checkout,
117+
getUnnormalizedLocalScriptPath(poisonable.(LocalScriptExecutionRunStep)),
118+
poisonable.(LocalScriptExecutionRunStep).getPath())
33119
or
34120
// Checking the path for non local script execution steps is very difficult
35121
not poisonable instanceof LocalScriptExecutionRunStep
@@ -40,9 +126,10 @@ where
40126
poisonable instanceof UsesStep and
41127
(
42128
not poisonable instanceof LocalActionUsesStep and
43-
checkout.getPath() = "GITHUB_WORKSPACE/"
129+
checkoutUsesWorkspaceRoot(checkout)
44130
or
45-
isSubpath(poisonable.(LocalActionUsesStep).getPath(), checkout.getPath())
131+
checkoutContainsPath(checkout, poisonable.(LocalActionUsesStep).getCallee(),
132+
poisonable.(LocalActionUsesStep).getPath())
46133
)
47134
) and
48135
// the checkout occurs in a privileged context
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* The `actions/untrusted-checkout/critical` query now recognizes scripts and local actions executed from explicit relative `actions/checkout` paths without matching sibling or escaping paths.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
name: Untrusted checkout paths
2+
3+
on:
4+
pull_request_target:
5+
6+
jobs:
7+
bare-path:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v4.2.2
11+
with:
12+
repository: ${{ github.event.pull_request.head.repo.full_name }}
13+
ref: ${{ github.event.pull_request.head.ref }}
14+
path: checkout
15+
- run: ./checkout/build.sh
16+
17+
dot-relative-path:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v4.2.2
21+
with:
22+
repository: ${{ github.event.pull_request.head.repo.full_name }}
23+
ref: ${{ github.event.pull_request.head.sha }}
24+
path: ./checkout-dot
25+
- run: bash ./checkout-dot/build.sh
26+
27+
trailing-slash-path:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4.2.2
31+
with:
32+
repository: ${{ github.event.pull_request.head.repo.full_name }}
33+
ref: ${{ github.event.pull_request.head.sha }}
34+
path: checkout-action/
35+
- uses: ./checkout-action/.github/actions/build
36+
37+
nested-path:
38+
runs-on: ubuntu-latest
39+
steps:
40+
- uses: actions/checkout@v4.2.2
41+
with:
42+
repository: ${{ github.event.pull_request.head.repo.full_name }}
43+
ref: ${{ github.event.pull_request.head.sha }}
44+
path: a/b
45+
- run: bash ./a/b/build.sh
46+
47+
canonicalized-candidate-path:
48+
runs-on: ubuntu-latest
49+
steps:
50+
- uses: actions/checkout@v4.2.2
51+
with:
52+
repository: ${{ github.event.pull_request.head.repo.full_name }}
53+
ref: ${{ github.event.pull_request.head.sha }}
54+
path: a/b
55+
- run: bash a/./b/build.sh
56+
57+
escaped-candidate-path:
58+
runs-on: ubuntu-latest
59+
steps:
60+
- uses: actions/checkout@v4.2.2
61+
with:
62+
repository: ${{ github.event.pull_request.head.repo.full_name }}
63+
ref: ${{ github.event.pull_request.head.sha }}
64+
path: checkout
65+
- run: bash checkout/../trusted.sh
66+
67+
leading-and-inner-dot-path:
68+
runs-on: ubuntu-latest
69+
steps:
70+
- uses: actions/checkout@v4.2.2
71+
with:
72+
repository: ${{ github.event.pull_request.head.repo.full_name }}
73+
ref: ${{ github.event.pull_request.head.sha }}
74+
path: a/b
75+
- run: bash ./a/./b/build.sh
76+
77+
quoted-candidate-path:
78+
runs-on: ubuntu-latest
79+
steps:
80+
- uses: actions/checkout@v4.2.2
81+
with:
82+
repository: ${{ github.event.pull_request.head.repo.full_name }}
83+
ref: ${{ github.event.pull_request.head.sha }}
84+
path: checkout-quoted
85+
- run: bash "checkout-quoted/build.sh"
86+
87+
leading-dot-escaped-candidate:
88+
runs-on: ubuntu-latest
89+
steps:
90+
- uses: actions/checkout@v4.2.2
91+
with:
92+
repository: ${{ github.event.pull_request.head.repo.full_name }}
93+
ref: ${{ github.event.pull_request.head.sha }}
94+
path: checkout
95+
- run: bash ./checkout/../trusted.sh
96+
97+
workspace-path:
98+
runs-on: ubuntu-latest
99+
steps:
100+
- uses: actions/checkout@v4.2.2
101+
with:
102+
repository: ${{ github.event.pull_request.head.repo.full_name }}
103+
ref: ${{ github.event.pull_request.head.sha }}
104+
path: .
105+
- run: ./build.sh
106+
107+
workspace-path-non-local-action:
108+
runs-on: ubuntu-latest
109+
steps:
110+
- uses: actions/checkout@v4.2.2
111+
with:
112+
repository: ${{ github.event.pull_request.head.repo.full_name }}
113+
ref: ${{ github.event.pull_request.head.sha }}
114+
path: .
115+
- uses: actions/setup-node@v4
116+
with:
117+
cache: yarn
118+
119+
sibling-directory:
120+
runs-on: ubuntu-latest
121+
steps:
122+
- uses: actions/checkout@v4.2.2
123+
with:
124+
repository: ${{ github.event.pull_request.head.repo.full_name }}
125+
ref: ${{ github.event.pull_request.head.sha }}
126+
path: checkout
127+
- run: ./checkout-other/build.sh
128+
129+
nested-sibling-directory:
130+
runs-on: ubuntu-latest
131+
steps:
132+
- uses: actions/checkout@v4.2.2
133+
with:
134+
repository: ${{ github.event.pull_request.head.repo.full_name }}
135+
ref: ${{ github.event.pull_request.head.sha }}
136+
path: a/b
137+
- run: ./a/b-other/build.sh
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: Workflow run untrusted checkout path
2+
3+
on:
4+
workflow_run:
5+
workflows: [Test]
6+
types: [completed]
7+
8+
jobs:
9+
nested-path:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4.2.2
13+
with:
14+
ref: ${{ github.event.workflow_run.head_sha }}
15+
path: nested/checkout
16+
- run: ./nested/checkout/build.sh

actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,19 @@ edges
338338
| .github/workflows/untrusted_checkout_6.yml:17:9:21:6 | Uses Step | .github/workflows/untrusted_checkout_6.yml:21:9:23:23 | Run Step |
339339
| .github/workflows/untrusted_checkout_no_needs.yml:8:9:16:6 | Uses Step: checkAccess | .github/workflows/untrusted_checkout_no_needs.yml:16:9:22:2 | Run Step |
340340
| .github/workflows/untrusted_checkout_no_needs.yml:26:9:31:6 | Uses Step | .github/workflows/untrusted_checkout_no_needs.yml:31:9:31:23 | Run Step |
341+
| .github/workflows/untrusted_checkout_paths.yml:10:9:15:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:15:9:17:2 | Run Step |
342+
| .github/workflows/untrusted_checkout_paths.yml:20:9:25:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:25:9:27:2 | Run Step |
343+
| .github/workflows/untrusted_checkout_paths.yml:30:9:35:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:35:9:37:2 | Uses Step |
344+
| .github/workflows/untrusted_checkout_paths.yml:40:9:45:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:45:9:47:2 | Run Step |
345+
| .github/workflows/untrusted_checkout_paths.yml:50:9:55:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:55:9:57:2 | Run Step |
346+
| .github/workflows/untrusted_checkout_paths.yml:60:9:65:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:65:9:67:2 | Run Step |
347+
| .github/workflows/untrusted_checkout_paths.yml:70:9:75:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:75:9:77:2 | Run Step |
348+
| .github/workflows/untrusted_checkout_paths.yml:80:9:85:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:85:9:87:2 | Run Step |
349+
| .github/workflows/untrusted_checkout_paths.yml:90:9:95:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:95:9:97:2 | Run Step |
350+
| .github/workflows/untrusted_checkout_paths.yml:100:9:105:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:105:9:107:2 | Run Step |
351+
| .github/workflows/untrusted_checkout_paths.yml:110:9:115:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:115:9:119:2 | Uses Step |
352+
| .github/workflows/untrusted_checkout_paths.yml:122:9:127:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:127:9:129:2 | Run Step |
353+
| .github/workflows/untrusted_checkout_paths.yml:132:9:137:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:137:9:137:34 | Run Step |
341354
| .github/workflows/untrusted_checkout_permission_check_reusable2.yml:8:9:16:6 | Uses Step: checkAccess | .github/workflows/untrusted_checkout_permission_check_reusable2.yml:16:9:22:2 | Run Step |
342355
| .github/workflows/untrusted_checkout_permission_check_reusable.yml:8:9:16:6 | Uses Step: checkAccess | .github/workflows/untrusted_checkout_permission_check_reusable.yml:16:9:22:2 | Run Step |
343356
| .github/workflows/untrusted_checkout_permission_check_reusable_level2.yml:8:9:16:6 | Uses Step: checkAccess | .github/workflows/untrusted_checkout_permission_check_reusable_level2.yml:16:9:22:2 | Run Step |
@@ -350,6 +363,7 @@ edges
350363
| .github/workflows/workflow_run_untrusted_checkout.yml:13:9:16:6 | Uses Step | .github/workflows/workflow_run_untrusted_checkout.yml:16:9:18:31 | Uses Step |
351364
| .github/workflows/workflow_run_untrusted_checkout_2.yml:13:9:16:6 | Uses Step | .github/workflows/workflow_run_untrusted_checkout_2.yml:16:9:18:31 | Uses Step |
352365
| .github/workflows/workflow_run_untrusted_checkout_3.yml:13:9:16:6 | Uses Step | .github/workflows/workflow_run_untrusted_checkout_3.yml:16:9:18:31 | Uses Step |
366+
| .github/workflows/workflow_run_untrusted_checkout_path.yml:12:9:16:6 | Uses Step | .github/workflows/workflow_run_untrusted_checkout_path.yml:16:9:16:40 | Run Step |
353367
#select
354368
| .github/actions/dangerous-git-checkout/action.yml:6:7:11:4 | Uses Step | .github/actions/dangerous-git-checkout/action.yml:6:7:11:4 | Uses Step | .github/workflows/untrusted_checkout3.yml:13:9:13:23 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout3.yml:4:3:4:14 | workflow_run | workflow_run |
355369
| .github/workflows/auto_ci.yml:20:9:27:6 | Uses Step | .github/workflows/auto_ci.yml:20:9:27:6 | Uses Step | .github/workflows/auto_ci.yml:32:9:37:6 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/auto_ci.yml:6:3:6:21 | pull_request_target | pull_request_target |
@@ -394,4 +408,14 @@ edges
394408
| .github/workflows/untrusted_checkout.yml:8:9:11:6 | Uses Step | .github/workflows/untrusted_checkout.yml:8:9:11:6 | Uses Step | .github/workflows/untrusted_checkout.yml:15:9:18:2 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout.yml:2:3:2:21 | pull_request_target | pull_request_target |
395409
| .github/workflows/untrusted_checkout.yml:23:9:26:6 | Uses Step | .github/workflows/untrusted_checkout.yml:23:9:26:6 | Uses Step | .github/workflows/untrusted_checkout.yml:30:9:32:23 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout.yml:2:3:2:21 | pull_request_target | pull_request_target |
396410
| .github/workflows/untrusted_checkout_no_needs.yml:26:9:31:6 | Uses Step | .github/workflows/untrusted_checkout_no_needs.yml:26:9:31:6 | Uses Step | .github/workflows/untrusted_checkout_no_needs.yml:31:9:31:23 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_no_needs.yml:2:3:2:21 | pull_request_target | pull_request_target |
411+
| .github/workflows/untrusted_checkout_paths.yml:10:9:15:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:10:9:15:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:15:9:17:2 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_paths.yml:4:3:4:21 | pull_request_target | pull_request_target |
412+
| .github/workflows/untrusted_checkout_paths.yml:20:9:25:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:20:9:25:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:25:9:27:2 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_paths.yml:4:3:4:21 | pull_request_target | pull_request_target |
413+
| .github/workflows/untrusted_checkout_paths.yml:30:9:35:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:30:9:35:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:35:9:37:2 | Uses Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_paths.yml:4:3:4:21 | pull_request_target | pull_request_target |
414+
| .github/workflows/untrusted_checkout_paths.yml:40:9:45:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:40:9:45:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:45:9:47:2 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_paths.yml:4:3:4:21 | pull_request_target | pull_request_target |
415+
| .github/workflows/untrusted_checkout_paths.yml:50:9:55:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:50:9:55:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:55:9:57:2 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_paths.yml:4:3:4:21 | pull_request_target | pull_request_target |
416+
| .github/workflows/untrusted_checkout_paths.yml:70:9:75:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:70:9:75:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:75:9:77:2 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_paths.yml:4:3:4:21 | pull_request_target | pull_request_target |
417+
| .github/workflows/untrusted_checkout_paths.yml:80:9:85:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:80:9:85:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:85:9:87:2 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_paths.yml:4:3:4:21 | pull_request_target | pull_request_target |
418+
| .github/workflows/untrusted_checkout_paths.yml:100:9:105:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:100:9:105:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:105:9:107:2 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_paths.yml:4:3:4:21 | pull_request_target | pull_request_target |
419+
| .github/workflows/untrusted_checkout_paths.yml:110:9:115:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:110:9:115:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:115:9:119:2 | Uses Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_paths.yml:4:3:4:21 | pull_request_target | pull_request_target |
397420
| .github/workflows/untrusted_checkout_permissions_check.yml:36:9:41:6 | Uses Step | .github/workflows/untrusted_checkout_permissions_check.yml:36:9:41:6 | Uses Step | .github/workflows/untrusted_checkout_permissions_check.yml:41:9:41:22 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/untrusted_checkout_permissions_check.yml:2:3:2:21 | pull_request_target | pull_request_target |
421+
| .github/workflows/workflow_run_untrusted_checkout_path.yml:12:9:16:6 | Uses Step | .github/workflows/workflow_run_untrusted_checkout_path.yml:12:9:16:6 | Uses Step | .github/workflows/workflow_run_untrusted_checkout_path.yml:16:9:16:40 | Run Step | Checkout of untrusted code in a privileged workflow with later potential execution (event trigger: $@). | .github/workflows/workflow_run_untrusted_checkout_path.yml:4:3:4:14 | workflow_run | workflow_run |

0 commit comments

Comments
 (0)