Skip to content

Full-SHA lock entries can accept a fork-only commit under the upstream action name #174

Open

Description

@fproulx-boostsecurity

Congrats @nodeselector for bringing it into public preview!

While testing fork pull requests, we found a narrow mismatch between fresh lock generation, gh actions-lock --verify, and backend Require lockfile enforcement for actions pinned to a full SHA.

Summary

A contributor can change a workflow from a tagged action to a raw SHA that exists only in a fork of that action, then manually edit actions.lock to use the same SHA while retaining the upstream repository IDs and a legitimate upstream ref value.

Fresh gh actions-lock generation correctly rejects that commit as absent from upstream history. However, gh actions-lock --verify accepts the manually edited lock, and a hosted runner with an active Require lockfile policy executes the fork-only commit through the upstream action name.

We reproduced this with actions/checkout in a public sandbox PR:

The action commit adds only a harmless log marker. It was never pushed or proposed to upstream actions/checkout.

Tested versions

  • gh-actions-lock v0.0.0-20261015144100-f43e069f9e87, source commit f43e069f9e87f8fedcc8f4fb69c617e3c17fe2af
  • GitHub-hosted runner 2.337.0
  • Active Require lockfile policy applying to all workflows, with no exclusions

Reproduction

  1. Fork actions/checkout and create a commit that exists only on a fork branch. Our test commit is 373d742f768e37e4e68d84ada06be93fb2ce64d8 on vikorium/checkout:research/fork-only-runtime-marker.

  2. In a fork PR against a consumer repository, change:

uses: actions/checkout@v6.1.0

to:

uses: actions/checkout@373d742f768e37e4e68d84ada06be93fb2ce64d8
  1. Manually edit the lockfile as follows. The IDs are the genuine upstream actions organization and actions/checkout repository IDs:
workflows:
    '.github/workflows/checkout.yml':
        - 'actions/checkout@373d742f768e37e4e68d84ada06be93fb2ce64d8'
dependencies:
    'actions/checkout@373d742f768e37e4e68d84ada06be93fb2ce64d8':
        ref: 'v6.1.0'
        commit: 'sha1-373d742f768e37e4e68d84ada06be93fb2ce64d8'
        owner_id: 44036562
        repo_id: 197814629
  1. As a control, fresh lock generation rejects the workflow and writes nothing:
Pinned commit not in upstream history actions/checkout@373d742f768e37e4e68d84ada06be93fb2ce64d8
commit 373d742f768e is not on any tag or branch of actions/checkout: it may be a fork or impostor commit served under the upstream name
  1. With the manual lock in place, gh actions-lock --verify accepts it:
Scanning 1 workflow
1 action is pinned to a bare SHA without a tag ref
All 1 workflow valid
  1. Open the fork PR against a repository with Require lockfile enabled. The hosted run succeeds and records:
Secret source: None
Using locked action versions from the workflow's lockfile
Download action repository 'actions/checkout@373d742f768e37e4e68d84ada06be93fb2ce64d8' (SHA:373d742f768e37e4e68d84ada06be93fb2ce64d8)
Run actions/checkout@373d742f768e37e4e68d84ada06be93fb2ce64d8
RESEARCH_CHECKOUT_FORK_ONLY_COMMIT

The marker also appears during the action's post phase.

Expected behavior

--verify and hosted Require lockfile enforcement should reject a full-SHA lock when the commit exists only in a fork and is not reachable from any upstream actions/checkout branch or tag. This would match fresh lock generation and the README statement that locked commits must have an upstream branch containing them.

Actual behavior

--verify reports the workflow valid, and the hosted runner accepts the lock and executes the fork-only object under the actions/checkout name.

Scope and limits

We acknowledge that this attack scenario does requires a contributor to make visible changes to both the workflow and lockfile, including a raw full SHA (so sort of social engineering, possible enough to fooling into merging to default branch). It does not replace an unchanged, reviewed pin. Our test used a normal fork pull_request job with no Actions secrets and a read-only token (Contents: read, Metadata: read).

A symbolic-ref control did reject the same fork-only commit: actions/checkout@main with a lock pointing to that SHA failed in both the CLI and runner. The observed gap is specific to a manually authored full-SHA lock entry.

Source observation

In internal/pipeline/parity.go, the parity check attaches Head only when the lockfile key ref is not a full SHA:

case verify && !parserlock.IsFullSha(o.lp.Pin.Ref):
    pins[i].Head = o.lp.Pin.Ref

For the manually authored entry above, the key ref is the full SHA, so verification checks repository identity and object existence without an upstream ancestry check. Because repositories in a fork network share Git objects, the fork-only object can resolve through the upstream repository name.

This does not appear to be listed as a documented limitation. The current README says lockfiles are checked for forgeries, that a SHA must exist in its stated refs, and that a locked action must have a branch containing the commit. The Limitations section currently mentions only local-path actions.

Suggested fix and regression coverage

For full-SHA lockfile keys, verify that the commit is reachable from the upstream ref recorded in the lock entry, or otherwise prove that at least one upstream branch or tag contains it, as fresh generation already does. The hosted lockfile enforcement path should apply the same provenance rule.

A regression test could construct a full-SHA lock entry with valid upstream repository IDs where the object is resolvable through the fork network but exists on no upstream ref, then assert that both --verify and runner enforcement reject it. A corresponding full SHA reachable from an upstream branch should remain valid.

Activity

  1. nodeselector commented on Oct 16, 2026

    @nodeselector
    Collaborator

    @fproulx-boostsecurity thank you for the report. I have fixed the code defect that allowed the bypass on the backend. I'll come back to the gap in the cli next week.

  2. nodeselector commented on Oct 20, 2026

    @nodeselector
    Collaborator

    I am also fixing gaps where you could use git describe and revision syntax to bypass the protections on both the backend resolver and the CLI. Backend is fixed and the CLI fix for those are in progress.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions