Skip to content

Commit 2e6f004

Browse files
author
Alvaro Muñoz
authored
Merge pull request #92 from github/fix/direct_cache_poison
Improve path checks for Artifact and Cache poisoning queries
2 parents 65d09b3 + 9d26a8d commit 2e6f004

File tree

13 files changed

+163
-98
lines changed

13 files changed

+163
-98
lines changed

ql/lib/codeql/actions/Ast.qll

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,8 @@ class Run extends Step instanceof RunImpl {
289289
ScalarValue getScriptScalar() { result = super.getScriptScalar() }
290290

291291
Expression getAnScriptExpr() { result = super.getAnScriptExpr() }
292+
293+
string getWorkingDirectory() { result = super.getWorkingDirectory() }
292294
}
293295

294296
abstract class SimpleReferenceExpression extends AstNode instanceof SimpleReferenceExpressionImpl {

ql/lib/codeql/actions/Helper.qll

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,30 @@ string getRepoRoot() {
283283
result = ""
284284
)
285285
}
286+
287+
bindingset[path]
288+
string normalizePath(string path) {
289+
exists(string trimmed_path | trimmed_path = trimQuotes(path) |
290+
// ./foo -> GITHUB_WORKSPACE/foo
291+
if path.indexOf("./") = 0
292+
then result = path.replaceAll("./", "GITHUB_WORKSPACE/")
293+
else
294+
// GITHUB_WORKSPACE/foo -> GITHUB_WORKSPACE/foo
295+
if path.indexOf("GITHUB_WORKSPACE/") = 0
296+
then result = path
297+
else
298+
// foo -> GITHUB_WORKSPACE/foo
299+
if path.regexpMatch("^[^/~].*")
300+
then result = "GITHUB_WORKSPACE/" + path.regexpReplaceAll("/$", "")
301+
else
302+
// ~/foo -> ~/foo
303+
// /foo -> /foo
304+
result = path
305+
)
306+
}
307+
308+
/**
309+
* Holds if the path cache_path is a subpath of the path untrusted_path.
310+
*/
311+
bindingset[subpath, path]
312+
predicate isSubpath(string subpath, string path) { subpath.substring(0, path.length()) = path }

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,18 @@ class RunImpl extends StepImpl {
13171317
override string toString() {
13181318
if exists(this.getId()) then result = "Run Step: " + this.getId() else result = "Run Step"
13191319
}
1320+
1321+
/** Gets the working directory for this `runs` mapping. */
1322+
string getWorkingDirectory() {
1323+
if exists(n.lookup("working-directory").(YamlString).getValue())
1324+
then
1325+
result =
1326+
n.lookup("working-directory")
1327+
.(YamlString)
1328+
.getValue()
1329+
.regexpReplaceAll("^\\./", "GITHUB_WORKSPACE/")
1330+
else result = "GITHUB_WORKSPACE/"
1331+
}
13201332
}
13211333

