Skip to main content
ship it and sleep

Versioning, Pinning, and Auditing Third-Party Actions

6 min read Chapter 9 of 66

Versioning, Pinning, and Auditing Third-Party Actions

The Failure

April 2025. A widely used GitHub Action, codecov/codecov-action, is compromised. An attacker gains access to the action’s repository and modifies the code at the v4 tag to exfiltrate environment variables, including GITHUB_TOKEN and any secrets passed to the workflow. Every repository using uses: codecov/codecov-action@v4 runs the compromised code on the next pipeline execution.

The attack exploits a fundamental property of Git tags: they are mutable. The v4 tag pointed to commit abc123 on Monday and commit def456 (the attacker’s code) on Tuesday. Every workflow referencing @v4 silently switched to the compromised commit.

Repositories that pinned to the full commit SHA (uses: codecov/codecov-action@abc123...) were not affected. The SHA is immutable. The attacker cannot change what commit abc123 points to.

This is not a theoretical risk. This class of attack has affected codecov/codecov-action (2025), tj-actions/changed-files (2025), and several smaller actions. The pattern is the same: compromise the repository, modify the code at a mutable tag, and wait for thousands of pipelines to execute the malicious code.

The Mechanism

Git tags are pointers to commits. Anyone with push access to a repository can move a tag to point to a different commit. Lightweight tags and annotated tags are both mutable. When a GitHub Actions workflow references @v4, GitHub resolves the tag to a commit SHA at execution time. If the tag moves, the next execution uses the new commit.

Git commit SHAs are content-addressed hashes. They are determined by the commit’s content: the tree (files), the parent commit, the author, and the message. Changing any byte of the code changes the SHA. Pinning to a SHA means the workflow always executes the exact code at that commit, regardless of what happens to the tags.

The trade-off: SHA pinning is secure but opaque. @d1a3f3c7b1f2e4a5b6c7d8e9f0a1b2c3d4e5f6a7 does not tell you which version it corresponds to. The solution is to pin to the SHA and add a comment with the version:

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

The Implementation

SHA Pinning with Version Comments

# FRAGILE: Mutable tag references
name: ci
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/acme/checkout-service:${{ github.sha }}
      - uses: aquasecurity/trivy-action@master # 'master' is especially dangerous
        with:
          image-ref: ghcr.io/acme/checkout-service:${{ github.sha }}
# HARDENED: SHA-pinned references with version comments
name: ci
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
      - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@263435318d21b8e681c14492fe198f0f7b9820e8 # v6.18.0
        with:
          push: true
          tags: ghcr.io/acme/checkout-service:${{ github.sha }}
      - uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # v0.31.0
        with:
          image-ref: ghcr.io/acme/checkout-service:${{ github.sha }}

Dependabot for Automated SHA Updates

# .github/dependabot.yml in each service repo
# HARDENED: Automated action updates with SHA pinning
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
    commit-message:
      prefix: "ci"
    labels:
      - "ci"
      - "dependencies"
    open-pull-requests-limit: 5

Dependabot detects SHA-pinned actions, looks up the latest release, and opens a pull request that updates the SHA and the version comment. The PR description includes the changelog diff. The team reviews the changelog, verifies the CI pipeline passes, and merges.

This workflow gives you the security of SHA pinning with the convenience of automated updates. The review step is critical: every action update is a potential supply chain attack vector, and the PR is the checkpoint.

Finding the SHA for an Action Version

# Get the commit SHA for a specific release tag
git ls-remote --tags https://github.com/actions/checkout.git | grep 'v4.2.2'
# Output: 11bd71901bbe5b1630ceea73d27597364c9af683  refs/tags/v4.2.2

# Or use the GitHub CLI
gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq '.object.sha'

The Gate

The gate for third-party action risk is the action audit checklist. Before adding any new third-party action to a pipeline, verify:

  1. Publisher identity. Is the action published by a verified organization (blue checkmark in GitHub Marketplace)? Verified publishers have a verified domain and identity. This does not guarantee the code is safe, but it confirms who published it.

  2. Source availability. Is the action’s source code public? Can you read every line that will execute in your pipeline? If the action is closed-source or obfuscated, do not use it.

  3. Permissions requested. What permissions does the action need? An action that requests contents: write can modify your repository. An action that reads secrets context has access to every secret passed to the job. Minimize permissions using the permissions: key at the job level.

  4. Dependency count. How many transitive dependencies does the action pull in? A JavaScript action with 400 npm dependencies has 400 potential supply chain attack surfaces. Prefer actions with minimal dependencies or Docker-based actions with controlled dependency trees.

  5. Maintenance activity. Is the repository actively maintained? Are security issues addressed promptly? Check the issue tracker for open security reports without responses.

  6. Alternative assessment. Can you achieve the same result with a shell command? A curl call to an API is more auditable than an action that wraps the same curl call in 200 lines of JavaScript.

# Example: Replace a third-party action with a shell command
# FRAGILE: Third-party action with unknown supply chain
- uses: some-org/slack-notify-action@v2
  with:
    webhook-url: ${{ secrets.SLACK_WEBHOOK }}
    message: "Build passed"

# HARDENED: Direct API call, zero third-party dependencies
- name: Notify Slack
  if: success()
  run: |
    curl -s -X POST "${{ secrets.SLACK_WEBHOOK }}" \
      -H 'Content-Type: application/json' \
      -d '{"text": "Build passed for ${{ github.repository }}@${{ github.sha }}"}'

The shell command has zero third-party dependencies. It does exactly what it says. It cannot be compromised by a supply chain attack on a third-party action repository.

The Recovery

When a third-party action is compromised:

  1. Immediately pin all references to the last known good SHA. If you are already SHA-pinned, you are not affected.

  2. Rotate secrets that were exposed to workflows using the compromised action. This includes GITHUB_TOKEN, any secrets passed via secrets: or environment variables, and any tokens generated during the workflow.

  3. Audit logs using the GitHub audit log API to identify which workflow runs used the compromised action version. Check for unexpected API calls, artifact uploads, or repository modifications.

  4. Evaluate alternatives. If the compromised action can be replaced with a shell command or a first-party GitHub action (published by actions/ or github/ organizations), make the switch.

# Find all workflows in the org using a specific action
gh search code "uses: codecov/codecov-action" --owner acme --type code --json path,repository \
  | jq -r '.[] | "\(.repository.fullName): \(.path)"'

The organization-wide search helps identify every repository that needs attention. In a platform with shared workflows, this list should be short: ideally one shared workflow file and zero service repos.