Writing Shell Safe Scripts
This course has covered a lot of individual gotchas — word splitting, local masking set -e, OPTIND going stale, trap commands expanding at the wrong time. This chapter pulls all of it into one place, adds a genuinely new and serious danger this course hasn’t covered yet — eval and command injection — and gives you a tool, ShellCheck, that catches a large fraction of these mistakes automatically before you ever run the script.
ShellCheck — Catching Mistakes Before You Run The Script
ShellCheck is a static analysis tool, external to Bash itself, that reads a script without running it and flags exactly the kinds of issues this course has covered — unquoted variables, unsafe patterns, likely typos:
shellcheck myscript.shIn myscript.sh line 4:
rm $filename
^-- SC2086: Double quote to prevent globbing and word splitting.Running ShellCheck routinely against everything you write is one of the highest-value habits available to you — it doesn’t replace understanding why these patterns are dangerous (which is what this whole course has been building toward), but it catches slips even after that understanding is solid, the same way a spell-checker still helps a confident writer.
eval — And Why It’s Dangerous
eval takes a string and runs it as if you’d typed it directly at the shell — meaning that string gets a second, full round of shell parsing, with all the expansion, splitting, and metacharacter interpretation that implies:
command="echo Hello"
eval "$command"HelloThat looks harmless. The danger appears the instant any part of what gets passed to eval includes data that isn’t fully under your control — because shell metacharacters inside that data don’t stay inert text through a second parsing pass; they become live syntax, executed as genuine commands.
Command Injection
This is the general term for exactly that failure: untrusted input being interpreted as executable shell syntax instead of being treated as inert data. eval is the most direct way to cause it in Bash, and this chapter’s Shell-Safety Considerations section demonstrates it concretely — including real, if safely contained, damage.
The general defense is straightforward to state: never pass anything derived from external or user-controlled input directly to eval. In practice, the vast majority of things people reach for eval to accomplish have a safer, purpose-built alternative — command arrays (declare -a my_commands=("ls", "-lh")) for building up commands dynamically, or indirect parameter expansion, introduced in this chapter’s safety section, for dynamic variable name lookups.
A Consolidated Quoting Checklist
- Quote every variable expansion by default.
- Use
$(...)for command substitution, not backticks in general. - Leave the pattern side of
[[ ... == pattern ]]or=~ regexunquoted deliberately, when you specifically want glob or regex matching. - Prefer
[[ ]]over[ ]— it never performs word splitting on unquoted content in the first place (unless you are inPOSIXtight environment). - Quote
"${array[@]}"and"$@", always, when iterating or forwarding — never the*form unless you specifically want everything joined into one string.
A Consolidated Safe-Filename-Handling Checklist
- Never loop over
$(ls)or any other unquoted command substitution to get a file list — use a glob (for f in *) instead. - Use
while IFS= read -r line; do ... done < filefor reading arbitrary lines safely. - For pipeline or command output specifically, prefer
while IFS= read -r line; do ... done < <(command)— process substitution avoids the pipeline subshell problem. - For maximum robustness against filenames containing newlines, use
find ... -print0combined withread -r -d ''. - Use
mktemp(never$$-based naming) for temporary files and directories, paired withtrap ... EXITfor guaranteed cleanup.
set -euo pipefail As A Default Header
If you want full restrict more, start most scripts with this line, and know its documented exceptions — commands tested by if/while, the left side of &&/||, and the local var=$(cmd) masking case — well enough to recognize when a failure genuinely won’t be caught by it.
Predictable Exit Behavior
A script’s exit code is part of its interface, not an afterthought. Rather than always exiting 1 on any failure, use distinct exit codes for distinct failure categories, so anything calling your script — another script, a monitoring system, a human reading logs later — can tell at a glance what actually went wrong:
readonly EXIT_MISSING_ARGS=2
readonly EXIT_FILE_NOT_FOUND=3
readonly EXIT_PERMISSION_DENIED=4
if [[ "$#" -lt 1 ]]; then
echo "Usage: $0 <file>" >&2
exit "$EXIT_MISSING_ARGS"
fi
if [[ ! -f "$1" ]]; then
echo "File not found: $1" >&2
exit "$EXIT_FILE_NOT_FOUND"
fiDocumenting these codes in your script’s usage text (or a comment near the top) turns exit status into genuinely useful, machine and human-readable information, rather than a single undifferentiated “something went wrong.”
Best Practices
- Run ShellCheck against every script you write, as a routine step, not an occasional afterthought.
- Never pass untrusted or externally-derived data to
eval. If you’re not sure whether data is trusted, treat it as untrusted. - Reach for indirect parameter expansion or command arrays instead of
evalfor the specific problemsevalis usually reached for — dynamic variable lookups and dynamically built commands, respectively. - Give a script’s exit codes real meaning, and document them, rather than treating every failure as an undifferentiated
exit 1.
Shell-Safety Considerations
Here’s eval’s danger made concrete, safely contained to a throwaway directory — followed by the safer alternative for the specific problem that usually leads people to eval in the first place.
The danger, demonstrated directly:
mkdir -p /tmp/shell-course-demo
touch /tmp/shell-course-demo/important-file.txt
user_input="; rm -rf /tmp/shell-course-demo"
eval "echo Hello $user_input"
ls /tmp/shell-course-demoHello
ls: cannot access '/tmp/shell-course-demo': No such file or directoryThe intent was simply to echo a greeting alongside some user-supplied text. But eval re-parsed the entire resulting string as genuine shell syntax — and user_input contained a semicolon, which eval interpreted as a real command separator, not literal text. What actually ran was two separate commands: echo Hello, followed immediately by rm -rf /tmp/shell-course-demo. The demo directory — created safely in /tmp specifically so this could be shown without real consequence — is genuinely gone. This is exactly what command injection means in practice: input that was supposed to be inert data instead got executed as a command.
The safer alternative for the specific case that often leads people to eval in the first place — looking up a variable by a name stored in another variable — is indirect parameter expansion, ${!name}, which needs no re-parsing at all:
database_host="localhost"
config_name="database_host"
echo "${!config_name}"localhost${!config_name} looks up the variable named by config_name’s value — database_host — and expands to its value directly, with no second parsing pass and no opportunity for injected syntax to be interpreted as anything other than a literal variable name lookup. Wherever eval is being reached for dynamic variable access, this is very likely the safer tool for the job.
Hands-On: Applying The Checklist
1. Set up a working directory.
mkdir -p ~/shell-course/ch20
cd ~/shell-course/ch202. Write a deliberately flawed script and check it with ShellCheck, if it’s installed on your system.
cat > flawed.sh << 'EOF'
#!/usr/bin/env bash
filename=$1
rm $filename
EOF
shellcheck flawed.shIf ShellCheck isn’t installed, install it via your system’s package manager first — it’s worth having available for every script you write going forward.
3. Reproduce the eval command-injection danger, safely.
mkdir -p /tmp/shell-course-demo
touch /tmp/shell-course-demo/important-file.txt
user_input="; rm -rf /tmp/shell-course-demo"
eval "echo Hello $user_input"
ls /tmp/shell-course-demo4. Replace a dynamic-variable-lookup use of eval with indirect expansion.
database_host="localhost"
api_key="secret-value"
config_name="database_host"
echo "${!config_name}"
config_name="api_key"
echo "${!config_name}"5. Rewrite a flawed script applying several checklist items at once.
cat > hardened.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
readonly EXIT_MISSING_ARGS=2
readonly EXIT_FILE_NOT_FOUND=3
if [[ "$#" -lt 1 ]]; then
echo "Usage: $0 <filename>" >&2
exit "$EXIT_MISSING_ARGS"
fi
filename="$1"
if [[ ! -f "$filename" ]]; then
echo "File not found: $filename" >&2
exit "$EXIT_FILE_NOT_FOUND"
fi
echo "Removing: $filename"
rm "$filename"
EOF
chmod +x hardened.sh
touch sample.txt
./hardened.sh sample.txt
./hardened.sh does-not-exist.txt
echo "Exit status: $?"6. Clean up.
cd ~
rm -rf ~/shell-course/ch20You’ve now run ShellCheck against a real flaw, watched eval turn injected input into an actually-executed, destructive command, replaced that use case with safe indirect expansion, and rewritten a bare script into one applying quoting discipline, set -euo pipefail, and meaningful distinct exit codes all at once. The next chapter covers debugging: bash -x, set -x, customizing trace output with PS4, and inspecting variables and arrays directly with declare -p.