diff --git a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.md b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.md index 16551e9c89a7..b8c7435adddf 100644 --- a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.md +++ b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.md @@ -1,10 +1,10 @@ ## Overview -Using a tag for a 3rd party Action that is not pinned to a commit can lead to executing an untrusted Action through a supply chain attack. +Using a mutable tag or branch for an Action or reusable workflow can lead to executing untrusted code through a supply chain attack. ## Recommendation -Pinning an action to a full length commit SHA is currently the only way to use a non-immutable action as an immutable release. Pinning to a particular SHA helps mitigate the risk of a bad actor adding a backdoor to the action's repository, as they would need to generate a SHA-1 collision for a valid Git object payload. When selecting a SHA, you should verify it is from the action's repository and not a repository fork. +Pinning an Action or reusable workflow to a full length commit SHA is currently the only way to use a mutable reference as an immutable release. Pinning to a particular SHA helps mitigate the risk of a bad actor adding a backdoor to the referenced repository, as they would need to generate a SHA-1 collision for a valid Git object payload. When selecting a SHA, you should verify it is from the intended repository and not a repository fork. ## Example @@ -14,12 +14,26 @@ Pinning an action to a full length commit SHA is currently the only way to use a - uses: tj-actions/changed-files@v44 ``` +```yaml +jobs: + call-workflow: + uses: example/actions/.github/workflows/build.yml@main +``` + ### Correct Usage ```yaml - uses: tj-actions/changed-files@c65cd883420fd2eb864698a825fc4162dd94482c # v44 ``` +```yaml +jobs: + call-workflow: + uses: example/actions/.github/workflows/build.yml@25b062c917b0c75f8b47d8469aff6c94ffd89abb +``` + ## References - GitHub Docs: [Using third-party actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions). +- GitHub Docs: [Reusing third-party workflows](https://docs.github.com/en/actions/reference/security/secure-use#reusing-third-party-workflows). +- GitHub Docs: [Workflow syntax for reusable workflow calls](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_iduses). diff --git a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql index 530b8e48e0f5..9a4bc007bb51 100644 --- a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql +++ b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql @@ -1,6 +1,6 @@ /** - * @name Unpinned tag for a non-immutable Action in workflow or composite action - * @description Using a tag for a non-immutable Action that is not pinned to a commit can lead to executing an untrusted Action through a supply chain attack. + * @name Unpinned tag for a non-immutable Action or reusable workflow + * @description Using a mutable reference for a non-immutable Action or reusable workflow can lead to executing untrusted code through a supply chain attack. * @kind problem * @security-severity 5.0 * @problem.severity warning @@ -33,7 +33,7 @@ private predicate isPinnedContainer(string version) { bindingset[nwo] private predicate isContainerImage(string nwo) { nwo.regexpMatch("^docker://.+") } -private predicate getStepContainerName(UsesStep uses, string name) { +private predicate hasUsesContainerName(Uses uses, string name) { exists(Workflow workflow | uses.getEnclosingWorkflow() = workflow and ( @@ -43,20 +43,32 @@ private predicate getStepContainerName(UsesStep uses, string name) { ) ) or - exists(CompositeAction action | - uses.getEnclosingCompositeAction() = action and + exists(UsesStep step, CompositeAction action | + uses = step and + step.getEnclosingCompositeAction() = action and name = action.getLocation().getFile().getBaseName() ) } -from UsesStep uses, string nwo, string version, string name +from Uses uses, string nwo, string version, string name, string message where uses.getCallee() = nwo and - getStepContainerName(uses, name) and + hasUsesContainerName(uses, name) and uses.getVersion() = version and not isTrustedOwner(nwo) and - not (if isContainerImage(nwo) then isPinnedContainer(version) else isPinnedCommit(version)) and - not isImmutableAction(uses, nwo) -select uses.getCalleeNode(), - "Unpinned 3rd party Action '" + name + "' step $@ uses '" + nwo + "' with ref '" + version + - "', not a pinned commit hash", uses, uses.toString() + not ( + if uses instanceof UsesStep and isContainerImage(nwo) + then isPinnedContainer(version) + else isPinnedCommit(version) + ) and + not exists(UsesStep step | uses = step and isImmutableAction(step, nwo)) and + if uses instanceof ExternalJob + then + message = + "Job $@ in '" + name + "' uses reusable workflow '" + nwo + "' with ref '" + version + + "', not a pinned commit hash" + else + message = + "Unpinned 3rd party Action '" + name + "' step $@ uses '" + nwo + "' with ref '" + version + + "', not a pinned commit hash" +select uses.getCalleeNode(), message, uses, uses.toString() diff --git a/actions/ql/src/change-notes/2026-08-05-unpinned-reusable-workflows.md b/actions/ql/src/change-notes/2026-08-05-unpinned-reusable-workflows.md new file mode 100644 index 000000000000..6269fdec14e6 --- /dev/null +++ b/actions/ql/src/change-notes/2026-08-05-unpinned-reusable-workflows.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* The `actions/unpinned-tag` query now detects mutable references to reusable workflows. diff --git a/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/unpinned_reusable_workflows.yml b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/unpinned_reusable_workflows.yml new file mode 100644 index 000000000000..72ce6b4dd05b --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/unpinned_reusable_workflows.yml @@ -0,0 +1,35 @@ +name: Reusable workflow pinning + +on: + pull_request: + +jobs: + unpinned-tag: + uses: example/actions/.github/workflows/build.yml@v2 + + unpinned-branch: + uses: example/actions/.github/workflows/build.yml@main + + unpinned-branch-with-slash: + uses: example/actions/.github/workflows/build.yml@release/v2 + + unpinned-short-sha: + uses: example/actions/.github/workflows/build.yml@25b062c917b0c75f8b47d8469aff6c94ffd89ab + + pinned-sha1: + uses: example/actions/.github/workflows/build.yml@25b062c917b0c75f8b47d8469aff6c94ffd89abb + + pinned-sha256: + uses: example/actions/.github/workflows/build.yml@25b062c917b0c75f8b47d8469aff6c94ffd89abb25b062c917b0c75f8b47d84d + + pinned-uppercase-sha1: + uses: example/actions/.github/workflows/build.yml@25B062C917B0C75F8B47D8469AFF6C94FFD89ABB + + trusted-owner: + uses: actions/reusable-workflows/.github/workflows/build.yml@main + + local-workflow: + uses: ./.github/workflows/reusable_local.yml + + local-workflow-dollar: + uses: $/.github/workflows/reusable_local.yml diff --git a/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected b/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected index 14cff70c3804..05f9cf3d8fd5 100644 --- a/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected +++ b/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected @@ -8,6 +8,10 @@ | .github/workflows/auto_ci.yml:94:15:94:39 | codecov/codecov-action@v3 | Unpinned 3rd party Action 'Python CI' step $@ uses 'codecov/codecov-action' with ref 'v3', not a pinned commit hash | .github/workflows/auto_ci.yml:93:9:96:6 | Uses Step | Uses Step | | .github/workflows/auto_ci.yml:111:15:111:48 | peter-evans/create-pull-request@v5 | Unpinned 3rd party Action 'Python CI' step $@ uses 'peter-evans/create-pull-request' with ref 'v5', not a pinned commit hash | .github/workflows/auto_ci.yml:108:9:119:6 | Uses Step: create_pr | Uses Step: create_pr | | .github/workflows/auto_ci.yml:127:15:127:56 | thollander/actions-comment-pull-request@v2 | Unpinned 3rd party Action 'Python CI' step $@ uses 'thollander/actions-comment-pull-request' with ref 'v2', not a pinned commit hash | .github/workflows/auto_ci.yml:125:9:133:6 | Uses Step | Uses Step | +| .github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested.yml:9:11:9:59 | TestOrg/TestRepo/.github/workflows/build.yml@main | Job $@ in 'build_nested.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build.yml' with ref 'main', not a pinned commit hash | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested.yml:9:5:13:7 | Job: build | Job: build | +| .github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested_branching.yml:27:11:27:66 | TestOrg/TestRepo/.github/workflows/build_nested.yml@main | Job $@ in 'build_nested_branching.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build_nested.yml' with ref 'main', not a pinned commit hash | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested_branching.yml:26:5:30:2 | Job: build_safe | Job: build_safe | +| .github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested_branching.yml:31:11:31:66 | TestOrg/TestRepo/.github/workflows/build_nested.yml@main | Job $@ in 'build_nested_branching.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build_nested.yml' with ref 'main', not a pinned commit hash | .github/workflows/external/TestOrg/TestRepo/.github/workflows/build_nested_branching.yml:31:5:33:43 | Job: build_unsafe | Job: build_unsafe | +| .github/workflows/formal.yml:12:11:12:60 | TestOrg/TestRepo/.github/workflows/formal.yml@main | Job $@ in 'Test Formalities' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/formal.yml' with ref 'main', not a pinned commit hash | .github/workflows/formal.yml:11:5:12:61 | Job: build | Job: build | | .github/workflows/issue_comment_3rd_party_action.yml:14:15:14:52 | xt0rted/pull-request-comment-branch@v2 | Unpinned 3rd party Action 'PR head from 3rd party action' step $@ uses 'xt0rted/pull-request-comment-branch' with ref 'v2', not a pinned commit hash | .github/workflows/issue_comment_3rd_party_action.yml:12:9:16:6 | Uses Step: comment-branch | Uses Step: comment-branch | | .github/workflows/issue_comment_3rd_party_action.yml:27:15:27:52 | xt0rted/pull-request-comment-branch@v2 | Unpinned 3rd party Action 'PR head from 3rd party action' step $@ uses 'xt0rted/pull-request-comment-branch' with ref 'v2', not a pinned commit hash | .github/workflows/issue_comment_3rd_party_action.yml:25:9:30:6 | Uses Step: comment-branch | Uses Step: comment-branch | | .github/workflows/issue_comment_3rd_party_action.yml:41:15:41:42 | eficode/resolve-pr-refs@main | Unpinned 3rd party Action 'PR head from 3rd party action' step $@ uses 'eficode/resolve-pr-refs' with ref 'main', not a pinned commit hash | .github/workflows/issue_comment_3rd_party_action.yml:39:9:45:6 | Uses Step: refs | Uses Step: refs | @@ -28,10 +32,24 @@ | .github/workflows/pr-workflow.yml:449:15:449:43 | cachix/install-nix-action@v20 | Unpinned 3rd party Action 'pr-workflow' step $@ uses 'cachix/install-nix-action' with ref 'v20', not a pinned commit hash | .github/workflows/pr-workflow.yml:449:9:452:6 | Uses Step | Uses Step | | .github/workflows/pr-workflow.yml:452:15:452:60 | DeterminateSystems/magic-nix-cache-action@main | Unpinned 3rd party Action 'pr-workflow' step $@ uses 'DeterminateSystems/magic-nix-cache-action' with ref 'main', not a pinned commit hash | .github/workflows/pr-workflow.yml:452:9:453:6 | Uses Step | Uses Step | | .github/workflows/pr-workflow.yml:453:15:453:41 | cachix/cachix-action@master | Unpinned 3rd party Action 'pr-workflow' step $@ uses 'cachix/cachix-action' with ref 'master', not a pinned commit hash | .github/workflows/pr-workflow.yml:453:9:459:6 | Uses Step | Uses Step | +| .github/workflows/reusable_caller1.yaml:8:11:8:62 | TestOrg/TestRepo/.github/workflows/reusable.yml@main | Job $@ in 'assets-test' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/reusable.yml' with ref 'main', not a pinned commit hash | .github/workflows/reusable_caller1.yaml:8:5:10:56 | Job: check-execution-context | Job: check-execution-context | +| .github/workflows/reusable_caller2.yaml:8:11:8:62 | TestOrg/TestRepo/.github/workflows/reusable.yml@main | Job $@ in 'assets-test' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/reusable.yml' with ref 'main', not a pinned commit hash | .github/workflows/reusable_caller2.yaml:8:5:10:56 | Job: check-execution-context | Job: check-execution-context | | .github/workflows/test7.yml:25:15:25:34 | pnpm/action-setup@v3 | Unpinned 3rd party Action 'Benchmark' step $@ uses 'pnpm/action-setup' with ref 'v3', not a pinned commit hash | .github/workflows/test7.yml:24:9:27:6 | Uses Step | Uses Step | | .github/workflows/test13.yml:15:13:15:53 | sushichop/action-repository-permission@v2 | Unpinned 3rd party Action 'test13.yml' step $@ uses 'sushichop/action-repository-permission' with ref 'v2', not a pinned commit hash | .github/workflows/test13.yml:14:7:20:4 | Uses Step | Uses Step | | .github/workflows/test17.yml:20:21:20:63 | sonarsource/sonarcloud-github-action@master | Unpinned 3rd party Action 'Sonar' step $@ uses 'sonarsource/sonarcloud-github-action' with ref 'master', not a pinned commit hash | .github/workflows/test17.yml:19:15:23:58 | Uses Step | Uses Step | | .github/workflows/test18.yml:37:21:37:63 | sonarsource/sonarcloud-github-action@master | Unpinned 3rd party Action 'Sonar' step $@ uses 'sonarsource/sonarcloud-github-action' with ref 'master', not a pinned commit hash | .github/workflows/test18.yml:36:15:40:58 | Uses Step | Uses Step | +| .github/workflows/unpinned_reusable_workflows.yml:8:11:8:56 | example/actions/.github/workflows/build.yml@v2 | Job $@ in 'Reusable workflow pinning' uses reusable workflow 'example/actions/.github/workflows/build.yml' with ref 'v2', not a pinned commit hash | .github/workflows/unpinned_reusable_workflows.yml:8:5:10:2 | Job: unpinned-tag | Job: unpinned-tag | +| .github/workflows/unpinned_reusable_workflows.yml:11:11:11:58 | example/actions/.github/workflows/build.yml@main | Job $@ in 'Reusable workflow pinning' uses reusable workflow 'example/actions/.github/workflows/build.yml' with ref 'main', not a pinned commit hash | .github/workflows/unpinned_reusable_workflows.yml:11:5:13:2 | Job: unpinned-branch | Job: unpinned-branch | +| .github/workflows/unpinned_reusable_workflows.yml:14:11:14:64 | example/actions/.github/workflows/build.yml@release/v2 | Job $@ in 'Reusable workflow pinning' uses reusable workflow 'example/actions/.github/workflows/build.yml' with ref 'release/v2', not a pinned commit hash | .github/workflows/unpinned_reusable_workflows.yml:14:5:16:2 | Job: unpinned-branch-with-slash | Job: unpinned-branch-with-slash | +| .github/workflows/unpinned_reusable_workflows.yml:17:11:17:93 | example/actions/.github/workflows/build.yml@25b062c917b0c75f8b47d8469aff6c94ffd89ab | Job $@ in 'Reusable workflow pinning' uses reusable workflow 'example/actions/.github/workflows/build.yml' with ref '25b062c917b0c75f8b47d8469aff6c94ffd89ab', not a pinned commit hash | .github/workflows/unpinned_reusable_workflows.yml:17:5:19:2 | Job: unpinned-short-sha | Job: unpinned-short-sha | | .github/workflows/unpinned_tags.yml:10:13:10:22 | foo/bar@v1 | Unpinned 3rd party Action 'unpinned_tags.yml' step $@ uses 'foo/bar' with ref 'v1', not a pinned commit hash | .github/workflows/unpinned_tags.yml:10:7:11:4 | Uses Step | Uses Step | | .github/workflows/unpinned_tags.yml:12:13:12:35 | docker://foo/bar@latest | Unpinned 3rd party Action 'unpinned_tags.yml' step $@ uses 'docker://foo/bar' with ref 'latest', not a pinned commit hash | .github/workflows/unpinned_tags.yml:12:7:13:4 | Uses Step | Uses Step | | .github/workflows/unpinned_tags.yml:19:13:19:70 | foo/bar@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2a1b2c3d4e5 | Unpinned 3rd party Action 'unpinned_tags.yml' step $@ uses 'foo/bar' with ref 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2a1b2c3d4e5', not a pinned commit hash | .github/workflows/unpinned_tags.yml:19:7:19:71 | Uses Step | Uses Step | +| .github/workflows/untrusted_checkout_permission_check_reusable2.yml:24:11:24:59 | TestOrg/TestRepo/.github/workflows/build.yml@main | Job $@ in 'untrusted_checkout_permission_check_reusable2.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build.yml' with ref 'main', not a pinned commit hash | .github/workflows/untrusted_checkout_permission_check_reusable2.yml:24:5:27:2 | Job: build_unsafe | Job: build_unsafe | +| .github/workflows/untrusted_checkout_permission_check_reusable2.yml:29:11:29:59 | TestOrg/TestRepo/.github/workflows/build.yml@main | Job $@ in 'untrusted_checkout_permission_check_reusable2.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build.yml' with ref 'main', not a pinned commit hash | .github/workflows/untrusted_checkout_permission_check_reusable2.yml:28:5:31:101 | Job: build_safe | Job: build_safe | +| .github/workflows/untrusted_checkout_permission_check_reusable.yml:24:11:24:59 | TestOrg/TestRepo/.github/workflows/build.yml@main | Job $@ in 'untrusted_checkout_permission_check_reusable.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build.yml' with ref 'main', not a pinned commit hash | .github/workflows/untrusted_checkout_permission_check_reusable.yml:23:5:26:101 | Job: build | Job: build | +| .github/workflows/untrusted_checkout_permission_check_reusable_branching_nested.yml:6:11:6:76 | TestOrg/TestRepo/.github/workflows/build_nested_branching.yml@main | Job $@ in 'untrusted_checkout_permission_check_reusable_branching_nested.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build_nested_branching.yml' with ref 'main', not a pinned commit hash | .github/workflows/untrusted_checkout_permission_check_reusable_branching_nested.yml:6:5:8:60 | Job: build | Job: build | +| .github/workflows/untrusted_checkout_permission_check_reusable_level2.yml:24:11:24:66 | TestOrg/TestRepo/.github/workflows/build_nested.yml@main | Job $@ in 'untrusted_checkout_permission_check_reusable_level2.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build_nested.yml' with ref 'main', not a pinned commit hash | .github/workflows/untrusted_checkout_permission_check_reusable_level2.yml:23:5:26:101 | Job: build | Job: build | +| .github/workflows/untrusted_checkout_permission_check_reusable_no_needs.yml:24:11:24:66 | TestOrg/TestRepo/.github/workflows/build_nested.yml@main | Job $@ in 'untrusted_checkout_permission_check_reusable_no_needs.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build_nested.yml' with ref 'main', not a pinned commit hash | .github/workflows/untrusted_checkout_permission_check_reusable_no_needs.yml:24:5:26:60 | Job: build | Job: build | +| .github/workflows/untrusted_checkout_two_callers_both_protected.yml:24:11:24:59 | TestOrg/TestRepo/.github/workflows/build.yml@main | Job $@ in 'untrusted_checkout_two_callers_both_protected.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build.yml' with ref 'main', not a pinned commit hash | .github/workflows/untrusted_checkout_two_callers_both_protected.yml:23:5:27:2 | Job: caller-a | Job: caller-a | +| .github/workflows/untrusted_checkout_two_callers_both_protected.yml:46:11:46:59 | TestOrg/TestRepo/.github/workflows/build.yml@main | Job $@ in 'untrusted_checkout_two_callers_both_protected.yml' uses reusable workflow 'TestOrg/TestRepo/.github/workflows/build.yml' with ref 'main', not a pinned commit hash | .github/workflows/untrusted_checkout_two_callers_both_protected.yml:45:5:48:60 | Job: caller-b | Job: caller-b |