Skip to content

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

Merged
hvitved merged 24 commits into
github:mainfrom
nodeselector:nodeselector-actions-lockfile-aware-pinning
Sep 15, 2026
Merged

hvitved merged 24 commits into
github:mainfrom
nodeselector:nodeselector-actions-lockfile-aware-pinning

Conversation

@nodeselector

@nodeselector nodeselector commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Why

actions/unpinned-tag reports 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

  • Read the lockfile extracted by #22487 through ActionsLock.
  • Suppress an action-step finding when the enclosing workflow lists the exact owner/repo@ref pin and the matching dependency has a full SHA-1 or SHA-256 commit.
  • Match repository names case-insensitively and treat sub-actions such as actions/cache/save as pins on actions/cache.
  • Ignore the lockfile version value and fail closed when the required structure is missing or malformed.
  • Exclude $/ 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

  • 9 focused CWE-829 query tests
  • Actions QLDoc coverage with no newly undocumented APIs

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.

@github-actions github-actions Bot added documentation Actions Analysis of GitHub Actions labels Jul 10, 2026
Comment thread actions/ql/lib/codeql/actions/config/ConfigExtensions.qll Fixed
@nodeselector
nodeselector force-pushed the nodeselector-actions-lockfile-aware-pinning branch 2 times, most recently from f1e28c2 to 75cbb24 Compare September 2, 2026 14:34
Comment thread actions/ql/test/utils/ActionsInlineExpectationsTestQuery.ql
@nodeselector
nodeselector marked this pull request as ready for review September 3, 2026 04:51
@nodeselector
nodeselector requested a review from a team as a code owner September 3, 2026 04:51
Copilot AI balanced review requested due to automatic review settings September 3, 2026 04:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity · 1 Medium severity

New issues introduced by this change (2)
Severity Finding
High severity actions/​ql/​lib/​codeql/​actions/​Lock.qll — The dependency is accepted without the required owner_id and repo_id fields, so a lock entry…
Medium severity 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.lock pin 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.

Comment thread actions/ql/lib/codeql/actions/Lock.qll
Comment thread actions/ql/lib/codeql/actions/Lock.qll Outdated

@hvitved hvitved left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have created nodeselector#1 against your branch with code review suggestions.

@nodeselector

Copy link
Copy Markdown
Contributor Author

I have created nodeselector#1 against your branch with code review suggestions.

Much cleaner. Thank you.

@nodeselector
nodeselector requested a review from hvitved September 4, 2026 15:32

@hvitved hvitved left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@nodeselector
nodeselector force-pushed the nodeselector-actions-lockfile-aware-pinning branch from a1fe250 to ff9bf5d Compare September 9, 2026 15:46
Comment thread actions/ql/lib/codeql/actions/Lock.qll Fixed
@nodeselector
nodeselector requested a review from hvitved September 9, 2026 15:59
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" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@hvitved
hvitved merged commit b6adb1a into github:main Sep 15, 2026
22 checks passed
jdx added a commit to jdx/jactionlint that referenced this pull request Oct 13, 2026
… 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>
jdx added a commit to jdx/jactionlint that referenced this pull request Oct 13, 2026
…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>
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.

4 participants