Make actions/unpinned-tag lockfile- and $/-aware - #22155
Conversation
059814e to
f1e28c2
Compare
A $/ reference (e.g. "uses: $/path/to/action") is a same-repo self-reference that resolves to the commit the workflow is running at. It is inherently pinned, exactly like a "./" local reference, so it must never be reported by actions/unpinned-tag. Adds an isSelfReference(nwo) guard to the query plus a test fixture covering the bare "$/actions/foo" form and the "$/actions/foo@v1" form (the latter is rejected by the $/ rule but writable by a user; the guard suppresses it either way). Part of github/actions-dispatch#755.
Adds the seam for making actions/unpinned-tag aware of a repository's Actions lockfile (.github/workflows/actions.lock), so that a tag ref bound to a verified commit in the lockfile is not reported as unpinned (Option A from the github#755 spike). Introduces the extensible predicate pinnedByLockfileDataModel(workflow_path, nwo, ref) in ConfigExtensions.qll, re-exported through Config.qll, with a data-extension stub in ext/config/pinned_by_lockfile.yml documenting the intended row shape. The query gains a "not pinnedByLockfile(...)" clause keyed on the workflow file's relative path. The predicate is meant to be populated by the CodeQL Actions extractor, which must parse actions.lock at database-creation time using the canonical parser github.com/github/actions-lockfile/go. That extractor work is a separate change and is not implemented here; until it ships the predicate is empty and the new clause is a no-op. A test-scoped data extension exercises the clause end to end. Part of github/actions-dispatch#755.
Add a Go tool that parses a repository's Actions lockfile (.github/workflows/actions.lock) with the canonical parser at github.com/github/actions-lockfile/go and emits a CodeQL data extension populating pinnedByLockfileDataModel, the predicate the actions/unpinned-tag query already consumes to suppress lockfile-pinned refs. The generator is transport-agnostic: it produces the same [workflow_path, nwo, ref] rows whether they ship as a model pack applied via --model-packs (as today, mirroring codeql/immutable-actions-list) or later feed an extractor-native relation, so the parsing core is reusable without touching the query. Lockfiles record the resolved ref (e.g. v4.3.1) while workflows usually write a shorter mutable tag (v4). Since the query matches the ref as written, the generator expands every full-semver resolved ref into its major.minor and major-only forms, so uses: owner/action@v4 is recognized as pinned by a v4.3.1 lockfile entry. Verified end to end against a synthetic repo: the lockfile-pinned short-tag ref is suppressed while unlocked refs still report. actions-lockfile is not yet public, so go.mod carries a local replace directive for building and testing; remove it once the module is published.
Wire the lockfile-extension-generator into the Actions extractor autobuild so that codeql database create automatically emits the pinnedByLockfileDataModel data extension from a repository's .github/workflows/actions.lock. The extension is written into the database as a self-contained model pack under <db>/lockfile-extension (codeql/actions-lockfile-pins). A new generate-lockfile-extension.sh runs after JS extraction: it locates the lockfile relative to the captured source root, resolves the generator (prebuilt binary if shipped, else builds from source when a Go toolchain is present), and writes the pack. It is a clean no-op when the repository has no lockfile, so it is safe to run against every database. CodeQL does not auto-apply extensions carried inside a database, so analysis still adds the pack explicitly via --model-packs codeql/actions-lockfile-pins (--additional-packs <db>/lockfile-extension). Wiring that into the analysis harness is the remaining step and lives outside this repo. Verified end to end locally by overlaying the modified extractor into the CLI bundle: database create emits the extension, and analyze suppresses a lockfile-pinned short-tag ref (uses: owner/action@v4 resolved to v4.3.1) while still reporting refs not covered by the lockfile.
…imits The committed go.mod for the lockfile-extension generator carried an absolute-path replace directive pointing at a local clone of the private actions-lockfile repo, which would leak a developer path and break builds for anyone else. Keep the how-to-test comment but drop the replace line; local testing uses `go mod edit -replace`. Also update the two change notes to state that the extractor now generates the pinnedByLockfileDataModel data into a database-local model pack (applied via --model-packs), and document the composite-action completeness gap in the generator README.
The previous commit re-staged a dirty working-tree go.mod, so the machine-specific replace directive pointing at a local actions-lockfile clone leaked back into the tree. Drop it for real and move the local replace into a gitignored go.work so committed module metadata stays portable while local builds still resolve the not-yet-public dependency.
Canonical terminology flip: `$/` resolves to the same REPOSITORY at the
running SHA ("self repository"), while `./` is "self workspace". Rename the
isSelfReference predicate to isSelfRepository, reword the code comment, and
update the change note (renamed to ...-self-repository.md) and test fixture
comments to match. No change to query results, the finding message, or any
.expected output.
Stage the generated model pack in a temp dir inside the WIP database and publish it with a single rename only after it is fully written, with an EXIT trap that cleans up on any failure. Previously a failed 'go build' (expected until the private actions-lockfile dependency is public) left a half-written pack dir behind (an ext/ with no qlpack.yml) that could break analyses run with --additional-packs. Verified across three cases: repo with a lockfile (atomic publish), repo without one (clean no-op), and no Go toolchain available (graceful skip, no partial pack).
The generator pulled in github.com/github/actions-lockfile/go purely to parse a small, stable YAML file, which meant it could not build without a local clone of that (currently private) module -- forcing a gitignored go.work with a machine-specific replace and breaking any CI/bazel build. Parse the minimal core of the lockfile format directly instead (new lockfile.go: YAML unmarshal, pin-key parsing, and the semver major/minor/full logic), faithfully mirroring the canonical parser's semantics. The golden fixture (testdata/expected.yml) is unchanged, byte for byte, which proves the reimplementation matches. Added unit tests for parsePin, parseSemVer/isFull, and parseLockfile. The generator now depends only on gopkg.in/yaml.v3 and builds anywhere the Go toolchain is available, with no replace directive and no go.work. Verified end-to-end through the real extractor: on-demand 'go build' during database create succeeds with a stock toolchain, and the lockfile-pinned ref is still suppressed while an unlocked ref still fires.
A lockfile that pins no repo-level actions (e.g. only sub-path actions like github/codeql-action/init@v3, which parsePin skips) produced a bare `data:` (YAML null) extension, which CodeQL's `resolve extensions-by-pack` rejects and aborts the analysis. Emit `data: []` for the zero-row case, matching the repo convention, and note the narrow transitive-per-path over-suppression edge in the change note.
…/repo The lockfile generator lower-cases owner/repo (GitHub treats them case-insensitively) while preserving the ref, so a mixed-case ref such as `uses: Azure/login@v1` never matched the lowercase pinnedByLockfileDataModel row and was still reported as unpinned. Lower-case nwo only for the data-model lookup, keeping source casing in the alert message. Adds a mixed-case suppression test plus a mixed-case control that must still fire.
behaviour -> behavior, recognised -> recognized, serialises -> serializes across the lockfile-aware pinning comments and change notes. Comments/docs only; no logic change.
Removes the Go lockfile-extension-generator tool and its extractor autobuild hook, keeping this PR to its intended discussion scope: the $/ self-repository suppression plus the QL seam (pinnedByLockfileDataModel extensible predicate and the not pinnedByLockfile(...) clause). Parsing actions.lock and populating the predicate is extractor-team work on the CodeQL CLI cycle, and the tool here reimplemented the canonical parser at github.com/github/actions-lockfile rather than depending on it, so it would drift. It also wrote a model pack into the database that CodeQL does not auto-apply, so it never took effect without extra analysis flags. Better to scaffold the seam and let the extractor own population. Reverts the additions to actions/extractor/tools/autobuild.sh and deletes actions/extractor/tools/generate-lockfile-extension.sh and actions/extractor/tools/lockfile-extension-generator/. Query behavior and test expected output are unchanged; the CWE-829 query tests still pass.
f1e28c2 to
75cbb24
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Lock matching mishandles valid mixed-case pins and accepts entries missing required repository identities.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Balanced
Findings: 1
New issues introduced by this change (2)
| Severity | Finding |
|---|---|
actions/ql/lib/codeql/actions/Lock.qll — The dependency is accepted without the required owner_id and repo_id fields, so a lock entry… |
|
actions/ql/lib/codeql/actions/Lock.qll — This equality does not normalize pinnedNwo; it requires the lockfile spelling to already be… |
What changed in this PR
Makes actions/unpinned-tag aware of lockfile-backed pins and self-repository references.
Changes:
- Adds workflow-scoped
actions.lockpin detection. - Excludes
$/references from unpinned-tag findings. - Adds focused query fixtures and change notes.
| File | Description |
|---|---|
actions/ql/lib/codeql/actions/Lock.qll |
Implements lockfile pin matching. |
actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql |
Suppresses locked and self-repository references. |
actions/ql/test/utils/ActionsInlineExpectationsTestQuery.ql |
Adds YAML inline-expectation processing. |
actions/ql/test/query-tests/Security/CWE-829/UntrustedCheckoutCritical.expected |
Updates expected data-flow edges. |
actions/ql/test/query-tests/Security/CWE-829/.github/workflows/self_ref_dollar.yml |
Tests $/ references. |
actions/ql/test/query-tests/Security/CWE-829-Lockfile/UnpinnedActionsTag.qlref |
Configures the lockfile query test. |
actions/ql/test/query-tests/Security/CWE-829-Lockfile/UnpinnedActionsTag.expected |
Records expected findings. |
actions/ql/test/query-tests/Security/CWE-829-Lockfile/options |
Enables lockfile YAML extraction. |
actions/ql/test/query-tests/Security/CWE-829-Lockfile/.github/workflows/rust-ci.yml |
Provides workflow test cases. |
actions/ql/test/query-tests/Security/CWE-829-Lockfile/.github/workflows/actions.lock |
Provides lockfile test data. |
actions/ql/src/change-notes/2026-07-09-unpinned-tag-self-repository.md |
Documents self-reference handling. |
actions/ql/src/change-notes/2026-07-09-unpinned-tag-lockfile-aware.md |
Documents lockfile awareness. |
actions/ql/lib/change-notes/2026-09-01-actions-lock-yaml.md |
Updates the ActionsLock API note. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
hvitved
left a comment
There was a problem hiding this comment.
I have created nodeselector#1 against your branch with code review suggestions.
…e-pinning Code review suggestions
Much cleaner. Thank you. |
hvitved
left a comment
There was a problem hiding this comment.
LGTM, but there is a merge conflict.


Why
actions/unpinned-tagreports symbolic action refs even when Actions resolves them to the commit recorded for that workflow in.github/workflows/actions.lock. It also reports$/self-repository references, which resolve at the running commit.This removes both false positives. See #22464.
What changed
ActionsLock.owner/repo@refpin and the matching dependency has a full SHA-1 or SHA-256 commit.actions/cache/saveas pins onactions/cache.$/references from this query because they resolve at the running repository commit.CodeQL trusts the checked-in lockfile syntax here. It does not call GitHub, verify provenance or validate the transitive dependency graph. Reusable workflow findings are unchanged.
Validation
Risk and rollback
Low. The new suppression requires a workflow-scoped pin, matching dependency ref and full commit digest. Reverting this PR restores the previous findings.