Skip to content

actions/unpinned-tag: recognize GitHub Immutable Releases per-tag instead of a static repo allow-list #22674

Description

@jessehouwing

actions/unpinned-tag incorrectly flags Actions pinned to immutable release tags

Problem

The actions/unpinned-tag (UnpinnedActionsTag.ql, CWE-829) query requires third-party Actions
to be pinned to a full commit SHA, and flags any other ref (branch, floating major/minor tag,
semver tag, etc.) as unpinned. This is the right default, since a mutable tag can be repointed by
the publisher (accidentally or maliciously) after the fact.

However, GitHub now supports Immutable Releases and tags:
once enabled for a repository and a specific release is published, that release's tag can never
be moved or have its target changed while it exists, and — critically — if the tag/release is deleted, GitHub does not allow the same tag name to be recreated, so an attacker can never repoint it at a different (e.g. compromised) commit. Pinning to owner/repo@v1.2.3 is then just as safe
as pinning to the commit SHA that tag pointed to at publish time — but the query still reports it
as an unpinned-tag finding, producing a false positive.

The existing immutableActionsDataModel(action) extensible predicate (consumed by
UseOfUnversionedImmutableAction.qll) does not solve this: it is a static, manually curated,
repository-level allow-list left over from an earlier, very limited Docker-image-based
immutable-actions preview. Immutable Releases are granted per tag/release, not per
repository — a repo can have some immutable releases and some ordinary mutable tags — so a
repo-level allow-list can never be accurate, and isn't maintainable at scale (every consuming repo
would need to keep it up to date for every third-party Action it uses).

