diff --git a/actions/ql/lib/change-notes/2026-07-09-pinned-by-lockfile.md b/actions/ql/lib/change-notes/2026-07-09-pinned-by-lockfile.md new file mode 100644 index 000000000000..2c441c182a97 --- /dev/null +++ b/actions/ql/lib/change-notes/2026-07-09-pinned-by-lockfile.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* Added a new extensible predicate `pinnedByLockfileDataModel(workflow_path, nwo, ref)`, which lets model packs record `uses:` references pinned by a repository's Actions lockfile (`.github/workflows/actions.lock`). diff --git a/actions/ql/lib/codeql/actions/config/Config.qll b/actions/ql/lib/codeql/actions/config/Config.qll index 27e24514c091..8b6fcaefa290 100644 --- a/actions/ql/lib/codeql/actions/config/Config.qll +++ b/actions/ql/lib/codeql/actions/config/Config.qll @@ -135,6 +135,26 @@ predicate trustedActionsOwnerDataModel(string owner) { Extensions::trustedActionsOwnerDataModel(owner) } +/** + * MaD models for `uses` references pinned by the repository's Actions lockfile + * (`.github/workflows/actions.lock`). + * Fields: + * - workflow_path: repo-relative path of the file containing the `uses:` reference + * - nwo: referenced action, optionally including a sub-action path (e.g. `actions/cache/save`) + * - ref: the ref as written in `uses:` (e.g. `v4`) + */ +bindingset[nwo] +predicate pinnedByLockfileDataModel(string workflow_path, string nwo, string ref) { + exists(string pinnedNwo | + Extensions::pinnedByLockfileDataModel(workflow_path, pinnedNwo, ref) and + ( + nwo.toLowerCase() = pinnedNwo + or + nwo.toLowerCase().prefix(pinnedNwo.length() + 1) = pinnedNwo + "/" + ) + ) +} + /** * MaD models for untrusted git commands * Fields: diff --git a/actions/ql/lib/codeql/actions/config/ConfigExtensions.qll b/actions/ql/lib/codeql/actions/config/ConfigExtensions.qll index 4ee438fc63cb..f40defd2b212 100644 --- a/actions/ql/lib/codeql/actions/config/ConfigExtensions.qll +++ b/actions/ql/lib/codeql/actions/config/ConfigExtensions.qll @@ -68,6 +68,23 @@ extensible predicate immutableActionsDataModel(string action); */ extensible predicate trustedActionsOwnerDataModel(string owner); +/** + * Holds if the `uses` reference `nwo`@`ref` in the workflow or composite action file at + * `workflow_path` is pinned by an entry in the repository's Actions lockfile + * (`.github/workflows/actions.lock`). + * + * Supply rows from a model pack generated with the canonical parser at + * `github.com/github/actions-lockfile/go/pkg/lockfile`. Each lockfile entry binds an `nwo`@`ref` + * to a verified commit SHA. Without such a model pack this predicate is empty. + * + * Fields: + * - `workflow_path`: repo-relative path of the file containing the `uses:` reference, + * e.g. `.github/workflows/ci.yml`. + * - `nwo`: canonical owner and repository from the lockfile pin, e.g. `actions/cache`. + * - `ref`: the ref (tag or branch) as written in `uses:`, e.g. `v4`. + */ +extensible predicate pinnedByLockfileDataModel(string workflow_path, string nwo, string ref); + /** * Holds for git commands that may introduce untrusted data when called on an attacker controlled branch. */ diff --git a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql index f98439063712..d84d0ade3309 100644 --- a/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql +++ b/actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql @@ -33,6 +33,12 @@ private predicate isPinnedContainer(string version) { bindingset[nwo] private predicate isContainerImage(string nwo) { nwo.regexpMatch("^docker://.+") } +// A `$/` reference is a same-repository (self repository) reference (e.g. `$/path/to/action`), +// resolved at the commit the calling workflow is running. Like `./` local (self workspace) +// references, it is inherently pinned and can never be an unpinned-tag finding, so we never flag it. +bindingset[nwo] +private predicate isSelfRepository(string nwo) { nwo.matches("$/%") } + private predicate hasUsesContainerName(Uses uses, string name) { exists(Workflow workflow | uses.getEnclosingWorkflow() = workflow and @@ -55,6 +61,11 @@ where hasUsesContainerName(uses, name) and uses.getVersion() = version and not isTrustedOwner(nwo) and + not isSelfRepository(nwo) and + not exists(UsesStep step | + uses = step and + pinnedByLockfileDataModel(step.getLocation().getFile().getRelativePath(), nwo, version) + ) and not ( if uses instanceof UsesStep and isContainerImage(nwo) then isPinnedContainer(version) diff --git a/actions/ql/src/change-notes/2026-07-09-unpinned-tag-lockfile-aware.md b/actions/ql/src/change-notes/2026-07-09-unpinned-tag-lockfile-aware.md new file mode 100644 index 000000000000..e02fdc01a905 --- /dev/null +++ b/actions/ql/src/change-notes/2026-07-09-unpinned-tag-lockfile-aware.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* The `actions/unpinned-tag` query no longer reports `uses:` references recorded by the new `pinnedByLockfileDataModel` extensible predicate. Lockfile pins match repository sub-actions while preserving ref casing. diff --git a/actions/ql/src/change-notes/2026-07-09-unpinned-tag-self-repository.md b/actions/ql/src/change-notes/2026-07-09-unpinned-tag-self-repository.md new file mode 100644 index 000000000000..02e1eac704b2 --- /dev/null +++ b/actions/ql/src/change-notes/2026-07-09-unpinned-tag-self-repository.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* The `actions/unpinned-tag` query no longer reports `$/` self repository references (e.g. `uses: $/path/to/action`), which resolve to the same repository at the running commit and are therefore inherently pinned, just like `./` self workspace (local) references. diff --git a/actions/ql/test/qlpack.yml b/actions/ql/test/qlpack.yml index 9c9e714fc67a..807ea41f522e 100644 --- a/actions/ql/test/qlpack.yml +++ b/actions/ql/test/qlpack.yml @@ -11,4 +11,5 @@ extractor: actions tests: . warnOnImplicitThis: true dataExtensions: + - query-tests/Security/CWE-829/*.model.yml - output-clobbering.model.yml diff --git a/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/lockfile_pinned.yml b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/lockfile_pinned.yml new file mode 100644 index 000000000000..6108f659bce1 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/lockfile_pinned.yml @@ -0,0 +1,29 @@ +on: + pull_request + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + steps: + # `some-owner/pinned-action@v1` is a tag ref that would normally be reported as an unpinned + # tag. The test data extension `pinned_by_lockfile.model.yml` records it as pinned by the + # repository's Actions lockfile, so the `not pinnedByLockfile(...)` clause suppresses it (this + # fixture is expected to produce no findings). This mirrors data supplied by an external + # model pack generated from `.github/workflows/actions.lock`. + # + # Negative control is provided for free by the many other fixtures in this directory whose tag + # refs are NOT recorded in the data extension and therefore remain reported. + - uses: some-owner/pinned-action@v1 + # `Mixed-Owner/Pinned-Action@v1` is pinned by the lockfile too, but written with the mixed-case + # owner/repo that authors commonly use (Azure, GoogleCloudPlatform, ...). + # Lockfile pins canonicalize owner/repo, so this ref is also expected to be suppressed (no + # finding). + - uses: Mixed-Owner/Pinned-Action@v1 + # Negative control: a mixed-case ref that is NOT recorded in the lockfile data must still be + # reported, guarding against over-suppression of every mixed-case ref. + - uses: Mixed-Owner/Unpinned-Action@v2 + # A repository-scoped pin also covers its sub-actions. + - uses: some-owner/pinned-action/save@v1 + # Ref casing must match the canonical lockfile pin exactly. + - uses: some-owner/pinned-action@V1 diff --git a/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/self_ref_dollar.yml b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/self_ref_dollar.yml new file mode 100644 index 000000000000..0d651b0bb0f6 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/.github/workflows/self_ref_dollar.yml @@ -0,0 +1,15 @@ +on: + pull_request + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + steps: + # `$/` is a same-repository (self repository) reference resolved at the running commit. It is + # inherently pinned (like `./` self workspace refs) and must never be reported as an unpinned tag. + - uses: $/actions/foo + # `$/…@ref` is rejected by the `$/` rule, but a user could still write it. It must also + # never be flagged; this case exercises the `not isSelfRepository(nwo)` suppression, since + # without it `$/actions/foo@v1` would otherwise be reported as an unpinned tag. + - uses: $/actions/foo@v1 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..e922727ce58f 100644 --- a/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected +++ b/actions/ql/test/query-tests/Security/CWE-829/UnpinnedActionsTag.expected @@ -21,6 +21,8 @@ | .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 | | .github/workflows/label_trusted_checkout2.yml:25:13:25:37 | fakerepo/comment-on-pr@v1 | Unpinned 3rd party Action 'label_trusted_checkout2.yml' step $@ uses 'fakerepo/comment-on-pr' with ref 'v1', not a pinned commit hash | .github/workflows/label_trusted_checkout2.yml:25:7:28:21 | Uses Step | Uses Step | | .github/workflows/level0.yml:36:15:36:47 | rlespinasse/github-slug-action@v4 | Unpinned 3rd party Action 'Poutine Level 0' step $@ uses 'rlespinasse/github-slug-action' with ref 'v4', not a pinned commit hash | .github/workflows/level0.yml:36:9:39:6 | Uses Step | Uses Step | +| .github/workflows/lockfile_pinned.yml:25:13:25:42 | Mixed-Owner/Unpinned-Action@v2 | Unpinned 3rd party Action 'lockfile_pinned.yml' step $@ uses 'Mixed-Owner/Unpinned-Action' with ref 'v2', not a pinned commit hash | .github/workflows/lockfile_pinned.yml:25:7:27:4 | Uses Step | Uses Step | +| .github/workflows/lockfile_pinned.yml:29:13:29:39 | some-owner/pinned-action@V1 | Unpinned 3rd party Action 'lockfile_pinned.yml' step $@ uses 'some-owner/pinned-action' with ref 'V1', not a pinned commit hash | .github/workflows/lockfile_pinned.yml:29:7:29:40 | Uses Step | Uses Step | | .github/workflows/mend.yml:31:15:31:34 | ruby/setup-ruby@v1 | Unpinned 3rd party Action 'Test' step $@ uses 'ruby/setup-ruby' with ref 'v1', not a pinned commit hash | .github/workflows/mend.yml:29:9:33:28 | Uses Step | Uses Step | | .github/workflows/pr-workflow.yml:60:15:60:52 | amannn/action-semantic-pull-request@v5 | Unpinned 3rd party Action 'pr-workflow' step $@ uses 'amannn/action-semantic-pull-request' with ref 'v5', not a pinned commit hash | .github/workflows/pr-workflow.yml:60:9:70:6 | Uses Step | Uses Step | | .github/workflows/pr-workflow.yml:109:15:109:42 | actionsdesk/lfs-warning@v3.2 | Unpinned 3rd party Action 'pr-workflow' step $@ uses 'actionsdesk/lfs-warning' with ref 'v3.2', not a pinned commit hash | .github/workflows/pr-workflow.yml:109:9:124:6 | Uses Step | Uses Step | 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 32999ec70e27..b17295e6837d 100644 --- a/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected +++ b/actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected @@ -170,6 +170,10 @@ edges | .github/workflows/level0.yml:125:9:129:6 | Uses Step | .github/workflows/level0.yml:129:9:133:6 | Uses Step | | .github/workflows/level0.yml:128:17:128:57 | github.event.pull_request.head.sha | .github/workflows/level0.yml:125:9:129:6 | Uses Step | | .github/workflows/level0.yml:129:9:133:6 | Uses Step | .github/workflows/level0.yml:133:9:135:23 | Run Step | +| .github/workflows/lockfile_pinned.yml:17:7:22:4 | Uses Step | .github/workflows/lockfile_pinned.yml:22:7:25:4 | Uses Step | +| .github/workflows/lockfile_pinned.yml:22:7:25:4 | Uses Step | .github/workflows/lockfile_pinned.yml:25:7:27:4 | Uses Step | +| .github/workflows/lockfile_pinned.yml:25:7:27:4 | Uses Step | .github/workflows/lockfile_pinned.yml:27:7:29:4 | Uses Step | +| .github/workflows/lockfile_pinned.yml:27:7:29:4 | Uses Step | .github/workflows/lockfile_pinned.yml:29:7:29:40 | Uses Step | | .github/workflows/mend.yml:13:9:22:6 | Run Step: set_ref | .github/workflows/mend.yml:22:9:29:6 | Uses Step | | .github/workflows/mend.yml:22:9:29:6 | Uses Step | .github/workflows/mend.yml:29:9:33:28 | Uses Step | | .github/workflows/mend.yml:27:17:27:48 | steps.set_ref.outputs.ref | .github/workflows/mend.yml:22:9:29:6 | Uses Step | @@ -241,6 +245,7 @@ edges | .github/workflows/resolve-args.yml:20:9:22:6 | Uses Step | .github/workflows/resolve-args.yml:22:9:36:13 | Run Step: resolve-step | | .github/workflows/reusable_local.yml:23:9:26:6 | Uses Step | .github/workflows/reusable_local.yml:26:9:29:7 | Run Step | | .github/workflows/reusable_local.yml:25:17:25:36 | inputs.branch | .github/workflows/reusable_local.yml:23:9:26:6 | Uses Step | +| .github/workflows/self_ref_dollar.yml:11:7:15:4 | Uses Step | .github/workflows/self_ref_dollar.yml:15:7:15:29 | Uses Step | | .github/workflows/test1.yml:18:9:21:6 | Uses Step | .github/workflows/test1.yml:21:9:24:6 | Run Step | | .github/workflows/test1.yml:21:9:24:6 | Run Step | .github/workflows/test1.yml:24:9:25:39 | Run Step | | .github/workflows/test2.yml:13:9:16:6 | Uses Step | .github/workflows/test2.yml:16:9:20:52 | Uses Step | diff --git a/actions/ql/test/query-tests/Security/CWE-829/pinned_by_lockfile.model.yml b/actions/ql/test/query-tests/Security/CWE-829/pinned_by_lockfile.model.yml new file mode 100644 index 000000000000..3250b8914d5a --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-829/pinned_by_lockfile.model.yml @@ -0,0 +1,9 @@ +extensions: + - addsTo: + pack: codeql/actions-all + extensible: pinnedByLockfileDataModel + # Canonical lockfile pin rows supplied by an external model pack. + data: + - [".github/workflows/lockfile_pinned.yml", "some-owner/pinned-action", "v1"] + # Owner/repo is canonicalized; ref casing is preserved. + - [".github/workflows/lockfile_pinned.yml", "mixed-owner/pinned-action", "v1"]