13221334
/**

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

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ class GitHubDownloadArtifactActionStep extends UntrustedArtifactDownloadStep, Us
3535
}
3636

3737
override string getPath() {
38-
if exists(this.getArgument("path")) then result = this.getArgument("path") else result = ""
38+
if exists(this.getArgument("path"))
39+
then result = normalizePath(this.getArgument("path"))
40+
else result = "GITHUB_WORKSPACE/"
3941
}
4042
}
4143

@@ -79,11 +81,11 @@ class DownloadArtifactActionStep extends UntrustedArtifactDownloadStep, UsesStep
7981

8082
override string getPath() {
8183
if exists(this.getArgument(["path", "download_path"]))
82-
then result = this.getArgument(["path", "download_path"])
84+
then result = normalizePath(this.getArgument(["path", "download_path"]))
8385
else
8486
if exists(this.getArgument("paths"))
85-
then result = this.getArgument("paths").splitAt(" ")
86-
else result = ""
87+
then result = normalizePath(this.getArgument("paths").splitAt(" "))
88+
else result = "GITHUB_WORKSPACE/"
8789
}
8890
}
8991

@@ -114,8 +116,8 @@ class LegitLabsDownloadArtifactActionStep extends UntrustedArtifactDownloadStep,
114116

115117
override string getPath() {
116118
if exists(this.getArgument("path"))
117-
then result = this.getArgument("path")
118-
else result = "./artifacts"
119+
then result = normalizePath(this.getArgument("path"))
120+
else result = "GITHUB_WORKSPACE/artifacts"
119121
}
120122
}
121123

@@ -161,14 +163,14 @@ class ActionsGitHubScriptDownloadStep extends UntrustedArtifactDownloadStep, Use
161163
.regexpMatch(unzipRegexp() + unzipDirArgRegexp())
162164
then
163165
result =
164-
trimQuotes(this.getAFollowingStep()
165-
.(Run)
166-
.getScript()
167-
.splitAt("\n")
168-
.regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2))
166+
normalizePath(trimQuotes(this.getAFollowingStep()
167+
.(Run)
168+
.getScript()
169+
.splitAt("\n")
170+
.regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2)))
169171
else
170172
if this.getAFollowingStep().(Run).getScript().splitAt("\n").regexpMatch(unzipRegexp())
171-
then result = ""
173+
then result = "GITHUB_WORKSPACE/"
172174
else none()
173175
}
174176
}
@@ -197,18 +199,20 @@ class GHRunArtifactDownloadStep extends UntrustedArtifactDownloadStep, Run {
197199
script.splitAt("\n").regexpMatch(unzipRegexp() + unzipDirArgRegexp())
198200
then
199201
result =
200-
trimQuotes(script.splitAt("\n").regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2)) or
202+
normalizePath(trimQuotes(script
203+
.splitAt("\n")
204+
.regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2))) or
201205
result =
202-
trimQuotes(this.getAFollowingStep()
203-
.(Run)
204-
.getScript()
205-
.splitAt("\n")
206-
.regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2))
206+
normalizePath(trimQuotes(this.getAFollowingStep()
207+
.(Run)
208+
.getScript()
209+
.splitAt("\n")
210+
.regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2)))
207211
else
208212
if
209213
this.getAFollowingStep().(Run).getScript().splitAt("\n").regexpMatch(unzipRegexp()) or
210214
script.splitAt("\n").regexpMatch(unzipRegexp())
211-
then result = ""
215+
then result = "GITHUB_WORKSPACE/"
212216
else none()
213217
}
214218
}
@@ -244,14 +248,16 @@ class DirectArtifactDownloadStep extends UntrustedArtifactDownloadStep, Run {
244248
.regexpMatch(unzipRegexp() + unzipDirArgRegexp())
245249
then
246250
result =
247-
trimQuotes(script.splitAt("\n").regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2)) or
251+
normalizePath(trimQuotes(script
252+
.splitAt("\n")
253+
.regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2))) or
248254
result =
249-
trimQuotes(this.getAFollowingStep()
250-
.(Run)
251-
.getScript()
252-
.splitAt("\n")
253-
.regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2))
254-
else result = ""
255+
normalizePath(trimQuotes(this.getAFollowingStep()
256+
.(Run)
257+
.getScript()
258+
.splitAt("\n")
259+
.regexpCapture(unzipRegexp() + unzipDirArgRegexp(), 2)))
260+
else result = "GITHUB_WORKSPACE/"
255261
}
256262
}
257263

@@ -268,18 +274,16 @@ class ArtifactPoisoningSink extends DataFlow::Node {
268274
(
269275
// Check if the poisonable step is a local script execution step
270276
// and the path of the command or script matches the path of the downloaded artifact
277+
isSubpath(poisonable.(LocalScriptExecutionRunStep).getPath(), download.getPath())
278+
or
271279
// Checking the path for non local script execution steps is very difficult
272280
not poisonable instanceof LocalScriptExecutionRunStep
273-
or
274-
// TODO: account for Run's working directory
275-
poisonable
276-
.(LocalScriptExecutionRunStep)
277-
.getCommand()
278-
.matches(["./", ""] + download.getPath() + "%")
281+
// Its not easy to extract the path from a non-local script execution step so skipping this check for now
282+
// and isSubpath(poisonable.(Run).getWorkingDirectory(), download.getPath())
279283
)
280284
or
281285
poisonable.(UsesStep) = this.asExpr() and
282-
download.getPath() = ""
286+
download.getPath() = "GITHUB_WORKSPACE/"
283287
)
284288
}
285289

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,17 @@ abstract class CacheWritingStep extends Step {
5151
class CacheActionUsesStep extends CacheWritingStep, UsesStep {
5252
CacheActionUsesStep() { this.getCallee() = "actions/cache" }
5353

54-
override string getPath() { result = this.(UsesStep).getArgument("path").splitAt("\n") }
54+
override string getPath() {
55+
result = normalizePath(this.(UsesStep).getArgument("path").splitAt("\n"))
56+
}
5557
}
5658

5759
class CacheActionSaveUsesStep extends CacheWritingStep, UsesStep {
5860
CacheActionSaveUsesStep() { this.getCallee() = "actions/cache/save" }
5961

60-
override string getPath() { result = this.(UsesStep).getArgument("path").splitAt("\n") }
62+
override string getPath() {
63+
result = normalizePath(this.(UsesStep).getArgument("path").splitAt("\n"))
64+
}
6165
}
6266

6367
class SetupRubyUsesStep extends CacheWritingStep, UsesStep {
@@ -66,5 +70,5 @@ class SetupRubyUsesStep extends CacheWritingStep, UsesStep {
6670
this.getArgument("bundler-cache") = "true"
6771
}
6872

69-
override string getPath() { result = "vendor/bundle" }
73+
override string getPath() { result = normalizePath("vendor/bundle") }
7074
}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,18 @@ class JavascriptImportnUsesStep extends PoisonableStep, UsesStep {
3636
}
3737

3838
class LocalScriptExecutionRunStep extends PoisonableStep, Run {
39-
string cmd;
39+
string path;
4040

4141
LocalScriptExecutionRunStep() {
42-
exists(string line, string regexp, int command_group |
42+
exists(string line, string regexp, int path_group |
4343
line = this.getScript().splitAt("\n").trim()
4444
|
45-
poisonableLocalScriptsDataModel(regexp, command_group) and
46-
cmd = line.regexpCapture(regexp, command_group)
45+
poisonableLocalScriptsDataModel(regexp, path_group) and
46+
path = line.regexpCapture(regexp, path_group)
4747
)
4848
}
4949

50-
string getCommand() { result = cmd }
50+
string getPath() { result = normalizePath(path.splitAt(" ")) }
5151
}
5252

5353
class LocalActionUsesStep extends PoisonableStep, UsesStep {

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

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,6 @@ predicate containsHeadRef(string s) {
151151
)
152152
}
153153

154-
private string getStepCWD() {
155-
// TODO: This should be the path of the git command.
156-
// Read if from the step's CWD, workspace or look for a cd command.
157-
result = "?"
158-
}
159-
160154
/** Checkout of a Pull Request HEAD */
161155
abstract class PRHeadCheckoutStep extends Step {
162156
abstract string getPath();
@@ -208,7 +202,7 @@ class ActionsMutableRefCheckout extends MutableRefCheckoutStep instanceof UsesSt
208202
override string getPath() {
209203
if exists(this.(UsesStep).getArgument("path"))
210204
then result = this.(UsesStep).getArgument("path")
211-
else result = "?"
205+
else result = "GITHUB_WORKSPACE/"
212206
}
213207
}
214208

