diff --git a/actions/ql/lib/codeql/actions/security/UseOfUnversionedImmutableAction.qll b/actions/ql/lib/codeql/actions/security/UseOfUnversionedImmutableAction.qll index 8595cd1086d6..8766712ec812 100644 --- a/actions/ql/lib/codeql/actions/security/UseOfUnversionedImmutableAction.qll +++ b/actions/ql/lib/codeql/actions/security/UseOfUnversionedImmutableAction.qll @@ -7,11 +7,23 @@ class UnversionedImmutableAction extends UsesStep { } } +/** + * Holds if `version` is a complete SemVer version (`X.Y.Z`, optionally with a `v` prefix, + * pre-release and build metadata), as opposed to a floating tag such as `v4` or `v4.1`. + * + * Only complete version tags (and full commit SHAs) of an immutable Action are immutable. + * Floating tags are moved by the Action's maintainers and so can change under a consumer. + */ bindingset[version] -predicate isSemVer(string version) { +predicate isFullSemVer(string version) { // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string with optional v prefix version - .regexpMatch("^v?(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$") or + .regexpMatch("^v?(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$") +} + +bindingset[version] +predicate isSemVer(string version) { + isFullSemVer(version) or // or N or N.x or N.N.x with optional v prefix version.regexpMatch("^v?[1-9]\\d*$") or version.regexpMatch("^v?[1-9]\\d*\\.(x|0|([1-9]\\d*))$") or diff --git a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql index 8a1f821d073a..bc62ed881ffd 100644 --- a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql +++ b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql @@ -68,7 +68,9 @@ where then isPinnedContainer(version) else isPinnedCommit(version) ) and - not exists(UsesStep step | uses = step and isImmutableAction(step, nwo)) and + // An immutable Action is only immutable when referenced by a complete version tag (or SHA). + // Floating tags such as `v4` or `v4.1` are moved by the maintainers and remain mutable. + not exists(UsesStep step | uses = step and isImmutableAction(step, nwo) and isFullSemVer(version)) and if uses instanceof ExternalJob then message = diff --git a/actions/ql/src/change-notes/2026-09-21-unpinned-tag-floating-immutable.md b/actions/ql/src/change-notes/2026-09-21-unpinned-tag-floating-immutable.md new file mode 100644 index 000000000000..6f12d12669fa --- /dev/null +++ b/actions/ql/src/change-notes/2026-09-21-unpinned-tag-floating-immutable.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* The `actions/unpinned-tag` query no longer treats an immutable Action as pinned when it is referenced by a floating tag such as `v4`, `v4.1`, `main` or `latest`. Only complete version tags (for example `v4.2.2`) and full commit SHAs are immutable, so floating tags are now reported for immutable Actions that are not covered by a trusted owner. diff --git a/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/.github/workflows/unpinned_immutable.yml b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/.github/workflows/unpinned_immutable.yml new file mode 100644 index 000000000000..3e44c775c827 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/.github/workflows/unpinned_immutable.yml @@ -0,0 +1,22 @@ +name: Immutable Actions +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + # `actions` is distrusted via `!actions`. `actions/checkout` is an immutable Action, but only + # a complete version tag (or a commit SHA) is immutable, so floating tags are still reported. + - uses: actions/checkout@v4 # $ Alert + - uses: actions/checkout@v4.1 # $ Alert + - uses: actions/checkout@main # $ Alert + - uses: actions/checkout@latest # $ Alert + # Complete version tags of an immutable Action are not reported. + - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@4.2.2 + - uses: actions/checkout@v4.2.2-rc.1 + # A pinned commit is not reported. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + # `octokit` is not trusted by default; `octokit/request-action` is an immutable Action. + - uses: octokit/request-action@v2 # $ Alert + - uses: octokit/request-action@v2.x # $ Alert + - uses: octokit/request-action@v2.1.9 diff --git a/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.expected b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.expected index a047196d85fa..4ad37a9de911 100644 --- a/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.expected +++ b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.expected @@ -1,2 +1,8 @@ | .github/workflows/unpinned_first_party.yml:9:15:9:42 | actions/first-interaction@v1 | Unpinned 3rd party Action 'Unpinned first-party actions' step $@ uses 'actions/first-interaction' with ref 'v1', not a pinned commit hash | .github/workflows/unpinned_first_party.yml:9:9:11:6 | Uses Step | Uses Step | | .github/workflows/unpinned_first_party.yml:13:15:13:24 | foo/bar@v1 | Unpinned 3rd party Action 'Unpinned first-party actions' step $@ uses 'foo/bar' with ref 'v1', not a pinned commit hash | .github/workflows/unpinned_first_party.yml:13:9:13:35 | Uses Step | Uses Step | +| .github/workflows/unpinned_immutable.yml:9:15:9:33 | actions/checkout@v4 | Unpinned 3rd party Action 'Immutable Actions' step $@ uses 'actions/checkout' with ref 'v4', not a pinned commit hash | .github/workflows/unpinned_immutable.yml:9:9:10:6 | Uses Step | Uses Step | +| .github/workflows/unpinned_immutable.yml:10:15:10:35 | actions/checkout@v4.1 | Unpinned 3rd party Action 'Immutable Actions' step $@ uses 'actions/checkout' with ref 'v4.1', not a pinned commit hash | .github/workflows/unpinned_immutable.yml:10:9:11:6 | Uses Step | Uses Step | +| .github/workflows/unpinned_immutable.yml:11:15:11:35 | actions/checkout@main | Unpinned 3rd party Action 'Immutable Actions' step $@ uses 'actions/checkout' with ref 'main', not a pinned commit hash | .github/workflows/unpinned_immutable.yml:11:9:12:6 | Uses Step | Uses Step | +| .github/workflows/unpinned_immutable.yml:12:15:12:37 | actions/checkout@latest | Unpinned 3rd party Action 'Immutable Actions' step $@ uses 'actions/checkout' with ref 'latest', not a pinned commit hash | .github/workflows/unpinned_immutable.yml:12:9:14:6 | Uses Step | Uses Step | +| .github/workflows/unpinned_immutable.yml:20:15:20:39 | octokit/request-action@v2 | Unpinned 3rd party Action 'Immutable Actions' step $@ uses 'octokit/request-action' with ref 'v2', not a pinned commit hash | .github/workflows/unpinned_immutable.yml:20:9:21:6 | Uses Step | Uses Step | +| .github/workflows/unpinned_immutable.yml:21:15:21:41 | octokit/request-action@v2.x | Unpinned 3rd party Action 'Immutable Actions' step $@ uses 'octokit/request-action' with ref 'v2.x', not a pinned commit hash | .github/workflows/unpinned_immutable.yml:21:9:22:6 | Uses Step | Uses Step | 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 05f9cf3d8fd5..d7456243c66a 100644 --- a/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected +++ b/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected @@ -15,7 +15,11 @@ | .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 | +| .github/workflows/issue_comment_octokit2.yml:13:15:13:41 | octokit/request-action@v2.x | Unpinned 3rd party Action 'Octokit (heuristics)' step $@ uses 'octokit/request-action' with ref 'v2.x', not a pinned commit hash | .github/workflows/issue_comment_octokit2.yml:12:9:19:6 | Uses Step: fetch_issue | Uses Step: fetch_issue | +| .github/workflows/issue_comment_octokit2.yml:20:15:20:43 | octokit/request-action@v2.x.x | Unpinned 3rd party Action 'Octokit (heuristics)' step $@ uses 'octokit/request-action' with ref 'v2.x.x', not a pinned commit hash | .github/workflows/issue_comment_octokit2.yml:19:9:26:6 | Uses Step: fetch_pr | Uses Step: fetch_pr | | .github/workflows/issue_comment_octokit2.yml:34:15:34:42 | some-action/some-repo@latest | Unpinned 3rd party Action 'Octokit (heuristics)' step $@ uses 'some-action/some-repo' with ref 'latest', not a pinned commit hash | .github/workflows/issue_comment_octokit2.yml:33:9:37:6 | Uses Step | Uses Step | +| .github/workflows/issue_comment_octokit.yml:13:15:13:41 | octokit/request-action@v2.x | Unpinned 3rd party Action 'Octokit (heuristics)' step $@ uses 'octokit/request-action' with ref 'v2.x', not a pinned commit hash | .github/workflows/issue_comment_octokit.yml:12:9:19:6 | Uses Step: fetch_issue | Uses Step: fetch_issue | +| .github/workflows/issue_comment_octokit.yml:20:15:20:41 | octokit/request-action@v2.x | Unpinned 3rd party Action 'Octokit (heuristics)' step $@ uses 'octokit/request-action' with ref 'v2.x', not a pinned commit hash | .github/workflows/issue_comment_octokit.yml:19:9:26:6 | Uses Step: fetch_pr | Uses Step: fetch_pr | | .github/workflows/label_trusted_checkout1.yml:20:13:20:36 | completely/fakeaction@v2 | Unpinned 3rd party Action 'label_trusted_checkout1.yml' step $@ uses 'completely/fakeaction' with ref 'v2', not a pinned commit hash | .github/workflows/label_trusted_checkout1.yml:20:7:24:4 | Uses Step | Uses Step | | .github/workflows/label_trusted_checkout1.yml:24:13:24:37 | fakerepo/comment-on-pr@v1 | Unpinned 3rd party Action 'label_trusted_checkout1.yml' step $@ uses 'fakerepo/comment-on-pr' with ref 'v1', not a pinned commit hash | .github/workflows/label_trusted_checkout1.yml:24:7:27:21 | Uses Step | Uses Step | | .github/workflows/label_trusted_checkout2.yml:21:13:21:36 | completely/fakeaction@v2 | Unpinned 3rd party Action 'label_trusted_checkout2.yml' step $@ uses 'completely/fakeaction' with ref 'v2', not a pinned commit hash | .github/workflows/label_trusted_checkout2.yml:21:7:25:4 | Uses Step | Uses Step |