From 74e5b3f38c7782852ccfb15cc6034b67beef2080 Mon Sep 17 00:00:00 2001 From: Jesse Houwing Date: Thu, 24 Sep 2026 11:39:39 +0200 Subject: [PATCH] Add per-tag immutable-release support for actions/unpinned-tag Adds a new immutableActionRefsDataModel(action, ref) extensible predicate, keyed on the normalized owner/repo plus tag, instead of the old static repo-level immutableActionsDataModel allow-list. GitHub's Immutable Releases feature grants immutability per tag/release, not per repository, so a repo-level allow-list can never be accurate. - New UseOfVersionedImmutableActionRef.qll library predicate/class, normalizing owner/repo/subpath refs to owner/repo before lookup. - UnpinnedActionsTag.ql excludes matches via this new predicate, mutually exclusive with (and independent of) the existing isImmutableAction exclusion. - New utility query actions/ql/src/utils/ListUnpinnedActionRefs.ql that leverages the Actions extractor itself to enumerate distinct, normalized (owner/repo, ref) candidates for external verification. - Tests covering repo-level match, sub-directory match, wrong-tag, and unrelated-repo cases. - Docs update describing the new predicate and normalization semantics. See github/codeql#22674 for the full proposal and end-to-end validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ql/lib/codeql/actions/config/Config.qll | 10 +++++ .../actions/config/ConfigExtensions.qll | 9 ++++ .../UseOfVersionedImmutableActionRef.qll | 38 +++++++++++++++++ .../Security/CWE-829/UnpinnedActionsTag.ql | 2 + .../ql/src/utils/ListUnpinnedActionRefs.ql | 41 +++++++++++++++++++ .../ql/test/immutable-action-refs.model.yml | 6 +++ actions/ql/test/qlpack.yml | 1 + .../workflows/immutable_release_tags.yml | 17 ++++++++ .../CWE-829/UnpinnedActionsTag.expected | 2 + .../UntrustedCheckoutCritical.expected | 3 ++ ...customizing-library-models-for-actions.rst | 20 +++++++++ 11 files changed, 149 insertions(+) create mode 100644 actions/ql/lib/codeql/actions/security/UseOfVersionedImmutableActionRef.qll create mode 100644 actions/ql/src/utils/ListUnpinnedActionRefs.ql create mode 100644 actions/ql/test/immutable-action-refs.model.yml create mode 100644 actions/ql/test/query-tests/Security/CWE-829/.github/workflows/immutable_release_tags.yml diff --git a/actions/ql/lib/codeql/actions/config/Config.qll b/actions/ql/lib/codeql/actions/config/Config.qll index f6b8d0c979f3..e79378e25531 100644 --- a/actions/ql/lib/codeql/actions/config/Config.qll +++ b/actions/ql/lib/codeql/actions/config/Config.qll @@ -126,6 +126,16 @@ predicate vulnerableActionsDataModel( */ predicate immutableActionsDataModel(string action) { Extensions::immutableActionsDataModel(action) } +/** + * MaD models for actions/refs known to point to immutable releases + * Fields: + * - action: action name (`owner/repo`, no sub-directory path) + * - ref: tag name known to be an immutable release of `action` + */ +predicate immutableActionRefsDataModel(string action, string ref) { + Extensions::immutableActionRefsDataModel(action, ref) +} + /** * MaD models for trusted actions owners * Fields: diff --git a/actions/ql/lib/codeql/actions/config/ConfigExtensions.qll b/actions/ql/lib/codeql/actions/config/ConfigExtensions.qll index 4ee438fc63cb..035a1ab9df56 100644 --- a/actions/ql/lib/codeql/actions/config/ConfigExtensions.qll +++ b/actions/ql/lib/codeql/actions/config/ConfigExtensions.qll @@ -63,6 +63,15 @@ extensible predicate vulnerableActionsDataModel( */ extensible predicate immutableActionsDataModel(string action); +/** + * Holds for `(action, ref)` pairs where `ref` (a tag, e.g. `v1.2.3`) is known to point to an + * immutable release of `action`. `action` must be an `owner/repo` pair without any sub-directory + * path, since GitHub's immutable-release feature applies at the repository + tag level, not to + * individual actions within a monorepo-style repository (e.g. an entry for `owner/repo` with ref + * `v1.2.3` also covers `owner/repo/path/to/action@v1.2.3`). + */ +extensible predicate immutableActionRefsDataModel(string action, string ref); + /** * Holds for trusted Actions owners. */ diff --git a/actions/ql/lib/codeql/actions/security/UseOfVersionedImmutableActionRef.qll b/actions/ql/lib/codeql/actions/security/UseOfVersionedImmutableActionRef.qll new file mode 100644 index 000000000000..1857e8ea50e8 --- /dev/null +++ b/actions/ql/lib/codeql/actions/security/UseOfVersionedImmutableActionRef.qll @@ -0,0 +1,38 @@ +/** + * Provides a class and predicate for identifying uses of Actions pinned to a tag/ref that is + * known to point to an immutable release (GitHub's Immutable Releases feature). Unlike + * `UseOfUnversionedImmutableAction.qll`, which relies on a static, repo-level allow-list of + * actions that were part of the older, more limited immutable-actions preview, this module + * consumes a per-`(action, ref)` data model, because immutability under the Immutable Releases + * feature is a property of an individual tag/release, not of the repository as a whole -- not + * every tag of an action's repository is guaranteed to be immutable. + * + * The `(action, ref)` data is expected to be populated externally (e.g. by a pre-processing step + * that queries the GitHub GraphQL API for the `immutable` flag on each referenced release) and + * supplied via the `immutableActionRefsDataModel` extensible predicate. + */ + +import actions + +bindingset[nwo] +private string normalizeToRepo(string nwo) { + // Immutability is a property of the repository + tag/release, not of the specific + // sub-directory action being referenced (e.g. `owner/repo/path/to/action@ref`), so we + // normalize to just the `owner/repo` portion before consulting the data model. + result = nwo.regexpCapture("^([^/]+/[^/]+)(?:/.*)?$", 1) +} + +/** + * Holds if `actionStep` uses `actionName` (which may include a sub-directory path) pinned to + * `ref`, and `ref` is known to point to an immutable release of `actionName`'s repository. + */ +predicate isImmutableActionRef(UsesStep actionStep, string actionName, string ref) { + actionStep.getCallee() = actionName and + actionStep.getVersion() = ref and + immutableActionRefsDataModel(normalizeToRepo(actionName), ref) +} + +/** A `UsesStep` that is pinned to a tag/ref known to point to an immutable release. */ +class VersionedImmutableActionRef extends UsesStep { + VersionedImmutableActionRef() { isImmutableActionRef(this, this.getCallee(), this.getVersion()) } +} diff --git a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql index 8a1f821d073a..1c17d7630009 100644 --- a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql +++ b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql @@ -13,6 +13,7 @@ import actions import codeql.actions.security.UseOfUnversionedImmutableAction +import codeql.actions.security.UseOfVersionedImmutableActionRef bindingset[version] private predicate isPinnedCommit(string version) { @@ -69,6 +70,7 @@ where else isPinnedCommit(version) ) and not exists(UsesStep step | uses = step and isImmutableAction(step, nwo)) and + not exists(UsesStep step | uses = step and isImmutableActionRef(step, nwo, version)) and if uses instanceof ExternalJob then message = diff --git a/actions/ql/src/utils/ListUnpinnedActionRefs.ql b/actions/ql/src/utils/ListUnpinnedActionRefs.ql new file mode 100644 index 000000000000..06ad8ca26f0e --- /dev/null +++ b/actions/ql/src/utils/ListUnpinnedActionRefs.ql @@ -0,0 +1,41 @@ +/** + * @name List candidate action refs for immutable-release verification + * @description Lists distinct `(owner/repo, ref)` pairs used by `uses:` steps and reusable + * workflow calls that are not already pinned to a commit SHA, and are therefore + * candidates for checking against GitHub's Immutable Releases feature. This query + * is intended to be run as a pre-processing step (e.g. by a CI job) to build a list + * of `(action, ref)` pairs to verify via the GitHub API, whose results can then be + * fed back into CodeQL as an `immutableActionRefsDataModel` data extension for the + * `actions/unpinned-tag` query. + * @kind table + * @id actions/list-unpinned-action-refs + * @tags utility + * actions + */ + +import actions + +bindingset[version] +private predicate isPinnedCommit(string version) { + version.regexpMatch("^[A-Fa-f0-9]{40}([A-Fa-f0-9]{24})?$") +} + +bindingset[nwo] +private predicate isContainerImage(string nwo) { nwo.regexpMatch("^docker://.+") } + +// A `$/` reference is a same-repository (self repository) reference, resolved at the commit +// the calling workflow is running. Like `./` local references, it is inherently pinned. +bindingset[nwo] +private predicate isSelfRepository(string nwo) { nwo.matches("$/%") } + +bindingset[nwo] +private string normalizeToRepo(string nwo) { result = nwo.regexpCapture("^([^/]+/[^/]+)(?:/.*)?$", 1) } + +from Uses uses, string nwo, string version +where + uses.getCallee() = nwo and + uses.getVersion() = version and + not isSelfRepository(nwo) and + not isContainerImage(nwo) and + not isPinnedCommit(version) +select normalizeToRepo(nwo) as action, version as ref diff --git a/actions/ql/test/immutable-action-refs.model.yml b/actions/ql/test/immutable-action-refs.model.yml new file mode 100644 index 000000000000..457decaefd98 --- /dev/null +++ b/actions/ql/test/immutable-action-refs.model.yml @@ -0,0 +1,6 @@ +extensions: + - addsTo: + pack: codeql/actions-all + extensible: immutableActionRefsDataModel + data: + - ["foo/immutable-release", "v1.2.3"] diff --git a/actions/ql/test/qlpack.yml b/actions/ql/test/qlpack.yml index 9c9e714fc67a..9162ff4dc844 100644 --- a/actions/ql/test/qlpack.yml +++ b/actions/ql/test/qlpack.yml @@ -12,3 +12,4 @@ tests: . warnOnImplicitThis: true dataExtensions: - output-clobbering.model.yml + - immutable-action-refs.model.yml diff --git a/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/immutable_release_tags.yml b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/immutable_release_tags.yml new file mode 100644 index 000000000000..18f17800a4d9 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/immutable_release_tags.yml @@ -0,0 +1,17 @@ +on: + pull_request + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + steps: + # Known immutable release tag for this exact repo - should NOT be flagged. + - uses: foo/immutable-release@v1.2.3 + # Known immutable release tag, referenced via a sub-directory action in the same repo + # (immutability is per repo+tag, not per sub-action) - should NOT be flagged. + - uses: foo/immutable-release/sub-action@v1.2.3 + # Same repo, but a different (unknown) tag - should be flagged. + - uses: foo/immutable-release@v1.2.4 + # Different repo entirely, unrelated to the data model - should be flagged. + - uses: foo/other-action@v1.2.3 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..61cb2ea33e30 100644 --- a/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected +++ b/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected @@ -12,6 +12,8 @@ | .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/immutable_release_tags.yml:15:13:15:40 | foo/immutable-release@v1.2.4 | Unpinned 3rd party Action 'immutable_release_tags.yml' step $@ uses 'foo/immutable-release' with ref 'v1.2.4', not a pinned commit hash | .github/workflows/immutable_release_tags.yml:15:7:17:4 | Uses Step | Uses Step | +| .github/workflows/immutable_release_tags.yml:17:13:17:35 | foo/other-action@v1.2.3 | Unpinned 3rd party Action 'immutable_release_tags.yml' step $@ uses 'foo/other-action' with ref 'v1.2.3', not a pinned commit hash | .github/workflows/immutable_release_tags.yml:17:7:17:36 | Uses Step | Uses Step | | .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 | 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 381ca41243f1..722683ed4395 100644 --- a/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected +++ b/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected @@ -110,6 +110,9 @@ edges | .github/workflows/gitcheckout.yml:10:11:18:8 | Run Step | .github/workflows/gitcheckout.yml:18:11:21:8 | Uses Step | | .github/workflows/gitcheckout.yml:17:27:17:48 | github.head_ref | .github/workflows/gitcheckout.yml:10:11:18:8 | Run Step | | .github/workflows/gitcheckout.yml:18:11:21:8 | Uses Step | .github/workflows/gitcheckout.yml:21:11:23:22 | Run Step | +| .github/workflows/immutable_release_tags.yml:10:7:13:4 | Uses Step | .github/workflows/immutable_release_tags.yml:13:7:15:4 | Uses Step | +| .github/workflows/immutable_release_tags.yml:13:7:15:4 | Uses Step | .github/workflows/immutable_release_tags.yml:15:7:17:4 | Uses Step | +| .github/workflows/immutable_release_tags.yml:15:7:17:4 | Uses Step | .github/workflows/immutable_release_tags.yml:17:7:17:36 | Uses Step | | .github/workflows/issue_comment_3rd_party_action.yml:12:9:16:6 | Uses Step: comment-branch | .github/workflows/issue_comment_3rd_party_action.yml:16:9:22:2 | Uses Step | | .github/workflows/issue_comment_3rd_party_action.yml:20:17:20:60 | steps.comment-branch.outputs.head_sha | .github/workflows/issue_comment_3rd_party_action.yml:16:9:22:2 | Uses Step | | .github/workflows/issue_comment_3rd_party_action.yml:25:9:30:6 | Uses Step: comment-branch | .github/workflows/issue_comment_3rd_party_action.yml:30:9:36:2 | Uses Step | diff --git a/docs/codeql/codeql-language-guides/customizing-library-models-for-actions.rst b/docs/codeql/codeql-language-guides/customizing-library-models-for-actions.rst index 3f37d728dbf2..d77c6482bf2a 100644 --- a/docs/codeql/codeql-language-guides/customizing-library-models-for-actions.rst +++ b/docs/codeql/codeql-language-guides/customizing-library-models-for-actions.rst @@ -34,6 +34,7 @@ Customizing Actions-specific analysis: - ``contextTriggerDataModel(trigger, context_prefix)`` - ``externallyTriggerableEventsDataModel(event)`` - ``immutableActionsDataModel(action)`` +- ``immutableActionRefsDataModel(action, ref)`` - ``poisonableActionsDataModel(action)`` - ``poisonableCommandsDataModel(regexp)`` - ``poisonableLocalScriptsDataModel(regexp, group)`` @@ -102,3 +103,22 @@ To distrust the first-party ``github`` owner, add a data extension file with the With this in place, the query will once again report unpinned tags for Actions published by ``github``. +Example: Suppress unpinned-tag alerts for Actions pinned to an immutable release tag +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +GitHub's `Immutable Releases and tags `_ feature lets a repository owner make a specific tag/release immutable. Because this is a property of an individual tag rather than of a repository as a whole, and CodeQL analysis has no network access to query this at analysis time, this data must be supplied externally, for example by a pre-processing step that queries the GitHub GraphQL API for the ``immutable`` flag of each referenced release before analysis runs. + +Populate the ``immutableActionRefsDataModel`` extensible predicate with ``(action, ref)`` tuples for each action and tag confirmed to be immutable. ``action`` must be an ``owner/repo`` pair without any sub-directory path, since immutability applies at the repository + tag level (an entry for ``owner/repo`` also covers sub-directory actions such as ``owner/repo/path/to/action``): + +.. code-block:: yaml + + extensions: + - addsTo: + pack: codeql/actions-all + extensible: immutableActionRefsDataModel + data: + - ["octodemo/3rd-party-action", "v1.2.3"] + +With this in place, the ``actions/unpinned-tag`` query will no longer report an alert for a step that uses ``octodemo/3rd-party-action@v1.2.3`` (or ``octodemo/3rd-party-action/some-subaction@v1.2.3``), since that specific tag is known to be immutable. Tags not present in the data model are still treated as mutable and continue to be flagged. + +