Script Security Best Practices & File Permissions
This chapter covers the implementation of production-grade shell scripts, focusing on security best practices, robust error handling, automated dependency resolution (fallbacks), and secure file permissions. Using a real-world AWS EC2 instance creation script as a case study, we will explore how to transition from fragile, manual scripts to resilient, self-healing automation pipelines.
1. The Anatomy of a Production-Ready Shell Script
A production-ready shell script must be predictable, secure, and self-documenting. To achieve this, every script should begin with a standard initialization block that configures the execution environment and enforces strict error handling.
#!/bin/bash
set -euo pipefail
The Shebang (#!/bin/bash)
The first line of the script, known as the Shebang, instructs the operating system's program loader to execute the script using the Bash interpreter located at /bin/bash. This prevents execution errors caused by systems defaulting to other shells (like sh or dash).
The Safety Flags (set -euo pipefail)
Often referred to as the "Bash Strict Mode," this combination of flags completely changes how Bash handles errors, preventing silent failures from cascading through your infrastructure:
-e(Exit Immediately): Instructs the shell to exit immediately if any command returns a non-zero exit status (indicating an error). By default, Bash continues executing subsequent lines even if a critical command fails.-u(Nounset): Treats unset variables and parameters as errors. If you attempt to reference a variable that has not been explicitly defined, the script will print an error message and terminate immediately, preventing disastrous operations likerm -rf /$UNSET_VAR.-o pipefail(Pipeline Tracking): By default, a pipeline returns the exit status of its last command. Ifcommand1fails butcommand2succeeds incommand1 | command2, the shell treats the entire pipeline as successful. Enablingpipefailensures that if any command in the pipeline fails, the entire pipeline returns a non-zero exit code.
2. Dependency Verification & The Fallback Pattern
In automated environments (such as CI/CD pipelines or clean VM deployments), you cannot assume that external CLI tools are pre-installed. A resilient script must verify its dependencies before executing core logic.
The Naive vs. Resilient Approach
- Naive Approach: Assume the tool is installed. If it is missing, the script fails halfway through, leaving resources in an inconsistent state.
- Resilient Approach (The Fallback Pattern): Check if the tool exists. If it is missing, execute a fallback routine to install it automatically. If the installation fails, exit gracefully with a clear error message.
# Function to check if AWS CLI is installed
check_aws_cli() {
if ! command -v aws &> /dev/null; then
return 1 # Return failure to trigger fallback
fi
return 0 # Return success
}
# Function to install AWS CLI v2
install_aws_cli() {
echo "AWS CLI is not installed. Initiating fallback installation..."
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install --update
rm -rf awscliv2.zip aws
}
3. Real-Life Analogy: The Automated Receptionist & Fallback Protocols
To understand dependency checks and fallbacks, consider a real-world business operation:
Real-Life Analogy
Imagine an automated office receptionist program. When a visitor arrives, the receptionist's primary task is to scan their digital ID card using an electronic scanner.
- The Dependency Check: Before scanning, the system checks if the scanner is plugged in and active.
- The Fallback Protocol: If the scanner is offline (not found), the system does not crash or ignore the visitor. Instead, it triggers a fallback protocol: it opens a digital keypad on the screen and prompts the visitor to manually type their ID number.
- The Hard Exit: If both the scanner is offline and the touch screen keypad is broken, the system finally halts operations, sounds an alarm, and pages a human administrator (exiting with an error code).
4. Automating AWS EC2 Creation via CLI
Once dependencies are verified, the script interacts with AWS using the Command Line Interface (CLI). To automate this cleanly, we must capture the output of the creation command, parse it for critical identifiers, and validate the result.
Capturing the Instance ID with Queries
When creating an EC2 instance, the AWS CLI returns a large JSON payload. We use the --query parameter to extract only the newly created InstanceId and store it in a variable.
create_ec2_instance() {
local ami_id="$1"
local instance_type="$2"
local key_name="$3"
local subnet_id="$4"
local security_group="$5"
echo "Creating EC2 Instance..."
# Execute run-instances and query the first Instance ID
INSTANCE_ID=$(aws ec2 run-instances \
--image-id "$ami_id" \
--instance-type "$instance_type" \
--key-name "$key_name" \
--subnet-id "$subnet_id" \
--security-group-ids "$security_group" \
--query 'Instances[0].InstanceId' \
--output text)
# Validate if the Instance ID was successfully generated
if [ -z "$INSTANCE_ID" ] \vert \vert [ "$INSTANCE_ID" == "None" ]; then
echo "Error: Failed to generate Instance ID. EC2 creation failed."
exit 1
fi
echo "Instance successfully created with ID: $INSTANCE_ID"
}
5. The Polling Loop: Waiting for Instance State (Running)
An EC2 instance is not immediately usable after creation; it transitions from pending to running. If your script attempts to perform post-installation tasks (like SSH provisioning) immediately, those tasks will fail. We must implement a polling loop to wait until the instance state is officially "running".
Implementing the Polling Loop
The following function queries the instance state every 10 seconds until the state matches "running":
wait_for_instance() {
local instance_id="$1"
echo "Waiting for instance $instance_id to enter 'running' state..."
while true; do
# Query the current state of the instance
local state
state=$(aws ec2 describe-instances \
--instance-ids "$instance_id" \
--query 'Reservations[0].Instances[0].State.Name' \
--output text)
echo "Current State: $state"
if [ "$state" == "running" ]; then
echo "Instance is running and ready for use!"
break
fi
# Wait 10 seconds before polling again
sleep 10
done
}
6. Structuring Scripts with the Main Function Pattern
To keep scripts modular, maintainable, and clean, avoid writing loose code at the root level of the file. Instead, wrap your variables and execution flow inside a main function, and pass command-line arguments explicitly.
Benefits of the Main Function Pattern
- Scope Control: Variables declared inside functions can be scoped as
local, preventing accidental modification of global variables. - Readability: The entry point of the script is clearly defined at the bottom of the file.
- Reusability: Functions can be sourced into other scripts without executing the main logic immediately.
main() {
# Configuration Variables
local ami_id="ami-0c7217cdde317cfec" # Example Ubuntu AMI
local instance_type="t2.micro"
local key_name="my-ssh-key"
local subnet_id="subnet-0123456789abcdef0"
local security_group="sg-0123456789abcdef0"
# Step 1: Verify or Install Dependencies
if ! check_aws_cli; then
install_aws_cli
# Re-verify after installation
if ! check_aws_cli; then
echo "Critical Error: AWS CLI installation failed."
exit 1
fi
fi
# Step 2: Create the EC2 Instance
create_ec2_instance "$ami_id" "$instance_type" "$key_name" "$subnet_id" "$security_group"
# Step 3: Wait for the Instance to be Ready
wait_for_instance "$INSTANCE_ID"
}
# Execute the main function with all script arguments
main "$@"
7. File Permissions & Security (chmod 700)
Shell scripts often contain sensitive configuration data, API calls, or paths to private SSH keys. Leaving script permissions open to any user on a system is a major security vulnerability.
Understanding Octal Permissions
File permissions in Linux are represented by three octal digits corresponding to three user classes: Owner, Group, and Others.
Permission Strategy Comparison
Securing Your Script
To secure your script so that only your user account can interact with it, execute:
chmod 700 create_ec2.sh
8. Real-World Debugging: Resolving Exit Codes & Copy-Paste Pitfalls
During live development, scripts often fail due to subtle logical errors or syntax issues introduced during refactoring or copy-pasting.
The Premature Exit Bug
In the lecture, the developer encountered a bug where the script terminated immediately with the message:
AWS CLI is not installed. Please install it first.
This happened because the dependency check function used a hard exit 1 instead of returning a status code:
# BROKEN LOGIC
check_aws_cli() {
if ! command -v aws &> /dev/null; then
echo "AWS CLI is not installed."
exit 1 # <--- BUG: This terminates the entire script, preventing the fallback from running!
fi
}
The Fix: Returning Status Codes
To allow the fallback mechanism to execute, functions must return control back to the caller using return instead of terminating the process with exit:
# CORRECTED LOGIC
check_aws_cli() {
if ! command -v aws &> /dev/null; then
return 1 # Returns control to the caller with failure status
fi
return 0 # Returns control with success status
}
The Copy-Paste Directory Bug (cd error)
Another common issue occurs when copying code blocks from other scripts (e.g., a deployment script from a previous day) that contain directory changes (cd). If a stray cd command is left in a function, it changes the working directory of the shell, causing subsequent relative path commands to fail with No such file or directory. Always sanitize copied code to ensure no context-specific commands (like directory changes) persist.
9. Complete Refactored Script Flow
Below is the complete, production-grade, self-healing script combining all security, fallback, and automation patterns discussed.
#!/bin/bash
# Enable strict error handling
set -euo pipefail
# 1. Dependency Check
check_aws_cli() {
if ! command -v aws &> /dev/null; then
return 1
fi
return 0
}
# 2. Fallback Installation
install_aws_cli() {
echo "AWS CLI not found. Initiating automated installation..."
# Download and unzip AWS CLI v2
curl -s "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
if ! command -v unzip &> /dev/null; then
echo "Installing unzip dependency..."
sudo apt-get update && sudo apt-get install -y unzip
fi
unzip -q awscliv2.zip
sudo ./aws/install --update
# Clean up installation files
rm -rf awscliv2.zip aws
echo "AWS CLI installed successfully."
}
# 3. EC2 Creation
create_ec2_instance() {
local ami_id="$1"
local instance_type="$2"
local key_name="$3"
local subnet_id="$4"
local security_group="$5"
echo "Provisioning EC2 instance..."
INSTANCE_ID=$(aws ec2 run-instances \
--image-id "$ami_id" \
--instance-type "$instance_type" \
--key-name "$key_name" \
--subnet-id "$subnet_id" \
--security-group-ids "$security_group" \
--query 'Instances[0].InstanceId' \
--output text)
if [ -z "$INSTANCE_ID" ] \vert \vert [ "$INSTANCE_ID" == "None" ]; then
echo "Error: Failed to retrieve Instance ID." >&2
exit 1
fi
echo "Instance created successfully. ID: $INSTANCE_ID"
}
# 4. Polling Loop
wait_for_instance() {
local instance_id="$1"
echo "Waiting for instance $instance_id to reach 'running' state..."
while true; do
local state
state=$(aws ec2 describe-instances \
--instance-ids "$instance_id" \
--query 'Reservations[0].Instances[0].State.Name' \
--output text)
echo "Current State: $state"
if [ "$state" == "running" ]; then
echo "Instance is now running!"
break
fi
sleep 10
done
}
# 5. Main Execution Block
main() {
local ami_id="ami-0c7217cdde317cfec"
local instance_type="t2.micro"
local key_name="my-ssh-key"
local subnet_id="subnet-0123456789abcdef0"
local security_group="sg-0123456789abcdef0"
# Execute Dependency Check & Fallback
if ! check_aws_cli; then
install_aws_cli
if ! check_aws_cli; then
echo "Critical Error: AWS CLI is still unavailable after installation." >&2
exit 1
fi
fi
create_ec2_instance "$ami_id" "$instance_type" "$key_name" "$subnet_id" "$security_group"
wait_for_instance "$INSTANCE_ID"
}
# Trigger main execution
main "$@"
10. Summary and Key Takeaways
- Enforce Strict Mode: Always start production scripts with
set -euo pipefailto catch errors, unassigned variables, and pipeline failures immediately. - Implement Fallback Mechanisms: Design scripts to check for dependencies dynamically and attempt self-healing installations before failing.
- Use Return Codes, Not Exits, in Functions: Use
returninside helper functions to pass control back to the calling block. Usingexitinside a helper function terminates the entire script prematurely, breaking fallback logic. - Secure File Permissions: Use
chmod 700on scripts containing sensitive infrastructure configurations or credentials to restrict read, write, and execution access solely to the owner. - Query and Validate API Outputs: When working with cloud CLIs, use precise query filters (like
--queryin AWS) to isolate critical values, and validate that those values are not empty before proceeding. - Implement Polling Loops for Asynchronous Tasks: Cloud resources take time to provision. Use a
whileloop with a definedsleepinterval to poll resource states until they reach the desired target state (e.g.,"running").