@@ -252,7 +246,7 @@ class ActionsSHACheckout extends SHACheckoutStep instanceof UsesStep {
252246
override string getPath() {
253247
if exists(this.(UsesStep).getArgument("path"))
254248
then result = this.(UsesStep).getArgument("path")
255-
else result = "?"
249+
else result = "GITHUB_WORKSPACE/"
256250
}
257251
}
258252

@@ -277,7 +271,7 @@ class GitMutableRefCheckout extends MutableRefCheckoutStep instanceof Run {
277271
)
278272
}
279273

280-
override string getPath() { result = getStepCWD() }
274+
override string getPath() { result = this.(Run).getWorkingDirectory() }
281275
}
282276

283277
/** Checkout of a Pull Request HEAD ref using git within a Run step */
@@ -298,7 +292,7 @@ class GitSHACheckout extends SHACheckoutStep instanceof Run {
298292
)
299293
}
300294

301-
override string getPath() { result = getStepCWD() }
295+
override string getPath() { result = this.(Run).getWorkingDirectory() }
302296
}
303297

304298
/** Checkout of a Pull Request HEAD ref using gh within a Run step */
@@ -321,7 +315,7 @@ class GhMutableRefCheckout extends MutableRefCheckoutStep instanceof Run {
321315
)
322316
}
323317

