Skip to content

Make actions/unpinned-tag lockfile- and $/-aware#22155

Draft
nodeselector wants to merge 11 commits into
github:mainfrom
nodeselector:nodeselector-actions-lockfile-aware-pinning
Draft

Make actions/unpinned-tag lockfile- and $/-aware#22155
nodeselector wants to merge 11 commits into
github:mainfrom
nodeselector:nodeselector-actions-lockfile-aware-pinning

Conversation

@nodeselector

@nodeselector nodeselector commented Jul 10, 2026

Copy link
Copy Markdown

Overview

Makes the actions/unpinned-tag query (CWE-829) aware of two cases where a mutable uses: tag is actually safe, so it stops reporting them:

  1. $/ self repository referencesuses: $/path/to/action targets an action in the same repository at the running commit, so it is inherently pinned (like ./ self-workspace references).
  2. Lockfile-pinned references — a uses: recorded as pinned by the repository's Actions lockfile (.github/workflows/actions.lock) resolves to an immutable commit at runtime, so the mutable tag written in the workflow is not a real risk.

How lockfile-awareness works

The query gains a single seam predicate:

pinnedByLockfile(uses, nwo, version) :-
  pinnedByLockfileDataModel(uses.getLocation().getFile().getRelativePath(), nwo, version)

pinnedByLockfileDataModel(workflow_path, nwo, ref) is a new extensible predicate, defaulting to empty (ext/config/pinned_by_lockfile.yml ships data: []), so behaviour is unchanged unless something populates it.

The extractor generates a database-local model pack

.github/workflows/actions.lock records the resolved ref for each uses: (e.g. v1.2.0), but workflows usually write a shorter mutable tag (@v1). The query matches the ref as written, so a naïve resolved == written comparison would never match.

This PR adds a small Go generator (actions/extractor/tools/lockfile-extension-generator/) that:

  • parses the minimal, stable core of the lockfile format directly — it depends only on gopkg.in/yaml.v3 and builds anywhere the Go toolchain is available (no external/private dependency),
  • normalizes each resolved ref into its variantsv1.2.0 also emits v1.2 and v1 — so a workflow pinned via @v1 is matched,
  • renders a pinnedByLockfileDataModel data-extension YAML.

The Actions extractor's autobuild.sh runs the generator during database create and atomically writes a self-contained model pack (codeql/actions-lockfile-pins) into <database>/lockfile-extension/. Generation is staged in a temp dir and published with a single rename only on success (EXIT-trap cleanup otherwise), so a failed build never leaves a half-written pack. Repositories without a lockfile are a clean no-op, and a missing Go toolchain / build failure degrades gracefully without aborting extraction.

The generator ships as packaged extractor source via the existing codeql_pkg_files(exes = glob(["tools/**"])) rule — no bazel go_binary target is needed.

Applying it at analysis time

CodeQL has no mechanism to auto-apply extensions carried inside a database (confirmed: extensions load only from --model-packs, --additional-packs, or as a static query-pack dependency). Because the data is per-repository and generated at extraction time, it cannot be a static dependency, so suppression takes effect only when the generated pack is supplied to analysis:

codeql database analyze <db> \
  --additional-packs <db>/lockfile-extension \
  --model-packs codeql/actions-lockfile-pins \
  actions/ql/src/Security/CWE-829/UnpinnedActionsTag.ql

This mirrors the established codeql/immutable-actions-list pattern in this repo: a separate model pack applied via --model-packs by GitHub's internal analysis harness, not wired into the query pack. Adding the two flags above to that harness (out of this repo, same boundary immutable-actions-list already lives at) is the final step to make suppression apply during real code scanning. A native dbscheme relation was considered and rejected — it would require touching the shared JavaScript dbscheme and diverges from the model-pack pattern GitHub deliberately uses for this class of data.

