diff --git a/actions/ql/lib/change-notes/2026-08-24-untrusted-actions-owner.md b/actions/ql/lib/change-notes/2026-08-24-untrusted-actions-owner.md new file mode 100644 index 000000000000..b244b67bef36 --- /dev/null +++ b/actions/ql/lib/change-notes/2026-08-24-untrusted-actions-owner.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* The `trustedActionsOwnerDataModel` extensible predicate, used by the `actions/unpinned-tag` query, now supports removing an owner from the trusted set by adding an entry prefixed with `!` (for example, `!github`). This makes it possible to distrust first-party owners (`actions`, `github`, `advanced-security`) so that unpinned tags for their Actions are reported. diff --git a/actions/ql/lib/codeql/actions/config/Config.qll b/actions/ql/lib/codeql/actions/config/Config.qll index 27e24514c091..f6b8d0c979f3 100644 --- a/actions/ql/lib/codeql/actions/config/Config.qll +++ b/actions/ql/lib/codeql/actions/config/Config.qll @@ -130,9 +130,16 @@ predicate immutableActionsDataModel(string action) { Extensions::immutableAction * MaD models for trusted actions owners * Fields: * - owner: owner name + * + * An entry may be prefixed with `!` to remove an owner from the trusted set, + * overriding any plain entry for the same owner (for example, `!github` distrusts + * the first-party `github` owner). This is unambiguous because GitHub owner names + * can never start with `!`. */ predicate trustedActionsOwnerDataModel(string owner) { - Extensions::trustedActionsOwnerDataModel(owner) + Extensions::trustedActionsOwnerDataModel(owner) and + not owner.matches("!%") and + not Extensions::trustedActionsOwnerDataModel("!" + owner) } /** diff --git a/actions/ql/lib/codeql/actions/test/InlineExpectationsTestQuery.ql b/actions/ql/lib/codeql/actions/test/InlineExpectationsTestQuery.ql new file mode 100644 index 000000000000..4fb223e63092 --- /dev/null +++ b/actions/ql/lib/codeql/actions/test/InlineExpectationsTestQuery.ql @@ -0,0 +1,22 @@ +/** + * @kind test-postprocess + */ + +private import actions +private import codeql.Locations +private import codeql.util.test.InlineExpectationsTest as T +private import codeql.actions.test.internal.InlineExpectationsTestImpl +import T::TestPostProcessing +import T::TestPostProcessing::Make + +private module Input implements T::TestPostProcessing::InputSig { + string getRelativeUrl(Location location) { + exists(File f, int startline, int startcolumn, int endline, int endcolumn | + location.hasLocationInfo(_, startline, startcolumn, endline, endcolumn) and + f = location.getFile() + | + result = + f.getRelativePath() + ":" + startline + ":" + startcolumn + ":" + endline + ":" + endcolumn + ) + } +} diff --git a/actions/ql/lib/codeql/actions/test/internal/InlineExpectationsTestImpl.qll b/actions/ql/lib/codeql/actions/test/internal/InlineExpectationsTestImpl.qll new file mode 100644 index 000000000000..5498ff795eb5 --- /dev/null +++ b/actions/ql/lib/codeql/actions/test/internal/InlineExpectationsTestImpl.qll @@ -0,0 +1,13 @@ +private import codeql.Locations as L +private import codeql.actions.ast.internal.Yaml +private import codeql.util.test.InlineExpectationsTest + +module Impl implements InlineExpectationsTestSig { + class ExpectationComment extends YamlNode { + ExpectationComment() { this.toString().matches("%$ %") } + + string getContents() { result = "$ " + this.toString().regexpCapture(".*\\$ (.*)", 1) } + } + + class Location = L::Location; +} diff --git a/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/.github/workflows/unpinned_first_party.yml b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/.github/workflows/unpinned_first_party.yml new file mode 100644 index 000000000000..6617f509693d --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/.github/workflows/unpinned_first_party.yml @@ -0,0 +1,13 @@ +name: Unpinned first-party actions +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + # `actions` is distrusted via `!actions`, and this action is not immutable, + # so this unpinned tag is reported. + - uses: actions/first-interaction@v1 # $ Alert + # `github` remains trusted, so this unpinned tag is not reported. + - uses: github/issue-labeler@v3.0 + # Third-party owner is always reported. + - uses: foo/bar@v1 # $ Alert 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 new file mode 100644 index 000000000000..a047196d85fa --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.expected @@ -0,0 +1,2 @@ +| .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 | diff --git a/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.ext.yml b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.ext.yml new file mode 100644 index 000000000000..45aea89e7186 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.ext.yml @@ -0,0 +1,8 @@ +extensions: + - addsTo: + pack: codeql/actions-all + extensible: trustedActionsOwnerDataModel + data: + # Distrust the first-party `actions` owner so unpinned tags for its + # Actions are reported. `github` is intentionally left trusted. + - ["!actions"] diff --git a/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.qlref b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.qlref new file mode 100644 index 000000000000..9d46bb88b264 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829-untrusted-owner/UnpinnedActionsTag.qlref @@ -0,0 +1,2 @@ +query: Security/CWE-829/UnpinnedActionsTag.ql +postprocess: codeql/actions/test/InlineExpectationsTestQuery.ql 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 0b78b37359f4..3f37d728dbf2 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 @@ -83,3 +83,22 @@ To allow any Action from the publisher ``octodemo``, such as ``octodemo/3rd-part 3. Ensure that the model pack is included in your CodeQL analysis. By following these steps, you will add ``octodemo`` to the list of trusted Action publishers, and the query will no longer generate security alerts for unpinned tags from this publisher. For more information, see `Extending CodeQL coverage with CodeQL model packs in default setup `_ and `Creating and working with CodeQL packs `_. + +Example: Remove a trusted Actions publisher for the ``actions/unpinned-tag`` query +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +GitHub's own organizations (``actions``, ``github`` and ``advanced-security``) are trusted by default. If you want unpinned tags for these first-party Actions to be reported as well, you can remove an owner from the trusted list by adding an entry prefixed with ``!``. A ``!`` entry always takes precedence over a plain entry for the same owner. + +To distrust the first-party ``github`` owner, add a data extension file with the following content: + +.. code-block:: yaml + + extensions: + - addsTo: + pack: codeql/actions-all + extensible: trustedActionsOwnerDataModel + data: + - ["!github"] + +With this in place, the query will once again report unpinned tags for Actions published by ``github``. +