diff --git a/actions/ql/src/Security/CWE-829/UntrustedCheckoutCritical.ql b/actions/ql/src/Security/CWE-829/UntrustedCheckoutCritical.ql index e0af03ca3d02..a23d761c0b3c 100644 --- a/actions/ql/src/Security/CWE-829/UntrustedCheckoutCritical.ql +++ b/actions/ql/src/Security/CWE-829/UntrustedCheckoutCritical.ql @@ -14,12 +14,51 @@ */ 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")) + ) + } +} + +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[checkout, path] +private predicate checkoutContainsPath(PRHeadCheckoutStep checkout, string path) { + exists(string root, string candidate | + root = getNormalizedActionsCheckoutPath(checkout).regexpReplaceAll("/+$", "") and + candidate = path.regexpReplaceAll("/+$", "") and + (candidate = root or candidate.indexOf(root + "/") = 0) + ) + or + not exists(getNormalizedActionsCheckoutPath(checkout)) and + isSubpath(path, checkout.getPath()) +} + from PRHeadCheckoutStep checkout, PoisonableStep poisonable, Event event where // the checkout is followed by a known poisonable step @@ -29,7 +68,7 @@ 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, poisonable.(LocalScriptExecutionRunStep).getPath()) or // Checking the path for non local script execution steps is very difficult not poisonable instanceof LocalScriptExecutionRunStep @@ -42,7 +81,7 @@ where not poisonable instanceof LocalActionUsesStep and checkout.getPath() = "GITHUB_WORKSPACE/" or - isSubpath(poisonable.(LocalActionUsesStep).getPath(), checkout.getPath()) + checkoutContainsPath(checkout, 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..4668046ff813 --- /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 directories. 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..1311d56a3195 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/untrusted_checkout_paths.yml @@ -0,0 +1,75 @@ +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 + + 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 + + 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..16b5b503ebe5 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,13 @@ 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:75: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 +357,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 +402,10 @@ 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_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 |