How curl | bash Happens in GitHub Actions and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | curl-pipe-shell (Download of Code Without Integrity Check) |
| CWE | CWE-494 |
| Language | YAML (GitHub Actions) |
| Risk | Arbitrary code execution in CI runner |
| Root Cause | run: step pipes curl/wget output directly to bash |
| Fix | Download → verify checksum → execute separately |
Introduction
The action.yml file defines a reusable GitHub Actions action — it controls exactly what commands run inside every CI job that invokes it. A security scan of this file revealed a run: step following the "curl pipe bash" install pattern: fetching a remote script and piping it directly into a shell interpreter in a single command. This is one of the highest-risk patterns in CI/CD infrastructure because it collapses the entire trust chain between a remote server and privileged code execution into a single, unverifiable step.
The pattern looks harmless in everyday developer usage — it is how many popular tools advertise their installers. But in a CI runner that holds repository secrets, deployment credentials, and signing keys, it is a loaded gun pointed at your supply chain.
The Vulnerability Explained
What the Vulnerable Pattern Looks Like
The problematic construct in action.yml follows this shape:
# VULNERABLE — do not use
- name: Install tool
run: curl -sSfL https://example.com/install.sh | bash
or equivalently with wget:
# VULNERABLE — do not use
- name: Install tool
run: wget -qO- https://example.com/install.sh | sh
The critical detail is the pipe (|). The shell never writes the downloaded content to disk. It reads bytes from curl's stdout and executes them immediately. There is no moment where you can inspect, hash, or verify what is being run.
Why the GitHub Actions Context Makes This Worse
A standard developer workstation running curl | bash risks only that one machine. A GitHub Actions runner is a different threat surface entirely:
- Secrets are mounted as environment variables —
GITHUB_TOKEN, cloud provider credentials, NPM tokens, and Docker Hub passwords are all accessible to any command that runs in the job. - The runner has network access — it can exfiltrate data, pivot to internal services, or push malicious artifacts to registries.
- Build artifacts are trusted — code produced by CI is often signed, published, or deployed automatically. Injecting malicious code at build time poisons every downstream consumer.
The Attack Scenario
Consider a concrete attack against this specific action.yml:
- URL hijacking: The domain hosting
install.shexpires or its DNS is hijacked. The attacker registers the domain and serves a modified script. - CDN compromise: The CDN delivering the script is breached. The attacker swaps the script content at the edge node closest to GitHub's runner infrastructure.
- Subdomain takeover: The install URL points to a subdomain backed by a cloud resource (S3 bucket, Azure Blob, GitHub Pages) that has been deleted. The attacker claims the resource and serves arbitrary content.
In all three cases, the next time a workflow runs and reaches the curl | bash step, the attacker's code executes with full access to the runner environment — including every secret configured in the repository.
Why HTTPS Does Not Help Here
A common misconception is that https:// URLs are safe because the transport is encrypted. HTTPS verifies that you are talking to the server that owns the certificate for that domain. It does not verify that the server is serving the content you intended to download. A compromised origin server with a valid certificate is indistinguishable from a legitimate one over HTTPS.
The Fix
The correct remediation breaks the "download and execute" single step into three distinct, auditable steps:
Step 1 — Download to a Named File
- name: Download installer
run: curl -sSfL https://example.com/install.sh -o /tmp/install.sh
Writing to a file gives you a concrete artifact you can inspect and hash.
Step 2 — Verify the Checksum
- name: Verify installer checksum
run: |
echo "a3f1c2d4e5b6789012345678901234567890abcdef1234567890abcdef123456 /tmp/install.sh" \
| sha256sum --check --strict
The expected SHA-256 hash must be hardcoded in the workflow file (or fetched from a separate, pinned source). It cannot come from the same server as the script — that would be like asking the suspect to verify their own alibi.
For tools that provide GPG-signed releases, prefer signature verification over checksums:
- name: Verify GPG signature
run: gpg --verify /tmp/install.sh.sig /tmp/install.sh
Step 3 — Execute the Verified File
- name: Run installer
run: bash /tmp/install.sh
Only after the hash or signature matches does execution proceed.
Full Before/After Comparison
Before (vulnerable):
steps:
- name: Install Rust toolchain helper
run: curl -sSfL https://sh.rustup.rs | sh -s -- -y
After (safe):
steps:
- name: Download rustup installer
run: curl -sSfL https://sh.rustup.rs -o /tmp/rustup-init.sh
- name: Verify rustup installer checksum
run: |
# Pin the expected SHA-256 from https://static.rust-lang.org/rustup/dist/
echo "EXPECTED_HASH /tmp/rustup-init.sh" | sha256sum --check --strict
- name: Install Rust toolchain
run: bash /tmp/rustup-init.sh -y
The three-step pattern eliminates the direct pipe between the network and the shell. Even if the remote server is compromised, the checksum step will fail and the workflow will halt before any malicious code executes.
Connection to the rustls-webpki Dependency Update
This PR also upgrades rustls-webpki from 0.103.9 to 0.103.13 in src-tauri/Cargo.lock to address GHSA-82j2-j2ch-gfr8 (denial of service via panic on malformed CRL BIT STRING). The Cargo.lock diff shows the version bump and the updated checksum:
[[package]]
name = "rustls-webpki"
-version = "0.103.9"
+version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
The checksum field in Cargo.lock is exactly the kind of integrity verification that the curl | bash pattern in action.yml was missing. Cargo enforces that the downloaded crate bytes match the pinned hash before any code from that crate is compiled — the same principle that the fixed action.yml now applies to downloaded shell scripts.
Prevention & Best Practices
1. Never Pipe Network Output to a Shell
Treat curl | bash, wget | sh, curl | python, and similar patterns as unconditionally forbidden in CI. Add a Semgrep rule or actionlint to your CI pipeline to catch regressions automatically.
2. Pin Checksums in the Workflow File
The expected hash must live in version control alongside the workflow. When you upgrade the tool version, update the hash at the same time in the same commit — this creates an auditable record of intentional upgrades.
3. Prefer Official GitHub Actions Over Shell Installers
Many tools now publish official GitHub Actions (e.g., actions-rs/toolchain for Rust, dtolnay/rust-toolchain). These actions are versioned, pinned by commit SHA, and reviewed by the community. They eliminate the need for shell-based installers entirely.
# Prefer this for Rust toolchain installation
- uses: dtolnay/rust-toolchain@stable
Pin actions by commit SHA rather than tag to prevent tag mutation attacks:
- uses: dtolnay/rust-toolchain@a54c7afa936d91e3b3d8f7b8d5d0b8b0e1234567
4. Use actionlint in Your CI Pipeline
actionlint is a static analysis tool specifically for GitHub Actions workflows. It catches a range of issues including dangerous shell patterns, expression injection, and misconfigured permissions.
5. Apply Least-Privilege Permissions
Even if a curl | bash attack succeeds, limiting the workflow's permissions reduces blast radius:
permissions:
contents: read # restrict to minimum required
Relevant Standards
- CWE-494: Download of Code Without Integrity Check
- OWASP A08:2021: Software and Data Integrity Failures
- SLSA Supply Chain Threats: Build-time dependency substitution
Key Takeaways
- The
run:step inaction.ymlis a privileged execution context — any code that runs there has access to all repository secrets and can modify build artifacts. curl | basheliminates every integrity check — there is no point at which you can verify that the downloaded script matches what you intended to execute.- HTTPS is not a substitute for checksum verification — transport security and content integrity are orthogonal properties.
- The
Cargo.lockchecksum field in the same PR demonstrates the correct model — pin the expected hash of every external artifact, whether it is a Rust crate or a shell installer. - Official versioned Actions (pinned by commit SHA) are the safest alternative to shell-based installers for common toolchain setup tasks in GitHub Actions.
How Orbis AppSec Detected This
- Source: Remote URL passed to
curlorwgetin arun:step insideaction.yml - Sink: Shell interpreter (
bash/sh) receiving the piped output of the HTTP fetch — thecurl <url> | bashconstruct in the workflow'srun:field - Missing control: No intermediate file write, no checksum comparison, no GPG signature verification between the network fetch and shell execution
- CWE: CWE-494 — Download of Code Without Integrity Check
- Fix: Replace the single-step pipe with a three-step download-verify-execute sequence, pinning the expected SHA-256 hash of the installer script in the workflow file
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
The curl | bash pattern is one of the most widely deployed anti-patterns in CI/CD infrastructure, normalized by years of tool documentation that prioritizes installation convenience over security. In a GitHub Actions action.yml, it is especially dangerous because the runner environment is loaded with credentials and produces trusted artifacts. The fix is straightforward: download to a named file, verify the hash against a value pinned in version control, and only then execute. This three-step pattern is the same integrity model that Cargo applies to every dependency in Cargo.lock — and it is the right model for any code you fetch from the internet and run with elevated privilege.