11.3. Defensive Scripting#
11.3.1. Common Pitfalls#
1. Not quoting variables with user input
# Dangerous
rm -rf $user_dir # If $user_dir contains spaces, breaks
rm -rf "$user_dir" # Correct, even with spaces
2. Missing output redirects
# Problem: Errors silently lost
tar -czf backup.tar.gz /var 2>&1 > /dev/null
# Both stdout and stderr lost!
# Better: Keep error output
tar -czf backup.tar.gz /var > /tmp/backup.log 2>&1
if [[ $? -ne 0 ]]; then
cat /tmp/backup.log >&2
exit 1
fi
3. Assuming commands exist
# Bad: jq might not be installed
jq '.field' input.json
# Good: Check first
if ! command -v jq &>/dev/null; then
echo "Error: jq not installed" >&2
exit 1
fi
jq '.field' input.json
4. Not handling partial success
# Bad: Stop at first failure
for file in *.txt; do
process_file "$file" || exit 1 # Stops on first error
done
# Better: Continue but track failures
failures=0
for file in *.txt; do
process_file "$file" || ((failures++))
done
if [[ $failures -gt 0 ]]; then
echo "Failed to process $failures files" >&2
exit 1
fi
11.3.2. Real-World Example: Defensive System Update Script#
#!/bin/bash
set -euo pipefail
# DEFENSIVE UPDATE SCRIPT
# Requirements: sudo, network, /tmp, 500MB disk space
readonly SCRIPT=$(basename "$0")
readonly BACKUP_DIR="/backups"
readonly UPDATE_LOG="/var/log/system_update.log"
# 1. Verify preconditions
verify_prerequisites() {
# Check root
[[ $EUID -eq 0 ]] || {
echo "Error: must run as root" >&2
exit 1
}
# Check disk space (need 500MB)
local free_space=$(df /tmp | awk 'NR==2 {print int($4/1024)}')
[[ $free_space -gt 500 ]] || {
echo "Error: insufficient disk space (need 500MB, have ${free_space}MB)" >&2
exit 1
}
# Check network
ping -c 1 8.8.8.8 &>/dev/null || {
echo "Error: no network connectivity" >&2
exit 1
}
}
# 2. Create backup
create_backup() {
mkdir -p "$BACKUP_DIR" || return 1
local backup_file="$BACKUP_DIR/system_$(date +%Y%m%d_%H%M%S).tar.gz"
tar -czf "$backup_file" /etc /home || {
echo "Error: backup failed" >&2
return 1
}
echo "Backup created: $backup_file"
}
# 3. Cleanup on error
handle_error() {
echo "[$(date)] Update failed at line $1" >> "$UPDATE_LOG"
echo "Error occurred. Check $UPDATE_LOG for details" >&2
exit 1
}
trap 'handle_error $LINENO' ERR
# Run verification and backup
verify_prerequisites
create_backup
# 4. Perform updates
echo "[$(date)] Starting system update..." >> "$UPDATE_LOG"
apt-get update -qq || { echo "Update failed" >&2; exit 1; }
apt-get upgrade -y || { echo "Upgrade failed" >&2; exit 1; }
echo "[$(date)] Update completed successfully" >> "$UPDATE_LOG"
11.3.3. State Management and Assumptions#
11.3.3.1. Document Assumptions#
#!/bin/bash
# ASSUMPTIONS:
# - Script must run as root
# - /var/log/app/ must exist
# - Database password in /etc/app.conf
# - Network connectivity required
# Verify assumptions at startup
verify_environment() {
[[ $EUID -eq 0 ]] || { echo "Must run as root" >&2; exit 1; }
[[ -d "/var/log/app" ]] || { echo "Log directory missing" >&2; exit 1; }
[[ -r "/etc/app.conf" ]] || { echo "Config not readable" >&2; exit 1; }
# Test connectivity
ping -c 1 8.8.8.8 &>/dev/null || {
echo "Warning: No network connectivity" >&2
# Continue anyway, but note the issue
}
}
verify_environment || exit 1
11.3.3.2. Idempotent Scripts#
# Good practice: Script can run multiple times safely
backup_system() {
local backup_dir="/backups"
# Create only if needed
[[ -d "$backup_dir" ]] || mkdir -p "$backup_dir" || return 1
# Use date for unique backups
local backup_file="$backup_dir/system_$(date +%Y%m%d_%H%M%S).tar.gz"
tar -czf "$backup_file" /etc /home 2>/dev/null || return 1
# Cleanup old backups (keep last 30 days)
find "$backup_dir" -name "system_*.tar.gz" -mtime +30 -delete
return 0
}
11.3.4. Preventing Command Injection#
11.3.4.1. Avoid eval#
# DANGEROUS: User input in eval
user_input=$1
eval "result=$user_input" # User could inject: $(rm -rf /)
# Safe alternatives
case "$user_input" in
add) do_add;;
remove) do_remove;;
*) echo "Unknown command" >&2; exit 1;;
esac
11.3.4.2. Proper Argument Passing#
# Bad: Command string passed as single argument
process_data "grep 'pattern' file.txt | sort" # Could be manipulated
# Good: Arguments as separate parameters
process_data "pattern" "file.txt" # Safe, no string parsing
11.3.4.3. Escaping User Input#
# If you must pass user input to a command
user_pattern=$1
# Use proper quoting
grep -- "$user_pattern" file.txt # -- prevents pattern from being flag
# For filenames
cp "$user_file" "$backup_dir/" 2>/dev/null || {
echo "Copy failed for: $user_file" >&2
exit 1
}
11.3.5. Defensive Programming Patterns#
11.3.5.1. Quoting Variables#
# Bad: Variable expansion without quotes
rm -rf $backup_dir/* # DISASTER if $backup_dir is unset!
# Good: Always quote variables
rm -rf "$backup_dir"/* # Safe, errors on unset
# Better: Check before using
[[ -n "$backup_dir" ]] || { echo "Backup dir not set" >&2; exit 1; }
rm -rf "$backup_dir"/*
11.3.5.2. Array Handling#
# Process array safely
files=("$@") # Quote to preserve spaces in filenames
# Check array is not empty
if [[ ${#files[@]} -eq 0 ]]; then
echo "No files provided" >&2
exit 1
fi
# Safe iteration
for file in "${files[@]}"; do
[[ -f "$file" ]] || { echo "Not a file: $file" >&2; continue; }
process "$file"
done
11.3.5.3. Safe Temporary Files#
# Bad: Predictable filename
temp_file="/tmp/mytemp.txt"
echo "data" > "$temp_file" # Security risk!
# Good: Use mktemp
temp_file=$(mktemp) || { echo "Failed to create temp file" >&2; exit 1; }
trap "rm -f '$temp_file'" EXIT
echo "data" > "$temp_file"
11.3.6. Input Validation#
Always validate user input before using it:
11.3.6.1. Checking for Required Arguments#
#!/bin/bash
process_file() {
local file=$1
# Check if argument provided
if [[ -z "$file" ]]; then
echo "Error: filename required" >&2
return 1
fi
# Check if file exists
if [[ ! -f "$file" ]]; then
echo "Error: file not found: $file" >&2
return 1
fi
# Check if file is readable
if [[ ! -r "$file" ]]; then
echo "Error: file not readable: $file" >&2
return 1
fi
# Now safe to process
grep pattern "$file"
}
11.3.6.2. Validating Parameter Values#
#!/bin/bash
set_log_level() {
local level=$1
# Whitelist valid values
case "$level" in
debug|info|warn|error)
LOG_LEVEL="$level"
;;
*)
echo "Error: invalid log level: $level" >&2
echo "Valid levels: debug, info, warn, error" >&2
return 1
;;
esac
}
# Usage
set_log_level "debug" || exit 1