Testing

  • Query tests (actions/ql/test/query-tests/Security/CWE-829, all 8 pass locally, CodeQL 2.26.0):
    • self_ref_dollar.yml — both $/ forms produce no findings.
    • lockfile_pinned.yml + pinned_by_lockfile.model.ymlsome-owner/pinned-action@v1 is suppressed via a test data-extension; confirms the dataExtensions glob in test/qlpack.yml loads the .model.yml.
    • UntrustedCheckoutCritical.expected gains one benign step-adjacency edge from the new two-uses: fixture (not a security finding).
  • Generator has golden + unit tests (generator_test.go, lockfile_test.go) covering pin parsing, semver normalization, lockfile parsing, and byte-exact rendered output.
  • Real end-to-end via the actual Actions extractor (codeql database create on a synthetic repo whose workflow writes some-owner/pinned-action@v1 while the lockfile resolves v1.2.0), with the generator built on demand by a stock Go toolchain:
    • baseline analysis (no pack) reports both some-owner/pinned-action@v1 and an unlocked other-owner/unpinned@v2;
    • analysis with the extractor-generated pack reports only other-owner/unpinned@v2 — the lockfile-pinned ref is suppressed through the full @v1v1.2.0 normalization path.
  • Atomic generation verified across three cases: lockfile present (atomic publish), absent (clean no-op), no Go toolchain (graceful skip, no partial pack).

Limitations

  • Composite actions: the lockfile keys pins transitively under the workflow path, so a uses: appearing only inside a composite action.yml is not emitted against that file's path and won't be suppressed. This is a completeness gap only — a row is emitted solely for (path, nwo, ref) the lockfile actually pins, so it can never wrongly suppress a reference.
  • Lockfile format coupling: the generator parses a minimal core of the lockfile format (lockfile.go) mirroring the canonical github.com/github/actions-lockfile semantics; if that format evolves, update lockfile.go and the golden fixture together.

Files

  • actions/ql/src/Security/CWE-829/UnpinnedActionsTag.qlnot isSelfRepository(...) + not pinnedByLockfile(...).
  • actions/ql/lib/codeql/actions/config/{Config,ConfigExtensions}.qll — declare/re-export pinnedByLockfileDataModel.
  • actions/ql/lib/ext/config/pinned_by_lockfile.yml — empty default extension.
  • actions/extractor/tools/lockfile-extension-generator/** — dependency-free Go generator + tests + fixtures + README.
  • actions/extractor/tools/generate-lockfile-extension.sh, autobuild.sh — extractor integration (atomic).
  • Change notes under actions/ql/lib/change-notes/ and actions/ql/src/change-notes/.

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.
@github-actions github-actions Bot added documentation Actions Analysis of GitHub Actions labels Jul 10, 2026
Comment on lines +71 to +88
/**
* 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`).
*
* This predicate is intended to be populated by the CodeQL Actions extractor, which parses
* `actions.lock` at database-creation time using the canonical lockfile parser at
* `github.com/github/actions-lockfile/go`. Each lockfile entry binds an `nwo`@`ref` to a
* verified commit SHA, which is exactly the pinning evidence the `actions/unpinned-tag` query
* otherwise lacks. Until the extractor populates this predicate it is empty, so any clause that
* consumes it is a clean no-op and behaviour is unchanged for repositories without a lockfile.
*
* Fields:
* - `workflow_path`: repo-relative path of the file containing the `uses:` reference,
* e.g. `.github/workflows/ci.yml`.
* - `nwo`: owner and name of the referenced action, e.g. `actions/checkout`.
* - `ref`: the ref (tag or branch) as written in `uses:`, e.g. `v4`.
*/
Adding the self_ref_dollar.yml fixture introduces one new step-adjacency
edge in the shared CWE-829 test database, which the UntrustedCheckoutCritical
query dumps under its edges relation. Regenerate the expected output to
include it. No new security findings: the $/ self-references and the
lockfile-pinned ref correctly produce no unpinned-tag results.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Actions Analysis of GitHub Actions documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants