DEV Community

Cover image for A Version Tag Leaked CI Secrets: Lessons From CVE-2025-30066 for Your GitHub Actions
Louis Wright
Louis Wright

Posted on

A Version Tag Leaked CI Secrets: Lessons From CVE-2025-30066 for Your GitHub Actions

This article was written with AI assistance and reviewed by the author.

On March 14 and 15, 2025, the version tags of a popular GitHub Action stopped meaning what everyone thought they meant. A threat actor modified the tags v1 through v45.0.7 of tj-actions/changed-files so they pointed at a single malicious commit, 0e58ed8. The result, as recorded in the National Vulnerability Database, was that remote attackers could discover secrets by reading Actions logs [1]. NVD rates it 8.6 (High) and tracks it as CVE-2025-30066 [1].

Nobody had to change a line of their own workflow to be affected. If a pipeline said uses: tj-actions/changed-files@v45, the next run quietly fetched the attacker's code.

Why a tag is not a version

A git tag feels like a release number, but it is just a movable pointer. Whoever controls the upstream repository can move it. That is exactly what happened here [1].

CISA's alert on the incident asked organizations to audit every project that used any version of tj-actions/changed-files between 2025-03-12 and 2025-03-15, and for public repositories that ran the malicious commit, to check for exposed access keys, GitHub Personal Access Tokens, npm tokens and private RSA keys [2]. In other words: assume anything the workflow could see was compromised.

How a mutable tag becomes a breach: retag, run, leak

GitHub's own guidance has long pointed at the fix. Its secure-use reference recommends pinning actions to a full-length commit SHA, because an attacker would then need a SHA-1 collision for a valid Git object to slip in a backdoor [3]. The same page notes that workflow dependency lockfiles now supersede SHA pinning for repositories that adopt them [3], so check which option fits your setup.

The quieter cousins of the same mistake

A retagged action is the dramatic case. GitHub Actions has a few other well-documented ways for an outsider to end up running code with your secrets:

  • Pwn requests. GitHub Security Lab warns that combining the pull_request_target trigger with an explicit checkout of an untrusted pull request is a dangerous practice that may lead to repository compromise [4].
  • Script injection. Fields such as github.event.pull_request.title are attacker controlled. The recommended fix is to pass them through an intermediate environment variable instead of interpolating ${{ }} straight into a run: script [5].
  • Over-privileged tokens. GitHub advises granting the GITHUB_TOKEN the minimum required permissions, with read-only access to repository contents as a good default [3].

Each of these is easy to spot in code review once you know the pattern, and easy to miss in a 200-line YAML file at 6pm on a Friday.

Letting a tool do the staring

workflow-sentinel is a small CLI that scans .github/workflows/*.yml and action.yml files and fails the build when it finds these patterns. To see it work, here is a deliberately bad workflow that combines all of the problems above:

name: PR checks
on: pull_request_target
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: tj-actions/changed-files@v45
        id: changed
      - run: echo "Checking ${{ github.event.pull_request.title }}"
      - run: npm ci && npm test
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

Running npx workflow-sentinel (version 1.1.0) in that repository produces this real output:

workflow-sentinel output: 2 critical, 3 high, 2 medium findings

Seven findings, each tied to a line number and a concrete fix: two CRITICAL (the pull-request-head checkout under pull_request_target, and the title interpolated into run:), three HIGH (two mutable tags, plus secrets exposed to a job running PR code) and two MEDIUM (no permissions: block, and checkout without persist-credentials: false). The exit code is non-zero because the default --fail-on level is high.

For contrast, the same command against the current workflows in the expressjs/express repository reported 0 findings across 5 files and passed.

Fixing it, not just flagging it

The part that makes this practical on a real codebase is --fix. It pins actions to commit SHAs using the GitHub API (with GH_TOKEN or GITHUB_TOKEN), and --fix-dry-run shows the changes first. A few other options:

  • --trusted-owner actions skips SHA-pin requirements for owners you choose to trust.
  • --sarif <file> emits SARIF 2.1.0 for GitHub code scanning.
  • --json and --markdown for reports and PR comments.

A minimal CI step:

- name: Harden workflows
  run: npx workflow-sentinel@1 --sarif workflow-sentinel.sarif
Enter fullscreen mode Exit fullscreen mode

The takeaway

CVE-2025-30066 did not exploit a bug in anyone's application. It exploited a habit: trusting that @v45 means the same code tomorrow as it did yesterday [1]. Pin what you run, give the token as little as possible, and keep attacker-controlled strings out of shell scripts [3][4][5]. Then let a linter check it on every push, because the YAML will keep changing.

References

  1. NIST National Vulnerability Database. "CVE-2025-30066 Detail". 2025. https://nvd.nist.gov/vuln/detail/CVE-2025-30066
  2. CISA. "Supply Chain Compromise of Third-Party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154)". 2025. https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-tj-actionschanged-files-cve-2025-30066-and-reviewdogaction
  3. GitHub Docs. "Secure use reference". Accessed Oct 2026. https://docs.github.com/en/actions/reference/security/secure-use
  4. GitHub Security Lab (jarlob). "Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests". 2021. https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
  5. GitHub Security Lab (jarlob). "Keeping your GitHub Actions and workflows secure Part 2: Untrusted input". 2021. https://securitylab.github.com/resources/github-actions-untrusted-input/

Top comments (2)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

Pin-by-SHA is the fix, and the deeper lesson — don’t trust a pointer someone else controls — shows up twice in this stack. In CI it’s a mutable tag; in a multi-tenant app it’s trusting a tenant/user id that arrives in the request body. Both are values that look like identity but are attacker-movable, and the correct move is the same: resolve them from something you verified (the commit you pinned, the JWT you validated), then act. On the secret side, the key this class of incident exfiltrates is often a database credential — and if it’s the service_role / DB-admin key, RLS is bypassed entirely by whoever holds it. That is the argument for treating that one key as the highest tier of secret: never in a bundle, never in a client env, rotated on any suspicion, and with the app’s normal reads on a least-privilege role that RLS actually constrains so a leaked read key is a contained blast radius. Two-account probe for the read path: github.com/cekuu35/supabase-rls-le...

Collapse
 
mrlouiswright profile image
Louis Wright •

Agreed, a version tag and a user id in the request body fail the same way: both look like identity but someone else can move them. Resolving from something you verified first (a pinned SHA, a validated JWT) is the right rule. The point about service_role keys is a good one too. A leaked admin key skips RLS entirely, so keeping it out of bundles and running normal reads on a least-privilege role keeps a leak contained.