Proposal

  1. New extensible predicate, keyed on (action, ref) not just action.
    Add immutableActionRefsDataModel(string action, string ref) to
    codeql/actions/ql/lib/codeql/actions/config/ConfigExtensions.qll (+ Config.qll), where
    action is normalized to owner/repo (no sub-directory path — immutability is a property of
    the repository + tag, so an entry for owner/repo also covers sub-actions such as
    owner/repo/path/to/action).

  2. New library predicate/class, UseOfVersionedImmutableActionRef.qll, defining
    isImmutableActionRef(step, action, ref). It normalizes the callee (stripping any
    sub-directory path) before consulting the data model.

  3. Mutual exclusion in UnpinnedActionsTag.ql: add
    not isImmutableActionRef(step, nwo, version) alongside the existing
    not isImmutableAction(step, nwo) exclusion. The two conditions are independent and
    non-overlapping by construction (there's no "mutually exclusive query" primitive in CodeQL;
    exclusivity is achieved purely by each side negating the other's firing condition).

  4. No new alert query. "This ref happens to point to an immutable release" is not itself a
    finding — it's purely a suppression signal for actions/unpinned-tag.

  5. Pre-processing step to populate the data, since CodeQL analysis has no network access at
    scan time and immutability is a live, per-tag fact:

    • github/codeql gains a small utility query,
      actions/ql/src/utils/ListUnpinnedActionRefs.ql (@kind table), that reuses the existing
      Actions extractor to enumerate every distinct (owner/repo, ref) pair used by uses: steps
      and reusable workflow calls in the scanned repo that is not already pinned to a commit
      SHA
      (excluding local/self refs and container image refs). This is "leveraging CodeQL
      itself to find all the action refs to check", per the request — no separate YAML parser
      needed.
    • github/codeql-action gets a new step (prototyped here as a standalone composite action,
      prepare-immutable-action-refs/) that:
      1. Runs the utility query above against the already-created actions-language CodeQL
        database (codeql query run + bqrs decode).
      2. Filters results to version-like tags (vX, vX.Y, vX.Y.Z) — branch names, latest,
        main, etc. can never correspond to a GitHub Release.
      3. For each remaining (owner/repo, ref), calls
        GET /repos/{owner}/{repo}/releases/tags/{ref} and reads the immutable boolean directly
        from the REST response (simpler than GraphQL, and works with an unauthenticated request
        subject to standard rate limits, or authenticated via the workflow's token for the higher
        limit).
      4. Emits a CodeQL data extension YAML (immutableActionRefsDataModel) containing only the
        (action, ref) pairs confirmed immutable. Missing/404/false ⇒ default is always "treat as
        mutable, still flag it" — fail-safe by construction, no allow-list to maintain.
      5. The generated extension is then passed to codeql database analyze as a model pack.

End-to-end validation performed

All of the following was implemented and verified against a downloaded CodeQL CLI bundle
(codeql-bundle-win64 v2.27.1) plus the live GitHub API — not just unit-tested in isolation:

  1. Library/query unit tests (actions/ql/test/query-tests/Security/CWE-829): added a fixture
    workflow (immutable_release_tags.yml) exercising:

    • a repo-level match (foo/immutable-release@v1.2.3, in the test data extension) → not
      flagged
    • the same repo+tag referenced via a sub-directory action
      (foo/immutable-release/sub-action@v1.2.3) → not flagged (proves the owner/repo
      normalization works)
    • the same repo with a different, unlisted tag (@v1.2.4) → still flagged
    • a completely unrelated repo (foo/other-action@v1.2.3) → still flagged

    Full CWE-829, CWE-829-untrusted-owner, and CWE-829-Lockfile query-test suites pass
    (11/11) with these changes.

  2. Utility query validation: ran ListUnpinnedActionRefs.ql against a real CodeQL database
    built from the test fixtures and confirmed it returns exactly the deduplicated, normalized
    (action, ref) candidates expected — e.g. both foo/immutable-release@v1.2.3 and
    foo/immutable-release/sub-action@v1.2.3 collapse to a single
    ("foo/immutable-release", "v1.2.3") row.

  3. Real-world, live-API end-to-end test (no mocking): built a throwaway workflow with

    - uses: actions/checkout@v4
    - uses: jessehouwing/azdo-marketplace@v6.3.8
    - uses: jessehouwing/azdo-marketplace@v6

    and ran the full prototype pipeline (prepare-immutable-action-refs/prepare.sh) against a real
    CodeQL database for it:

    • Confirmed via a live call to
      GET /repos/jessehouwing/azdo-marketplace/releases/tags/v6.3.8 that GitHub's REST API
      returns "immutable": true for that release.
    • Confirmed GET /repos/jessehouwing/azdo-marketplace/releases/tags/v6 returns 404 (no
      release exists for that floating major tag) — correctly defaults to "mutable".
    • Confirmed GET /repos/actions/checkout/releases/tags/v4 also returns 404 — actions/checkout
      doesn't publish releases under that exact tag scheme, so it's correctly left as a flagged,
      unpinned tag rather than silently trusted.
    • The script produced:
      extensions:
        - addsTo:
            pack: codeql/actions-all
            extensible: immutableActionRefsDataModel
          data:
            - ["jessehouwing/azdo-marketplace", "v6.3.8"]
    • Fed this generated file into the actual UnpinnedActionsTag.ql query
      (codeql query run + --model-packs) against the same database:
      • Baseline (no immutable-refs data): both jessehouwing/azdo-marketplace@v6.3.8 and
        @v6 were flagged as unpinned.
      • With the generated extension applied: only jessehouwing/azdo-marketplace@v6 was
        flagged; @v6.3.8 was correctly suppressed.

This confirms the full loop — CodeQL discovers candidate refs → live GitHub API confirms which are
immutable → generated data extension is consumed by the real query → false positive is eliminated
while true positives remain — works end-to-end, not just in theory.

Scope / what's not yet done

  • The github/codeql side (predicate, library class, query wiring, docs, tests) is
    implementation-ready.
  • The github/codeql-action side is currently a standalone prototype action
    (prepare-immutable-action-refs/), not yet wired into the production init/analyze steps.
    Full integration would need:
    • A supported way to pass the generated model pack into the existing analyze step (e.g. a new
      --model-packs-equivalent input).
    • Rate-limit/backoff handling for repos with many distinct refs.
    • Unit/integration tests in codeql-action's own test suite.
    • A decision on default enablement (opt-in vs. on-by-default) and caching of results across runs
      to avoid re-querying the GitHub API on every scan.

Why this matters

Without this, users following GitHub's own recommended best practice (Immutable Releases +
semver tags instead of hand-pinning commit SHAs) are incorrectly flagged by CodeQL, which either
trains them to ignore/dismiss actions/unpinned-tag alerts or discourages adoption of the safer,
more maintainable Immutable Releases feature.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions