Scripts running in production have significant power — they touch databases, execute system commands, and often run with root or service account privileges. A single security mistake can expose credentials, allow arbitrary code execution, or let attackers escalate privileges. These patterns prevent the most common bash security issues.
1
Always quote variables — prevent word splitting and globbing
BASH
# ── The fundamental rule: always double-quote variables ────
filename="my file.txt"
# UNSAFE — word splitting: treats spaces as argument separators
rm $filename # runs: rm my file.txt — removes "my" AND "file.txt"!
# SAFE
rm "${filename}" # removes "my file.txt" (one file)
# ── Glob injection ────────────────────────────────────────
user_input="*.sh"
# UNSAFE — expands glob, could match unintended files
echo $user_input # expands to: script1.sh script2.sh ...
# SAFE
echo "${user_input}" # prints: *.sh (literal)
# ── Command injection ─────────────────────────────────────
user_host="prod-01; rm -rf /"
# UNSAFE — allows command injection via semicolon
ping $user_host # pings prod-01 AND then runs rm -rf /!
# SAFE — always quote, always validate
if [[ "${user_host}" =~ ^[a-zA-Z0-9._-]+$ ]]; then
ping -c 1 "${user_host}"
else
echo "Invalid hostname" >&2; exit 1
fi
2
Input validation — never trust external data
BASH
# ── Validate all external input ───────────────────────────
validate_hostname() {
local h="${1}"
[[ "${h}" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]] \
|| { echo "Invalid hostname: ${h}" >&2; return 1; }
}
validate_port() {
local p="${1}"
[[ "${p}" =~ ^[0-9]+$ ]] && (( p >= 1 && p <= 65535 )) \
|| { echo "Invalid port: ${p}" >&2; return 1; }
}
validate_env() {
case "${1}" in
development|staging|production) return 0 ;;
*) echo "Invalid env: ${1}" >&2; return 1 ;;
esac
}
validate_path() {
local p="${1}"
# Reject path traversal
[[ "${p}" == *..* ]] && { echo "Path traversal denied: ${p}" >&2; return 1; }
[[ "${p}" =~ ^[a-zA-Z0-9/_.-]+$ ]] \
|| { echo "Invalid path: ${p}" >&2; return 1; }
realpath -e "${p}" >/dev/null 2>&1 \
|| { echo "Path does not exist: ${p}" >&2; return 1; }
}
# Usage
HOST="${1}"
PORT="${2}"
validate_hostname "${HOST}" || exit 1
validate_port "${PORT}" || exit 1
3
Secrets handling — never leak credentials
BASH
# ── NEVER hardcode secrets ────────────────────────────────
# WRONG:
DB_PASS="s3cr3tpassword" # visible in git history, ps aux, logs
# RIGHT: read from environment or file
DB_PASS="${DB_PASS:?DB_PASS must be set}"
# ── Prevent secrets appearing in ps aux ───────────────────
# WRONG — password visible in process list:
mysql -u root -p"${DB_PASS}" mydb
# RIGHT — use config file or stdin:
mysql --defaults-extra-file=<(printf '[client]\npassword=%s\n' "${DB_PASS}") mydb
# Or set MYSQL_PWD env var (visible in env but not ps args):
MYSQL_PWD="${DB_PASS}" mysql -u root mydb
# ── Don't log secrets ─────────────────────────────────────
# WRONG:
echo "Connecting with pass: ${DB_PASS}" # goes to log!
# RIGHT:
echo "Connecting to ${DB_HOST} as ${DB_USER}"
# ── Secure temp files for secrets ─────────────────────────
SECRET_FILE=$(mktemp)
chmod 600 "${SECRET_FILE}"
trap 'rm -f "${SECRET_FILE}"' EXIT
printf '[client]\npassword=%s\n' "${DB_PASS}" > "${SECRET_FILE}"
mysql --defaults-extra-file="${SECRET_FILE}" mydb
4
Safe temp files, PATH hardening, and privilege checks
BASH
# ── Safe temp files ───────────────────────────────────────
# WRONG — predictable name, race condition (TOCTOU):
tmp=/tmp/script_temp.$$
# RIGHT — unpredictable, atomic creation:
tmp=$(mktemp)
chmod 600 "${tmp}"
trap 'rm -f "${tmp}"' EXIT
# ── Harden PATH ───────────────────────────────────────────
# Always set explicit PATH at the top of privileged scripts
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
# ── Check who is running the script ──────────────────────
require_root() {
[[ $(id -u) -eq 0 ]] || { echo "ERROR: Must run as root" >&2; exit 1; }
}
refuse_root() {
[[ $(id -u) -eq 0 ]] && { echo "ERROR: Do not run as root" >&2; exit 1; }
}
# ── Run commands with minimal privilege ──────────────────
# Drop to a specific user for sensitive operations
su -s /bin/bash -c "./backup.sh" vriddh
runuser -l vriddh -c "./report.sh"
# ── umask — control default file permissions ─────────────
umask 077 # new files: 600 (owner only), dirs: 700
# Place at top of script to prevent world-readable temp files
vriddh@prod-01:~/scripts$shellcheck deploy.sh
deploy.sh:14:8: warning: Double quote to prevent globbing and word splitting. [SC2086]
deploy.sh:22:5: error: Prefer ${var:-default} to handle empty/unset vars. [SC2236]
deploy.sh:31:12: warning: Use 'find ... -print0 | xargs -0' for filenames with spaces. [SC2038]
vriddh@prod-01:~/scripts$shellcheck --severity=error deploy.sh && echo "No errors"
No errors
█
✔ Security checklist — Always double-quote
"${variables}". Always validate user input with regex before use. Never hardcode secrets — read from env or file. Set umask 077 in privileged scripts. Always hardcode PATH in scripts that run as root. Use mktemp not predictable filenames. Run shellcheck before every deploy — it catches most injection risks automatically.