324-
override string getPath() { result = getStepCWD() }
318+
override string getPath() { result = this.(Run).getWorkingDirectory() }
325319
}
326320

327321
/** Checkout of a Pull Request HEAD ref using gh within a Run step */
@@ -341,5 +335,5 @@ class GhSHACheckout extends SHACheckoutStep instanceof Run {
341335
)
342336
}
343337

344-
override string getPath() { result = getStepCWD() }
338+
override string getPath() { result = this.(Run).getWorkingDirectory() }
345339
}

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

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,6 @@ import codeql.actions.security.CachePoisoningQuery
1818
import codeql.actions.security.PoisonableSteps
1919
import codeql.actions.security.ControlChecks
2020

21-
/**
22-
* Holds if the path cache_path is a subpath of the path untrusted_path.
23-
*/
24-
bindingset[cache_path, untrusted_path]
25-
predicate controlledCachePath(string cache_path, string untrusted_path) {
26-
exists(string normalized_cache_path, string normalized_untrusted_path |
27-
(
28-
cache_path.regexpMatch("^[a-zA-Z0-9_-].*") and
29-
normalized_cache_path = "./" + cache_path.regexpReplaceAll("/$", "")
30-
or
31-
normalized_cache_path = cache_path.regexpReplaceAll("/$", "")
32-
) and
33-
(
34-
untrusted_path.regexpMatch("^[a-zA-Z0-9_-].*") and
35-
normalized_untrusted_path = "./" + untrusted_path.regexpReplaceAll("/$", "")
36-
or
37-
normalized_untrusted_path = untrusted_path.regexpReplaceAll("/$", "")
38-
) and
39-
normalized_cache_path.substring(0, normalized_untrusted_path.length()) =
40-
normalized_untrusted_path
41-
)
42-
}
43-
4421
query predicate edges(Step a, Step b) { a.getNextStep() = b }
4522

4623
from LocalJob job, Event event, Step source, Step step, string message, string path
@@ -86,7 +63,7 @@ where
8663
step.(CacheWritingStep).getPath() = "?"
8764
or
8865
// the cache writing step reads from a path the attacker can control
89-
not path = "?" and controlledCachePath(step.(CacheWritingStep).getPath(), path)
66+
not path = "?" and isSubpath(step.(CacheWritingStep).getPath(), path)
9067
) and
9168
not step instanceof PoisonableStep
9269
select step, source, step,
Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Test
22

33
on:
4-
issue_comment:
4+
pull_request_target:
55

66
permissions:
77
actions: write
@@ -11,6 +11,8 @@ jobs:
1111
runs-on: ubuntu-latest
1212
steps:
1313
- uses: actions/checkout@v4
14+
with:
15+
ref: ${{ github.event.pull_request.head.sha }}
1416
- name: Set up Python 3.10
1517
uses: actions/setup-python@v5
1618
with:
@@ -22,14 +24,3 @@ jobs:
2224
path: ./results/pip
2325
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
2426
restore-keys: ${{ runner.os }}-pip-
25-
- name: Download artifact
26-
uses: dawidd6/action-download-artifact@v2
27-
with:
28-
name: results
29-
path: results/
30-
- name: Upload results
31-
uses: actions/upload-artifact@v4
32-
with:
33-
name: results
34-
path: results/
35-
if-no-files-found: ignore

0 commit comments

Comments
 (0)