| name | bash-script-hardening |
| description | Writes robust, safe, shellcheck-clean Bash scripts using strict mode, defensive quoting, error traps, safe temp files, and signal handling. Use this skill when authoring or reviewing shell/Bash scripts, when asked to "harden a bash script", "add strict mode", "fix shellcheck warnings", "make this script safe", debug silent failures, unquoted-variable bugs, word-splitting/globbing issues, or to add cleanup traps and error handling. |
| license | MIT |
Bash Script Hardening
Overview
Most Bash bugs are silent: an unset variable expands to nothing, a failed command is ignored, an unquoted path word-splits, and the script marches on corrupting state. This skill makes Bash scripts fail loudly, fail early, and clean up after themselves.
Keywords: bash, shell, strict mode, set -euo pipefail, IFS, quoting, word splitting, globbing, ShellCheck, trap, ERR, EXIT, cleanup, mktemp, signal handling, defensive scripting, POSIX, exit codes, here-doc, arrays.
Use this skill to author a new script from a hardened template, retrofit an existing script, or review a script for safety defects.
Workflow
- Set the shebang and strict mode. Use
#!/usr/bin/env bash and the canonical strict-mode preamble (see below). Never rely on /bin/sh if Bash features are used.
- Lock down word splitting. Set
IFS=$'\n\t' so unquoted expansions don't split on spaces.
- Quote everything. Every variable expansion and command substitution gets double quotes unless you have a deliberate, commented reason not to.
- Use arrays for lists and command arguments. Never build commands by concatenating strings.
- Add a cleanup trap. Register an
EXIT trap that removes temp files and restores state, plus an ERR trap for diagnostics.
- Handle errors explicitly where strict mode is insufficient (conditionals, command substitution in older Bash, pipelines you intend to tolerate).
- Create temp files safely with
mktemp, and reference them through the cleanup trap.
- Validate inputs and dependencies up front: required args, required commands (
command -v), required env vars.
- Run ShellCheck and resolve every finding; suppress only with a justified
# shellcheck disable=SCxxxx comment.
- Test failure paths, not just the happy path.
Reference files:
references/strict-mode.md — deep dive on set -euo pipefail, IFS, and the gotchas/escape hatches.
references/shellcheck-codes.md — the most common ShellCheck codes, what they mean, and the correct fix.
templates/hardened-script.sh — drop-in starting template with strict mode, traps, arg parsing, logging.
scripts/harden_check.sh — static auditor that flags missing strict mode, unquoted vars, and other red flags.
examples/before-after.md — a real fragile script transformed into a hardened one, line by line.
The canonical strict-mode preamble
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
What each flag buys you:
| Flag | Effect | Why it matters |
|---|
-e (errexit) | Exit on any command returning non-zero | Stops a broken script from continuing on corrupt state |
-u (nounset) | Error on use of unset variables | Catches typos like "$fle" and missing args |
-o pipefail | A pipeline fails if any stage fails | cmd | grep x no longer hides cmd crashing |
-E (errtrace) | ERR trap is inherited by functions/subshells | Your error trap actually fires inside functions |
IFS=$'\n\t' | Split only on newline/tab, not spaces | Filenames with spaces stop silently breaking loops |
-e has sharp edges. Read references/strict-mode.md for where it does NOT trigger (e.g. inside if, &&, ||, the last command of a function used as a condition) and the correct patterns to keep error checking explicit there.
Quoting and word-splitting rules
- Always quote expansions:
"$var", "${arr[@]}", "$(cmd)".
- Use
"${arr[@]}" (quoted, @) to preserve elements; never ${arr[*]} for argument lists.
- Prefer
[[ ... ]] over [ ... ] in Bash — it does not word-split or glob inside.
- Use
printf '%s\n' "$x" instead of echo "$x" for arbitrary data (echo mangles -n, backslashes).
- Loop over files with globs or
find -print0 | while IFS= read -r -d '', never for f in $(ls).
for f in $(ls *.txt); do rm $f; done
shopt -s nullglob
for f in ./*.txt; do
rm -- "$f"
done
Traps and cleanup
Register cleanup once, early, and make it idempotent:
cleanup() {
local rc=$?
[[ -n "${tmpdir:-}" && -d "$tmpdir" ]] && rm -rf -- "$tmpdir"
return $rc
}
trap cleanup EXIT
on_err() {
local rc=$? line=$1
printf 'ERROR: line %s exited with status %s\n' "$line" "$rc" >&2
}
trap 'on_err "$LINENO"' ERR
The EXIT trap runs on normal exit, error exit (with -e), and most signals — making it the single reliable place to clean up. See templates/hardened-script.sh for the full pattern including signal-specific traps.
Safe temp files
tmpdir="$(mktemp -d)"
tmpfile="$(mktemp "$tmpdir/XXXXXX.json")"
Never use /tmp/myscript.$$ — predictable names are a symlink-attack vector and collide.
Input and dependency validation
require_cmd() {
command -v "$1" >/dev/null 2>&1 || { printf 'Missing dependency: %s\n' "$1" >&2; exit 127; }
}
require_cmd jq
require_cmd curl
: "${API_TOKEN:?API_TOKEN must be set}"
if (( $# < 1 )); then
printf 'usage: %s <input-file>\n' "${0##*/}" >&2
exit 2
fi
Hardening checklist
Best Practices
- Make scripts re-runnable (idempotent). Guard mutations so a re-run after partial failure is safe.
- Separate config from logic. Read tunables from env with documented defaults:
: "${TIMEOUT:=30}".
- Log to stderr, data to stdout. Keep stdout pure so the script composes in pipelines.
- Use functions with
local variables; avoid global mutation. Declare local separately from command substitution to avoid masking exit codes (local x; x="$(cmd)").
- Pin behavior, not the environment. Use full option names in comments; prefer
printf over echo.
- Fail with the right exit code. Reserve nonzero codes for distinct failure classes so callers can branch.
- Run ShellCheck in CI and treat it as a gate. See
references/shellcheck-codes.md.
Common Pitfalls
local x=$(cmd) swallows errors. The local builtin's own success masks cmd's exit status even under -e. Split into two lines.
-e does not fire inside if/&&/||/! or the condition of a loop. Don't assume strict mode covers everything; check return codes explicitly there.
cmd | while read ... runs the loop in a subshell — variables set inside are lost in older Bash. Use process substitution while read ...; do ...; done < <(cmd) instead.
set -e + a function whose last command "fails" as a test silently disables errexit for that function's body. Avoid using such functions as if conditions when you also rely on -e inside them.
- Unquoted
$@ vs "$@". Bare $@ re-splits arguments; always "$@".
read without -r mangles backslashes; always read -r, and set IFS= per-read to preserve leading/trailing whitespace.
- Predictable temp paths (
/tmp/$$) cause races and symlink attacks — always mktemp.
rm -rf "$dir/"* when $dir is empty/unset can target /. Quote, validate, and use --; consider ${dir:?} to abort on empty.