diff --git a/actions/ql/src/Security/CWE-829/UntrustedCheckoutCritical.ql b/actions/ql/src/Security/CWE-829/UntrustedCheckoutCritical.ql index e0af03ca3d02..450cdcbb5250 100644 --- a/actions/ql/src/Security/CWE-829/UntrustedCheckoutCritical.ql +++ b/actions/ql/src/Security/CWE-829/UntrustedCheckoutCritical.ql @@ -14,12 +14,96 @@ */ import actions +private import codeql.util.FilePath import codeql.actions.security.UntrustedCheckoutQuery import codeql.actions.security.PoisonableSteps import codeql.actions.security.ControlChecks query predicate edges(Step a, Step b) { a.getNextStep() = b } +private class ActionsCheckoutPathInput extends NormalizableFilepath { + ActionsCheckoutPathInput() { + exists(PRHeadCheckoutStep checkout | + checkout instanceof UsesStep and + checkout.(UsesStep).getCallee() = "actions/checkout" and + this = trimQuotes(checkout.(UsesStep).getArgument("path")) + ) + } +} + +/** Gets the modeled script operand before shared path normalization can discard dot segments. */ +private string getUnnormalizedLocalScriptPath(LocalScriptExecutionRunStep step) { + exists(string regexp, int pathGroup | + poisonableLocalScriptsDataModel(regexp, pathGroup) and + result = step.getScript().getACommand().regexpCapture(regexp, pathGroup).splitAt(" ") + ) +} + +private class ExecutionPathInput extends NormalizableFilepath { + ExecutionPathInput() { + exists(LocalScriptExecutionRunStep step | + this = trimQuotes(getUnnormalizedLocalScriptPath(step)) + ) + or + exists(LocalActionUsesStep step | this = step.getCallee()) + } +} + +private string getNormalizedActionsCheckoutPath(PRHeadCheckoutStep checkout) { + exists(ActionsCheckoutPathInput checkoutPath, string normalized | + checkout instanceof UsesStep and + checkout.(UsesStep).getCallee() = "actions/checkout" and + checkoutPath = trimQuotes(checkout.(UsesStep).getArgument("path")) and + not checkoutPath.regexpMatch(".*\\$\\{\\{.*") and + normalized = checkoutPath.getNormalizedPath() and + not normalized.matches("/%") and + normalized != ".." and + not normalized.matches("../%") and + if normalized = "." + then result = "GITHUB_WORKSPACE" + else result = "GITHUB_WORKSPACE/" + normalized + ) +} + +bindingset[path] +private string getNormalizedExecutionPath(string path) { + exists(ExecutionPathInput executionPath, string normalized | + executionPath = trimQuotes(path) and + not executionPath.regexpMatch(".*\\$\\{\\{.*") and + normalized = executionPath.getNormalizedPath() and + not normalized.matches("/%") and + normalized != ".." and + not normalized.matches("../%") and + if normalized = "." + then result = "GITHUB_WORKSPACE" + else ( + normalized.regexpMatch("^[^$/~].*") and + result = "GITHUB_WORKSPACE/" + normalized + ) + ) +} + +bindingset[checkout, rawPath, path] +private predicate checkoutContainsPath(PRHeadCheckoutStep checkout, string rawPath, string path) { + exists(string root, string candidate | + root = getNormalizedActionsCheckoutPath(checkout) and + candidate = getNormalizedExecutionPath(rawPath) and + // Canonicalize both paths so dot segments cannot enter or escape the checkout while still + // passing a lexical prefix check. + (candidate = root or candidate.indexOf(root + "/") = 0) + ) + or + not exists(getNormalizedActionsCheckoutPath(checkout)) and + isSubpath(path, checkout.getPath()) +} + +private predicate checkoutUsesWorkspaceRoot(PRHeadCheckoutStep checkout) { + getNormalizedActionsCheckoutPath(checkout) = "GITHUB_WORKSPACE" + or + not exists(getNormalizedActionsCheckoutPath(checkout)) and + checkout.getPath() = "GITHUB_WORKSPACE/" +} + from PRHeadCheckoutStep checkout, PoisonableStep poisonable, Event event where // the checkout is followed by a known poisonable step @@ -29,7 +113,9 @@ where ( // Check if the poisonable step is a local script execution step // and the path of the command or script matches the path of the downloaded artifact - isSubpath(poisonable.(LocalScriptExecutionRunStep).getPath(), checkout.getPath()) + checkoutContainsPath(checkout, + getUnnormalizedLocalScriptPath(poisonable.(LocalScriptExecutionRunStep)), + poisonable.(LocalScriptExecutionRunStep).getPath()) or // Checking the path for non local script execution steps is very difficult not poisonable instanceof LocalScriptExecutionRunStep @@ -40,9 +126,10 @@ where poisonable instanceof UsesStep and ( not poisonable instanceof LocalActionUsesStep and - checkout.getPath() = "GITHUB_WORKSPACE/" + checkoutUsesWorkspaceRoot(checkout) or - isSubpath(poisonable.(LocalActionUsesStep).getPath(), checkout.getPath()) + checkoutContainsPath(checkout, poisonable.(LocalActionUsesStep).getCallee(), + poisonable.(LocalActionUsesStep).getPath()) ) ) and // the checkout occurs in a privileged context diff --git a/actions/ql/src/change-notes/2026-07-21-untrusted-checkout-paths.md b/actions/ql/src/change-notes/2026-07-21-untrusted-checkout-paths.md new file mode 100644 index 000000000000..f3cd9e30bebf --- /dev/null +++ b/actions/ql/src/change-notes/2026-07-21-untrusted-checkout-paths.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* 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. diff --git a/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/untrusted_checkout_paths.yml b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/untrusted_checkout_paths.yml new file mode 100644 index 000000000000..02a0f1a13038 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/untrusted_checkout_paths.yml @@ -0,0 +1,137 @@ +name: Untrusted checkout paths + +on: + pull_request_target: + +jobs: + bare-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.ref }} + path: checkout + - run: ./checkout/build.sh + + dot-relative-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: ./checkout-dot + - run: bash ./checkout-dot/build.sh + + trailing-slash-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: checkout-action/ + - uses: ./checkout-action/.github/actions/build + + nested-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: a/b + - run: bash ./a/b/build.sh + + canonicalized-candidate-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: a/b + - run: bash a/./b/build.sh + + escaped-candidate-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: checkout + - run: bash checkout/../trusted.sh + + leading-and-inner-dot-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: a/b + - run: bash ./a/./b/build.sh + + quoted-candidate-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: checkout-quoted + - run: bash "checkout-quoted/build.sh" + + leading-dot-escaped-candidate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: checkout + - run: bash ./checkout/../trusted.sh + + workspace-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: . + - run: ./build.sh + + workspace-path-non-local-action: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: . + - uses: actions/setup-node@v4 + with: + cache: yarn + + sibling-directory: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: checkout + - run: ./checkout-other/build.sh + + nested-sibling-directory: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: a/b + - run: ./a/b-other/build.sh diff --git a/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/workflow_run_untrusted_checkout_path.yml b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/workflow_run_untrusted_checkout_path.yml new file mode 100644 index 000000000000..d9396afad90e --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/workflow_run_untrusted_checkout_path.yml @@ -0,0 +1,16 @@ +name: Workflow run untrusted checkout path + +on: + workflow_run: + workflows: [Test] + types: [completed] + +jobs: + nested-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + with: + ref: ${{ github.event.workflow_run.head_sha }} + path: nested/checkout + - run: ./nested/checkout/build.sh diff --git a/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected b/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected index b6c349bd64fe..4b3e1400f3b0 100644 --- a/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected +++ b/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected @@ -338,6 +338,19 @@ edges | .github/workflows/untrusted_checkout_6.yml:17:9:21:6 | Uses Step | .github/workflows/untrusted_checkout_6.yml:21:9:23:23 | Run Step | | .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 | | .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 | +| .github/workflows/untrusted_checkout_paths.yml:10:9:15:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:15:9:17:2 | Run 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 | +| .github/workflows/untrusted_checkout_paths.yml:30:9:35:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:35:9:37:2 | 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 | +| .github/workflows/untrusted_checkout_paths.yml:50:9:55:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:55:9:57:2 | Run Step | +| .github/workflows/untrusted_checkout_paths.yml:60:9:65:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:65:9:67:2 | Run 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 | +| .github/workflows/untrusted_checkout_paths.yml:80:9:85:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:85:9:87:2 | Run Step | +| .github/workflows/untrusted_checkout_paths.yml:90:9:95:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:95:9:97:2 | Run 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 | +| .github/workflows/untrusted_checkout_paths.yml:110:9:115:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:115:9:119:2 | Uses Step | +| .github/workflows/untrusted_checkout_paths.yml:122:9:127:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:127:9:129:2 | Run Step | +| .github/workflows/untrusted_checkout_paths.yml:132:9:137:6 | Uses Step | .github/workflows/untrusted_checkout_paths.yml:137:9:137:34 | Run Step | | .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 | | .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 | | .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 | .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 | | .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 | | .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 | +| .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 | #select | .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 | | .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 | .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 | | .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 | | .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 | +| .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 | +| .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 | +| .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 | +| .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 | +| .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 | +| .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 | +| .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 | +| .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 | +| .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 | | .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 | +| .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 |