Repository navigation
Conversation
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.
Much cleaner. Thank you. |
hvitved
left a comment
There was a problem hiding this comment.
LGTM, but there is a merge conflict.
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.
a1fe250 to
ff9bf5d
Compare
| ActionsLock() { this.getFile().getBaseName() = "actions.lock" } | ||
| /** A `.github/workflows/actions.lock` file. */ | ||
| class ActionsLock extends YamlDocument, YamlMapping { | ||
| ActionsLock() { this.getFile().getRelativePath() = ".github/workflows/actions.lock" } |
There was a problem hiding this comment.
Because of this change, actions/ql/test/library-tests/actions-lock/actions.lock needs to be moved into actions/ql/test/library-tests/actions-lock/.github/workflows/actions.lock, and actions/ql/test/library-tests/actions-lock/test.expected needs to be updated accordingly.
There was a problem hiding this comment.
Turns out that actions/ql/integration-tests/actions-lock/src/actions.lock also needs to be moved (into actions/ql/integration-tests/actions-lock/src/.github/workflows/actions.lock).
There was a problem hiding this comment.
Got eager to rerequest review before checks finished, and then distracted by something else. Thanks.
| ActionsLock() { this.getFile().getBaseName() = "actions.lock" } | ||
| /** A `.github/workflows/actions.lock` file. */ | ||
| class ActionsLock extends YamlDocument, YamlMapping { | ||
| ActionsLock() { this.getFile().getRelativePath() = ".github/workflows/actions.lock" } |
There was a problem hiding this comment.
Turns out that actions/ql/integration-tests/actions-lock/src/actions.lock also needs to be moved (into actions/ql/integration-tests/actions-lock/src/.github/workflows/actions.lock).
… a commit (#228) Part of #167 (section 2, `actions.lock`: `unpinned-uses` accepts what the lock pins, and the pin fix does not inline a SHA there). ## What `unpinned-uses` no longer reports `uses: owner/repo@v1` in a workflow when the repository's `.github/workflows/actions.lock` pins that ref to a full commit for that workflow. The `--online` pin fix (tag to SHA) is not offered for a step of such a workflow, because a SHA is a ref the lock file has no entry for. A follow-up PR (`actions-lock` rule that lints the lock file) is based on this branch. The reader is in its own file, `actions_lock.go`. It is loaded from the project (`Project.actionsLock()`, read once per project like the config) and has no effect when the file is absent or cannot be trusted. ## The real format (primary sources) I did not guess the format. It is defined and implemented in public repositories: - **Producer, behaviour**: [github/gh-actions-lock](https://github.com/github/gh-actions-lock) (README, `internal/lockfile/state.go`, `internal/pipeline/checks/structural.go`, `internal/workflowfile/workflowfile.go`). "Public preview. gh-actions-lock is pre-1.0 ... The lockfile format, command flags, and behavior may change without notice." - **Format and parser**: [github/actions-lockfile](https://github.com/github/actions-lockfile) (`schema/lockfile-v0.0.3.json`, `go/pkg/lockfile/{lockfile,compat,pin,uses}.go`). - **How CodeQL reads it**: [github/codeql#22487](github/codeql#22487) (extraction) and [#22155](github/codeql#22155) (`actions/ql/lib/codeql/actions/Lock.qll`, the `actions/unpinned-tag` query). Their test fixture `CWE-829-Lockfile` is where the edge cases below come from. - Roadmap: [github/roadmap#1253](github/roadmap#1253) (preview) and [#1268](github/roadmap#1268) (GA); the [2026 security roadmap post](https://github.blog/news-insights/product-news/whats-coming-to-our-github-actions-2026-security-roadmap/) describes the idea (it still says the lock lives in the workflow YAML; what shipped is the file). Runner: [actions/runner#4546](actions/runner#4546) logs "Running with locked dependencies". zizmor tracks it in [zizmor#2371](zizmorcore/zizmor#2371). What the file is (v0.0.2 and v0.0.3 have this shape; v0.0.3 adds an optional `hostname`): ```yaml version: 'v0.0.3' workflows: # a workflow path -> the actions its file uses (direct uses only) '.github/workflows/ci.yml': - 'actions/checkout@v6.0.2' # "owner/repo@ref", owner and repo lower case, ref as written dependencies: # the dependency graph, one entry per "owner/repo@ref" 'actions/checkout@v6.0.2': ref: 'v6.0.2' # equal to the ref of the key (unless the key's ref is a full SHA) commit: 'sha1-de0fac2e...' # sha1-<40 hex> or sha256-<64 hex> owner_id: 44036562 # positive integers; they detect a transferred or re-created repository repo_id: 197814629 uses: # only composite actions: their own direct uses (transitive graph) - 'actions/cache@v4' ``` Facts that decide the behaviour, with where I found them: - **Per workflow.** "workflows are onboarded to the lockfile on a per-workflow basis" (gh-actions-lock README). The runner holds only listed workflows to it. - **Repository granularity.** A pin is `owner/repo@ref`; a sub action (`actions/cache/save`) is pinned by its repository (`pin.go`, and CodeQL's `Lock.qll` covers `nwo + "/"` prefixes). - **Case.** owner/repo are canonicalised to lower case, the ref is case sensitive (`Pin.Canonical`); CodeQL's fixture has `dtolnay/rust-toolchain@V1` reported while `@v1` is accepted. - **Reusable workflows and Docker images.** The current `gh actions-lock` records neither (`ParseActionRef` returns nil for a workflow file; zizmor#2371: "This currently doesn't replace pinning callable workflow refs"; CodeQL leaves reusable workflows unchanged), so they are judged as before. Some lock files in the wild do list the pin of a reusable workflow (17 of the 47 remote reusable calls of listed workflows in the corpus below), so the next PR counts a listed one as used, but nothing relies on it. - **Local actions.** `./` actions are not onboardable; `$/path` is the in-repo form, "inherently pinned", and the actions that an in-repo action uses are attributed to the workflows that run it (`ParsedWorkflow.Refs`: "includes refs found inside in-repo `$/…` actions"). Real lock files confirm it: `shun-shobon/littlexml` lists `jdx/mise-action` under `ci.yml`, which only the composite `.github/actions/setup` uses. - **A SHA written in `uses:` is a different ref.** `checkNotPinned` in gh-actions-lock reports a bare SHA with no matching lock entry as `not-pinned` (it "was never history-checked"). So rewriting a tag to an inline SHA in a listed workflow can make it fail the lock check, which is why the fix is not offered there. - **Versions.** The official parser accepts v0.0.1-v0.0.3 and rejects unknown top-level keys. Mine is more tolerant on purpose (unknown keys are ignored) and stricter on versions: only v0.0.2 and v0.0.3 are trusted; any other version (a future one, v0.0.1 whose entries have `tag`/`branch` instead of `ref`) is treated as no lock file, so a format change can only bring findings back, never hide one. ## Behaviour A step `uses: owner/repo[/path]@ref` of a workflow is accepted when the lock file: 1. lists the workflow under `workflows` with the pin `owner/repo@ref` (same ref), and 2. has a `dependencies` entry for it with the same `ref` and a full commit digest (`sha1-` + 40 hex or `sha256-` + 64 hex). For a step of a local action (`action.yml`): every workflow that runs the action (directly or through other local actions, using the existing call graph) must satisfy 1 and 2; an action no workflow runs is not locked. Unchanged when: no lock file; the file is not YAML or not a mapping; no or an unknown `version`; `workflows`/`dependencies` of the wrong shape; the workflow is not listed; the entry is missing or malformed. Reusable workflow calls and Docker images are always judged as before. New option `unpinned-uses.actions-lock` (default `true`) switches the lock file off. ## How it was tested - Unit and fixture tests: `actions_lock_test.go` (golden `testdata/actions_lock/covered.out`: sub action, owner case, ref case, other workflow's pin, ref mismatch, non-digest commit, sha256, entry without dependency, unlisted workflow, docker, reusable workflow; plus 18 table cases for unusable files), `actions_lock_action_test.go` (local actions with one or two callers), `actions_lock_fix_test.go` (the online fix is withheld in a listed workflow and offered elsewhere). - `go build ./...`, `go vet ./...`, `go test -race ./...` and `hk check --all` pass. - **Corpus**: the repositories which have a `.github/workflows/actions.lock` (GitHub code search `filename:actions.lock path:.github/workflows` finds 363; I cloned 82, at most 4 per owner, because 242 are one account's near-identical repositories). With the lock file ignored (`actions-lock: false`) `unpinned-uses` reports **1,307** findings in these repositories, with it **57** (71 of the 82 repositories change). I classified all 57: 7 are Docker images and reusable workflow calls (not recorded by the tool, judged as before); 18 are in `action.yml` files that no workflow of the repository runs (published actions and test fixtures); 32 are steps of workflows for which the lock file has no exact pin (the lock lists the action at another ref, or not at all). I read the lock files of two of those repositories to confirm that it is staleness and not a reader bug (`atomic-works/github-actions-reusable-workflows` locks `actions/checkout@v3.7.0` and uses `@v7.0.1`; `metadatastician/burble` bumped `upload-artifact` to v7.0.2 and kept v7.0.1 in the lock), and the independent script agrees. I also checked the other side with an independent script (a regexp read of the `uses:` lines and a YAML read of the lock, no jactionlint code): of 1,258 tag-pinned step `uses:` lines in these workflows it finds 1,225 pinned and 33 not, and jactionlint reports 32 of the 33 (the 33rd is in a file that `generated-files` skips by default). No false negative was found. ## Not measured - Whether the runner really enforces the file on a given plan (the sources say the preview is public and describe enforcement for listed workflows, I could not verify which plans enforce it), so the docs say jactionlint trusts the checked-in file. - The false-positive rate of workflows that are not listed (they are reported as before, which is what the previous behaviour was). - Lock files of v0.0.1 are not read. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes default pinning policy for repos using actions.lock and alters online fix behavior for locked workflows; malformed lock files are conservatively ignored rather than partially trusted. > > **Overview** > Adds support for GitHub’s technical-preview **Actions lock file** (`.github/workflows/actions.lock`) so **`unpinned-uses`** treats tag-pinned `uses:` as satisfied when the lock lists the workflow and pins that `owner/repo@ref` to a full commit digest. > > A new **`actions_lock.go`** module parses lockfile v0.0.2/v0.0.3 (unknown versions are ignored so findings never disappear on format changes), caches the result per linter run, and uses the existing **call graph** so composite **local actions** are covered only when every workflow that runs them lists the same pins. Reusable workflows and Docker images are unchanged. > > **`unpinned-uses.actions-lock`** (default `true`) disables this behavior. For workflows listed in the lock, **`--online` tag→SHA pin fixes** are withheld (inline SHAs are a different ref and can fail `gh actions-lock` verification), with guidance to use `gh actions-lock` instead. > > Docs, JSON schema, zizmor parity notes, and broad tests (golden fixtures, unusable lock cases, cache scope, fix behavior) accompany the linter wiring in `RuleEnv`, `RuleAction`, and `attachPinFixes`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 09c2db5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
…lows (#237) > **Based on #228** (`feat/actions-lock-unpinned-uses`), which adds the lock file reader (`actions_lock.go`). Merge #228 first; this branch then rebases onto `main` with only its own commit. Part of #167 (section 2, `actions.lock`: report missing and stale entries, and with `--online` check the locked commits). ## What Two new rules for repositories that lock their workflows with GitHub's Actions lock file (`.github/workflows/actions.lock`, technical preview). Neither reports anything when the repository has no lock file or when the file's `version` is not `v0.0.2`/`v0.0.3`. **`actions-lock`** (security, `default` profile, `error`; option `pedantic`). It reads the file like `gh actions-lock --verify-local`, offline: | Where | Finding | | --- | --- | | a workflow, or a local action that a listed workflow runs | an action the lock file does not list for the workflow, or lists at another ref (a SHA in `uses:` matches the entry resolved to that commit, as `gh actions-lock` does) | | the lock file | not YAML or wrong shape; a pin that is not `owner/repo@ref`; no `ref`; `commit` that is not `sha1-<40 hex>`/`sha256-<64 hex>`; no positive `owner_id`/`repo_id`; `ref` different from the ref of the key (except when the key is a SHA); key SHA with another `commit`; bad `hostname`; workflow path leaving the repository; a listed pin without a `dependencies` entry; `uses` pointing nowhere; cycles | | the lock file | stale entries: a workflow that does not exist, a pin its workflow no longer uses, a dependency no workflow reaches (skipped when something the workflow runs cannot be read: an expression in `uses:`, a missing local action) | | `pedantic` only | a workflow the lock file does not list (workflows are onboarded one by one, so this can be on purpose); a commit SHA whose version comment disagrees with the `ref` the lock file resolved it from (`# v4` is accepted for `v4.3.1`) | **`actions-lock-drift`** (security, online, `error`). Under `--online`, for each pin: the repository is not the locked one (GitHub's `id`/`owner.id` differ from `repo_id`/`owner_id`), the repository was renamed or transferred (`full_name` differs; the runner refuses it), a full-version tag (`v4.2.1`) now points to another commit than `commit`, or `commit` is on no branch or tag of the repository (the existing `impostor-commit` machinery). This extends `GitHubRepo` with optional `id`, `full_name` and `owner.id` (fixtures in `testdata/online/github.json` got them for `actions/checkout`, `actions/setup-node` and a made-up `example/renamed-action`; the recorded answers of the other repositories are unchanged). The lock file is linted with the other files of the repository mode (`jactionlint` with no arguments, `--fix` and so on find it through `Project.actionsLockFiles()`, next to `dependabot.yml`) and when named explicitly. The usage of each workflow is computed once through the existing call graph, so `$/path` actions count. ## The format Researched from primary sources and written up in #228 (README and source of [github/gh-actions-lock](https://github.com/github/gh-actions-lock), schema and parser of [github/actions-lockfile](https://github.com/github/actions-lockfile), CodeQL [#22487](github/codeql#22487) and [#22155](github/codeql#22155)). The checks above mirror what `gh actions-lock` reports (`structural.go`: `not-pinned`, `ref-changed`, `stale`, `stale-workflow`) and what the official parser rejects (`compat.go`, `lockfile.go`), except that unknown keys are ignored here. The docs say the format may change; a version whose shape is not known is silent. ## Sub-items Done: malformed entries, missing entry, stale entry, malformed digest, ref against version comment (pedantic), and online: tag still points at the commit, repository not renamed/replaced (IDs), commit not on a branch or tag. Limits, not skipped for lack of evidence but because the API cannot say: - "The locked commit exists": GitHub's answers for a deleted commit and for a commit it never knew look the same, so a commit that is gone is reported only when the comparison API still knows it and no branch or tag has it. A repository that returns 404 (deleted, or private for the token) gets no verdict. The docs say so. - Mutable refs (`v4`, branches) legitimately move (`--relock`), so only the history of their commit is checked, like `gh actions-lock --verify`. - Pins with a `hostname` other than `github.com` are not looked up. ## How it was tested - Goldens in `testdata/actions_lock/` for both rules and both profiles: `check_ok` (a consistent lock file with a local action, a hand-pinned SHA, transitive `uses`, an empty workflow, an unknown key, `sha256`, a `hostname`; no finding, also with `pedantic`), `check_err` and `check_err_pedantic` (23 and 25 findings), `check_gone` (a missing workflow and an unreadable one: no stale-entry cascade), `check_online` (four drift findings and a 404 that is left alone; offline run reports nothing), plus a test that a repository without a lock file is never reported, one that the repository mode finds the file only when it exists, and the docs example (`TestActionsLockDocsExample` compares the output in `docs/checks.md` with the real one). - `go build ./...`, `go vet ./...`, `go test -race ./...` and `hk check --all` pass; `mise run rule-ids actions-lock`, the profile snapshot and `docs/rules.md` are regenerated. - **Corpus** (same 82 repositories as #228, the ones with a `.github/workflows/actions.lock`, found by code search): offline, **95 findings in 11 repositories**, none of them about the format of the file (no lock file of the 82 has a finding of that kind): 35 actions whose ref changed after the lock was written, 31 pins their workflow no longer uses, 25 dependencies no workflow reaches, 4 actions with no entry at all. I read the lock files and workflows behind the repositories (`atomic-works/github-actions-reusable-workflows` locks `checkout@v3.7.0` and uses `@v7.0.1`; `meechain-hall/board.v1` still lists `trivy-action`, `upload-artifact` and `codeql-action` that its workflow no longer uses; `hyperpolymath/academic-workflow-suite` keeps `gitleaks-action` which nothing mentions; `hyperpolymath/action-trust-layers` uses `codeql-action` by SHA in a workflow that lists only `checkout`). All 95 are real disagreements between the file and the workflows; I found no false positive. The pedantic option adds 5: 3 version comments (`# v3` for a commit the lock resolved from `v4.34.0`), 2 unlisted workflows. Findings that are consequences of one staleness are many, which is why the stale checks are one message per entry. - **Online**: a real run (token) over the lock files of `casey/just`, `casey/filepack`, `github/gh-actions-lock`, `actix/actix-web` and `b-editor/beutl` (about 60 pins) found nothing; the same file with a wrong `repo_id` and a changed commit produces the replaced-repository and moved-tag findings against the live API. Only these 5 repositories were checked online because the shared token hit the rate limit during the larger run. ## Why the `default` profile The rule needs a lock file, which only a repository that adopted GitHub's preview has, and the findings are failures of `gh actions-lock --verify-local`. The measured false-positive rate on 82 repositories is zero, but that is a small corpus of one preview feature (242 of the 363 repositories with a lock file belong to one account and I capped the sample at 4 per owner); the risk is a change of the format, which the version gate covers. The two noisier checks are `pedantic`. ## Not measured - Repositories on GHE.com (data residency) and GHES: not supported by `gh actions-lock`/not tested; `hostname` is only validated. - Lock files of version `v0.0.1` and future versions are skipped silently. - Behaviour on a repository where the lock file is generated in CI and not checked in (nothing to read, nothing reported). - `--online` on the 77 other repositories. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > New default-profile security rules affect CI for repos using the lock preview; online drift relies on GitHub API identity fields and shares impostor-commit limits. > > **Overview** > Adds **`actions-lock`** and **`actions-lock-drift`** so jactionlint can validate GitHub’s `.github/workflows/actions.lock` the same way offline `gh actions-lock --verify-local` does, plus optional online drift checks. > > **`actions-lock`** (default profile) compares listed workflows and local actions they run against the lock file: missing pins, wrong refs (including SHA matched to locked commits), malformed lock entries, stale workflows/pins/dependencies, and optional **pedantic** checks (unlisted workflows, version-comment mismatches). **`actions-lock-drift`** runs with **`--online`** and flags pins whose `repo_id`/`owner_id` or `full_name` no longer match GitHub, moved full-version tags, or commits not on any branch/tag. > > The linter now includes `actions.lock` in repository scans and can lint lock content passed in memory. Lock parsing gains **`parseActionsLock`**, **`lookup`**, and **`lockScope`** (replacing the narrower `workflowLock` path for rule wiring). **`GitHubRepo`** grows **`id`** and **`owner`** for drift detection. Docs, config schema, fixtures, and tests (including a docs example sync test) cover the new behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit dcf97bf. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>


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.